Description
When an ACP agent ends a turn with no content and no error (a successful stopReason: end_turn carrying zero output tokens), OpenAB renders the bare placeholder _(no response)_. This gives the operator no signal about why the turn was empty — in practice it is almost always a recoverable misconfiguration (bad/missing provider credentials, unauthenticated model, provider no-op), but it looks identical to a model that legitimately chose to stay silent.
OpenAB already has the data to do better: the ACP result includes the usage object. A turn that returns end_turn with outputTokens == 0 and an empty composed body is a strong "silent failure" signature and should surface an actionable diagnostic instead of _(no response)_.
Concrete case (verified with RUST_LOG=info,openab_core::acp=debug on a live opencode bot):
- Backend:
opencode acp (OpenCode 1.17.9), model cloudflare-workers-ai/@cf/zai-org/glm-5.2
- The opencode config referenced
{env:CLOUDFLARE_ACCOUNT_ID} / {env:CLOUDFLARE_API_TOKEN}, which were not present in the agent's environment, so opencode substituted empty strings and the provider call never authenticated.
- The
session/prompt reply was a success with zero usage and no error:
acp_recv id=3 result={"stopReason":"end_turn","usage":{"inputTokens":0,"outputTokens":0,"totalTokens":0}}
- No JSON-RPC
error object, and nothing on the agent's stderr (OpenAB captures agent stderr at WARN — only the routine [agent].env notice appeared).
- Result: OpenAB showed
_(no response)_. There was no error for OpenAB to catch — opencode reported a successful empty turn.
This is the relevant code path — final_content is empty and response_error is None, so it falls through to the placeholder:
|
let final_content = |
|
compose_display(&tool_lines, &text_buf, false, tool_display); |
|
let final_content = if final_content.is_empty() { |
|
if let Some(err) = response_error { |
|
format!("⚠️ {err}") |
|
} else { |
|
"_(no response)_".to_string() |
|
} |
|
} else if let Some(err) = response_error { |
|
format!("⚠️ {err}\n\n{final_content}") |
|
} else { |
Steps to Reproduce
- Configure any ACP backend whose underlying provider will fail silently — e.g. opencode with a provider whose API key comes from an unset
{env:VAR} (so it resolves to an empty string).
- Start the bot and send any message (
@bot hi).
- Observe the ACP traffic:
session/prompt returns result.stopReason = "end_turn" with usage.outputTokens = 0, no error, no stderr.
- The bot replies
_(no response)_ with no indication that the model/provider failed to authenticate.
Expected Behavior
When a turn ends empty due to a silent failure — i.e. stopReason == "end_turn" and usage.outputTokens == 0 and the composed body is empty — OpenAB should do two things instead of silently rendering _(no response)_:
1. Send a meaningful error to the consumer (Discord/Slack/etc.) so the end user knows the turn failed rather than the agent choosing silence:
⚠️ The agent produced no output (0 output tokens). This usually means a provider/model error or missing credentials/config, not an intentional empty reply. Check the agent backend configuration and auth.
2. Log a detailed error in the backend at WARN/ERROR (visible without enabling debug) so operators can diagnose from logs alone — including the signals OpenAB already has: agent command, model, stopReason, inputTokens/outputTokens/totalTokens, and session id. e.g.:
ERROR openab_core::...: agent returned empty turn (0 output tokens) — likely provider/model/auth failure
agent="opencode acp" model="cloudflare-workers-ai/@cf/zai-org/glm-5.2"
stop_reason="end_turn" input_tokens=0 output_tokens=0 total_tokens=0
session_id="ses_…" thread="discord:…"
The existing _(no response)_ placeholder (no consumer error, no backend error log) can remain for the genuinely-silent case: non-zero tokens but empty rendered body (e.g. thought-only / narration-only turns).
Current vs Expected
CURRENT
──────────────────────────────────────────────────────────────────────
ACP agent (opencode / mimo)
│ session/prompt
▼
result: { stopReason: "end_turn", usage.outputTokens: 0 }
(no JSON-RPC error, no stderr)
│
▼
adapter compose: final_content == "" && response_error == None
│
▼
┌──────────────────────┐
│ _(no response)_ │ ✗ operator sees nothing actionable
└──────────────────────┘
EXPECTED
──────────────────────────────────────────────────────────────────────
ACP agent (opencode / mimo)
│ session/prompt
▼
result: { stopReason: "end_turn", usage.outputTokens: 0 }
│
▼
adapter compose: final_content == ""
│
│ stopReason == "end_turn" && outputTokens == 0 && body empty ?
│
┌────┴───────────────────────────┐
│ yes (silent failure) │ no (thought/narration-only: tokens > 0)
▼ ▼
├──► (1) CONSUMER ┌───────────────────────────────┐ ┌──────────────────┐
│ Discord/Slack│ ⚠️ agent produced no output │ │ _(no response)_ │
│ │ (0 tokens) — likely provider/ │ │ (unchanged) │
│ │ model/auth/config error. │ └──────────────────┘
│ └───────────────────────────────┘
│
└──► (2) BACKEND LOG (WARN/ERROR, no debug needed)
ERROR ... agent returned empty turn (0 output tokens)
agent="opencode acp" model="…glm-5.2"
stop_reason="end_turn" in=0 out=0 total=0 session="ses_…"
✓ operator diagnoses from logs; user is not left guessing
Upstream context (OpenCode)
This is partly an OpenCode defect — it should surface a real error rather than a silent empty end_turn. Tracked upstream:
Upstream context (MiMoCode)
MiMoCode (mimo acp) is a fork of OpenCode and several OpenAB bots use it, so the same silent/empty-turn class applies. Tracked upstream at XiaomiMiMo/MiMo-Code:
- Issue XiaomiMiMo/MiMo-Code#914 — "Loop-thinking: 1,803 repetitions / 569s stuck / zero output tokens" (exact match: model loops in reasoning, emits 0 output tokens, aborts → renders as
(no response))
- Issue XiaomiMiMo/MiMo-Code#1181 — "MiMo gets stuck in repeated reasoning loop without making progress"
- Issue XiaomiMiMo/MiMo-Code#250 — "思考陷入重複螺旋" (reasoning stuck in a repeated spiral)
- Issue XiaomiMiMo/MiMo-Code#865 — "ACP mode ignores default model set by
mimo auth login" (ACP-specific)
Because MiMoCode forks OpenCode's config code, it also inherits the missing-{env:VAR} → empty-substitution behavior that opencode PR #33854 addresses.
Even after these land upstream, OpenAB surfacing the empty-turn diagnostic is valuable as defense-in-depth across all ACP backends (opencode, mimocode, codex, etc.).
Feasibility / Implementation notes
OpenAB cannot recover the true upstream error (opencode/mimo swallow e.g. a provider 401 and return a clean, successful empty end_turn — no JSON-RPC error, no stderr). So OpenAB can only detect a silent failure by proxy: an empty turn that reported 0 output tokens. Surfacing the real root cause depends on the upstream fixes (opencode #33854 et al.).
The good news: OpenAB already receives the prompt result carrying stopReason + usage — it just discards it. In session_prompt, the oneshot receiver for the session/prompt response is dropped (_resp_rx), and the turn is driven purely off the session/update notification stream:
|
let data = serde_json::to_string(&req)?; |
|
|
|
let (resp_tx, _resp_rx) = oneshot::channel(); |
|
self.pending.lock().await.insert(id, resp_tx); |
|
|
So the fix is mostly "stop throwing away data we already have." Two tiers:
Tier 1 — no new ACP parsing (uses only existing signals). At the empty-final_content branch (adapter.rs), OpenAB already knows the body is empty, how many tool calls ran (tool_lines), and whether Thinking occurred. Even without the result, it can already (1) send a clearer consumer message ("agent ran N tools but produced no reply" vs "agent produced no output") and (2) emit a WARN/ERROR backend log with agent command + thread + tool count.
Tier 2 — capture the discarded result (recommended). Read _resp_rx to obtain stopReason + usage, surface it to the adapter (e.g. a new AcpEvent::TurnEnd { stop_reason, input_tokens, output_tokens } emitted when the prompt result arrives, or as a return value). Then the empty-turn branch can use the unambiguous signal stopReason == "end_turn" && outputTokens == 0 && body empty to do both outcomes precisely (consumer error + detailed backend log with model/tokens/stop_reason/session id).
Out of scope for OpenAB: identifying which provider error occurred (auth vs rate-limit vs config) — that requires the agent to report it (upstream).
Proposed Implementation Plan
One focused PR (Tier 2): stop discarding the session/prompt result, classify it as a terminal event on the existing stream, and branch the empty-turn case into a consumer error + a backend log. Route the result through the existing classify pipeline (rather than a second await) so the adapter keeps its single rx.recv() loop.
Options considered:
- A — Tier 1, existing signals only (no result parsing). At the empty-
final_content branch, use only what the adapter already has (tool_lines, whether Thinking occurred) to emit a clearer consumer message + backend log. Pro: smallest change, no protocol additions. Con: can't distinguish a genuine silent failure from an intentionally-empty/thought-only turn → either over-warns or under-warns. Kept as a fallback, not the primary.
- B — Tier 2, capture
stopReason/usage (recommended). Use the precise end_turn && outputTokens == 0 signal. Pro: accurate failure detection; data is already received (just dropped). Con: small protocol/plumbing additions. Chosen.
- Result plumbing — B1 vs B2. B1: change
session_prompt to return the oneshot resp_rx and await it after the notification stream ends. B2: forward the result into the notify stream as a terminal TurnEnd event and classify it like any other update. Chose B2 — keeps the adapter's single rx.recv() loop and reuses the classify pipeline; B1 adds a second await with its own error/lifecycle handling.
- Consumer surfacing — reuse vs new path. Reuse the existing
response_error → "⚠️ {err}" render path vs introduce a separate empty-turn placeholder type. Chose reuse — minimal diff, consistent rendering/splitting, and it already coexists with the _(no response)_ fallback.
- Out of scope (rejected for this PR): trying to recover the specific provider error (auth vs rate-limit) by scraping agent stderr or probing the provider — unreliable and agent-specific; belongs upstream (opencode#33854).
1. acp/connection.rs — stop dropping the result
- In
run_reader_loop, when the id-bearing response matches the in-flight prompt id, forward it to notify_tx as the final message before closing the channel (today it only goes to the dropped pending oneshot — see the _resp_rx discard above).
- Net effect: the adapter receives the prompt result as the last item on
rx, then None.
2. acp/protocol.rs — add a terminal event + parser
AcpEvent::TurnEnd { stop_reason: Option<String>, output_tokens: Option<u64>, total_tokens: Option<u64> }.
- In
classify, detect an id-bearing result carrying stopReason/usage → TurnEnd. Parse usage.outputTokens defensively (Option — some agents omit usage).
3. adapter.rs — branch the empty case (at the final_content.is_empty() site)
- Track
turn_end: Option<TurnEnd> from the new event.
silent_failure = final_content.is_empty() && turn_end.stop_reason == "end_turn" && turn_end.output_tokens == Some(0).
- If
silent_failure:
- (consumer) reuse the existing
response_error → "⚠️ {err}" render path with: "The agent produced no output (0 output tokens) — likely a provider/model error or missing credentials/config, not an intentional empty reply."
- (backend)
tracing::error!(agent, stop_reason, input_tokens, output_tokens, total_tokens, session_id, thread, "agent returned empty turn (0 output tokens)").
- Else (empty body but
output_tokens > 0 or usage absent → thought/narration-only) → keep _(no response)_ (optionally a debug!).
Edge cases / safety (backward-compat):
usage absent → output_tokens == None → do not label a failure; keep _(no response)_ (avoids false positives for agents that omit usage).
- Process died / JSON-RPC error → unchanged;
response_error already wins those paths.
- Non-empty turns → completely untouched (only the empty branch changes).
- Lifecycle: forwarding-then-closing preserves "stream ends → turn ends"; if the agent dies before a result, no
TurnEnd arrives and the existing death path handles it.
Tests:
- Extend existing
connection.rs stopReason fixtures: end_turn + 0 output + empty body → asserts consumer error + error log fires.
end_turn + non-zero output + empty body → still _(no response)_.
- Result with no
usage → _(no response)_, no false failure.
- Normal non-empty turn → unaffected.
Notes:
- Also improves the mimo loop-thinking case (MiMo-Code#914 — aborts with 0 output tokens would now be surfaced + logged).
- Does not identify which provider error occurred (upstream; opencode#33854).
- Open question: also log the model in the backend line. OpenAB parses
configOptions (carries current model) — if not already threaded to the adapter, ship with agent + tokens + stop_reason + session now and add model as a follow-up.
Description
When an ACP agent ends a turn with no content and no error (a successful
stopReason: end_turncarrying zero output tokens), OpenAB renders the bare placeholder_(no response)_. This gives the operator no signal about why the turn was empty — in practice it is almost always a recoverable misconfiguration (bad/missing provider credentials, unauthenticated model, provider no-op), but it looks identical to a model that legitimately chose to stay silent.OpenAB already has the data to do better: the ACP result includes the
usageobject. A turn that returnsend_turnwithoutputTokens == 0and an empty composed body is a strong "silent failure" signature and should surface an actionable diagnostic instead of_(no response)_.Concrete case (verified with
RUST_LOG=info,openab_core::acp=debugon a live opencode bot):opencode acp(OpenCode 1.17.9), modelcloudflare-workers-ai/@cf/zai-org/glm-5.2{env:CLOUDFLARE_ACCOUNT_ID}/{env:CLOUDFLARE_API_TOKEN}, which were not present in the agent's environment, so opencode substituted empty strings and the provider call never authenticated.session/promptreply was a success with zero usage and no error:errorobject, and nothing on the agent's stderr (OpenAB captures agent stderr at WARN — only the routine[agent].envnotice appeared)._(no response)_. There was no error for OpenAB to catch — opencode reported a successful empty turn.This is the relevant code path —
final_contentis empty andresponse_errorisNone, so it falls through to the placeholder:openab/crates/openab-core/src/adapter.rs
Lines 1031 to 1041 in d83bbc9
Steps to Reproduce
{env:VAR}(so it resolves to an empty string).@bot hi).session/promptreturnsresult.stopReason = "end_turn"withusage.outputTokens = 0, noerror, no stderr._(no response)_with no indication that the model/provider failed to authenticate.Expected Behavior
When a turn ends empty due to a silent failure — i.e.
stopReason == "end_turn"andusage.outputTokens == 0and the composed body is empty — OpenAB should do two things instead of silently rendering_(no response)_:1. Send a meaningful error to the consumer (Discord/Slack/etc.) so the end user knows the turn failed rather than the agent choosing silence:
2. Log a detailed error in the backend at
WARN/ERROR(visible without enablingdebug) so operators can diagnose from logs alone — including the signals OpenAB already has: agent command, model,stopReason,inputTokens/outputTokens/totalTokens, and session id. e.g.:The existing
_(no response)_placeholder (no consumer error, no backend error log) can remain for the genuinely-silent case: non-zero tokens but empty rendered body (e.g. thought-only / narration-only turns).Current vs Expected
Upstream context (OpenCode)
This is partly an OpenCode defect — it should surface a real error rather than a silent empty
end_turn. Tracked upstream:fix(config): error on missing {env:VAR} in config templates(directly fixes the missing-credential root cause seen here)fix(session): avoid stuck sessions from empty responses…(closes #30411)fix(opencode): no response handlingUpstream context (MiMoCode)
MiMoCode (
mimo acp) is a fork of OpenCode and several OpenAB bots use it, so the same silent/empty-turn class applies. Tracked upstream atXiaomiMiMo/MiMo-Code:(no response))mimo auth login" (ACP-specific)Because MiMoCode forks OpenCode's config code, it also inherits the missing-
{env:VAR}→ empty-substitution behavior that opencode PR #33854 addresses.Even after these land upstream, OpenAB surfacing the empty-turn diagnostic is valuable as defense-in-depth across all ACP backends (opencode, mimocode, codex, etc.).
Feasibility / Implementation notes
OpenAB cannot recover the true upstream error (opencode/mimo swallow e.g. a provider 401 and return a clean, successful empty
end_turn— no JSON-RPC error, no stderr). So OpenAB can only detect a silent failure by proxy: an empty turn that reported 0 output tokens. Surfacing the real root cause depends on the upstream fixes (opencode #33854 et al.).The good news: OpenAB already receives the prompt result carrying
stopReason+usage— it just discards it. Insession_prompt, the oneshot receiver for thesession/promptresponse is dropped (_resp_rx), and the turn is driven purely off thesession/updatenotification stream:openab/crates/openab-core/src/acp/connection.rs
Lines 597 to 601 in d83bbc9
So the fix is mostly "stop throwing away data we already have." Two tiers:
Tier 1 — no new ACP parsing (uses only existing signals). At the empty-
final_contentbranch (adapter.rs), OpenAB already knows the body is empty, how many tool calls ran (tool_lines), and whetherThinkingoccurred. Even without the result, it can already (1) send a clearer consumer message ("agent ran N tools but produced no reply" vs "agent produced no output") and (2) emit aWARN/ERRORbackend log with agent command + thread + tool count.Tier 2 — capture the discarded result (recommended). Read
_resp_rxto obtainstopReason+usage, surface it to the adapter (e.g. a newAcpEvent::TurnEnd { stop_reason, input_tokens, output_tokens }emitted when the prompt result arrives, or as a return value). Then the empty-turn branch can use the unambiguous signalstopReason == "end_turn" && outputTokens == 0 && body emptyto do both outcomes precisely (consumer error + detailed backend log with model/tokens/stop_reason/session id).Out of scope for OpenAB: identifying which provider error occurred (auth vs rate-limit vs config) — that requires the agent to report it (upstream).
Proposed Implementation Plan
One focused PR (Tier 2): stop discarding the
session/promptresult, classify it as a terminal event on the existing stream, and branch the empty-turn case into a consumer error + a backend log. Route the result through the existing classify pipeline (rather than a secondawait) so the adapter keeps its singlerx.recv()loop.Options considered:
final_contentbranch, use only what the adapter already has (tool_lines, whetherThinkingoccurred) to emit a clearer consumer message + backend log. Pro: smallest change, no protocol additions. Con: can't distinguish a genuine silent failure from an intentionally-empty/thought-only turn → either over-warns or under-warns. Kept as a fallback, not the primary.stopReason/usage(recommended). Use the preciseend_turn && outputTokens == 0signal. Pro: accurate failure detection; data is already received (just dropped). Con: small protocol/plumbing additions. Chosen.session_promptto return the oneshotresp_rxandawaitit after the notification stream ends. B2: forward the result into the notify stream as a terminalTurnEndevent and classify it like any other update. Chose B2 — keeps the adapter's singlerx.recv()loop and reuses the classify pipeline; B1 adds a second await with its own error/lifecycle handling.response_error → "⚠️ {err}"render path vs introduce a separate empty-turn placeholder type. Chose reuse — minimal diff, consistent rendering/splitting, and it already coexists with the_(no response)_fallback.1.
acp/connection.rs— stop dropping the resultrun_reader_loop, when the id-bearing response matches the in-flight prompt id, forward it tonotify_txas the final message before closing the channel (today it only goes to the droppedpendingoneshot — see the_resp_rxdiscard above).rx, thenNone.2.
acp/protocol.rs— add a terminal event + parserAcpEvent::TurnEnd { stop_reason: Option<String>, output_tokens: Option<u64>, total_tokens: Option<u64> }.classify, detect an id-bearingresultcarryingstopReason/usage→TurnEnd. Parseusage.outputTokensdefensively (Option— some agents omitusage).3.
adapter.rs— branch the empty case (at thefinal_content.is_empty()site)turn_end: Option<TurnEnd>from the new event.silent_failure = final_content.is_empty() && turn_end.stop_reason == "end_turn" && turn_end.output_tokens == Some(0).silent_failure:response_error → "⚠️ {err}"render path with: "The agent produced no output (0 output tokens) — likely a provider/model error or missing credentials/config, not an intentional empty reply."tracing::error!(agent, stop_reason, input_tokens, output_tokens, total_tokens, session_id, thread, "agent returned empty turn (0 output tokens)").output_tokens > 0or usage absent → thought/narration-only) → keep_(no response)_(optionally adebug!).Edge cases / safety (backward-compat):
usageabsent →output_tokens == None→ do not label a failure; keep_(no response)_(avoids false positives for agents that omit usage).response_erroralready wins those paths.TurnEndarrives and the existing death path handles it.Tests:
connection.rsstopReasonfixtures:end_turn+ 0 output + empty body → asserts consumer error + error log fires.end_turn+ non-zero output + empty body → still_(no response)_.usage→_(no response)_, no false failure.Notes:
configOptions(carries current model) — if not already threaded to the adapter, ship with agent + tokens + stop_reason + session now and add model as a follow-up.