Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/vs/platform/agentHost/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ Customization discovery (agents, skills, instructions and workspace MCP configur

Steering received during turn preparation or configuration refresh is buffered for the addressed chat. A dispatched turn confirmed running by root SDK events accepts and flushes steering immediately, even if its admission RPC has not returned; successful admission also flushes it. Preparation or send failure retains buffered steering for a successful retry, rather than dispatching against an unready runtime. Pending updates replace or remove buffered steering; abort clears it and emits `steering_consumed` to clear the pending UI. The live SDK-session wrapper takes ownership synchronously before MCP reconciliation, so abort and disposal can clear steering throughout delivery. Chat disposal and host shutdown discard buffered delivery, and pending-message updates are ignored once host shutdown starts.

AHP stores steering and queued messages together in `queuedMessages`, distinguished by `PendingMessage.kind`. Steering entries use `IAgent.sendSteeringMessage` individually, bypassing Copilot's replaceable legacy-slot buffer. The host retries deferred handoff on provider progress and correlates consumption by message ID. Cancellation clears every pending steering entry, including prompts handed off but not yet consumed by the SDK, without draining queued follow-ups.
AHP stores steering and queued messages together in `queuedMessages`, distinguished by `PendingMessage.kind`. Steering entries use `IAgent.sendSteeringMessage` individually, bypassing Copilot's replaceable legacy-slot buffer. The host retries deferred handoff on provider progress and correlates consumption by message ID. Cancellation clears every pending steering entry, including prompts handed off but not yet consumed by the SDK, without draining queued follow-ups. A client may remove handed-off steering during a turn only when the provider implements `IAgent.withdrawSteeringMessage`; Copilot then withdraws it from the SDK with `rpc.queue.withdrawMessage`, or keeps it as its own turn if the model already received it.

`CopilotSessionLauncher` sets `mcpOAuthTokenStorage: 'in-memory'` for created, resumed, and ephemeral SDK sessions. VS Code owns durable MCP credentials through `onMcpAuthRequest`; the runtime must not consult its persistent MCP OAuth keychain store.

Expand Down
7 changes: 7 additions & 0 deletions src/vs/platform/agentHost/common/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,13 @@ export interface IAgent {
/** Hands off one independent submission without replacing the legacy slot; false defers until ready. */
sendSteeringMessage?(chat: URI, message: PendingMessage, sender?: IAgentPendingMessageSender): boolean | void;

/**
* Withdraws a steering message, previously handed off through {@link sendSteeringMessage}, that a
* client removed before the model received it. A message the model already received stays in its
* turn. Without this hook, the host rejects removing steering while a turn is active.
*/
withdrawSteeringMessage?(chat: URI, messageId: string): void;

/** Optional history mutation for providers with a native truncation operation. */
truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise<void>;

Expand Down
3 changes: 2 additions & 1 deletion src/vs/platform/agentHost/node/agentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11098,9 +11098,10 @@ export class AgentService extends Disposable implements IAgentService {
this._stateManager.rejectClientAction(channel, action, origin, 'The session provider does not support steering messages.');
return;
}
// Steering handed to the provider cannot be edited, and can be removed only if the provider can withdraw it.
if (state?.activeTurn && existing?.kind === PendingMessageKind.Steering
&& (action.type === ActionType.ChatPendingMessageRemoved
? action.kind === PendingMessageKind.Steering
? action.kind === PendingMessageKind.Steering && !this._providerService.getProviderForSession(sessionChannel)?.withdrawSteeringMessage
: action.kind !== existing.kind || !equals(existing.message, action.message))) {
this._stateManager.rejectClientAction(channel, action, origin, 'Cannot change a steering message while it is being processed.');
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat
...(observed.credentials ? { credentials: observed.credentials } : {}),
}, undefined);
}
if (action.type === ActionType.ChatPendingMessageRemoved && action.kind === PendingMessageKind.Steering) {
this._withdrawSubmittedSteering(observed.channel, action.id);
}
if (this._handlePendingMessageAction(observed.channel, action)) {
this._tryConsumeNextQueuedMessage(observed.channel);
}
Expand Down Expand Up @@ -158,6 +161,16 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat
&& getPendingSteeringMessages(state).some(message => message.id === action.id);
}

