From 513c5231ae408b7af6446265e6cbf0328c9cd3f3 Mon Sep 17 00:00:00 2001 From: howie <2318485+howie@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:52:10 +0800 Subject: [PATCH 1/2] fix(adapter): surface error on agent EOF without final response The ACP recv loop's EOF branch (rx.recv() -> None) broke out of the loop without setting response_error. When a bridged agent crashes on a backend error (e.g. Gemini HTTP 500 / quota exhausted) it exits without ever emitting an ACP error notification, so the pipe closes, recv() returns None, and final_content falls through to the "_(no response)_" sentinel -- hiding the real failure from the user. Mark unexpected termination explicitly on EOF when no error was recorded and no text was streamed, so the user sees a concrete error instead of a vague sentinel. The text_buf.is_empty() guard preserves any partial text that did arrive before the connection closed. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X6Vv8eNtc4T9ESSSzZ2KgM --- crates/openab-core/src/adapter.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index a0c07d262..e76401bd9 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -812,8 +812,21 @@ impl AdapterRouter { let notification = tokio::select! { msg = rx.recv() => match msg { Some(n) => n, - // Reader saw EOF and already drained pending; nothing to abandon. - None => break, + // Reader saw EOF: the agent's stdout closed before it sent + // a final response. For ACP-native agents this is normal + // completion (the final response already broke the loop above), + // but bridged agents that crash on a backend error (e.g. HTTP + // 500 / quota exhausted) exit without ever emitting an ACP error + // notification — leaving response_error None. Surface that as an + // explicit error instead of falling through to "_(no response)_". + // The text_buf guard preserves any partial text already streamed. + None => { + if response_error.is_none() && text_buf.is_empty() { + response_error = + Some("Agent process exited unexpectedly".into()); + } + break; + } }, _ = tokio::time::sleep(liveness_check_interval) => { if !conn.alive() { From 6697c2115a2fe874f51caf9620cf6e0492558f42 Mon Sep 17 00:00:00 2001 From: howie <2318485+howie@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:44:03 +0800 Subject: [PATCH 2/2] fix(adapter): surface error on any agent EOF without final response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mob review (Codex + Claude multi-finder) found the original text_buf.is_empty() guard predicates error surfacing on buffer contents, which is not a reliable proxy for turn completion. Three crash classes still slipped through silently: - session-reset turns pre-seed text_buf with the expiry notice, so the guard is always false and a crash is masked; - send-once mode slices off inter-tool narration before delivery, so a crash after a tool (non-empty buffer, empty delivered body) shows no error; - native-streaming partial-text crashes were shown as complete answers. Drop the text_buf.is_empty() conjunct: on EOF, set response_error whenever none was recorded. This is safe because a successful turn is always signalled by the id-bearing JSON-RPC response to session/prompt, which breaks the loop before EOF — so the EOF arm is reachable only on abnormal termination, regardless of buffer contents. When partial text was streamed, final_content prepends the warning to it, preserving the output while flagging the truncation. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01X6Vv8eNtc4T9ESSSzZ2KgM --- crates/openab-core/src/adapter.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index e76401bd9..3a7b25b8f 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -812,16 +812,26 @@ impl AdapterRouter { let notification = tokio::select! { msg = rx.recv() => match msg { Some(n) => n, - // Reader saw EOF: the agent's stdout closed before it sent - // a final response. For ACP-native agents this is normal - // completion (the final response already broke the loop above), - // but bridged agents that crash on a backend error (e.g. HTTP - // 500 / quota exhausted) exit without ever emitting an ACP error - // notification — leaving response_error None. Surface that as an - // explicit error instead of falling through to "_(no response)_". - // The text_buf guard preserves any partial text already streamed. + // Reader saw EOF: the agent's stdout closed. A *successful* + // turn is always signalled by the id-bearing JSON-RPC response to + // `session/prompt`, which breaks the loop at the id branch below + // *before* any EOF — so reaching this arm means the turn ended + // without a final response, i.e. the agent terminated abnormally + // (bridged agents that crash on a backend error such as HTTP 500 / + // quota exhausted exit without ever emitting an ACP error + // notification). Surface that as an explicit error instead of + // falling through to "_(no response)_" or, worse, presenting a + // partially-streamed buffer as a complete answer. + // + // Do NOT gate on `text_buf.is_empty()`: the buffer is pre-seeded + // on session reset (the expiry notice) and, in send-once mode, + // carries inter-tool narration that is sliced off before delivery — + // so a non-empty buffer is not evidence the turn completed. When + // partial text *was* streamed, `final_content` prepends the warning + // to it (⚠️ … \n\n ), preserving the output while flagging + // the truncation. None => { - if response_error.is_none() && text_buf.is_empty() { + if response_error.is_none() { response_error = Some("Agent process exited unexpectedly".into()); }