Skip to content

feat(acp): steer claude-code and codex agents via _session/steering - #3007

Draft
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/acp-session-steering
Draft

feat(acp): steer claude-code and codex agents via _session/steering#3007
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/acp-session-steering

Conversation

@wpfleger96

Copy link
Copy Markdown
Member

Mid-turn steering was reachable only through goose's _goose/unstable/session/steer, which requires an expectedRunId sourced from _meta.goose.activeRunId. claude-agent-acp and codex-acp never emit a run id, so every mid-turn mention to those harnesses bailed at the run-id guard before writing a byte and degraded to cancel + merge, destroying in-flight tool calls.

Both adapters ship _session/steering (params {sessionId, prompt}, result {outcome}) and advertise it as _meta.steering.supported on the initialize response. This adds it as a second steer transport selected at write time, reusing the existing withhold/release, ack routing, and cancel+merge fallback machinery unchanged.

Transport selection

active_run_id steering_supported Transport
Some(run_id) any _goose/unstable/session/steer + expectedRunId (unchanged)
None true _session/steering with {sessionId, prompt}
None false ack ExpectedRunIdMissing, write nothing (unchanged)

goose keeps priority when both are present — expectedRunId is strictly more precise about which run is being steered.

Two load-bearing safety properties

The advertised capability is the only gate — never error-code probing. codex-acp's extMethod answers unrecognized extension methods with a bare {}, which is a JSON-RPC success rather than -32601. Buzz maps a steer success to queue.remove_event, so probing an unknown method would silently delete the user's message with no error, no fallback, and no log line.

An outcome must be positively recognized. Only injected and startedNewTurn count as delivery. Anything else — codex's failed, an unknown value, or a missing outcome entirely — is SteerError::OutcomeRejected, which releases the withheld event and fires the cancel+merge fallback. This makes the silent-loss path above unreachable even if an adapter mis-advertises.

startedNewTurn acks Success, because the message really was delivered and must not be redelivered, but deliberately does not renew the read loop's hard deadline: the turn Buzz was awaiting had already settled, and renewing would extend the clock on a finished turn.

Notes for reviewers

  • SteerError::OutcomeRejected needs no new arm in the PoolEvent::SteerAck match — the existing catch-all Ok(SteerAck::Err(_)) => (true, false, true) already gives release + fallback, and the two AgentError arms above it match that variant specifically, so they do not shadow it.
  • Comments that described the old goose-only "try-and-tolerate" -32601 behavior are corrected; that assumption was never valid for codex-acp.
  • No CI job runs buzz-acp tests. The full package suite was run locally: 617 passing, 0 failing.

Mid-turn steering was reachable only through goose's `_goose/unstable/session/steer`, which requires an `expectedRunId` sourced from `_meta.goose.activeRunId`. claude-agent-acp and codex-acp never emit a run id, so every mid-turn mention to those harnesses bailed before writing a byte and degraded to cancel + merge, destroying in-flight tool calls.

Both adapters ship `_session/steering` (params `{sessionId, prompt}`, result `{outcome}`) and advertise it as `_meta.steering.supported` at initialize. Select the transport at write time: a run id picks the goose method, since `expectedRunId` is strictly more precise about which run is steered; otherwise the advertised capability picks the extension; neither leaves the existing cancel+merge fallback untouched.

The capability flag is the only gate — never error-code probing. codex-acp answers unrecognized extension methods with a bare `{}`, a JSON-RPC success rather than -32601, and Buzz maps a steer success to `queue.remove_event`. Probing would silently delete the user's message. As second-layer defense, an `outcome` must be positively recognized: anything other than `injected` or `startedNewTurn`, including absent, is `SteerError::OutcomeRejected` and falls back.

`startedNewTurn` acks Success (the message was delivered) but does not renew the read loop's hard deadline — the turn being awaited had already settled, so renewing would extend the wrong clock.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner July 26, 2026 17:42
@wpfleger96
wpfleger96 marked this pull request as draft July 26, 2026 17:45
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.

1 participant