Skip to content

feat(acp): cancel in-flight session/prompt on session/cancel (#3192)#3698

Merged
Hmbown merged 1 commit into
Hmbown:mainfrom
findshan:feat/acp-session-cancel
Jun 27, 2026
Merged

feat(acp): cancel in-flight session/prompt on session/cancel (#3192)#3698
Hmbown merged 1 commit into
Hmbown:mainfrom
findshan:feat/acp-session-cancel

Conversation

@findshan

Copy link
Copy Markdown
Contributor

What & why

The ACP stdio adapter processed input strictly serially: the read loop awaited each request — including the full provider call behind session/prompt — before reading the next line. So a session/cancel could not even be observed until the turn it was meant to cancel had already finished. Per the readiness audit in docs/ACP_REGISTRY_SUBMISSION.md, session/cancel was therefore a documented no-op (returned null).

Editors like Zed send session/cancel when the user hits Esc. Today that does nothing: the in-flight turn keeps running and emits a session/update + result the user no longer wants. This closes that gap.

Approach

Drive session/prompt concurrently with the reader, keeping a single writer so stdout stays protocol-clean:

  • run_prompt borrows &self only and never touches the writer, so it is raced against reader.next_line() inside a new drive_prompt_with_cancellation while the main task retains exclusive ownership of stdout. The existing single-line-JSON invariant (and its test) is preserved.
  • A session/cancel for the in-flight session (request or notification form) aborts the awaited provider call; the prompt returns stopReason: "cancelled" and the assistant turn is not appended to history.
  • A no-prompt session/cancel stays an idempotent null no-op.
  • The turn is single-flight: a cancel for a different session is acknowledged and ignored; any other concurrent request gets a clear -32603 "prompt in progress" error instead of being silently dropped (notifications without an id are ignored).

The &mut self prep (validate + record the user turn) and the borrow-free provider call are split into begin_prompt / run_prompt / finish_prompt so the long call can be raced without holding a mutable borrow across the await.

Testing

The cancellation control logic is a generic free function over the future + reader/writer, so it is unit-tested deterministically with a delayed future and an in-memory reader — no real provider call. 4 new tests:

  • drive_prompt_completes_when_no_cancel_arrives
  • drive_prompt_cancels_when_matching_cancel_arrives
  • drive_prompt_ignores_cancel_for_a_different_session
  • drive_prompt_rejects_a_concurrent_request_but_keeps_running

Local: cargo fmt --all -- --check clean; clippy (bin, repo flags) clean; full bin suite 5518 passed, 0 failed.

Scope / open questions

  • This is deliberately the minimal correct cancel: it aborts the awaited non-streaming call. It does not add token streaming — but it shares the concurrent-loop foundation that streaming would build on, so if you'd prefer to land cancel + streaming together (or shape the concurrency model differently), happy to adjust.
  • Single-flight rejection of concurrent requests is a conservative default; if you'd rather queue them, that's a small change.

Refs #3192. Updates the session/cancel lines in docs/ACP_REGISTRY_SUBMISSION.md to match.

…3192)

The ACP stdio adapter processed input strictly serially: the read loop
awaited each request — including the full provider call behind
`session/prompt` — before reading the next line. A `session/cancel`
therefore could not even be observed until the turn it was meant to
cancel had already finished, so it was a documented no-op (it just
returned `null`). Editors like Zed send `session/cancel` when the user
hits Esc, so the in-flight turn kept running and streamed a result the
user no longer wanted.

Drive `session/prompt` concurrently with the reader instead:

- `run_prompt` borrows `&self` only and never touches the writer, so it
  is raced against `reader.next_line()` in `drive_prompt_with_cancellation`
  while the main task keeps exclusive ownership of stdout — stdout stays
  protocol-clean (the existing single-line-JSON invariant holds).
- A `session/cancel` for the in-flight session (request or notification
  form) aborts the awaited provider call and the prompt returns
  `stopReason: "cancelled"`; the assistant turn is not appended to history.
- A no-prompt `session/cancel` remains an idempotent `null` no-op.
- The turn is single-flight: a cancel for a different session is
  acknowledged and ignored; any other concurrent request gets a clear
  "prompt in progress" error rather than being silently dropped.

The cancellation control logic is extracted into a generic free function
so it is unit-tested with a delayed future and an in-memory reader — no
real provider call required. 4 new tests cover complete, cancel,
other-session-ignored, and concurrent-request-rejected paths.

Refs Hmbown#3192 (advances the ACP readiness audit in docs/ACP_REGISTRY_SUBMISSION.md).

Signed-off-by: findshan <224246733+findshan@users.noreply.github.com>
@findshan findshan requested a review from Hmbown as a code owner June 27, 2026 14:37
@github-actions

Copy link
Copy Markdown

Thanks @findshan for taking the time to contribute.

This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant recurring PR access by commenting /lgtm on a pull request.

@Hmbown Hmbown merged commit f6637c4 into Hmbown:main Jun 27, 2026
14 checks passed
Hmbown pushed a commit that referenced this pull request Jun 27, 2026
Follow-up to #3698. The ACP adapter buffered the whole turn: it called the
non-streaming `create_message` and emitted a single `session/update` chunk
only after the provider finished. Editors like Zed show agent output
incrementally, so users waited for the entire response with no feedback, and
the readiness audit listed non-streaming as a known limitation.

Stream the turn instead, reusing the existing `LlmClient::create_message_stream`
path that the TUI engine already uses:

- `open_prompt_stream` builds the request with `stream: true` and returns the
  `'static` `StreamEventBox`. It borrows `&self` only to read config/model and
  open the stream, so the caller can race it against the reader without holding
  a borrow on the server.
- `drive_prompt_stream` consumes the stream concurrently with the input reader:
  each text delta (`content_block_delta`/`content_block_start` text) is emitted
  as its own `session/update` agent_message_chunk as it arrives; `message_stop`
  or stream end completes the turn with the accumulated text (recorded in
  history); a provider stream error surfaces as a JSON-RPC error.
- Cancellation is preserved and now interrupts mid-stream: a matching
  `session/cancel` returns `stopReason: "cancelled"` and dropping the stream
  aborts the underlying provider connection. Single-flight semantics are
  unchanged (other-session cancel ignored; concurrent request rejected with a
  clear error). The single-writer invariant holds, so streamed chunks and
  acknowledgements stay on one protocol-clean stdout stream.

The streaming + cancellation control logic stays a free function over the
reader/writer + boxed stream, so it is unit-tested with canned in-memory
streams (delta-by-delta chunking, cancel mid-stream, other-session cancel
ignored, concurrent request rejected) — no real provider call. Full bin suite
5521 green; fmt + clippy clean. Updates docs/ACP_REGISTRY_SUBMISSION.md.

Refs #3192.

Signed-off-by: findshan <224246733+findshan@users.noreply.github.com>
Hmbown added a commit that referenced this pull request Jun 27, 2026
Refs #3192.

Streams ACP session/prompt text deltas as session/update chunks instead of buffering the full provider response, while preserving the single-writer stdout path and mid-stream session/cancel behavior from #3698.

Verification: GitHub CI green across lint, Ubuntu/macOS/Windows tests, npm/mobile smoke, DCO/gate, Version drift, Claude review, and GitGuardian. Local targeted verification also passed in an isolated worktree: cargo test -p codewhale-tui --bin codewhale-tui --locked acp_server:: -- --nocapture (18 passed).

Thank you @findshan for the careful ACP streaming slice and @Jengro777 for keeping the ACP registry/Zed path concrete.
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.

2 participants