/** A client removed steering that was already handed to the provider, which would otherwise still deliver it. */
private _withdrawSubmittedSteering(channel: ProtocolURI, id: string): void {
if (!this._context.memento(SubmittedSteering, channel).get().has(id)) {
return;
}
const session = this._chatOwnership.getChatOwner(channel);
const provider = session !== undefined ? this._providerService.getProviderForSession(session) : undefined;
provider?.withdrawSteeringMessage?.(URI.parse(channel), id);
}

private _syncPendingMessages(channel: ProtocolURI): void {
const state = this._stateManager.getSessionState(channel);
if (!state) {
Expand Down
4 changes: 4 additions & 0 deletions src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6894,6 +6894,10 @@ export class CopilotAgent extends Disposable implements IAgent {
return true;
}

withdrawSteeringMessage(chat: URI, messageId: string): void {
void this._findChatByUri(chat)?.withdrawSteering(messageId);
}

private async _getChatMessages(chat: URI, sessionOrContext: URI | IAgentChatContext): Promise<readonly Turn[]> {
if (this._isShuttingDown) {
return [];
Expand Down
90 changes: 75 additions & 15 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,10 @@ class CopilotTurn extends Disposable {
interface IPendingSteering {
readonly pendingMessage: PendingMessage;
readonly sender: IAgentPendingMessageSender | undefined;
/** Set once the message is handed to the SDK; identifies it for withdrawal. */
sdkSend?: { readonly prompt: string; readonly messageId: Promise<string> };
/** The SDK's id for the message once `send` returns; its `user.message` echo carries the same id. */
sdkMessageId?: string;
}

/** Where a transient chat activity comes from; see `CopilotAgentSession._publishActivity` for the precedence. */
Expand Down Expand Up @@ -2309,20 +2313,34 @@ export class CopilotAgentSession extends Disposable {
}

/**
* Pops the buffered steering message whose text is contained in the SDK
* `user.message` content we just observed. Matching by content (rather
* than just popping FIFO) keeps us robust against the SDK reordering
* or coalescing entries — concurrent steering messages with different
* texts are still matched to the correct one. Returns `undefined` if
* no buffered entry matches; the caller treats the `user.message` as
* an ordinary echo and skips the turn flip.
* Pops the buffered steering message delivered by the SDK `user.message`
* we just observed. The echo carries the id `send` returned, so an exact
* id match wins; that keeps identical texts apart, e.g. one being
* withdrawn while the other is delivered. Otherwise matches by content,
* skipping entries the SDK already identified as a different message.
* Matching by content (rather than just popping FIFO) keeps us robust
* against the SDK reordering or coalescing entries — concurrent steering
* messages with different texts are still matched to the correct one.
* Returns `undefined` if no buffered entry matches; the caller treats the
* `user.message` as an ordinary echo and skips the turn flip.
*/
private _takeMatchingPendingSteering(content: string): IPendingSteering | undefined {
private _takeMatchingPendingSteering(content: string, messageId: string | undefined): IPendingSteering | undefined {
if (this._pendingSteeringFlips.size === 0) {
return undefined;
}
if (messageId) {
for (const [id, pending] of this._pendingSteeringFlips) {
if (pending.sdkMessageId === messageId) {
this._pendingSteeringFlips.delete(id);
return pending;
}
}
}
let substringMatch: [string, IPendingSteering] | undefined;
for (const [id, pending] of this._pendingSteeringFlips) {
if (messageId && pending.sdkMessageId) {
continue;
}
if (pending.pendingMessage.message.text === content) {
this._pendingSteeringFlips.delete(id);
return pending;
Expand Down Expand Up @@ -5590,8 +5608,9 @@ export class CopilotAgentSession extends Disposable {
}
const steeringTurn = this._currentTurn.value;
const abortToken = this._abortToken;
const pending: IPendingSteering = { pendingMessage: steeringMessage, sender };
this._steeringMessagesInFlight.add(steeringMessage.id);
this._pendingSteeringFlips.set(steeringMessage.id, { pendingMessage: steeringMessage, sender });
this._pendingSteeringFlips.set(steeringMessage.id, pending);
this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.message.text.substring(0, 100)}"`);
try {
await this._reconcileMcpServerEnablement();
Expand All @@ -5606,15 +5625,25 @@ export class CopilotAgentSession extends Disposable {
: steeringMessage.message.text;
const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString());
const execution = await this._executeSdkOperation(
() => this._otelService.withTraceContext(traceContext, () => this._wrapper.session.send({
prompt: steeringPrompt,
attachments: sdkAttachments?.length ? sdkAttachments : undefined,
mode: 'immediate',
})),
async () => {
if (this._pendingSteeringFlips.get(steeringMessage.id) !== pending) {
this._logService.info(`[Copilot:${this.sessionId}] Steering message was withdrawn or cleared before it was sent: id=${steeringMessage.id}`);
return undefined;
}
const messageId = this._otelService.withTraceContext(traceContext, () => this._wrapper.session.send({
prompt: steeringPrompt,
attachments: sdkAttachments?.length ? sdkAttachments : undefined,
mode: 'immediate',
}));
pending.sdkSend = { prompt: steeringPrompt, messageId };
return messageId;
},
steeringTurn, abortToken,
);
if (execution.kind === 'skipped' && this._pendingSteeringFlips.delete(steeringMessage.id)) {
this._fireSteeringConsumed(steeringMessage.id);
} else if (execution.kind === 'executed' && execution.value !== undefined) {
pending.sdkMessageId = execution.value;
}
} catch (err) {
this._logService.error(`[Copilot:${this.sessionId}] Steering message failed`, err);
Expand All @@ -5626,6 +5655,37 @@ export class CopilotAgentSession extends Disposable {
}
}

/**
* Withdraws a steering message the user removed. A message still being prepared is never sent; one
* the SDK holds is withdrawn from its steering queue. If the SDK already delivered it to the model,
* the entry is kept so its `user.message` echo still shows it as its own turn.
*/
async withdrawSteering(id: string): Promise<void> {
const pending = this._pendingSteeringFlips.get(id);
if (!pending) {
return;
}
if (!pending.sdkSend) {
this._pendingSteeringFlips.delete(id);
this._logService.info(`[Copilot:${this.sessionId}] Withdrew steering message before sending it: id=${id}`);
return;
}
try {
const messageId = await pending.sdkSend.messageId;
const result = await this._wrapper.session.rpc.queue.withdrawMessage({ messageId, expectedPrompt: pending.sdkSend.prompt });
Comment thread
karthiknadig marked this conversation as resolved.
if (!result.removed) {
this._logService.info(`[Copilot:${this.sessionId}] Steering message was already delivered and could not be withdrawn: id=${id}`);
return;
}
if (this._pendingSteeringFlips.get(id) === pending) {
this._pendingSteeringFlips.delete(id);
}
this._logService.info(`[Copilot:${this.sessionId}] Withdrew steering message: id=${id}`);
} catch (err) {
this._logService.warn(`[Copilot:${this.sessionId}] Failed to withdraw steering message: id=${id}: ${getErrorMessage(err)}`);
}
}

async getMessages(): Promise<readonly Turn[]> {
const result = await this._getMappedEvents();
return result.turns;
Expand Down Expand Up @@ -7850,7 +7910,7 @@ export class CopilotAgentSession extends Disposable {
this._currentTurn.value?.markRunning();
// The message is admitted only after required plugins are prepared.
this._setManagedPluginActivity(undefined);
const steering = this._takeMatchingPendingSteering(e.data.content);
const steering = this._takeMatchingPendingSteering(e.data.content, e.data.messageId);
if (steering) {
this._beginSteeringTurn(steering);
if (e.data.interactionId) {
Expand Down
35 changes: 35 additions & 0 deletions src/vs/platform/agentHost/test/node/agentService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8322,6 +8322,41 @@ suite('AgentService (node dispatcher)', () => {
});
});

test('a user can remove a steering message mid-turn when the provider can withdraw it', async () => {
const { svc, agent, session } = await setup();
const withdrawn: string[] = [];
agent.withdrawSteeringMessage = (_chat, id) => { withdrawn.push(id); };
await dispatchTurnAndWait(svc, agent, session, []);
const chat = buildDefaultChatUri(session);
const accepted = Event.toPromise(Event.filter(svc.onDidAction, envelope =>
envelope.action.type === ActionType.ChatPendingMessageSet && envelope.action.id === 'steer-a'));
await svc.dispatchAction(chat, {
type: ActionType.ChatPendingMessageSet,
kind: PendingMessageKind.Steering, id: 'steer-a', message: { text: 'Use tabs, not spaces', origin: { kind: MessageKind.User } },
}, 'test-client', 2);
await accepted;
const rejections: string[] = [];
disposables.add(svc.onDidAction(envelope => {
if (envelope.rejectionReason) {
rejections.push(envelope.rejectionReason);
}
}));
const removed = Event.toPromise(Event.filter(svc.onDidAction, envelope => envelope.origin?.clientSeq === 3));
svc.dispatchAction(chat, { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Steering, id: 'steer-a' }, 'test-client', 3);
await removed;
assert.deepStrictEqual({
handedToAgent: agent.setPendingMessagesCalls.map(call => call.steeringMessage?.id),
stillQueued: getStateManager(svc).getChatState(chat)?.queuedMessages?.map(message => message.message.text),
rejections,
withdrawn,
}, {
handedToAgent: ['steer-a'],
stillQueued: undefined,
rejections: [],
withdrawn: ['steer-a'],
});
});

test('snapshots embedded text attachments as text files without retaining the payload in state', async () => {
const { svc, agent, session, attachmentsRoot } = await setup();
const metadata = { kind: 'paste' };
Expand Down
35 changes: 34 additions & 1 deletion src/vs/platform/agentHost/test/node/chatContributions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1109,7 +1109,7 @@ function createQueueDrainContributions(disposables: ReturnType<typeof ensureNoDi
disposables.add(service.registerContribution(LocalCommandContribution as unknown as IConstructorSignature<IAgentHostChatContribution, [IAgentHostChatContributionContext]> & { readonly id: string }));
disposables.add(service.registerContribution(SessionWorkspaceConversionContribution as unknown as IConstructorSignature<IAgentHostChatContribution, [IAgentHostChatContributionContext]> & { readonly id: string }));
disposables.add(service.registerContribution(QueueDrainContribution as unknown as IConstructorSignature<IAgentHostChatContribution, [IAgentHostChatContributionContext]> & { readonly id: string }));
return { service, stateManager, session, chat, pendingMessages, pendingMessageSenders, admitted, admittedCredentials, titleController, telemetryService, clearAgent: () => agent = undefined, setConversionPending: (pending: boolean) => conversionPending = pending, setSteeringReady: (ready: boolean) => steeringReady = ready };
return { service, stateManager, session, chat, agent: mockAgent, pendingMessages, pendingMessageSenders, admitted, admittedCredentials, titleController, telemetryService, clearAgent: () => agent = undefined, setConversionPending: (pending: boolean) => conversionPending = pending, setSteeringReady: (ready: boolean) => steeringReady = ready };
}

function appliedClientAction(channel: string, session: string, action: IAppliedClientAction['action'], clientId = 'client'): IAppliedClientAction {
Expand Down Expand Up @@ -1787,6 +1787,39 @@ suite('AgentHostChatContributions', () => {
}, { delivered: ['a', 'b'], senders: ['client-a', 'client-b'], pending: ['b'] });
});

test('queue drain withdraws steering the user removes mid-turn, but not steering the agent consumed', () => {
const queue = createQueueDrainContributions(disposables);
const withdrawn: string[] = [];
queue.agent.withdrawSteeringMessage = (_chat, id) => { withdrawn.push(id); };
queue.stateManager.dispatchServerAction(queue.chat, {
type: ActionType.ChatTurnStarted, turnId: 'active-turn',
startedAt: '2026-10-17T00:00:00.000Z',
message: { text: 'Working', origin: { kind: MessageKind.User } },
});
for (const id of ['removed-by-user', 'consumed-by-agent']) {
const steer: IAppliedClientAction['action'] = {
type: ActionType.ChatPendingMessageSet,
kind: PendingMessageKind.Steering, id, message: { text: id, origin: { kind: MessageKind.User } },
};
queue.stateManager.dispatchServerAction(queue.chat, steer);
queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, steer));
}
const userRemoval: IAppliedClientAction['action'] = { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Steering, id: 'removed-by-user' };
queue.stateManager.dispatchServerAction(queue.chat, userRemoval);
queue.service.didApplyClientAction(appliedClientAction(queue.chat, queue.session, userRemoval));
const agentCleanup: IDispatchedAction['action'] = { type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Steering, id: 'consumed-by-agent' };
queue.stateManager.dispatchServerAction(queue.chat, agentCleanup);
queue.service.didDispatchAction(dispatchedAction(queue.chat, queue.session, agentCleanup));

assert.deepStrictEqual({
handedToAgent: queue.pendingMessages.map(message => message?.id),
withdrawn,
}, {
handedToAgent: ['removed-by-user', 'consumed-by-agent'],
withdrawn: ['removed-by-user'],
});
});

test('queue drain holds independent steering until a turn starts', () => {
const queue = createQueueDrainContributions(disposables);
const steer: IDispatchedAction['action'] = {
Expand Down
Loading
Loading