Skip to content

acp/agent: prompt() silently swallows SDK errors — end_turn returned with no content on failure #27779

Closed

Description

@agentbridge-fixer

Bug Description

In packages/opencode/src/acp/agent.ts, the prompt() handler calls sdk.session.prompt() without throwOnError: true:

// line ~1436
const response = await this.sdk.session.prompt({ sessionID, model, variant, parts, agent, directory })
const msg = response.data?.info

return {
  stopReason: "end_turn" as const,
  usage: msg ? buildUsage(msg) : undefined,
  _meta: {},
}

When the HTTP call fails (e.g. returns 400 or 500), response.data is undefined. The handler has no error check and returns a bare end_turn with usage: undefined — no exception, no error event, no diagnostic.

Reproduction

Upload an image that causes image.normalize() to fail (e.g. DecodeError or SizeError on an unusual PNG). The ACP client receives { stopReason: "end_turn", usage: undefined } with no preceding events, indistinguishable from a normal empty turn.

Impact

ACP clients (e.g. the AgentBridge IntelliJ plugin) cannot distinguish this failure from a "session corrupted" case. Without a real error signal they can only apply heuristics — ultimately leading to misleading UX (session-was-reset message) and lost session context.

Proposed Fix

Add { throwOnError: true } to the sdk.session.prompt() call:

const response = await this.sdk.session.prompt(
  { sessionID, model, variant, parts, agent, directory },
  { throwOnError: true },   // propagate HTTP errors as exceptions
)

When the SDK throws, the ACP JSON-RPC framework will surface it as a proper error response to the client. This gives ACP clients a real signal to act on (show a targeted error, keep the session alive) rather than silently pretending the turn succeeded with no output.

Note: other SDK calls in the same file already use throwOnError: true (e.g. listSessions, forkSession, message), so this pattern is already established.

Activity

  1. github-actions commented on May 15, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    If your issue is distinct (e.g. specific to the throwOnError fix path rather than the msg.error check path), please clarify the difference so this can be tracked separately.

  2. catatafishen commented on May 15, 2026

    @catatafishen

    Thanks for the pointer to #24494 — the root cause is the same: sdk.session.prompt() in acp/agent.ts doesn't surface errors over ACP, so clients can't distinguish a clean empty turn from a failed one.

    Our specific trigger is image normalization, not LLM retry exhaustion, so it's worth clarifying whether PRs #26306 + #26352 cover it:

    Image normalization failure path:

    • image.normalize() in session/prompt.ts throws DecodeError/SizeError
    • These propagate as unhandled Effect failures → HTTP 500 from the internal REST API
    • sdk.session.prompt() (no throwOnError) returns {data: undefined}
    • No session.error bus event is published — the failure is pure HTTP-level, before processor.ts halt() is ever called

    What #26352 covers: halt() failures (retry-exhausted LLM calls). These do publish session.error to the bus, so the new case "session.error": in handleEvent() will catch them.

    What #26352 does NOT cover (our case): prompt-preprocessing failures like image.normalize(), which die at the sdk.session.prompt() HTTP call layer, never touching the bus. The fix there would be either:

    1. Check response.data?.info.error after sdk.session.prompt() returns and emit agent_error there too (as ACP adapter returns end_turn even when assistant message has internal error #24494 suggested), or
    2. Have prompt.ts catch DecodeError/SizeError and publish bus.Session.Event.Error explicitly

    So these are complementary. #24494 + #26352 fix the processor side; the prompt-preprocessing side is still a gap.

  3. carlosflorencio commented on May 31, 2026

    @carlosflorencio

    Related failure mode worth folding in: when the underlying error is a retryable one that never succeeds (a hard opencode-go GoUsageLimitError 429), prompt() doesn't even return a bare end_turn — it never returns at all, because SessionRetry.policy() keeps sleeping on the multi-hour retry-after indefinitely (#21960). Over ACP that's total silence:

    → session/prompt   … no response, no session/update, no stopReason
    

    So there are two layers here: once the retry loop is bounded (#21960 / #26369), this degrades into exactly the bare-end_turn-with-no-content symptom you describe — which means adding throwOnError / surfacing the assistant message's internal error is necessary for the failure to ever become visible to the client. Both fixes are needed; this issue is the one that makes the turn-level provider error protocol-visible.

    PR #29061 implements this (session.prompt(..., { throwOnError: true })) and declares it Closes this issue — though note it currently surfaces the error as a text chunk and still returns stopReason: "end_turn", and it only fires once the prompt RPC returns (which the unbounded retry in #21960 prevents for hard Go limits).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions