Skip to content

Steered turn can hang unsettled — no session/prompt response until the force-cancel floor #1027

Description

@OSadovy

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

  1. 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.

  2. 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.

  3. 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

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