Summary
session_state_changed: idle is the authoritative turn-over signal but carries no turn attribution, so the adapter compensates with a counter (session.owedTrailingIdles) that is deliberately biased to over-count. When the idle that the counter absorbs happens to be a turn's own final idle, nothing further arrives and session/prompt never resolves.
Since result frames already carry user_message_uuid (#1065), stamping the same field on session_state_changed would let the adapter match instead of count, and the counter could be deleted.
Versions
@agentclientprotocol/claude-agent-acp 0.79.0
@anthropic-ai/claude-agent-sdk 0.3.275
- Claude Code CLI 2.1.275
We run the published adapter with a few local logging-only patches; the debt logic quoted below is stock.
Symptom
A prompt never resolves. The agent has clearly finished — no further output, no open tool calls — but session/prompt does not return and the session stays running. Our ACP client observes inflight=1, openToolCalls=0, and no further session/update for 10+ minutes, until our own force-cancel floor fires and releases it.
Seen twice on the same client, eleven days apart. In both incidents the state recorded at the last idle was owed=1, held=false, steering=false, and then silence. One of the two parked prompts was itself a /compact.
Mechanism
The idle frame has no attribution field:
export declare type SDKSessionStateChangedMessage = {
type: 'system';
subtype: 'session_state_changed';
state: 'idle' | 'running' | 'requires_action';
uuid: UUID; // this frame's own id
session_id: string;
};
uuid identifies the frame, not the turn it ends. So a late trailing idle belonging to an already-settled turn is indistinguishable from the idle that ends the current turn. owedTrailingIdles (incremented at five sites in acp-agent) stands in for that missing information, and is consumed in the idle lane ahead of every settle lane:
else if (session.owedTrailingIdles > 0) {
// Absorb a settled turn's trailing idle. ...
session.owedTrailingIdles--;
}
The bias is intentional and documented in the source:
over-counting is benign (absorbs one future idle) while under-counting risks the false fail this debt exists to prevent.
Harmless if it never comes: the debt absorbs one future idle.
"Absorbs one future idle" is harmless only if another idle follows. When the absorbed idle is the turn's own last one, none does, and the prompt parks.
Why the 0.79.0 sweep does not cover this
0.79.0 clears unpaid debt on the transition into running:
Debt still outstanding here can never be paid. ... Sweep it.
That stops the debt leaking into the next turn, which is valuable. It cannot rescue the turn whose idle was already absorbed: the next running transition requires a new prompt, and the parked prompt is the one that would have to resolve first.
Proposed fix — attribution instead of counting
Result frames carry user_message_uuid and user_message_uuids (up to 64 entries, per its doc comment) since #1065, and the adapter already binds results to turns with it.
If session_state_changed carried the same field(s):
- idle stamped with an already-settled turn → ignore it,
- idle stamped with the active turn → settle it.
No absorbing, no counter, and #825's false-fail is prevented by the match itself rather than by a heuristic tuned against it. Because one idle can span several prompts the host merged into a single turn, the plural user_message_uuids is probably the right field to stamp.
Reproduction
We do not have a deterministic minimal reproduction, and both incidents were on long-lived production sessions, so I would rather not attach raw traces. What I can share is the state-transition summary around each incident (frame order, state, and the adapter's debt/held/steering values) — happy to post those, or to test a preview build that stamps the field.
The shape in both cases: a turn whose results took an interrupt or background-agent path that incremented the debt, followed by a turn whose ending idle was the next idle to arrive. Both were on a session that receives frequent mid-turn steering.
Summary
session_state_changed: idleis the authoritative turn-over signal but carries no turn attribution, so the adapter compensates with a counter (session.owedTrailingIdles) that is deliberately biased to over-count. When the idle that the counter absorbs happens to be a turn's own final idle, nothing further arrives andsession/promptnever resolves.Since result frames already carry
user_message_uuid(#1065), stamping the same field onsession_state_changedwould let the adapter match instead of count, and the counter could be deleted.Versions
@agentclientprotocol/claude-agent-acp0.79.0@anthropic-ai/claude-agent-sdk0.3.275We run the published adapter with a few local logging-only patches; the debt logic quoted below is stock.
Symptom
A prompt never resolves. The agent has clearly finished — no further output, no open tool calls — but
session/promptdoes not return and the session staysrunning. Our ACP client observesinflight=1,openToolCalls=0, and no furthersession/updatefor 10+ minutes, until our own force-cancel floor fires and releases it.Seen twice on the same client, eleven days apart. In both incidents the state recorded at the last idle was
owed=1,held=false,steering=false, and then silence. One of the two parked prompts was itself a/compact.Mechanism
The idle frame has no attribution field:
uuididentifies the frame, not the turn it ends. So a late trailing idle belonging to an already-settled turn is indistinguishable from the idle that ends the current turn.owedTrailingIdles(incremented at five sites inacp-agent) stands in for that missing information, and is consumed in the idle lane ahead of every settle lane:The bias is intentional and documented in the source:
"Absorbs one future idle" is harmless only if another idle follows. When the absorbed idle is the turn's own last one, none does, and the prompt parks.
Why the 0.79.0 sweep does not cover this
0.79.0 clears unpaid debt on the transition into
running:That stops the debt leaking into the next turn, which is valuable. It cannot rescue the turn whose idle was already absorbed: the next
runningtransition requires a new prompt, and the parked prompt is the one that would have to resolve first.Proposed fix — attribution instead of counting
Result frames carry
user_message_uuidanduser_message_uuids(up to 64 entries, per its doc comment) since #1065, and the adapter already binds results to turns with it.If
session_state_changedcarried the same field(s):No absorbing, no counter, and #825's false-fail is prevented by the match itself rather than by a heuristic tuned against it. Because one idle can span several prompts the host merged into a single turn, the plural
user_message_uuidsis probably the right field to stamp.Reproduction
We do not have a deterministic minimal reproduction, and both incidents were on long-lived production sessions, so I would rather not attach raw traces. What I can share is the state-transition summary around each incident (frame order,
state, and the adapter's debt/held/steering values) — happy to post those, or to test a preview build that stamps the field.The shape in both cases: a turn whose results took an interrupt or background-agent path that incremented the debt, followed by a turn whose ending idle was the next idle to arrive. Both were on a session that receives frequent mid-turn steering.