Skip to content
Draft
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
8 changes: 7 additions & 1 deletion src/vs/platform/agentHost/common/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,12 @@ export interface IAgentChatAdoptionResult {
readonly reason?: AgentChatAdoptionReason;
}

/** Identifies the client that submitted a pending message. */
export interface IAgentPendingMessageSender {
Comment thread
roblourens marked this conversation as resolved.
readonly clientId: string | undefined;
readonly clientContext: IAgentHostClientTelemetryContext;
}

/**
* Implemented by each agent backend (e.g. Copilot SDK).
* The {@link IAgentService} dispatches to the appropriate agent based on
Expand Down Expand Up @@ -1215,7 +1221,7 @@ export interface IAgent {
materializeChat(chat: URI, context: URI | IAgentChatContext, providerData: string | undefined): Promise<IAgentCreateChatResult | void>;

/** Optional steering hook for providers that can accept messages during an active turn. */
setPendingMessages?(chat: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void;
setPendingMessages?(chat: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], steeringSender?: IAgentPendingMessageSender): void;

/** Optional history mutation for providers with a native truncation operation. */
truncateChat?(chat: URI, turnId: string | undefined, context?: URI | IAgentChatContext): Promise<void>;
Expand Down
15 changes: 11 additions & 4 deletions src/vs/platform/agentHost/node/agentSideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,9 @@ export class AgentSideEffects extends Disposable {
this._notifyClientToolCallComplete(sessionChannel, envelope.channel, action.toolCallId, action.result, 'server-envelope');
}
}
if (!envelope.origin && envelope.action.type === ActionType.SessionActiveClientRemoved) {
this._removeActiveClient(envelope.channel, envelope.action.clientId);
}
// A chat joining the catalog changes the session's authoritative
// membership, so every already-contributing client is re-fanned-out
// over the new set. Handled here (not `handleAction`) because every
Expand Down Expand Up @@ -419,6 +422,13 @@ export class AgentSideEffects extends Disposable {
}
}

private _removeActiveClient(session: ProtocolURI, clientId: string): void {
const agent = this._options.getAgent(session);
for (const chat of getSessionChatsForFanOut(this._stateManager, session) ?? []) {
agent?.removeActiveClient(chat, this._chatContext(session, chat.toString()), clientId);
}
}

