Skip to content

Ignore a steering message that is re-delivered while still in flight - #333919

Open
Ryan Ewen (RyanEwen) wants to merge 2 commits into
microsoft:mainfrom
RyanEwen:fix/claude-steering-idempotent-injection
Open

Ignore a steering message that is re-delivered while still in flight#333919
Ryan Ewen (RyanEwen) wants to merge 2 commits into
microsoft:mainfrom
RyanEwen:fix/claude-steering-idempotent-injection

Conversation

@RyanEwen

Copy link
Copy Markdown
Contributor

IAgent.setPendingMessages is a state sync rather than a command. QueueDrainContribution._syncPendingMessages calls it with whatever state.steeringMessage currently is, on every ChatPendingMessageSet, on every ChatPendingMessageRemoved of any kind, and on ChatQueuedMessagesReordered. So while a steering message is pending, unrelated pending-message activity on that chat re-delivers the same PendingMessage.id to the provider.

ClaudeSdkPipeline.injectSteering queued it every time it arrived. The effect is that a single steering message can be yielded to the model twice, under two different turns, and the second copy can leave a turn open that no work will ever fill.

Observed in a dev container: the same id enqueued at two points about two and a half minutes apart, followed by a chat/turnStarted for that id with no chat/turnComplete and no deltas after it.

The other two providers already guard

This is not a new idea, and Codex names the cause exactly:

// node/codex/codexAgent.ts
// `_syncPendingMessages` re-sends the current steering message on every
// pending-state change; ignore a steering message already in flight.
if (session.pendingSteeringFlips.has(steeringMessage.id)) {
	return;
}

Copilot does the same in copilotAgentSession.sendSteering. The Claude path had no equivalent, so this brings it to parity rather than introducing a new mechanism.

What this changes

ClaudeSdkPipeline tracks the steering ids it has accepted into the queue and not yet seen consumed, and ignores a repeat of one already in flight. The set is cleared when the queue reports the steer consumed, and on abort, since an aborted queue consumes nothing.

Clearing on consumption rather than only on abort matters: it keeps a genuinely new steer with a recycled id working, and it keeps the set bounded.

Related: #333174 groups this with the other provider-parity gaps.

AI disclosure: this pull request and the related code were written with the assistance of AI.

`setPendingMessages` is a state sync rather than a command. The queue drain
contribution re-delivers the current steering message to the provider on every
pending-message action, so the same `PendingMessage.id` arrives repeatedly
until it is consumed. `ClaudeSdkPipeline.injectSteering` queued it each time,
so a single steering message could reach the model twice, and the second copy
could open a turn that no work would ever fill.

Track the ids accepted into the queue and not yet consumed, and ignore a
repeat. The set is cleared when the queue reports the steer consumed, and on
abort, since an aborted queue consumes nothing.

Codex and Copilot already guard this way; this brings the Claude path to
parity with them.
Copilot AI balanced review requested due to automatic review settings September 2, 2026 00:28
@vs-code-engineering

Copy link
Copy Markdown
Contributor

📬 CODENOTIFY

The following users are being notified based on files changed in this PR:

TylerLeonhardt

Matched files:

  • src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Crash cleanup can retain steering IDs indefinitely, and the deduplication behavior lacks regression coverage.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Prevents duplicate Claude steering messages during pending-state synchronization.

Changes:

  • Tracks steering IDs until consumed.
  • Clears tracking on consumption or abort.
File summaries
File Description
claudeSdkPipeline.ts Adds steering-message deduplication and lifecycle cleanup.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


private _wireAbortHandler(controller: AbortController): void {
controller.signal.addEventListener('abort', () => {
this._steeringInFlight.clear();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Fixed in 84870a0: _failQueue fails the queue and clears the in-flight set together, and the aborted-rebind and fatal-stream paths both go through it, so those ids are released without an abort.

AI disclosure: this comment and the related code were written with the assistance of AI.

Comment on lines +473 to +475
if (this._steeringInFlight.has(pendingMessageId)) {
this._logService.trace(`[Claude:${this.sessionId}] injectSteering: already in flight id=${pendingMessageId}`);
return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 84870a0: a steering message re-delivered before it is consumed is only queued once delivers the same id three times before consumption and asserts exactly one priority: 'now' prompt reaches the SDK. It fails without the guard. A second test covers the same id being accepted again once the first delivery is consumed.

AI disclosure: this comment and the related code were written with the assistance of AI.

The guard released its tracked ids only when the abort controller fired, but
two paths fail the queue without aborting it: an aborted rebind, and a fatal
stream error in `_processMessages`. A steer whose id was recorded and never
consumed stayed recorded, so every later delivery of that still-pending
message was ignored. The message was silently dropped rather than duplicated,
which is worse than the problem the guard exists to solve.

Fail the queue through one helper that clears the tracking with it, so every
failure path releases the ids.

Adds two regression tests: the same id delivered repeatedly before it is
consumed reaches the SDK once, and the same id is accepted again once the
first delivery has been consumed.
@RyanEwen

Copy link
Copy Markdown
Contributor Author

Both points were correct and are fixed in 84870a0.

The retained-ids path

Correct, and the consequence is worse than the bug the guard was added for. Three paths fail the queue and only one aborts the controller first:

site aborts first ids released before
abort() yes yes
aborted rebind no no
fatal stream error in _processMessages no no

So after a crash or a failed rebind, a steer that was recorded and never consumed stayed recorded, and every later delivery of that still-pending message was ignored. The message was silently dropped rather than duplicated.

Fixed by routing all three through one helper that fails the queue and clears the tracking together:

private _failQueue(error: Error): void {
    this._queue.failAll(error);
    this._steeringInFlight.clear();
}

That leaves a single choke point, which is the same shape #330785 already uses for its own steering state.

The missing coverage

Also correct; there was none. Two tests added, both passing (290 passing, 0 failing in claudeAgent.test.ts):

  • a steering message re-delivered before it is consumed is only queued once delivers the same id three times before consumption and asserts exactly one priority: 'now' prompt reaches the SDK. It fails without the guard.
  • a steering id is accepted again once the first delivery has been consumed covers the reset semantics, so a later message carrying a reused id is not swallowed.

One gap worth stating rather than hiding: I could not build a reliable test for acceptance after a failure specifically. Driving abort-then-rebind through the existing harness, the second steer never reaches a live parent in the rebuilt queue, so the test failed for reasons unrelated to the guard. Instrumenting it showed the rebind happening and the second turn starting, with no now prompt queued against it. That looks like a sequencing limitation of the test harness rather than product behaviour, and I did not want to ship a test whose failure mode I could not explain. The failure path is covered by inspection through the single _failQueue choke point.

AI disclosure: this comment and the related code were written with the assistance of AI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants