I was debugging what looked like a permanently "busy" agent-shell: the agent's
answer was fully rendered in the buffer, but the shell never returned to idle.
It had happened after I sent a few steering prompts mid-turn. C-c C-c (interrupt current turn) appeared
to do nothing for ~30s, then printed cancel floor elapsed without the SDK yielding and finally released.
What follows is Claude Opus's analysis of the adapter, including a reproduction.
Summary
A steered turn settles on exactly one predicate, evaluated only on idle:
// acp-agent.ts, consumer idle lane
} else if (isSteering(session.activeTurn)) {
const steered: Turn = session.activeTurn;
if (steered.steeredEchoes?.size === 0 && steered.steeredSettle !== undefined) {
...settle...
}
// no else — the idle is discarded
}
If either half never becomes true, every subsequent idle is silently
discarded, the turn never settles, and session/prompt never gets a response.
Only forceCancelGraceMs (30s, and only if the client cancels) ever ends it.
The practical effect is that issue #825's fail-fast does not cover steered
turns. That branch sits two below this one and is itself triggered by an
idle, so an idle swallowed here never reaches it. A plain turn that goes idle
without a result gets no_result and a terminal response; a steered turn in the
same state hangs silently.
Plain turns are otherwise unaffected: they settle on their result. Steering
moves settlement off the result — which always arrives — and onto idle.
Reproduction
Driving ClaudeAcpAgent directly against a scripted SDK message stream
(no ACP socket, no client), v0.70.0 / d334766:
| SDK message sequence |
outcome |
steer; echo returns; no result; idle × 2 |
never settles |
steer; steered message never echoed; result, idle × 3 |
never settles |
steer; echo; result; idle |
settles end_turn |
result first, steer lands after it; idle |
settles end_turn |
steer; echo; result; stream ends |
settles end_turn |
Row 1 is the #825 condition — the model stream dropping mid-turn, which that
issue exists for — landing on a steered turn instead of a plain one. Row 2 is
the other half of the same predicate. I can show that a steer sitting unechoed
is a real state (the cancel path handles it explicitly: "A priority steer may
still be queued in the SDK when cancellation settles its owning turn"), and
that queued messages do get dropped without running (interrupt_receipt_v1's
still_queued exists for exactly that; my CLI transcript records 31 enqueue /
29 dequeue / 2 remove). But I can't name a sequence where a steered message is
dropped and its owning turn survives — every drop path I found also cancels
the turn, which settles it. So row 2 is an unguarded state rather than a
demonstrated production path.
Isolating it further — same stream, one difference:
| turn |
stream |
outcome |
| plain |
result(stop_reason: end_turn), no idle |
settles end_turn |
| steered |
result(stop_reason: end_turn), no idle |
never settles |
Independently, seeding session.owedTrailingIdles = 1 hangs a fully healthy
steered turn (result and idle both arrive): the owedTrailingIdles > 0
branch sits before the steer branch and consumes the idle that would have
settled it. Debt 0 settles end_turn; debt 1 hangs. (I could not find a
sequence that reaches debt 1 naturally — eight cancel/steer/reprompt variants
all balanced back to 0 — so this may only be reachable via paths I didn't model,
e.g. background subagents.)
Corroborating evidence from the real session
- The CLI transcript's final record is
stop_reason: end_turn — the model
finished cleanly, so the missing signal is downstream of it.
- The whole assistant message was rendered in the client, i.e. every
session/update notification arrived. Only the session/prompt response
was missing.
- The CLI transcript records 27
api_error entries over the session, so the
model stream does break in practice here.
- The adapter logged
cancel floor elapsed without the SDK yielding, which
means it still considered the turn active at cancel time — it had not
resolved session/prompt.
In fairness: I did not have ACP traffic logging on when this happened, so I
can't say which of the two rows above actually bit me — the message stream
wasn't retained. The reproduction demonstrates the mechanism, not the specific
incident. The code-level gap stands on its own either way.
Suggested direction
-
Stop swallowing once echoes are drained. If steeredEchoes.size === 0,
the SDK ran the steered message; an idle with no recorded result is exactly
the #825 signature and should fall through to that fail-fast rather than be
discarded. Fixes row 1, pure logic.
-
While echoes are outstanding, swallow a bounded count. The legitimate
reason to swallow is the interrupted cycle's own trailing idle — a quantity
(at most one per interrupt), not an open-ended wait. owedTrailingIdles
already models this; having the steer lane draw from it would also remove the
double-consumption in the seeded-debt case above.
-
For a dropped steer, acknowledge rather than infer. "The steer was
dropped" and "the SDK is about to run it" are genuinely indistinguishable
without waiting — the queued-turn branch says as much ("only a timer could
tell those apart"). But cancel already consumes an interrupt_receipt_v1
with still_queued; an equivalent receipt for steer would let the adapter
un-mark steeredEchoes immediately when the message was dropped, closing
this without any timer.
steer() currently returns outcome: "injected" on the strength of a local
"some turn is unsettled" check plus a push, with no confirmation the CLI
accepted the message — and that marking is what permanently moves settlement
onto the idle lane.
Environment
@agentclientprotocol/claude-agent-acp 0.70.0 (d334766)
- Client: agent-shell (Emacs), with my patch to support steering prompts into the
running turn instead of queueing them (xenodium/agent-shell#777)
— which is what started exercising this.
- Linux (WSL2), Node 24.19.0
I was debugging what looked like a permanently "busy" agent-shell: the agent's
answer was fully rendered in the buffer, but the shell never returned to idle.
It had happened after I sent a few steering prompts mid-turn.
C-c C-c(interrupt current turn) appearedto do nothing for ~30s, then printed
cancel floor elapsed without the SDK yieldingand finally released.What follows is Claude Opus's analysis of the adapter, including a reproduction.
Summary
A steered turn settles on exactly one predicate, evaluated only on
idle:If either half never becomes true, every subsequent
idleis silentlydiscarded, the turn never settles, and
session/promptnever gets a response.Only
forceCancelGraceMs(30s, and only if the client cancels) ever ends it.The practical effect is that issue #825's fail-fast does not cover steered
turns. That branch sits two below this one and is itself triggered by an
idle, so an idle swallowed here never reaches it. A plain turn that goes idlewithout a result gets
no_resultand a terminal response; a steered turn in thesame state hangs silently.
Plain turns are otherwise unaffected: they settle on their
result. Steeringmoves settlement off the
result— which always arrives — and ontoidle.Reproduction
Driving
ClaudeAcpAgentdirectly against a scripted SDK message stream(no ACP socket, no client), v0.70.0 /
d334766:result;idle× 2result,idle× 3result;idleend_turnresultfirst, steer lands after it;idleend_turnresult; stream endsend_turnRow 1 is the #825 condition — the model stream dropping mid-turn, which that
issue exists for — landing on a steered turn instead of a plain one. Row 2 is
the other half of the same predicate. I can show that a steer sitting unechoed
is a real state (the cancel path handles it explicitly: "A priority steer may
still be queued in the SDK when cancellation settles its owning turn"), and
that queued messages do get dropped without running (
interrupt_receipt_v1'sstill_queuedexists for exactly that; my CLI transcript records 31 enqueue /29 dequeue / 2 remove). But I can't name a sequence where a steered message is
dropped and its owning turn survives — every drop path I found also cancels
the turn, which settles it. So row 2 is an unguarded state rather than a
demonstrated production path.
Isolating it further — same stream, one difference:
result(stop_reason: end_turn), noidleend_turnresult(stop_reason: end_turn), noidleIndependently, seeding
session.owedTrailingIdles = 1hangs a fully healthysteered turn (
resultandidleboth arrive): theowedTrailingIdles > 0branch sits before the steer branch and consumes the idle that would have
settled it. Debt
0settlesend_turn; debt1hangs. (I could not find asequence that reaches debt 1 naturally — eight cancel/steer/reprompt variants
all balanced back to 0 — so this may only be reachable via paths I didn't model,
e.g. background subagents.)
Corroborating evidence from the real session
stop_reason: end_turn— the modelfinished cleanly, so the missing signal is downstream of it.
session/updatenotification arrived. Only thesession/promptresponsewas missing.
api_errorentries over the session, so themodel stream does break in practice here.
cancel floor elapsed without the SDK yielding, whichmeans it still considered the turn active at cancel time — it had not
resolved
session/prompt.In fairness: I did not have ACP traffic logging on when this happened, so I
can't say which of the two rows above actually bit me — the message stream
wasn't retained. The reproduction demonstrates the mechanism, not the specific
incident. The code-level gap stands on its own either way.
Suggested direction
Stop swallowing once echoes are drained. If
steeredEchoes.size === 0,the SDK ran the steered message; an
idlewith no recorded result is exactlythe
#825signature and should fall through to that fail-fast rather than bediscarded. Fixes row 1, pure logic.
While echoes are outstanding, swallow a bounded count. The legitimate
reason to swallow is the interrupted cycle's own trailing idle — a quantity
(at most one per interrupt), not an open-ended wait.
owedTrailingIdlesalready models this; having the steer lane draw from it would also remove the
double-consumption in the seeded-debt case above.
For a dropped steer, acknowledge rather than infer. "The steer was
dropped" and "the SDK is about to run it" are genuinely indistinguishable
without waiting — the queued-turn branch says as much ("only a timer could
tell those apart"). But
cancelalready consumes aninterrupt_receipt_v1with
still_queued; an equivalent receipt forsteerwould let the adapterun-mark
steeredEchoesimmediately when the message was dropped, closingthis without any timer.
steer()currently returnsoutcome: "injected"on the strength of a local"some turn is unsettled" check plus a push, with no confirmation the CLI
accepted the message — and that marking is what permanently moves settlement
onto the
idlelane.Environment
@agentclientprotocol/claude-agent-acp0.70.0 (d334766)running turn instead of queueing them (xenodium/agent-shell#777)
— which is what started exercising this.