Summary
We (the olai editor, an ACP client that ships this adapter pinned at 0.66.0) found that once a session has held a queued turn, _session/steering into any later turn leaves that turn's session/prompt unanswered forever. The steer itself answers {"outcome":"injected"}, the injected message runs and its output streams normally — and the session/prompt that owns the turn never resolves. session/cancel frees it ({"stopReason":"cancelled"}).
It reproduces against the adapter alone, with a ~60-line stdio client and no olai in the picture. We have not pushed a branch or opened a PR here — this issue is the whole of what we have done on this repo.
What decides it: a queued turn earlier in the same session
Same script, same prompts, only the session's history before the steer differs.
| the session before the steer |
session/prompt that is steered into |
| fresh (steer into the very first turn) |
resolves — end_turn at 2.6s |
| one earlier turn, run to completion |
resolves — end_turn at 7.1s |
| two earlier turns, run to completion |
resolves — end_turn at 37.1s |
| one earlier turn that was QUEUED behind another |
never resolves (watched past 240s) |
The last row does not depend on when the steer is sent: 150 ms after the prompt (before any output) and 6 s into a streaming answer both hang. In every hung run the steered message was answered — the agent_message_chunks arrive and read correctly — and usage_updates continue; it is only the settlement that never comes.
Reproduction
// bun repro.js /path/to/claude-agent-acp
import { spawn } from "node:child_process"
import { mkdtempSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const cwd = mkdtempSync(join(tmpdir(), "acp-repro-"))
const child = spawn(process.argv[2], [], { cwd, stdio: ["pipe", "pipe", "inherit"] })
const t0 = Date.now(), at = () => `${String(Date.now() - t0).padStart(6)}ms`
let next = 1
const pending = new Map()
let buf = ""
child.stdout.on("data", (c) => {
buf += c
for (let nl; (nl = buf.indexOf("\n")) >= 0; ) {
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1)
if (!line) continue
const m = JSON.parse(line)
if (m.id !== undefined && m.method === undefined) {
console.log(`${at()} <- #${m.id} ${JSON.stringify(m.result ?? m.error).slice(0, 90)}`)
pending.get(m.id)?.(m); pending.delete(m.id)
} else if (m.method === "session/update") {
const u = m.params.update
console.log(`${at()} <- ${u.sessionUpdate} ${JSON.stringify(u.content?.text ?? "").slice(0, 50)}`)
}
}
})
const ask = (method, params) => {
const id = next++
console.log(`${at()} -> ${method} #${id}`)
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`)
return new Promise((r) => pending.set(id, r))
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
const ESSAY = (x) => [{ type: "text", text: `Write an 800-word essay about ${x}. Output only the essay.` }]
await ask("initialize", {
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
clientInfo: { name: "repro", version: "0.1.0" },
})
const { result: { sessionId } } = await ask("session/new", { cwd, mcpServers: [] })
// THE ONE THING THAT MATTERS: a turn that was QUEUED behind another.
// Comment these four lines out and the steer below settles cleanly.
const first = ask("session/prompt", { sessionId, prompt: ESSAY("the tides") })
await wait(3000)
const queued = ask("session/prompt", { sessionId, prompt: [{ type: "text", text: "Say BANANA and nothing else." }] })
await Promise.all([first, queued])
const steered = ask("session/prompt", { sessionId, prompt: ESSAY("the sea floor") })
await wait(6000)
await ask("_session/steering", {
sessionId,
prompt: [{ type: "text", text: "Say PINEAPPLE and nothing else." }],
_meta: { steering: { idleBehavior: "promptRequired" } },
})
console.log(`${at()} steered; waiting for the prompt to settle…`)
console.log(await Promise.race([steered, wait(240000).then(() => "STILL OPEN after 240s")]))
Observed tail of a hung run (the essay is streaming, the steer answers, the injected
message is answered too, and #5 never settles):
28340ms -> session/prompt #5
29869ms <- agent_message_chunk "The Shape of the Deep\n\nFor most of human histor
30553ms <- agent_message_chunk "ailors knew the depth of harbors and the treacher
…
34072ms <- agent_message_chunk "ear, it rewrote geology.\n\nThe sea floor is not
34341ms -> _session/steering #6
34342ms <- #6 {"outcome":"injected"}
34342ms steered; waiting for the prompt to settle…
35029ms <- agent_message_chunk "P"
35688ms <- agent_message_chunk "INEAPPLE"
STILL OPEN after 240s
…and the same sequence with a session/cancel sent 20 s after the steer instead of
waiting it out:
49468ms -> (notify) session/cancel
49472ms <- #5 {"stopReason":"cancelled","usage":{…all zero…}}
Where we would look
Guessing, from reading dist/acp-agent.js rather than from instrumenting it — a steered turn is marked to settle at the SDK's idle rather than at the interrupted cycle's result (Turn.steeredEchoes, and the comment above steer() says exactly this). Separately, the queue's own bookkeeping tracks results that must be skipped rather than attributed (orphanCommands / pendingOrphanResults, ensureActiveTurn). Our reading is that a session which has run a queued turn is left in a state where the frame that would settle the steered turn is consumed as one of those, so the deferred settle never fires — which would fit the evidence exactly: the injected message is delivered and answered, and only the settlement is lost.
Why it matters to a client
It is the interaction of the two things this adapter offers a host: promptQueueing and the steering extension. A client that takes the queue as its default for mid-turn messages — which is the whole point of advertising it, and is what we changed olai to do so that a message typed during a /compact stops aborting the compaction — makes every busy conversation one that has queued. The interruption gesture then hangs the conversation's turn until somebody presses cancel.
Nothing is lost when it happens (the words run, the answer is on screen, cancel is one press and returns cancelled), so it is recoverable rather than destructive. But we could not find a way for a client to tell it from a long turn: turns carry no deadline by design, so "this settlement hung" and "this turn is still working" look identical from the host side. We have shipped a client-side guard that withdraws the interruption from any conversation that has queued, which costs the gesture in exactly the sessions it is most wanted in; we would much rather delete that guard than keep it.
Happy to run any instrumented build against the same script, or to try a fix if you would welcome a PR.
Summary
We (the olai editor, an ACP client that ships this adapter pinned at 0.66.0) found that once a session has held a queued turn,
_session/steeringinto any later turn leaves that turn'ssession/promptunanswered forever. The steer itself answers{"outcome":"injected"}, the injected message runs and its output streams normally — and thesession/promptthat owns the turn never resolves.session/cancelfrees it ({"stopReason":"cancelled"}).It reproduces against the adapter alone, with a ~60-line stdio client and no olai in the picture. We have not pushed a branch or opened a PR here — this issue is the whole of what we have done on this repo.
What decides it: a queued turn earlier in the same session
Same script, same prompts, only the session's history before the steer differs.
session/promptthat is steered intoend_turnat 2.6send_turnat 7.1send_turnat 37.1sThe last row does not depend on when the steer is sent: 150 ms after the prompt (before any output) and 6 s into a streaming answer both hang. In every hung run the steered message was answered — the
agent_message_chunks arrive and read correctly — andusage_updates continue; it is only the settlement that never comes.Reproduction
Observed tail of a hung run (the essay is streaming, the steer answers, the injected
message is answered too, and
#5never settles):…and the same sequence with a
session/cancelsent 20 s after the steer instead ofwaiting it out:
Where we would look
Guessing, from reading
dist/acp-agent.jsrather than from instrumenting it — a steered turn is marked to settle at the SDK'sidlerather than at the interrupted cycle'sresult(Turn.steeredEchoes, and the comment abovesteer()says exactly this). Separately, the queue's own bookkeeping tracks results that must be skipped rather than attributed (orphanCommands/pendingOrphanResults,ensureActiveTurn). Our reading is that a session which has run a queued turn is left in a state where the frame that would settle the steered turn is consumed as one of those, so the deferred settle never fires — which would fit the evidence exactly: the injected message is delivered and answered, and only the settlement is lost.Why it matters to a client
It is the interaction of the two things this adapter offers a host:
promptQueueingand the steering extension. A client that takes the queue as its default for mid-turn messages — which is the whole point of advertising it, and is what we changed olai to do so that a message typed during a/compactstops aborting the compaction — makes every busy conversation one that has queued. The interruption gesture then hangs the conversation's turn until somebody presses cancel.Nothing is lost when it happens (the words run, the answer is on screen, cancel is one press and returns
cancelled), so it is recoverable rather than destructive. But we could not find a way for a client to tell it from a long turn: turns carry no deadline by design, so "this settlement hung" and "this turn is still working" look identical from the host side. We have shipped a client-side guard that withdraws the interruption from any conversation that has queued, which costs the gesture in exactly the sessions it is most wanted in; we would much rather delete that guard than keep it.Happy to run any instrumented build against the same script, or to try a fix if you would welcome a PR.