Skip to content

fix(acp): return stopReason: cancelled (not end_turn) on user cancel#25977

Open
truenorth-lj wants to merge 1 commit into
anomalyco:devfrom
truenorth-lj:fix-acp-cancel-stop-reason
Open

fix(acp): return stopReason: cancelled (not end_turn) on user cancel#25977
truenorth-lj wants to merge 1 commit into
anomalyco:devfrom
truenorth-lj:fix-acp-cancel-stop-reason

Conversation

@truenorth-lj
Copy link
Copy Markdown

@truenorth-lj truenorth-lj commented May 6, 2026

Issue for this PR

Closes #25899

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Returns stopReason: "cancelled" instead of "end_turn" on the ACP session/prompt RPC when the turn was interrupted by session/cancel, and omits the misleading all-zero usage field on cancel.

Agent.prompt() currently resolves the ACP RPC with stopReason: "end_turn" whether the turn finished naturally or was interrupted. ACP defines a dedicated stopReason: "cancelled" for the cancel case, so reporting end_turn makes a user cancel indistinguishable from a clean completion.

Cancelled turns can also return usage: { totalTokens: 0, inputTokens: 0, outputTokens: 0 }. The LLM stream is killed before the AI SDK emits its finish-step usage event, so the assistant message tokens are still at their zero initializer. Reporting zero is misleading; omitting usage lets clients treat it as unknown.

The fix detects msg?.error?.name === "MessageAbortedError" and uses that at both Agent.prompt() return sites that return an assistant message:

const cancelled = wasCancelled(msg)
return {
  stopReason: cancelled ? ("cancelled" as const) : ("end_turn" as const),
  usage: msg && !cancelled ? buildUsage(msg) : undefined,
  _meta: {},
}

The command-only return site is unchanged because it has no assistant message to inspect.

A larger follow-up could recover provider usage during interrupt cleanup, but this PR intentionally keeps the fix scoped to reporting the correct stop reason and avoiding known-bad zero usage.

How did you verify your code works?

  • bun test test/acp/event-subscription.test.ts - 12 pass / 0 fail
    • prompt() returns stopReason: cancelled with no usage when message has MessageAbortedError
    • prompt() returns stopReason: end_turn with usage on normal completion
  • bun run typecheck - clean

Screenshots / recordings

N/A - backend protocol fix.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Closes anomalyco#25899

`Agent.prompt()` resolves the ACP RPC with `stopReason: "end_turn"`
whether the turn finished naturally or was interrupted by
`session/cancel`. ACP defines a dedicated `stopReason: "cancelled"` for
exactly that case (`zStopReason` in the SDK schema), so reporting
`end_turn` makes a user cancel look like a clean completion to clients.

The same paths also returned `usage: { totalTokens: 0, inputTokens: 0,
outputTokens: 0 }` on cancel. The LLM stream is interrupted before the
AI SDK emits its `finish-step` event, so `assistantMessage.tokens`
stays at the zero initialiser from `session/prompt.ts`. Reporting `0`
is wrong (Anthropic / Bedrock have already consumed the prompt
tokens — we just don't know the count); omit `usage` so clients show
"unknown" instead of "zero work".

Detection signal: when the runner is interrupted, the `Effect.onInterrupt`
in `session/processor.ts` runs `halt(new DOMException("Aborted",
"AbortError"))`, which `MessageV2.fromError` maps to a
`MessageAbortedError` written to `assistantMessage.error`. The
runner's onInterrupt callback then returns the in-flight assistant
message, so `sdk.session.prompt()` resolves with `info.error.name ===
"MessageAbortedError"`. That's the wire-stable tag we check.

Applied to both prompt-RPC return sites in `Agent.prompt(...)` (the
default path and the slash-command path); the third path that handles
top-level commands like `/compact` is left untouched because it
synthesises its own response without a message.

Test plan:
- `bun test test/acp/event-subscription.test.ts` — 12 pass / 0 fail (was 10)
  - new: prompt() returns stopReason: cancelled with no usage when message has MessageAbortedError
  - new: prompt() returns stopReason: end_turn with usage on normal completion
- `bun run typecheck` — clean
- staging E2E (real sandbox + cancel mid-bash): pre-fix observed
  `stopReason: "end_turn", usage.totalTokens: 0`; post-fix expected to
  observe `stopReason: "cancelled"` with no `usage` field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@truenorth-lj
Copy link
Copy Markdown
Author

@rekram1-node — would you have a moment when you're next in the ACP queue? Same area as the end_turn race fix in #25683 (still open, also pinging there) — both are about stopReason correctness on session/prompt completion.

This one returns cancelled rather than end_turn when the user cancels mid-turn, so ACP clients can distinguish a user-aborted turn from a clean completion. CI green, no conflicts. v2 of #25901 (template-bot auto-close).

Happy to add a regression test or reshape if anything's off.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ACP prompt() returns stopReason: end_turn + zero usage on user cancel; should be cancelled with no usage

1 participant