Skip to content

Steering after a queued turn leaves the turn's session/prompt unanswered forever (0.66.0) #1039

Description

@srid

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions