Repository navigation
ACP prompt() returns stopReason: end_turn + zero usage on user cancel; should be cancelled with no usage #25899
Description
Summary
Agent.prompt() in packages/opencode/src/acp/agent.ts returns stopReason: "end_turn" whether the turn finished naturally or was interrupted by session/cancel. The two outcomes are indistinguishable on the wire. ACP defines stopReason: "cancelled" for exactly this case; reporting end_turn for a user-cancel makes a stopped turn look like a clean completion.
The same paths can also report usage: { totalTokens: 0, inputTokens: 0, outputTokens: 0 } for cancelled turns. The LLM stream is killed before the AI SDK emits its finish-step usage event, so assistantMessage.tokens is still at its zero initializer when prompt() returns. The provider may already have consumed the prompt tokens, but the exact count is not available. Reporting 0 is misleading; omitted usage is more accurate.
Reproducer
- Send a
session/promptthat triggers a long-running tool, such as a 60s shell loop. - Once the tool starts, send
session/cancel. - Observe the prompt RPC reply.
Expected:
{ "id": 100, "result": { "stopReason": "cancelled", "_meta": {} } }Actual:
{
"id": 100,
"result": {
"stopReason": "end_turn",
"usage": { "totalTokens": 0, "inputTokens": 0, "outputTokens": 0 },
"_meta": {}
}
}Trimmed event sequence:
T1> agent_thought_chunk x 4
T1> tool_call title="bash" status="pending" kind="execute"
-- send session/cancel --
CX> tool_call_update title="bash" status="failed" kind="execute"
CX> usage_update
CX> RESULT id=100 stop=end_turn totalTokens=0 inputTokens=0 outputTokens=0
Root cause
When the runner is interrupted by session/cancel, the Effect.onInterrupt path in packages/opencode/src/session/processor.ts runs:
yield* halt(new DOMException("Aborted", "AbortError"))halt calls MessageV2.fromError, which maps DOMException("AbortError") to a MessageAbortedError wire-level name tag and assigns it to ctx.assistantMessage.error. The runner interrupt callback then returns the in-flight assistant message, so sdk.session.prompt(...) resolves with that message instead of rejecting.
finish-step is what populates ctx.assistantMessage.tokens from the AI SDK usage event. It only fires when the streaming response completes, which does not happen on a mid-stream interrupt. The tokens therefore remain at the zero initializer set when the message was created.
Then acp/agent.ts unconditionally returns:
return {
stopReason: "end_turn" as const,
usage: msg ? buildUsage(msg) : undefined,
_meta: {},
}Neither the MessageAbortedError tag nor the all-zero token state is checked.
Impact
- ACP clients that special-case
stopReason: "cancelled"cannot distinguish a user stop from a clean completion. - Cost / usage telemetry can undercount cancelled turns by treating unknown usage as zero.
- Downstream heuristics that use token counts can incorrectly treat cancelled turns as zero work.
Proposed fix
In agent.ts, detect msg?.error?.name === "MessageAbortedError" and:
- Return
stopReason: "cancelled"instead of"end_turn". - Omit
usagerather than reporting all-zero usage, so clients treat it as unknown.
A separate, larger fix could recover provider usage during interrupt cleanup, but the stop-reason fix is correct independently.
Activity
github-actions commentedon May 5, 2026
This issue might be a partial duplicate of an existing issue. Please check:
MessageAbortedErrorand zero-state properties (though that issue focuses on the web UI rendering aspect rather than the ACP wire protocolstopReason).The
stopReason: "cancelled"and omittingusageon cancel are distinct from what #17895 covers, so this may still be a separate issue worth tracking independently — but a cross-reference could be useful.aiden-leong commentedon May 16, 2026
I've also encountered the exact same bug, and it severely hinders my ability to develop complex features using ACP.
Please fix this bug as soon as possible.
Perhaps a comprehensive test suite should be added to ACP?
Keesan12 commentedon May 20, 2026
Strong +1.
end_turnhere collapses two very different outcomes:the agent finishedandthe human stopped it.Once those share a stop reason, retry logic, analytics, and budget accounting all get polluted. I'd also keep the receipt asymmetric on purpose:
stopReason: cancelledplus omitted usage is better than fake zeros, becauseunknown after abortis a real state and downstream systems can reason about it honestly.github-actions commentedon Jul 20, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.