mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat(steering): settle a steered turn at idle, not at the interrupt (#958)
Steering is delivered at priority `now`, which makes the CLI **abort** the running cycle instead of folding the message into it. The aborted cycle ends with an ordinary human-origin `result`, so we answered `session/prompt` with `end_turn` while the steered work was still going — its remaining tool calls, its answer, and its file edits then streamed with no turn in flight. A steered turn's results now only record their outcome, and the turn settles at the SDK's `idle` — the only signal spanning both the interrupted and the steered cycle. The injected message's replay tells them apart: an idle arriving before it means the steered cycle hasn't started, so it is swallowed. `session/cancel` and the next prompt still settle the turn early, with the recorded stop reason. Four steering tests cover the interrupt, an early idle, the next-prompt hand-off, and cancel.
This commit is contained in:
124
src/acp-agent.ts
124
src/acp-agent.ts
@@ -361,6 +361,26 @@ type Turn = {
|
||||
* pre-hold behavior (pending wakes are not countable: notifications can
|
||||
* batch into one followup). */
|
||||
deferredSettle?: PromptResponse;
|
||||
/** Uuids of `steer()`-injected messages the SDK has not replayed back yet.
|
||||
*
|
||||
* A steer is delivered at {@link STEER_PRIORITY} (`now`), so the CLI ABORTS
|
||||
* the running cycle: it emits its own human-origin `result` —
|
||||
* indistinguishable from a turn's terminal one — and the steered message runs
|
||||
* as a SECOND cycle. Settling at that result would answer `session/prompt`
|
||||
* mid-work, so a steered turn's results only RECORD their outcome
|
||||
* (`steeredSettle`) and it settles at the SDK's `idle`, the only signal
|
||||
* spanning both cycles (CLI 2.1.220).
|
||||
*
|
||||
* A non-empty set at an idle means the steered cycle hasn't started — the CLI
|
||||
* replays a message only when it picks it up, always after the interrupted
|
||||
* cycle's result — so that idle is swallowed. Drained by the replay handler.
|
||||
*
|
||||
* Residual: a message the CLI drops unreplayed parks the turn until
|
||||
* `session/cancel` or the next prompt (both settle it). */
|
||||
steeredEchoes?: Set<string>;
|
||||
/** What a steered turn settles with once its steered work has run: the outcome
|
||||
* of its latest result, so its usage covers every cycle the turn ran. */
|
||||
steeredSettle?: PromptResponse;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
@@ -689,6 +709,12 @@ function isHeldOpen(
|
||||
return turn != null && turn.deferredSettle !== undefined && !turn.settled;
|
||||
}
|
||||
|
||||
/** Whether a steer moved this turn's settlement off the next result and onto the
|
||||
* SDK's `idle` (see Turn.steeredEchoes). Shared by the consumer's settle lanes. */
|
||||
function isSteering(turn: Turn | null | undefined): turn is Turn & { steeredEchoes: Set<string> } {
|
||||
return turn != null && turn.steeredEchoes !== undefined && !turn.settled;
|
||||
}
|
||||
|
||||
/** Disarm the force-cancel backstop (see Session.forceCancelTimer). Every
|
||||
* path that settles the active turn must run this so a timer can never fire
|
||||
* on an already-settled turn — and must leave the field undefined, or the
|
||||
@@ -1884,6 +1910,10 @@ export class ClaudeAcpAgent {
|
||||
* calls). The steered message's own output streams via `session/update`, not
|
||||
* this response.
|
||||
*
|
||||
* Pre-empting means ABORTING: the interrupted cycle emits a `result` of its
|
||||
* own and the steered message runs as a second one, so the turn is marked
|
||||
* (`Turn.steeredEchoes`) to settle at the SDK's `idle` instead of that result.
|
||||
*
|
||||
* When the session is idle, the opt-in path returns `promptRequired` WITHOUT
|
||||
* calling `prompt()`, pushing SDK input, or mutating `turnQueue`: the content
|
||||
* stays Host-owned so the Host can submit it through a standard
|
||||
@@ -1904,7 +1934,7 @@ export class ClaudeAcpAgent {
|
||||
// which is exactly the window in which steering is meaningful. This check
|
||||
// and the active-path push below stay in one synchronous section so the
|
||||
// turn cannot settle in the gap between deciding to inject and enqueueing.
|
||||
const turnInFlight = (session.turnQueue ?? []).some((turn) => !turn.settled);
|
||||
const turnInFlight = (session.turnQueue ?? []).find((turn) => !turn.settled);
|
||||
if (!turnInFlight) {
|
||||
const promptRequest: PromptRequest = {
|
||||
sessionId,
|
||||
@@ -1929,10 +1959,23 @@ export class ClaudeAcpAgent {
|
||||
prompt: params.prompt,
|
||||
};
|
||||
const userMessage = promptToClaude(promptRequest);
|
||||
userMessage.uuid = randomUUID();
|
||||
const steeredUuid = randomUUID();
|
||||
userMessage.uuid = steeredUuid;
|
||||
// Deliver into the running turn rather than queuing behind it as a fresh
|
||||
// prompt would.
|
||||
userMessage.priority = STEER_PRIORITY;
|
||||
// Mark before the push and in the same synchronous section as the in-flight
|
||||
// check: the interrupt can have the CLI finalizing the aborted cycle by the
|
||||
// time the consumer next runs, and an unmarked result would settle the turn
|
||||
// (see Turn.steeredEchoes).
|
||||
(turnInFlight.steeredEchoes ??= new Set()).add(steeredUuid);
|
||||
// A turn already held for background subagents has a recorded outcome the
|
||||
// steer supersedes: move it into the steer lane so one lane owns settlement.
|
||||
// The idle handler re-applies the hold through the subagent gate.
|
||||
if (turnInFlight.deferredSettle !== undefined) {
|
||||
turnInFlight.steeredSettle = turnInFlight.deferredSettle;
|
||||
turnInFlight.deferredSettle = undefined;
|
||||
}
|
||||
session.input.push(userMessage);
|
||||
return { outcome: "injected" };
|
||||
}
|
||||
@@ -2263,6 +2306,14 @@ export class ClaudeAcpAgent {
|
||||
* calling settleActive directly bypasses the hold and re-opens the
|
||||
* out-of-turn permission deadlock (issue #866) through its lane. */
|
||||
const settleOrDefer = (outcome: PromptResponse) => {
|
||||
// No result ends a steered turn: the steer aborted the cycle this result
|
||||
// may belong to, and the steered one is still to come. Record the outcome
|
||||
// for the idle lane (see Turn.steeredEchoes); later cycles overwrite it,
|
||||
// so the last result and its usage win.
|
||||
if (isSteering(session.activeTurn)) {
|
||||
session.activeTurn.steeredSettle = outcome;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
session.activeTurn &&
|
||||
!session.activeTurn.settled &&
|
||||
@@ -2752,7 +2803,31 @@ export class ClaudeAcpAgent {
|
||||
// this lagged idle (no active turn to settle): the idle
|
||||
// still belongs to that settled turn, and skipping the
|
||||
// decrement would leak the debt permanently.
|
||||
// Deliberately BEFORE the steer lane below: an owed idle
|
||||
// belongs to an earlier turn, and reading it as the steered
|
||||
// sequence's turn-over signal would settle a turn whose
|
||||
// steered cycle is still running. Steered results owe none.
|
||||
session.owedTrailingIdles--;
|
||||
} else if (isSteering(session.activeTurn)) {
|
||||
// A steered turn settles here, not at a result: this idle is
|
||||
// the only signal spanning the interrupted and steered cycles
|
||||
// (see Turn.steeredEchoes). An idle before the steered echo,
|
||||
// or before any result was recorded, means the answer is
|
||||
// still ahead — swallow it, and never let it reach the #825
|
||||
// fail below, which would reject a prompt about to answer.
|
||||
//
|
||||
// Plain Turn so the fields can be cleared below; isSteering
|
||||
// narrows steeredEchoes to non-optional.
|
||||
const steered: Turn = session.activeTurn;
|
||||
if (steered.steeredEchoes?.size === 0 && steered.steeredSettle !== undefined) {
|
||||
// Via the subagent gate, not settleActive: a steered turn
|
||||
// can also have spawned background subagents, which own it
|
||||
// from here (settles now if none is live, holds otherwise).
|
||||
steered.deferredSettle = steered.steeredSettle;
|
||||
steered.steeredEchoes = undefined;
|
||||
steered.steeredSettle = undefined;
|
||||
settleDeferredIfDrained();
|
||||
}
|
||||
} else if (
|
||||
!session.cancelled &&
|
||||
session.activeTurn &&
|
||||
@@ -3201,7 +3276,16 @@ export class ClaudeAcpAgent {
|
||||
// turn's OWN result — a followup result arriving inside the
|
||||
// cancel window still gets its own trailer and must be counted,
|
||||
// or that idle would later false-fail the next prompt.
|
||||
if (isAutonomousResult || !session.cancelled || !session.activeTurn) {
|
||||
// A second exclusion: a steered turn's own results (see
|
||||
// Turn.steeredEchoes). One idle covers the whole interrupted +
|
||||
// steered sequence and that idle settles the turn, so counting
|
||||
// either result would leave a debt that swallows it. Autonomous
|
||||
// results inside a steered turn keep their own trailers.
|
||||
const owesTrailingIdle = isAutonomousResult || !isSteering(session.activeTurn);
|
||||
if (
|
||||
owesTrailingIdle &&
|
||||
(isAutonomousResult || !session.cancelled || !session.activeTurn)
|
||||
) {
|
||||
session.owedTrailingIdles++;
|
||||
}
|
||||
|
||||
@@ -3654,6 +3738,23 @@ export class ClaudeAcpAgent {
|
||||
// either. Its trailing-idle debt stands and is absorbed
|
||||
// when the drain idle eventually arrives.
|
||||
settleActive(session.activeTurn.deferredSettle);
|
||||
} else if (
|
||||
isSteering(session.activeTurn) &&
|
||||
session.activeTurn.steeredSettle !== undefined
|
||||
) {
|
||||
// Same for a turn with an open steer (see
|
||||
// Turn.steeredEchoes): the user moving on outranks the
|
||||
// steered continuation, but its last result's stop reason
|
||||
// stands. With no result yet it falls through to the
|
||||
// end_turn hand-off below, owing nothing there.
|
||||
//
|
||||
// The steer lane normally pays for that result's trailing
|
||||
// idle by settling on it; this hand-off settles instead,
|
||||
// so count it here or it arrives un-owed against the fresh
|
||||
// turn and false-fails it (issue #825). Harmless if it
|
||||
// never comes: the debt absorbs one future idle.
|
||||
session.owedTrailingIdles++;
|
||||
settleActive(session.activeTurn.steeredSettle);
|
||||
} else {
|
||||
settleActive({ stopReason: "end_turn", usage: sessionUsage(session) });
|
||||
}
|
||||
@@ -3670,6 +3771,13 @@ export class ClaudeAcpAgent {
|
||||
break;
|
||||
}
|
||||
if ("isReplay" in message && message.isReplay) {
|
||||
// A steered message's echo matches no turn (steer() creates
|
||||
// none), but it marks the steered cycle as running — how the idle
|
||||
// lane tells "the answer is still ahead" from "the turn is over"
|
||||
// (see Turn.steeredEchoes).
|
||||
if (isSteering(session.activeTurn)) {
|
||||
session.activeTurn.steeredEchoes.delete(message.uuid);
|
||||
}
|
||||
// Unrelated replay (e.g. the echo of an already-settled turn).
|
||||
break;
|
||||
}
|
||||
@@ -4147,6 +4255,16 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
// A steered active turn (see Turn.steeredEchoes) settles "cancelled" in the
|
||||
// consumer at the interrupt's trailing idle, like any live turn. But the
|
||||
// steer lane pays for its last result's trailer by settling on it, which
|
||||
// this cancel pre-empts, so count that trailer here or it arrives un-owed
|
||||
// against the next prompt and false-fails it (issue #825). Over-counting
|
||||
// when only one trailer comes absorbs a future idle.
|
||||
if (isSteering(session.activeTurn) && session.activeTurn.steeredSettle !== undefined) {
|
||||
session.owedTrailingIdles++;
|
||||
}
|
||||
|
||||
// Arm a backstop before interrupting: if a turn is actively consuming the
|
||||
// query and interrupt() doesn't make the SDK yield (e.g. a wedged TaskOutput
|
||||
// block — issue #680), force the consumer to settle the active turn
|
||||
|
||||
Reference in New Issue
Block a user