/**
* Publishes agent descriptors using the last known model lists.
*/
Expand Down Expand Up @@ -1572,10 +1582,7 @@ export class AgentSideEffects extends Disposable {
break;
}
case ActionType.SessionActiveClientRemoved: {
const agent = this._options.getAgent(channel);
for (const chat of getSessionChatsForFanOut(this._stateManager, channel) ?? []) {
agent?.removeActiveClient(chat, this._chatContext(channel, chat.toString()), action.clientId);
}
this._removeActiveClient(channel, action.clientId);
break;
}
case ActionType.RootConfigChanged: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { URI } from '../../../../../base/common/uri.js';
import { generateUuid } from '../../../../../base/common/uuid.js';
import { IInstantiationService } from '../../../../instantiation/common/instantiation.js';
import { ILogService } from '../../../../log/common/log.js';
import type { IAgentPendingMessageSender } from '../../../common/agent.js';
import { AgentHostClientType } from '../../../common/agentHostClientInfo.js';
import { createUnknownAgentHostClientTelemetryContext } from '../../../common/agentHostTelemetry.js';
import { IAgentHostChatContributions, createChatMementoKey, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IAppliedClientAction, type IQueuedMessageSender, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js';
Expand All @@ -20,6 +21,7 @@ import { startTurn } from '../../agentHostTurnStarter.js';
import { ISessionWorkspaceConversionService } from '../sessionWorkspaceConversion/sessionWorkspaceConversionService.js';

const QueuedSender = createChatMementoKey<IQueuedMessageSender | undefined, [messageId: string]>('queueDrain.sender', () => undefined);
const SteeringSender = createChatMementoKey<{ readonly messageId: string; readonly sender: IAgentPendingMessageSender } | undefined>('queueDrain.steeringSender', () => undefined);

/** Owns queued-message sender state and decides when a queued turn can be admitted. */
export class QueueDrainContribution extends Disposable implements IAgentHostChatContribution {
Expand Down Expand Up @@ -53,19 +55,32 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat
const action = observed.action;
switch (action.type) {
case ActionType.ChatPendingMessageSet: {
const queuedMessageExists = this._stateManager.getChatState(observed.channel)?.queuedMessages?.some(message => message.id === action.id) === true;
if (action.kind === PendingMessageKind.Queued && queuedMessageExists) {
const state = this._stateManager.getChatState(observed.channel);
if (action.kind === PendingMessageKind.Queued && state?.queuedMessages?.some(message => message.id === action.id) === true) {
this._context.memento(QueuedSender, observed.channel, action.id).set({
clientId: observed.clientId,
clientContext: observed.clientContext,
}, undefined);
} else if (action.kind === PendingMessageKind.Steering && state?.steeringMessage?.id === action.id) {
this._context.memento(SteeringSender, observed.channel).set({
messageId: action.id,
sender: {
clientId: observed.clientId,
clientContext: observed.clientContext,
},
}, undefined);
}
this._syncPendingMessages(observed.channel);
break;
}
case ActionType.ChatPendingMessageRemoved: {
if (action.kind === PendingMessageKind.Queued) {
this._context.deleteMemento(QueuedSender, observed.channel, action.id);
} else {
const steeringSender = this._context.memento(SteeringSender, observed.channel);
if (steeringSender.get()?.messageId === action.id) {
steeringSender.set(undefined, undefined);
}
}
this._syncPendingMessages(observed.channel);
break;
Expand All @@ -86,7 +101,13 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat
return;
}
const session = parseRequiredSessionUriFromChatUri(channel);
this._providerService.getProviderForSession(session)?.setPendingMessages?.(URI.parse(channel), state.steeringMessage, []);
const steeringSender = this._context.memento(SteeringSender, channel).get();
this._providerService.getProviderForSession(session)?.setPendingMessages?.(
URI.parse(channel),
state.steeringMessage,
[],
steeringSender && steeringSender.messageId === state.steeringMessage?.id ? steeringSender.sender : undefined,
);
this._tryConsumeNextQueuedMessage(channel);
}

Expand Down
6 changes: 3 additions & 3 deletions src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliCo
import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, agentHostProxyConfigSchema, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js';
import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js';
import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js';
import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatAdoptionResult, type IAgentAdoptedWorktree, IAgentChatConfigCompletionsParams, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentLegacyChat, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentDiscoveredChat, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentKnownSessionsFilter, IAgentMaterializeChatEvent, IAgentModelInfo, type IAgentPendingMessageSender, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveAgentHostInstructions, resolveSubagentChatParent, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js';
import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js';
import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js';
import { isAutoModel } from './modelIdentifiers.js';
Expand Down Expand Up @@ -4333,7 +4333,7 @@ export class CopilotAgent extends Disposable implements IAgent {
};
}

setPendingMessages(chat: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void {
setPendingMessages(chat: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[], steeringSender?: IAgentPendingMessageSender): void {
const backing = this._chatBackings.get(chat.toString());
const target = backing ? this._findSessionBySdkId(backing.sdkSessionId) : undefined;
if (!target) {
Expand All @@ -4343,7 +4343,7 @@ export class CopilotAgent extends Disposable implements IAgent {

// Steering: send with mode 'immediate' so the SDK injects it mid-turn
if (steeringMessage) {
target.sendSteering(steeringMessage);
target.sendSteering(steeringMessage, steeringSender);
}

// Queued messages are consumed by the server (AgentSideEffects)
Expand Down
41 changes: 23 additions & 18 deletions src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { getSessionSandboxOverrides } from '../sessionSandbox.js';
import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js';
import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyAnswer, AgentHostAutoReplyEnabledConfigKey, AgentHostDisableRepoInfoTelemetryConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js';
import { createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js';
import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js';
import { AgentSession, AgentSignal, AgentWorkingDirectoryChangedError, AuthenticateParams, IMcpNotification, type AgentSubagentTaskModelSource, type AgentTurnProviderCallState, type IAgentPendingMessageSender, type IAgentToolPendingConfirmationSignal, type IAgentTurnDiagnosticSnapshot, type IAgentTurnTokenUsage } from '../../common/agent.js';
import { isReasoningEffortLevel } from '../../common/reasoningEffort.js';
import { ObservedTokenUsage } from './observedTokenUsage.js';
import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js';
Expand Down Expand Up @@ -796,6 +796,11 @@ class CopilotTurn extends Disposable {
}
}

interface IPendingSteering {
readonly pendingMessage: PendingMessage;
readonly sender: IAgentPendingMessageSender | undefined;
}

/**
* Encapsulates a single Copilot SDK session and all its associated bookkeeping.
*
Expand Down Expand Up @@ -1099,7 +1104,7 @@ export class CopilotAgentSession extends Disposable {
* `steering_consumed` signals so the chat UI's pending state still
* clears in cleanup paths where we never observe the echo.
*/
private readonly _pendingSteeringFlips = new Map<string, PendingMessage>();
private readonly _pendingSteeringFlips = new Map<string, IPendingSteering>();

/** Snapshot captured at session creation for refresh detection. */
private readonly _appliedSnapshot: IActiveClientSnapshot;
Expand Down Expand Up @@ -1425,15 +1430,15 @@ export class CopilotAgentSession extends Disposable {
* handler) can associate the SDK event id with the steering turn for
* history.truncate / sessions.fork mapping.
*/
private _beginSteeringTurn(steering: PendingMessage): string {
private _beginSteeringTurn(steering: IPendingSteering): string {
this._completeActiveTurn();
const newTurnId = generateUuid();
this._emitAction({
type: ActionType.ChatTurnStarted,
turnId: newTurnId,
startedAt: new Date().toISOString(),
message: steering.message,
queuedMessageId: steering.id,
message: steering.pendingMessage.message,
queuedMessageId: steering.pendingMessage.id,
});
// Mirror `resetTurnState` so per-turn counters/mappings (usage total,
// streaming part ids) don't bleed from the preempted turn into the new
Expand All @@ -1442,10 +1447,10 @@ export class CopilotAgentSession extends Disposable {
// response: mark it `running` immediately rather than leaving it
// `pending`, otherwise an abort during the steering turn would treat it
// as a not-yet-started queued turn and leave it open.
this.resetTurnState(newTurnId);
this.resetTurnState(newTurnId, steering.sender?.clientId, steering.sender?.clientContext.clientType, steering.sender?.clientContext);
const turn = this._currentTurn.value;
if (turn) {
turn.messageCharLen = steering.message.text.length;
turn.messageCharLen = steering.pendingMessage.message.text.length;
turn.markRunning();
}
if (this._activeRootSdkTurnId) {
Expand Down Expand Up @@ -1484,20 +1489,20 @@ export class CopilotAgentSession extends Disposable {
* no buffered entry matches; the caller treats the `user.message` as
* an ordinary echo and skips the turn flip.
*/
private _takeMatchingPendingSteering(content: string): PendingMessage | undefined {
private _takeMatchingPendingSteering(content: string): IPendingSteering | undefined {
if (this._pendingSteeringFlips.size === 0) {
return undefined;
}
let substringMatch: [string, PendingMessage] | undefined;
for (const [id, msg] of this._pendingSteeringFlips) {
if (msg.message.text === content) {
let substringMatch: [string, IPendingSteering] | undefined;
for (const [id, pending] of this._pendingSteeringFlips) {
if (pending.pendingMessage.message.text === content) {
this._pendingSteeringFlips.delete(id);
return msg;
return pending;
}
if (msg.message.text.length > 0
&& content.includes(msg.message.text)
&& (!substringMatch || msg.message.text.length > substringMatch[1].message.text.length)) {
substringMatch = [id, msg];
if (pending.pendingMessage.message.text.length > 0
&& content.includes(pending.pendingMessage.message.text)
&& (!substringMatch || pending.pendingMessage.message.text.length > substringMatch[1].pendingMessage.message.text.length)) {
substringMatch = [id, pending];
}
}
if (substringMatch) {
Expand Down Expand Up @@ -3434,15 +3439,15 @@ export class CopilotAgentSession extends Disposable {
return this._configurationService.getRootValue(platformRootSchema, AgentHostAutoReplyEnabledConfigKey) === true;
}

async sendSteering(steeringMessage: PendingMessage): Promise<void> {
async sendSteering(steeringMessage: PendingMessage, sender?: IAgentPendingMessageSender): Promise<void> {
if (this._steeringMessagesInFlight.has(steeringMessage.id) || this._pendingSteeringFlips.has(steeringMessage.id)) {
return;
}
this._steeringMessagesInFlight.add(steeringMessage.id);
this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.message.text.substring(0, 100)}"`);
try {
await this._reconcileMcpServerEnablement();
this._pendingSteeringFlips.set(steeringMessage.id, steeringMessage);
this._pendingSteeringFlips.set(steeringMessage.id, { pendingMessage: steeringMessage, sender });
const sdkAttachments = await this._toSdkAttachments(steeringMessage.message.attachments);
// Steering is injected into the active turn and never fires the SDK's `user-prompt-submitted`
// hook, so the read-only snapshot signal can't ride `additionalContext` here. Fold it into the
Expand Down
Loading
Loading