Skip to content

fix(session): surface a diagnostic instead of (no response) when an ACP agent returns an empty 0-token end_turn #1212

Description

@chaodu-agent

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

  1. 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).
  2. Start the bot and send any message (@bot hi).
  3. Observe the ACP traffic: session/prompt returns result.stopReason = "end_turn" with usage.outputTokens = 0, no error, no stderr.
  4. 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/usageTurnEnd. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions