diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 1ff7931c47a591..ad89c7639ffe87 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -748,6 +748,9 @@ export interface IAgentChats { */ sendMessage(chat: URI, prompt: string, workingDirectoriesOrDirectory: readonly URI[] | URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, clientTypeOrContext?: AgentHostClientType | URI | IAgentChatContext, context?: URI | IAgentChatContext): Promise; + /** Resume a failed turn without adding another user message. */ + resumeTurn?(chat: URI, turnId: string, context: AgentChatOperationContext, senderClientId?: string, clientType?: AgentHostClientType): Promise; + /** Abort the in-flight turn for `chat`. */ abort(chat: URI, context: AgentChatOperationContext): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts index 9bf423634add13..a5a8cda7751fb9 100644 --- a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts @@ -23,7 +23,7 @@ export const IAgentHostChatContributions = createDecorator(); + for (const total of [...previous ?? [], ...current ?? []]) { + const existing = totals.get(total.model); + totals.set(total.model, existing ? { + model: total.model, + inputTokens: existing.inputTokens + total.inputTokens, + cachedTokens: existing.cachedTokens + total.cachedTokens, + outputTokens: existing.outputTokens + total.outputTokens, + } : { ...total }); + } + return [...totals.values()]; +} + /** * Well-known keys that may appear on {@link UsageInfo._meta}. * Clients MAY read these to provide enhanced UI (e.g. credit cost display). diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8a056eb501ff12..44ea6e27d71414 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -35,7 +35,7 @@ import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } f import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Annotation, type AnnotationEntry, type AnnotationOrigin, type AnnotationsState, type ChatOrigin, type Customization, type Message, type MessageAttachment, type MessageResourceAttachment, type TextRange } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withMessageHiddenFromTranscript, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_CREATED_BY_SESSION_DB_KEY, readSessionCreationReference, readSessionSpawnDepth, withSessionSpawnDepth, withSessionCreationReference, parseSessionCreationReference, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, TurnState, AH_META_WORKSPACELESS_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, getErrorResponsePart, isAhpChatChannel, isChatReadOnly, isDefaultChatUri, isSubagentChatUri, isSubagentSession, needsSessionGitStateRefresh, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withMessageHiddenFromTranscript, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionEhcliAdopted, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../common/meta/agentSnapshotAttachmentMeta.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; @@ -4391,6 +4391,42 @@ export class AgentService extends Disposable implements IAgentService { private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void { const origin = { clientId, clientSeq }; + if (action.type === ActionType.ChatTurnCancelled) { + const resumedDuration = this._sideEffects.getResumedTurnDuration(channel, action.turnId); + if (resumedDuration !== undefined) { + action = { ...action, duration: resumedDuration }; + } + } + let resumedTurn: Turn | undefined; + if (action.type === ActionType.ChatTurnResume) { + if (!isAhpChatChannel(channel)) { + this._stateManager.rejectClientAction(channel, action, origin, 'Turn resume requires a chat channel.'); + return; + } + const chatState = this._stateManager.getChatState(channel); + const sessionState = this._stateManager.getSessionState(sessionChannel); + const sessionArchived = ((sessionState?.status ?? 0) & SessionStatus.IsArchived) === SessionStatus.IsArchived; + const turn = chatState?.turns.at(-1); + const errorPart = getErrorResponsePart(turn); + const provider = this._findProviderForSession(sessionChannel); + if (chatState?.activeTurn) { + this._stateManager.rejectClientAction(channel, action, origin, 'Cannot resume while a turn is active.'); + return; + } + if (isChatReadOnly(chatState?.interactivity, sessionArchived)) { + this._stateManager.rejectClientAction(channel, action, origin, 'Cannot resume a read-only or archived chat.'); + return; + } + if (!turn || turn.id !== action.turnId || turn.state !== TurnState.Error || errorPart?.resumable !== true) { + this._stateManager.rejectClientAction(channel, action, origin, 'The requested turn is not the latest resumable errored turn.'); + return; + } + if (!provider?.chats.resumeTurn) { + this._stateManager.rejectClientAction(channel, action, origin, 'The session provider does not support turn resume.'); + return; + } + resumedTurn = turn; + } if (action.type === ActionType.ChatTurnStarted && this._isTurnIdUsedByAnotherChat(sessionChannel, channel, action.turnId)) { this._stateManager.rejectClientAction(channel, action, origin, 'Turn id is already used by another chat in this session.'); return; @@ -4441,7 +4477,7 @@ export class AgentService extends Disposable implements IAgentService { this._editAttributionService.setEnabled(editTelemetryEnabled); } } - this._sideEffects.handleAction(channel, action, clientId, clientContext); + this._sideEffects.handleAction(channel, action, clientId, clientContext, resumedTurn); } private _getUnresolvedPeerChats(sessionChannel: string): readonly string[] | undefined { return this._stateManager.getSessionState(sessionChannel)?.chats.filter(chat => !isDefaultChatUri(chat.resource) && !this._stateManager.getChatState(chat.resource)).map(chat => chat.resource); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5253a07b1b188c..10b9b2cedc314a 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -34,12 +34,15 @@ import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCa import { buildSubagentChatUri, chatStorageUri, + createErrorResponsePart, + getErrorResponsePart, getToolFileEdits, getInlineToolInput, isAhpChatChannel, buildDefaultChatUri, isSubagentChatUri, isChatReadOnly, + mergeLogicalTurnUsage, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, MessageAttachmentKind, @@ -64,6 +67,7 @@ import { type ToolCallResult, type ToolResultContent, type Turn, + type UsageInfo, type Customization, type McpServerCustomization, type PluginCustomization @@ -172,6 +176,12 @@ function getCustomizationEnablementCandidates(customizations: readonly Customiza type AgentSignalTurnIdRouting = 'preserve' | 'remap'; +interface IResumedTurnExecution { + readonly duration: number; + readonly usage: UsageInfo | undefined; + readonly stopWatch: StopWatch; +} + /** * Shared implementation of agent side-effect handling. * @@ -188,6 +198,7 @@ export class AgentSideEffects extends Disposable { private readonly _toolCallAgents = new Map(); /** Managed confirmations are human-only and must never seed host-side session permissions. */ private readonly _managedApprovalToolCalls = new Set(); + private readonly _resumedTurnExecutions = new Map(); private _lastAgentInfos: readonly AgentInfo[] = []; private readonly _permissionManager: SessionPermissionManager; @@ -299,6 +310,22 @@ export class AgentSideEffects extends Disposable { const chatState = this._stateManager.getChatState(envelope.channel); const action = envelope.action; switch (action.type) { + case ActionType.ChatTurnStarted: { + if (envelope.rejectionReason) { + break; + } + const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); + const previousTurn = chatState?.turns.at(-1); + if (!this._stateManager.isEphemeralSession(sessionChannel) + && previousTurn + && previousTurn.id !== action.turnId + && getErrorResponsePart(previousTurn)?.resumable === true) { + void this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), URI.parse(envelope.channel), previousTurn.id).catch(error => { + this._logService.warn(`[AgentSideEffects] Failed to discard checkpoint for superseded resumable turn ${previousTurn.id}`, error); + }); + } + break; + } case ActionType.ChatInputRequested: { const turnId = chatState?.activeTurn?.id; const provider = this._options.getAgent(parseRequiredSessionUriFromChatUri(envelope.channel))?.id; @@ -908,6 +935,15 @@ export class AgentSideEffects extends Disposable { return; } } + const attemptUsage = action.type === ActionType.ChatUsage ? action.usage : undefined; + const resumedExecution = this._resumedTurnExecutions.get(this._resumedTurnExecutionKey(sessionKey, turnId)); + if (resumedExecution) { + if (action.type === ActionType.ChatUsage) { + action = { ...action, usage: mergeLogicalTurnUsage(resumedExecution.usage, action.usage) ?? action.usage }; + } else if (action.type === ActionType.ChatTurnComplete || action.type === ActionType.ChatTurnCancelled || action.type === ActionType.ChatError) { + action = { ...action, duration: resumedExecution.duration + action.duration }; + } + } if (action.type === ActionType.ChatToolCallStart && agent) { this._toolCallAgents.set(`${sessionKey}:${action.toolCallId}`, agent.id); @@ -923,7 +959,7 @@ export class AgentSideEffects extends Disposable { } } if (action.type === ActionType.ChatUsage) { - const usageMeta = readUsageInfoMeta(action.usage); + const usageMeta = readUsageInfoMeta(attemptUsage ?? action.usage); this._turnTracker.updateDirectUsage( sessionKey, action.turnId, @@ -1043,8 +1079,21 @@ export class AgentSideEffects extends Disposable { const clientContext = this._turnTracker.getClientTelemetryContext(sessionKey, turnId); this._completeTurn(sessionKey, turnId, 'error', { stage: 'provider', error: action.part.error }); this._toolCallTracker.clearSession(sessionKey); - this._chatContributions.turnEnd({ session: sessionUri, channel: sessionKey, turnId, reason: { kind: 'error', error: action.part.error }, clientContext }); + this._chatContributions.turnEnd({ + session: sessionUri, + channel: sessionKey, + turnId, + reason: { kind: 'error', error: action.part.error, resumable: action.part.resumable === true }, + clientContext + }); } + if (action.type === ActionType.ChatTurnComplete || action.type === ActionType.ChatTurnCancelled || action.type === ActionType.ChatError) { + this._resumedTurnExecutions.delete(this._resumedTurnExecutionKey(sessionKey, turnId)); + } + } + + private _resumedTurnExecutionKey(chat: ProtocolURI, turnId: string): string { + return `${chat}\0${turnId}`; } private _recordModelCallCompleted(signal: IAgentModelCallCompletedSignal, sessionKey: ProtocolURI, turnId: string, turnIdRouting: AgentSignalTurnIdRouting): void { @@ -1319,12 +1368,23 @@ export class AgentSideEffects extends Disposable { clearChannelTelemetry(channel: ProtocolURI): void { this._toolCallTracker.clearSession(channel); this._turnTracker.clearSession(channel); + const prefix = `${channel}\0`; + for (const key of this._resumedTurnExecutions.keys()) { + if (key.startsWith(prefix)) { + this._resumedTurnExecutions.delete(key); + } + } } clearInputRequestsForSession(session: ProtocolURI): void { this._inputRequestTracker.clearAgentSession(session); } + getResumedTurnDuration(channel: ProtocolURI, turnId: string): number | undefined { + const execution = this._resumedTurnExecutions.get(this._resumedTurnExecutionKey(channel, turnId)); + return execution ? execution.duration + execution.stopWatch.elapsed() : undefined; + } + /** * Finds the subagent session that owns a given tool call by checking * whether the tool call was previously registered under a subagent @@ -1464,7 +1524,7 @@ export class AgentSideEffects extends Disposable { this._turnTracker.markActivity(sessionKey, turnId, readyAction.type); } - handleAction(channel: ProtocolURI, action: StateAction, clientId?: string, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void { + handleAction(channel: ProtocolURI, action: StateAction, clientId?: string, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown, resumedTurn?: Turn): void { let clientContext = typeof clientContextOrType === 'string' ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) : clientContextOrType; @@ -1481,6 +1541,7 @@ export class AgentSideEffects extends Disposable { const turnStopWatch = StopWatch.create(false); // Per-turn streaming part tracking is owned by the agent // (e.g. CopilotAgentSession) and reset on its `send()` call. + const state = this._stateManager.getSessionState(channel); // Generic, agent-agnostic host commands (`/rename`, `!command`, // …) are intercepted here and handled by the local-command @@ -1493,7 +1554,6 @@ export class AgentSideEffects extends Disposable { break; } - const state = this._stateManager.getSessionState(channel); if (!state) { this._logService.info(`[AgentSideEffects] Turn started for session not in state manager: ${channel}, turnId=${action.turnId} - status/summary updates may be dropped unless the session is restored`); } @@ -1505,7 +1565,7 @@ export class AgentSideEffects extends Disposable { type: ActionType.ChatError, turnId: action.turnId, duration: this._turnDuration(turnStopWatch), - part: { kind: ResponsePartKind.Error, error: { errorType: 'noAgent', message: 'No agent found for session' } }, + part: createErrorResponsePart({ errorType: 'noAgent', message: 'No agent found for session' }), }); return; } @@ -1525,6 +1585,48 @@ export class AgentSideEffects extends Disposable { }); break; } + case ActionType.ChatTurnResume: { + if (!chatChannel || !resumedTurn) { + throw new Error(`ChatTurnResume must be accepted with its previous turn on an AHP chat channel: ${channel}`); + } + const agent = this._options.getAgent(sessionChannel); + if (!agent?.chats.resumeTurn) { + throw new Error(`ChatTurnResume reached side effects without provider support: ${sessionChannel}`); + } + const state = this._stateManager.getSessionState(channel); + const { model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode } = getTurnTelemetryContext(agent, channel, this._chatContext(sessionChannel, channel), state, resumedTurn.message.model?.id); + this._turnTracker.turnStarted(agent, channel, action.turnId, model, modelTelemetryKind, modelSelectionKind, permissionLevel, interactionMode, clientContext, clientId); + this._turnTracker.setCurrentStage(channel, action.turnId, 'provider'); + const key = this._resumedTurnExecutionKey(channel, action.turnId); + const execution: IResumedTurnExecution = { + duration: resumedTurn.duration ?? 0, + usage: resumedTurn.usage, + stopWatch: StopWatch.create(false), + }; + this._resumedTurnExecutions.set(key, execution); + void agent.chats.resumeTurn( + URI.parse(channel), + action.turnId, + { ...this._chatContext(sessionChannel, channel), clientTelemetryContext: clientContext }, + clientId, + clientContext.clientType, + ).catch(error => { + if (this._resumedTurnExecutions.get(key) !== execution) { + return; + } + const failure = buildTurnFailure('sendMessage', error); + this._stateManager.dispatchServerAction(channel, { + type: ActionType.ChatError, + turnId: action.turnId, + duration: execution.duration + execution.stopWatch.elapsed(), + part: createErrorResponsePart(failure.error, true), + }); + this._completeTurn(channel, action.turnId, 'error', failure); + this._toolCallTracker.clearSession(channel); + this._resumedTurnExecutions.delete(key); + }); + break; + } case ActionType.ChatToolCallConfirmed: { if (!chatChannel) { throw new Error(`ChatToolCallConfirmed must be handled on an AHP chat channel: ${channel}`); @@ -1570,6 +1672,7 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatTurnCancelled must be handled on an AHP chat channel: ${channel}`); } this._completeTurn(channel, action.turnId, 'cancelled'); + this._resumedTurnExecutions.delete(this._resumedTurnExecutionKey(channel, action.turnId)); this._toolCallTracker.clearSession(channel); void this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), URI.parse(channel), action.turnId).catch(() => undefined); // Cancel all subagent sessions for this parent @@ -1883,7 +1986,7 @@ export class AgentSideEffects extends Disposable { type: ActionType.ChatError, turnId, duration: this._turnDuration(turnStopWatch), - part: { kind: ResponsePartKind.Error, error }, + part: createErrorResponsePart(error), }); this._completeTurn(turnChannel, turnId, 'error', { stage: 'validation', error }); this._toolCallTracker.clearSession(turnChannel); @@ -1939,7 +2042,7 @@ export class AgentSideEffects extends Disposable { type: ActionType.ChatError, turnId, duration: this._turnDuration(turnStopWatch), - part: { kind: ResponsePartKind.Error, error }, + part: createErrorResponsePart(error), }); this._completeTurn(turnChannel, turnId, 'error', failure); this._toolCallTracker.clearSession(turnChannel); diff --git a/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts b/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts index 41a6bbff3378ed..568cdc651e9c0e 100644 --- a/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/checkpointAndChangeset/checkpointAndChangesetContribution.ts @@ -31,6 +31,9 @@ export class CheckpointAndChangesetContribution extends Disposable implements IA if (turn.reason.kind !== 'success' && turn.reason.kind !== 'error') { return; } + if (turn.reason.kind === 'error' && turn.reason.resumable) { + return; + } if (turn.turnId === undefined) { this._changesets.onTurnComplete(turn.session, turn.turnId, turn.clientContext); return; diff --git a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts index 050384a69dc284..246a51f838c1e6 100644 --- a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts @@ -12,7 +12,7 @@ import { AgentHostClientType } from '../../../common/agentHostClientInfo.js'; import { createUnknownAgentHostClientTelemetryContext } from '../../../common/agentHostTelemetry.js'; import { IAgentHostChatContributions, createChatMementoKey, type IAgentHostChatContribution, type IAgentHostChatContributionContext, type IAgentHostChatContributionHost, type IObservedAction, type IQueuedMessageSender, type ITurnEnd } from '../../../common/agentHostChatContributionsService.js'; import { ActionType } from '../../../common/state/sessionActions.js'; -import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, PendingMessageKind, ResponsePartKind, type Message, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; +import { createErrorResponsePart, getErrorResponsePart, isAhpChatChannel, parseRequiredSessionUriFromChatUri, PendingMessageKind, TurnState, type Message, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; import { createAgentChatContext } from '../../agentChatContext.js'; import { IAgentHostProviderLocator } from '../../agentHostProviderLocator.js'; @@ -103,6 +103,10 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat if (!state?.queuedMessages?.length || state.steeringMessage) { return; } + const latestTurn = state.turns.at(-1); + if (latestTurn?.state === TurnState.Error && getErrorResponsePart(latestTurn)?.resumable) { + return; + } const host = this._getHost(); if (!host) { return; @@ -147,7 +151,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat type: ActionType.ChatError, turnId, duration: Math.max(0, turnStopWatch.elapsed()), - part: { kind: ResponsePartKind.Error, error: { errorType: 'noAgent', message: 'No agent found for session' } }, + part: createErrorResponsePart({ errorType: 'noAgent', message: 'No agent found for session' }), }); return; } diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index 37f33bce6e002f..e7d56874155bd4 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -8,7 +8,7 @@ import type { URI } from '../../../../base/common/uri.js'; import { LogLevel, type ILogService } from '../../../log/common/log.js'; import type { AgentSignal } from '../../common/agent.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolResultContentType, type ToolResultContent, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; +import { createErrorResponsePart, ResponsePartKind, ToolResultContentType, type ToolResultContent, type ToolResultFileEditContent } from '../../common/state/sessionState.js'; import { extractForwardedErrorInfo } from '../shared/proxyChatError.js'; import { buildTopLevelSubagentReadyAction, emitInnerAssistantSignals, mapSubagentSystemMessage, SUBAGENT_SPAWNING_TOOL_NAMES, tagWithParent } from './claudeSubagentSignals.js'; import type { SubagentRegistry } from './claudeSubagentRegistry.js'; @@ -498,13 +498,10 @@ function mapResult( type: ActionType.ChatError, turnId, duration: typeof turnDuration === 'number' && Number.isFinite(turnDuration) ? Math.max(0, turnDuration) : 0, - part: { - kind: ResponsePartKind.Error, - error: { - errorType: message.subtype, - ...extractForwardedErrorInfo(errorText), - }, - }, + part: createErrorResponsePart({ + errorType: message.subtype, + ...extractForwardedErrorInfo(errorText), + }), }, }); } diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 410f2eeacbab64..2274780803b621 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -36,7 +36,7 @@ import { ActionType, isChatAction, type SessionAction, type ChatAction } from '. import { parseLeadingSlashCommand } from '../../common/agentHostSlashCommand.js'; import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition, AgentSelection } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { buildDefaultChatUri, chatStorageUri, isDefaultChatUri, parseRequiredSessionUriFromChatUri, withSessionWorkspaceless, CustomizationType, type ClientPluginCustomization, type DirectoryCustomization, type ISessionFolderPickerDecision, type McpServerCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PluginCustomization, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn, ResponsePartKind } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, chatStorageUri, createErrorResponsePart, isDefaultChatUri, parseRequiredSessionUriFromChatUri, withSessionWorkspaceless, CustomizationType, type ClientPluginCustomization, type DirectoryCustomization, type ISessionFolderPickerDecision, type McpServerCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PluginCustomization, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn, ResponsePartKind } from '../../common/state/sessionState.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { McpCustomizationController } from '../shared/mcpCustomizationController.js'; @@ -3809,7 +3809,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId, duration, - part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' } }, + part: createErrorResponsePart({ errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }), }); this._fire(session.sessionUri, { type: ActionType.ChatTurnComplete, turnId, duration }); } @@ -5410,7 +5410,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexMaterializeFailed', message } }, + part: createErrorResponsePart({ errorType: 'CodexMaterializeFailed', message }), }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; @@ -5452,7 +5452,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexMaterializeFailed', message } }, + part: createErrorResponsePart({ errorType: 'CodexMaterializeFailed', message }), }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; @@ -5476,13 +5476,10 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - part: { - kind: ResponsePartKind.Error, - error: { - errorType: 'CodexResumeFailed', - message: err instanceof Error ? err.message : String(err), - }, - }, + part: createErrorResponsePart({ + errorType: 'CodexResumeFailed', + message: err instanceof Error ? err.message : String(err), + }), }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; @@ -5559,7 +5556,7 @@ export class CodexAgent extends Disposable implements IAgent { type: ActionType.ChatError, turnId: effectiveTurnId, duration, - part: { kind: ResponsePartKind.Error, error: { errorType: isCompactCommand ? 'CodexCompactionError' : 'CodexTurnError', ...extractForwardedErrorInfo(message) } }, + part: createErrorResponsePart({ errorType: isCompactCommand ? 'CodexCompactionError' : 'CodexTurnError', ...extractForwardedErrorInfo(message) }), }); this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); } finally { diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index c2798a18f40c72..92cfc7020d419b 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -9,7 +9,7 @@ import { localize } from '../../../../nls.js'; import type { IAgentModelCallCompletedSignal } from '../../common/agent.js'; import { toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { ActionType, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState, type ErrorInfo } from '../../common/state/sessionState.js'; +import { createErrorResponsePart, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolResultContentType, TurnState, type ErrorInfo } from '../../common/state/sessionState.js'; import { extractForwardedErrorInfo } from '../shared/proxyChatError.js'; import { getServerToolDisplay } from '../shared/serverToolGroups.js'; import { ActiveClientToolSet } from '../activeClientState.js'; @@ -1239,7 +1239,7 @@ export function mapTurnCompleted( type: ActionType.ChatError, turnId, duration, - part: { kind: ResponsePartKind.Error, error: mapCodexTurnError(params.turn.error) }, + part: createErrorResponsePart(mapCodexTurnError(params.turn.error)), }, { type: ActionType.ChatTurnComplete, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 1a03ff153a1975..575eac03c1e09b 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2917,6 +2917,9 @@ export class CopilotAgent extends Disposable implements IAgent { changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { return this._changeModel(chatUri, model, context); }, + resumeTurn: (chatUri: URI, turnId: string, context: URI | IAgentChatContext, senderClientId?: string, clientType?: AgentHostClientType): Promise => { + return this._resumeTurn(chatUri, turnId, context, senderClientId, clientType); + }, changeAgent: (chatUri: URI, agent: AgentSelection | undefined, context: URI | IAgentChatContext): Promise => { return this._changeAgent(chatUri, agent, context); }, @@ -3102,6 +3105,42 @@ export class CopilotAgent extends Disposable implements IAgent { }; } + private async _resumeTurn(chat: URI, turnId: string, operationContext: URI | IAgentChatContext, senderClientId?: string, clientType = AgentHostClientType.Unknown): Promise { + try { + await this._resumeTurnOnce(chat, turnId, operationContext, senderClientId, clientType); + } catch (error) { + const recovery = await this._handleClientOperationFailure(error, 'resumeTurn', this._clientFailureCorrelation(chat, turnId, operationContext)); + if (recovery?.failedTurnIds.has(turnId)) { + return; + } + throw error; + } + } + + private async _resumeTurnOnce(chat: URI, turnId: string, operationContext: URI | IAgentChatContext, senderClientId?: string, clientType = AgentHostClientType.Unknown): Promise { + const context = this._resolveChatContext(chat, operationContext); + const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext.clientTelemetryContext; + await this._queueChat(context.configurationId, context.sequencerKey, async () => { + const current = this._resolveChatContext(chat, operationContext); + let entry = current.target ?? await this._ensureResolvedChatSession(current); + if (!entry) { + throw new Error(`[Copilot] resumeTurn for unknown chat: ${chat.toString()}`); + } + const activeClient = this._activeClients.get(current.configurationResource); + const currentSnapshot = activeClient ? await activeClient.snapshot(current.chatKey) : undefined; + if (activeClient && currentSnapshot && await activeClient.requiresRestart(entry.appliedSnapshot, current.chatKey, currentSnapshot)) { + await this._destroyLiveSession(entry, true); + entry = entry.sessionId === current.configurationId + ? await this._resumeSession(current.configurationId, current.chat) + : await this._ensureResolvedChatSession(current); + } + if (!entry) { + throw new Error(`[Copilot] resumeTurn for unavailable chat: ${chat.toString()}`); + } + await entry.resume(turnId, this._resolveSdkMode(current.configurationResource), senderClientId, clientType, clientTelemetryContext); + }); + } + /** Mints the chat's backing from an imported conversation supplied by Agent Host. */ private async _importChatBacking(chat: URI, context: IAgentChatContext, options: IAgentCreateChatOptions): Promise { const session = context.configurationResource; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index a32f9a43829c81..5a043850128da2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -27,6 +27,7 @@ import { INativeEnvironmentService } from '../../../environment/common/environme import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; +import product from '../../../product/common/product.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { getCopilotHomePath } from '../../common/copilotHome.js'; import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; @@ -51,7 +52,7 @@ import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataS import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { MessageAttachmentKind, ToolCallContributorKind, type FileEdit, type MessageAttachment, type ToolCallContributor } from '../../common/state/protocol/state.js'; import { ActionType, isChatAction, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, isSubagentSession, parseRequiredSessionUriFromChatUri, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; +import { MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolResultContentType, buildSubagentSessionUri, createErrorResponsePart, isSubagentSession, parseRequiredSessionUriFromChatUri, type Customization, type Message, type PendingMessage, type ChatInputAnswer, type ChatInputOption, type ChatInputQuestion, type ChatInputRequest, type ToolCallResult, type ToolResultContent, type ToolResultTerminalContent, type Turn, type ITurnTokenTotal, type UsageInfo, type UsageInfoMeta, type IContextAttributionData, type ISessionPromptCacheState } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, isMcpServerExplicitlyProjected, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; @@ -429,6 +430,8 @@ export interface ICopilotAgentSessionOptions { readonly serverToolHost?: IAgentServerToolHost; /** Returns whether the token that launched this session is still the active account token. */ readonly isLaunchTokenCurrent?: () => boolean; + /** Overrides source-launch detection for deterministic tests. */ + readonly enableDevelopmentErrorInjection?: boolean; /** * Invoked whenever this chat's in-flight turn ends — normal completion, @@ -821,6 +824,10 @@ export class CopilotAgentSession extends Disposable { * replacing or clearing it disposes the old turn. */ private readonly _currentTurn = this._register(new MutableDisposable()); + private _resumingTurnAwaitingProviderStart: CopilotTurn | undefined; + private _developmentRecoverableError: { readonly turnId: string; remainingFailures: number; readonly totalFailures: number } | undefined; + private readonly _developmentErrorInjectionEnabled: boolean; + private _dropLateRootTurnEvents = false; /** Monotonic 0-based ordinal assigned to each turn as it starts, for numeric `turnIndex` telemetry parity. */ private _nextTurnOrdinal = 0; /** @@ -979,6 +986,7 @@ export class CopilotAgentSession extends Disposable { private readonly _onDidSessionProgress: Emitter; private readonly _sessionLauncher: ICopilotSessionLauncher; private readonly _launchPlan: CopilotSessionLaunchPlan; + private _detectInterruptedTurnOnRestore: boolean; private readonly _isLaunchTokenStillCurrent: () => boolean; /** Notifies the agent that this chat's turn ended. See {@link ICopilotAgentSessionOptions.onTurnEnded}. */ private readonly _onTurnEnded: () => void; @@ -1054,6 +1062,7 @@ export class CopilotAgentSession extends Disposable { ) { super(); this._abortCts.value = new CancellationTokenSource(); + this._developmentErrorInjectionEnabled = options.enableDevelopmentErrorInjection ?? !product.commit; this.sessionId = options.rawSessionId; this._ownerSessionUri = options.sessionUri; this.resourceUri = options.resource ?? options.sessionUri; @@ -1063,6 +1072,7 @@ export class CopilotAgentSession extends Disposable { this._onDidSessionProgress = options.onDidSessionProgress; this._sessionLauncher = options.sessionLauncher; this._launchPlan = options.launchPlan; + this._detectInterruptedTurnOnRestore = options.launchPlan.kind === 'resume'; this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true); this._onTurnEnded = options.onTurnEnded ?? (() => { }); this._shellManager = options.shellManager; @@ -1150,9 +1160,25 @@ export class CopilotAgentSession extends Disposable { // ---- AgentSignal helpers ------------------------------------------------ + private _shouldDropLateRootTurnEvent(eventType: string): boolean { + if (!this._dropLateRootTurnEvents) { + return false; + } + this._logService.error(`[Copilot:${this.sessionId}] ${eventType} emitted after cancellation; dropping`); + return true; + } + /** Wraps a {@link SessionAction} in an {@link AgentSignal} envelope and emits it. */ /** todo@connor4312: AHP is missing a chat activity update action which is needed to drop `SessionAction` here */ - private _emitAction(action: SessionAction | ChatAction, parentToolCallId?: string): void { + private _emitAction(action: SessionAction | ChatAction, parentToolCallId?: string, trustedRootTurn = false): void { + if (!trustedRootTurn + && this._dropLateRootTurnEvents + && isChatAction(action) + && hasKey(action, { turnId: true }) + && action.type !== ActionType.ChatTurnStarted) { + this._logService.error(`[Copilot:${this.sessionId}] ${action.type} emitted after cancellation; dropping`); + return; + } this._onDidSessionProgress.fire({ kind: 'action', resource: isChatAction(action) ? this._chatChannelUri : this._ownerSessionUri, @@ -1262,6 +1288,9 @@ export class CopilotAgentSession extends Disposable { } private _resumeSubagentForEvent(e: { readonly agentId?: string }, message?: Message): void { + if (this._dropLateRootTurnEvents) { + return; + } if (!e.agentId || this._activeSubagentAgentIds.has(e.agentId)) { return; } @@ -1293,6 +1322,12 @@ export class CopilotAgentSession extends Disposable { if (!parentToolCallId) { return; } + if (this._dropLateRootTurnEvents) { + this._rootTurnIdBySubagentToolCallId.delete(parentToolCallId); + this._subagentDirectUsageByToolCallId.delete(parentToolCallId); + this._lastSubagentUsageByToolCallId.delete(parentToolCallId); + return; + } this._onDidSessionProgress.fire({ kind: 'subagent_completed', chat: this._chatChannelUri, @@ -1437,6 +1472,7 @@ export class CopilotAgentSession extends Disposable { * response part. The turn becomes `running` on the first SDK event. */ resetTurnState(turnId: string, senderClientId?: string, clientType = AgentHostClientType.Unknown, clientContext = createUnknownAgentHostClientTelemetryContext(clientType)): void { + this._detectInterruptedTurnOnRestore = false; this._streamingToolCalls.clear(); this._streamingToolDisplaySchedulers.clearAndDisposeAll(); this._currentTurn.value = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId, clientContext); @@ -1516,7 +1552,7 @@ export class CopilotAgentSession extends Disposable { return attribution; } - private _completeActiveTurn(): void { + private _completeActiveTurn(trustedRootTurn = false): void { const turn = this._currentTurn.value; if (!turn) { return; @@ -1527,7 +1563,7 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatTurnComplete, turnId: turn.id, duration: turn.duration, - }); + }, undefined, trustedRootTurn); this._clearActiveTurn(); } @@ -1541,7 +1577,7 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatError, turnId: turn.id, duration: turn.duration, - part: { kind: ResponsePartKind.Error, error }, + part: createErrorResponsePart(error, true), }); this._clearActiveTurn(); return turn.id; @@ -1560,6 +1596,9 @@ export class CopilotAgentSession extends Disposable { * is not stranded waiting on a turn that already ended. */ private _clearActiveTurn(): void { + if (this._resumingTurnAwaitingProviderStart === this._currentTurn.value) { + this._resumingTurnAwaitingProviderStart = undefined; + } this._currentTurn.clear(); this._streamingToolCalls.clear(); this._streamingToolDisplaySchedulers.clearAndDisposeAll(); @@ -1655,15 +1694,18 @@ export class CopilotAgentSession extends Disposable { * messages (e.g. the worktree-created announcement) at the top of the * first response. */ - emitInitialMarkdown(content: string): void { - this._emitMarkdownDelta(content); + emitInitialMarkdown(content: string, trustedRootTurn = false): void { + this._emitMarkdownDelta(content, undefined, trustedRootTurn); } /** * Emits a streaming text delta. The first delta of a turn allocates a * markdown response part; subsequent deltas append to it. */ - private _emitMarkdownDelta(content: string, parentToolCallId?: string): void { + private _emitMarkdownDelta(content: string, parentToolCallId?: string, trustedRootTurn = false): void { + if (parentToolCallId === undefined && !trustedRootTurn && this._shouldDropLateRootTurnEvent('assistant.message_delta')) { + return; + } const turn = this._currentTurn.value; if (!turn) { // A markdown delta should only ever arrive while a turn is active. @@ -1682,7 +1724,7 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatResponsePart, turnId: turn.id, part: { kind: ResponsePartKind.Markdown, id: partId, content }, - }, parentToolCallId); + }, parentToolCallId, trustedRootTurn); return; } this._emitAction({ @@ -1690,11 +1732,14 @@ export class CopilotAgentSession extends Disposable { turnId: turn.id, partId, content, - }, parentToolCallId); + }, parentToolCallId, trustedRootTurn); } /** Emits a reasoning delta, similar to {@link _emitMarkdownDelta} but for reasoning parts. */ private _emitReasoningDelta(content: string, parentToolCallId?: string): void { + if (parentToolCallId === undefined && this._shouldDropLateRootTurnEvent('assistant.reasoning_delta')) { + return; + } const turn = this._currentTurn.value; if (!turn) { this._logService.error(`[Copilot:${this.sessionId}] Reasoning delta emitted with no active turn; dropping`); @@ -2277,6 +2322,9 @@ export class CopilotAgentSession extends Disposable { const turn = this._currentTurn.value; this._hostInstructions = hostInstructions; this._pendingSnapshotReminder = this._snapshotReadonlyReminder(attachments); + if (this._tryStartDevelopmentRecoverableError(prompt)) { + return; + } try { await this._send(prompt, attachments, mode); } catch (err) { @@ -2380,13 +2428,13 @@ export class CopilotAgentSession extends Disposable { model: this._lastSeenModelId, ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), }, - }); + }, undefined, true); } - this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed")); + this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed"), true); } catch (err) { if (getErrorMessage(err).toLowerCase().includes('nothing to compact')) { - this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed")); - this._completeActiveTurn(); + this.emitInitialMarkdown(localize('copilotAgent.compactionCompleted', "Compaction completed"), true); + this._completeActiveTurn(true); return; } this._logService.error(err, `[Copilot:${this.sessionId}] rpc.history.compact failed`); @@ -2396,7 +2444,7 @@ export class CopilotAgentSession extends Disposable { // driving an SDK turn, so the SDK never fires `onIdle` to close the // turn. Complete the turn here so the session returns to idle // instead of spinning forever. - this._completeActiveTurn(); + this._completeActiveTurn(true); return; } const configAction = slashCommand ? resolveCopilotConfigSlashCommandOnSend(slashCommand.command, slashCommand.rawRest) : undefined; @@ -2438,11 +2486,11 @@ export class CopilotAgentSession extends Disposable { } switch (result.kind) { case 'text': - this._emitMarkdownDelta(result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text)); + this._emitMarkdownDelta(result.markdown === true ? result.text : escapeMarkdownSyntaxTokens(result.text), undefined, true); break; case 'completed': if (result.message) { - this._emitMarkdownDelta(result.message); + this._emitMarkdownDelta(result.message, undefined, true); } break; case 'agent-prompt': { @@ -2459,7 +2507,7 @@ export class CopilotAgentSession extends Disposable { "The /{0} command requires selecting a subcommand. Available options: {1}", result.command, result.options.map(option => option.name).join(', '), - )); + ), undefined, true); break; default: // The runtime can be newer than these compiled SDK types, so an @@ -2472,7 +2520,7 @@ export class CopilotAgentSession extends Disposable { this._slashCommandProvider.clearCache(); } if (result.kind !== 'agent-prompt') { - this._completeActiveTurn(); + this._completeActiveTurn(true); return; } } @@ -2485,7 +2533,15 @@ export class CopilotAgentSession extends Disposable { const sendingTurn = this._currentTurn.value; sendingTurn?.markProviderCallPending(); try { - await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined })); + await this._otelService.withTraceContext(traceContext, () => { + if (!this._environmentService.isBuilt && prompt === '$error') { + return this._wrapper.session.rpc.sendMessages({ + messages: [{ prompt }], + requestHeaders: { Authorization: '******' }, + }); + } + return this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined }); + }); sendingTurn?.markProviderCallResolved(); } catch (error) { sendingTurn?.markProviderCallRejected(); @@ -2494,6 +2550,118 @@ export class CopilotAgentSession extends Disposable { this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } + async resume(turnId: string, mode?: CopilotSdkMode, senderClientId?: string, clientType = AgentHostClientType.Unknown, clientContext = createUnknownAgentHostClientTelemetryContext(clientType)): Promise { + this._resetAbortToken(); + this.resetTurnState(turnId, senderClientId, clientType, clientContext); + if (this._tryContinueDevelopmentRecoverableError(turnId)) { + return; + } + const turn = this._currentTurn.value; + this._resumingTurnAwaitingProviderStart = turn; + turn?.markProviderCallPending(); + try { + await this._prepareSdkTurn(mode); + const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); + await this._otelService.withTraceContext(traceContext, () => this._wrapper.session.rpc.sendMessages({ messages: [] })); + turn?.markProviderCallResolved(); + this._logService.info(`[Copilot:${this.sessionId}] zero-message continuation returned`); + } catch (error) { + if (this._resumingTurnAwaitingProviderStart === turn) { + this._resumingTurnAwaitingProviderStart = undefined; + } + if (turn && this._currentTurn.value === turn) { + turn.markProviderCallRejected(); + this._clearActiveTurn(); + } + throw error; + } + } + + private _tryStartDevelopmentRecoverableError(prompt: string): boolean { + if (!this._developmentErrorInjectionEnabled) { + return false; + } + const match = /^\$error-ui(?-tool)?(?::(?[1-9]))?$/.exec(prompt); + const turn = this._currentTurn.value; + if (!match || !turn) { + return false; + } + const totalFailures = match.groups?.count ? Number(match.groups.count) : 1; + this._developmentRecoverableError = { + turnId: turn.id, + remainingFailures: totalFailures - 1, + totalFailures, + }; + this._hostInstructions = undefined; + this._pendingSnapshotReminder = undefined; + if (match.groups?.tool) { + this._emitDevelopmentCompletedToolCall(turn); + } + this._emitDevelopmentRecoverableError(turn, 1, totalFailures); + return true; + } + + private _tryContinueDevelopmentRecoverableError(turnId: string): boolean { + const state = this._developmentRecoverableError; + const turn = this._currentTurn.value; + if (!state || state.turnId !== turnId || !turn) { + return false; + } + if (state.remainingFailures > 0) { + const attempt = state.totalFailures - state.remainingFailures + 1; + state.remainingFailures--; + this._emitDevelopmentRecoverableError(turn, attempt, state.totalFailures); + return true; + } + this._developmentRecoverableError = undefined; + this._emitMarkdownDelta(localize('copilotAgent.developmentRecoverableErrorRecovered', "Recovered after {0} injected failure(s).", state.totalFailures), undefined, true); + this._completeActiveTurn(true); + return true; + } + + private _emitDevelopmentRecoverableError(turn: CopilotTurn, attempt: number, totalFailures: number): void { + this._emitAction({ + type: ActionType.ChatError, + turnId: turn.id, + duration: turn.duration, + part: createErrorResponsePart({ + errorType: 'developmentRecoverableError', + message: localize('copilotAgent.developmentRecoverableError', "Injected recoverable development error ({0}/{1}).", attempt, totalFailures), + }, true), + }); + this._clearActiveTurn(); + } + + private _emitDevelopmentCompletedToolCall(turn: CopilotTurn): void { + const toolCallId = `${turn.id}-development-tool`; + this._emitAction({ + type: ActionType.ChatToolCallStart, + turnId: turn.id, + toolCallId, + toolName: 'view', + displayName: 'Read', + intention: 'Read README.md before the injected failure', + }); + this._emitAction({ + type: ActionType.ChatToolCallReady, + turnId: turn.id, + toolCallId, + invocationMessage: 'Reading README.md', + toolInput: '{"path":"README.md"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + this._emitAction({ + type: ActionType.ChatToolCallComplete, + turnId: turn.id, + toolCallId, + result: { + success: true, + pastTenseMessage: 'Read README.md', + content: [{ type: ToolResultContentType.Text, text: 'Captured tool output before the injected failure.' }], + }, + }); + } + /** * Applies the per-turn SDK configuration shared by every operation that starts * an agent loop (normal `session.send` and the `/fleet` start path): agent mode, @@ -2583,6 +2751,7 @@ export class CopilotAgentSession extends Disposable { return; } throw new Error(localize('copilotAgent.fleet.notStarted', "Fleet could not be started.")); + } private async _toSdkAttachments(attachments: readonly MessageAttachment[] | undefined): Promise { @@ -2820,6 +2989,12 @@ export class CopilotAgentSession extends Disposable { model: this._launchPlan.kind === 'create' ? this._launchPlan.model : this._launchPlan.fallback.model, + ...(this._detectInterruptedTurnOnRestore ? { + interruptedTurnError: { + errorType: 'executionInterrupted', + message: localize('copilotAgent.interruptedTurn', "The agent was interrupted before this request finished."), + }, + } : {}), }); this._logService.trace(`[Copilot:${this.sessionId}] Reconstructed ${result.turns.length} turn(s) from ${events.length} event(s)`); return result; @@ -2832,6 +3007,11 @@ export class CopilotAgentSession extends Disposable { async abort(): Promise { this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); + const abortingTurn = this._currentTurn.value; + const resumingTurn = this._resumingTurnAwaitingProviderStart; + if (abortingTurn) { + this._dropLateRootTurnEvents = true; + } this._beginAbort(); this._drainPendingSteeringFlips(); try { @@ -2840,6 +3020,10 @@ export class CopilotAgentSession extends Disposable { this._resetAbortToken(); throw error; } + if (resumingTurn && this._resumingTurnAwaitingProviderStart === resumingTurn && this._currentTurn.value === resumingTurn && !resumingTurn.providerTurnStarted) { + resumingTurn.markAborted(); + this._clearActiveTurn(); + } } /** @@ -4115,6 +4299,9 @@ export class CopilotAgentSession extends Disposable { return; } + // A turn-starting notification is an authoritative new root boundary, + // even though it completes without an assistant.turn_start event. + this._dropLateRootTurnEvents = false; const turnId = generateUuid(); this.resetTurnState(turnId); this._emitAction({ @@ -4153,6 +4340,10 @@ export class CopilotAgentSession extends Disposable { if (e.data.source && e.data.source.toLowerCase() !== 'user') { return; } + // A genuine root user-message echo is the provider boundary for a + // normal send. Zero-message continuation has no such echo and remains + // quarantined until assistant.turn_start instead. + this._dropLateRootTurnEvents = false; // First SDK event for the loop: promote the turn out of `pending`. this._currentTurn.value?.markRunning(); const steering = this._takeMatchingPendingSteering(e.data.content); @@ -4177,6 +4368,9 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); this._resumeSubagentForEvent(e); + if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.message')) { + return; + } const stableModelCallId = e.data.apiCallId ?? e.data.clientRequestId; const isCompleteModelCall = stableModelCallId !== undefined || e.data.chunkCount === undefined @@ -4296,6 +4490,9 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onToolCallDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Tool call delta: ${e.data.toolName ?? ''} (${e.data.toolCallId})`); this._resumeSubagentForEvent(e); + if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.tool_call_delta')) { + return; + } if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.tool_call_delta')) { return; } @@ -4345,6 +4542,9 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onToolStart(e => { + if (!e.agentId && this._shouldDropLateRootTurnEvent('tool.execution_start')) { + return; + } if (isHiddenTool(e.data.toolName)) { this._streamingToolDisplaySchedulers.deleteAndDispose(e.data.toolCallId); this._streamingToolCalls.delete(e.data.toolCallId); @@ -4542,6 +4742,9 @@ export class CopilotAgentSession extends Disposable { this._autoApprovals.delete(e.data.toolCallId); this._toolApprovalRecords.delete(e.data.toolCallId); this._pendingAutoApprovals.respond(e.data.toolCallId, undefined); + if (!parentToolCallId && !e.agentId && this._shouldDropLateRootTurnEvent('tool.execution_complete')) { + return; + } const displayName = tracked.displayName; const toolOutput = e.data.error?.message ?? e.data.result?.content; @@ -4655,18 +4858,18 @@ export class CopilotAgentSession extends Disposable { // - if `turn` is the aborted (running) turn, the client-dispatched // `ChatTurnCancelled` finalizes the protocol turn; drop our handle // so a later idle can't complete it. - // - if `turn` is still `pending`, a queued message started it after - // the abort and the SDK has not run it yet; completing it would - // emit an empty `ChatTurnComplete` and orphan its real response. - // Leave it open for its own (non-abort) idle. - // The structural `pending` guard below already protects the - // queued-message case; reading `e.data.aborted` is the authoritative - // SDK signal that lets us also tear down the aborted running turn. + // - if `turn` is the pending failed-turn continuation being aborted, + // drop it before the provider starts. + // - any other pending turn is a queued message started after the + // abort; leave it open for its own non-abort idle. if (e.data.aborted) { this._cancelActiveRepoInfoTelemetry(); - if (turn.isRunning) { - this._logService.trace(`[Copilot:${sessionId}] Idle from abort; tearing down running turn ${turn.id}`); - this._reportToolCallDetails(turn, 'cancelled'); + if (turn.isRunning || turn === this._resumingTurnAwaitingProviderStart) { + this._logService.trace(`[Copilot:${sessionId}] Idle from abort; tearing down cancelled turn ${turn.id}`); + if (turn.isRunning) { + this._reportToolCallDetails(turn, 'cancelled'); + } + this._dropLateRootTurnEvents = true; turn.markAborted(); this._clearActiveTurn(); } else { @@ -4674,6 +4877,10 @@ export class CopilotAgentSession extends Disposable { } return; } + if (turn === this._resumingTurnAwaitingProviderStart && !turn.providerTurnStarted) { + this._logService.trace(`[Copilot:${sessionId}] Ignoring idle from the failed execution while resumed turn ${turn.id} awaits provider start`); + return; + } // Only a `running` turn is completed by a normal idle. A `pending` // turn here means the SDK went idle before emitting any event for it // (a degenerate no-op send); complete it defensively so the session @@ -4732,6 +4939,10 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onSubagentStarted(e => { + if (this._dropLateRootTurnEvents) { + this._logService.error(`[Copilot:${sessionId}] subagent.started emitted after cancellation; dropping`); + return; + } if (e.agentId) { this._parentToolCallIdsByAgentId.set(e.agentId, e.data.toolCallId); this._activeSubagentAgentIds.add(e.agentId); @@ -4759,10 +4970,14 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onSessionError(e => { this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`); + if (!e.agentId && this._shouldDropLateRootTurnEvent('session.error')) { + return; + } if (isCopilotSdkAuthRejection(e.data)) { this._onDidRequireAuth.fire(); } reportCopilotSdkSessionError(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId, this._currentTurn.value?.clientContext)); + const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); const turn = this._currentTurn.value; if (turn) { this._reportToolCallDetails(turn, 'failed'); @@ -4771,8 +4986,11 @@ export class CopilotAgentSession extends Disposable { type: ActionType.ChatError, turnId: this._turnId, duration: turn?.duration ?? 0, - part: { kind: ResponsePartKind.Error, error: buildChatErrorInfoFromCopilotSdkFields(e.data) }, - }); + part: createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data), !parentToolCallId && turn !== undefined), + }, parentToolCallId); + if (!parentToolCallId) { + this._clearActiveTurn(); + } })); this._register(wrapper.onModelCallFailure(e => { @@ -4786,6 +5004,9 @@ export class CopilotAgentSession extends Disposable { let autoModeResolved: { readonly turnId: string; readonly data: NonNullable } | undefined; this._register(wrapper.onAutoModeResolved(e => { + if (!e.agentId && this._shouldDropLateRootTurnEvent('session.auto_mode_resolved')) { + return; + } this._lastSeenModelId = e.data.chosenModel; const turnId = this._turnId; this._logService.info(`[Copilot:${sessionId}] Auto mode resolved to ${e.data.chosenModel}${e.data.reasoningBucket ? ` (${e.data.reasoningBucket})` : ''}`); @@ -4834,6 +5055,9 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onUsage(e => { this._resumeSubagentForEvent(e); + if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.usage')) { + return; + } // Usage events for a subagent's model calls carry the subagent's // `agentId`. Every model call — the parent's own and every subagent's — // is folded into the turn's cost below, so such an event additionally @@ -4988,6 +5212,9 @@ export class CopilotAgentSession extends Disposable { // Losing this re-emit to a turn that ended mid-flight costs only the session // total's freshness; the turn's own cost was already reported synchronously. this._register(wrapper.onUsage(async e => { + if (!e.agentId && this._shouldDropLateRootTurnEvent('assistant.usage')) { + return; + } const isSubagentEvent = !!this._parentToolCallIdForSubagentEvent(e); const turnId = this._turnId; // Capture the base usage before the await boundary so concurrent @@ -5717,8 +5944,15 @@ export class CopilotAgentSession extends Disposable { })); this._register(wrapper.onTurnStart(e => { - this._currentTurn.value?.markProviderTurnStarted(); - this._currentTurn.value?.markRunning(); + const turn = this._currentTurn.value; + turn?.markProviderTurnStarted(); + turn?.markRunning(); + if (!e.agentId) { + this._dropLateRootTurnEvents = false; + if (this._resumingTurnAwaitingProviderStart === turn) { + this._resumingTurnAwaitingProviderStart = undefined; + } + } this._logService.trace(`[Copilot:${sessionId}] Turn started: ${e.data.turnId}`); if (!e.agentId) { const telemetryMessageId = this._currentTurn.value?.id ?? e.data.turnId; diff --git a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts index 3595bf3b2d5ac5..d62d42d19fcfa8 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotFailureTelemetry.ts @@ -13,7 +13,7 @@ import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTel import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { toInitiatorTelemetry, type IAgentHostInitiatorClassification, type IAgentHostInitiatorTelemetry } from '../agentHostTelemetryReporter.js'; -export type CopilotClientOperation = 'abort' | 'changeAgent' | 'changeModel' | 'getSessionMetadata' | 'listSessions' | 'modelRefresh' | 'sendMessage'; +export type CopilotClientOperation = 'abort' | 'changeAgent' | 'changeModel' | 'getSessionMetadata' | 'listSessions' | 'modelRefresh' | 'resumeTurn' | 'sendMessage'; export type CopilotClientOperationFailureKind = 'clientNotConnected' | 'connectionClosed' | 'connectionDisposed' | 'runtimeConnectionClosed'; type CopilotClientStartupOutcome = 'success' | 'failure' | 'cancelled'; type CopilotStartupFailureCause = 'nativeModuleProcedureNotFound' | 'nativeModuleInitializationFailed' | 'nativeModuleNotFound' | 'permissionDenied' | 'timeout' | 'spawnFailed' | 'processExitedUnexpectedly' | 'processExited' | 'configurationChanged' | 'other'; diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 66677f300e8f15..b21dcc4b6a09be 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -15,7 +15,7 @@ import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { toToolCallMeta, type IToolCallUiMeta, type ToolKind } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; -import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseChatUri, type AgentSelection, type ErrorInfo, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type TerminalCommandResult, type ToolCallCompletedState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { createErrorResponsePart, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, parseChatUri, type AgentSelection, type ErrorInfo, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type TerminalCommandResult, type ToolCallCompletedState, type ToolResultContent, type ToolResultTerminalContent, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { buildNonPtyShellTerminalUri } from './copilotNonPtyShellTerminals.js'; import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../../common/sessionDbUri.js'; @@ -180,9 +180,10 @@ interface ITurnBuilder { startedAt: string | undefined; /** ISO 8601 timestamp of the most recent SDK event that belonged to this turn. */ lastEventAt: string | undefined; + waitingStartedAt: string | undefined; + waitingDuration: number; readonly responseParts: ResponsePart[]; usage: UsageInfo | undefined; - error: ErrorInfo | undefined; /** Tool starts seen but not yet completed in this turn, keyed by toolCallId. */ readonly pendingTools: Map; } @@ -191,6 +192,7 @@ export interface IMapSessionEventsOptions { readonly workingDirectory?: URI; readonly model?: ModelSelection; readonly agent?: AgentSelection; + readonly interruptedTurnError?: ErrorInfo; } function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection; origin?: MessageKind; startedAt?: string }): ITurnBuilder { @@ -201,7 +203,7 @@ function newTurnBuilder(id: string, text: string, options?: { attachments?: Mess ...(options?.model ? { model: options.model } : {}), ...(options?.agent ? { agent: options.agent } : {}), }; - return { id, message, startedAt: options?.startedAt, lastEventAt: options?.startedAt, responseParts: [], usage: undefined, error: undefined, pendingTools: new Map() }; + return { id, message, startedAt: options?.startedAt, lastEventAt: options?.startedAt, waitingStartedAt: undefined, waitingDuration: 0, responseParts: [], usage: undefined, pendingTools: new Map() }; } /** Reads the SDK envelope's ISO 8601 `timestamp`, or `undefined` when missing or unparseable. */ @@ -277,16 +279,14 @@ function finalizeTurn(builder: ITurnBuilder, state: TurnState): Turn { const startedAtMs = builder.startedAt === undefined ? undefined : Date.parse(builder.startedAt); const endedAtMs = builder.lastEventAt === undefined ? undefined : Date.parse(builder.lastEventAt); const duration = startedAtMs !== undefined && endedAtMs !== undefined && Number.isFinite(startedAtMs) && Number.isFinite(endedAtMs) - ? Math.max(0, endedAtMs - startedAtMs) + ? Math.max(0, endedAtMs - startedAtMs - builder.waitingDuration) : undefined; return { id: builder.id, ...(builder.startedAt !== undefined ? { startedAt: builder.startedAt } : {}), ...(duration !== undefined ? { duration } : {}), message: builder.message, - responseParts: builder.error - ? [...builder.responseParts, { kind: ResponsePartKind.Error, error: builder.error }] - : builder.responseParts, + responseParts: builder.responseParts, usage: builder.usage, state, }; @@ -409,6 +409,7 @@ export async function mapSessionEvents( let parentTurnState = TurnState.Cancelled; let parentTurnTerminated = false; let rootAssistantTurnActive = false; + let rootRequestActive = false; let pendingAutoModeResolved: Extract['data'] | undefined; /** Envelope timestamp of the event currently being processed. */ @@ -441,7 +442,7 @@ export async function mapSessionEvents( const state = subagentTurnStates.get(parentToolCallId) ?? TurnState.Complete; subagentTurnStates.delete(parentToolCallId); terminatedSubagentTurns.delete(parentToolCallId); - if (builder.responseParts.length === 0 && !builder.error) { + if (builder.responseParts.length === 0) { return; } const list = subagentTurns.get(parentToolCallId) ?? []; @@ -475,13 +476,33 @@ export async function mapSessionEvents( switch (e.type) { case 'assistant.turn_start': if (!e.agentId) { + if (parentBuilder && parentTurnState === TurnState.Error) { + const waitingStartedAt = parentBuilder.waitingStartedAt === undefined ? undefined : Date.parse(parentBuilder.waitingStartedAt); + const resumedAt = currentEventTimestamp === undefined ? undefined : Date.parse(currentEventTimestamp); + if (waitingStartedAt !== undefined && resumedAt !== undefined && Number.isFinite(waitingStartedAt) && Number.isFinite(resumedAt)) { + parentBuilder.waitingDuration += Math.max(0, resumedAt - waitingStartedAt); + } + parentBuilder.waitingStartedAt = undefined; + parentTurnState = TurnState.Cancelled; + parentTurnTerminated = false; + } else if (parentBuilder && rootAssistantTurnActive) { + const interruptedAt = parentBuilder.lastEventAt === undefined ? undefined : Date.parse(parentBuilder.lastEventAt); + const resumedAt = currentEventTimestamp === undefined ? undefined : Date.parse(currentEventTimestamp); + if (interruptedAt !== undefined && resumedAt !== undefined && Number.isFinite(interruptedAt) && Number.isFinite(resumedAt)) { + parentBuilder.waitingDuration += Math.max(0, resumedAt - interruptedAt); + } + } rootAssistantTurnActive = true; + rootRequestActive = true; touch(parentBuilder); } break; case 'assistant.turn_end': if (!e.agentId) { rootAssistantTurnActive = false; + rootRequestActive = parentTurnState !== TurnState.Complete + && parentTurnState !== TurnState.Error + && !parentTurnTerminated; touch(parentBuilder); } break; @@ -538,6 +559,7 @@ export async function mapSessionEvents( flushParent(); const turnId = e.id ?? messageId; parentBuilder = newTurnBuilder(turnId, content, { attachments, model: currentModel, agent: currentAgent, startedAt: currentEventTimestamp }); + rootRequestActive = true; if (pendingAutoModeResolved) { parentBuilder.usage = { model: pendingAutoModeResolved.chosenModel, @@ -555,6 +577,10 @@ export async function mapSessionEvents( const reasoningText = d.reasoningText; const hasToolRequests = !!d.toolRequests && d.toolRequests.length > 0; const parentToolCallId = resolveParentToolCallId(e.agentId, d.parentToolCallId); + if ((!parentToolCallId && parentTurnTerminated && parentTurnState === TurnState.Error) + || (parentToolCallId && terminatedSubagentTurns.has(parentToolCallId) && subagentTurnStates.get(parentToolCallId) === TurnState.Error)) { + break; + } if (!content && !reasoningText && !hasToolRequests) { if (!parentToolCallId && parentBuilder && !parentTurnTerminated) { parentTurnState = TurnState.Complete; @@ -597,12 +623,20 @@ export async function mapSessionEvents( if (!notification) { break; } - if (parentBuilder && (rootAssistantTurnActive || notification.startsTurn)) { + if (parentBuilder && (rootAssistantTurnActive || (notification.startsTurn && !(parentTurnTerminated && parentTurnState === TurnState.Error)))) { + rootRequestActive ||= notification.startsTurn; parentBuilder.responseParts.push({ kind: ResponsePartKind.SystemNotification, content: notification.messageText, }); touch(parentBuilder); + } else if (notification.startsTurn) { + flushParent(); + parentBuilder = newTurnBuilder(e.id ?? generateUuid(), notification.messageText, { + origin: MessageKind.SystemNotification, + startedAt: currentEventTimestamp, + }); + rootRequestActive = true; } break; } @@ -615,15 +649,17 @@ export async function mapSessionEvents( const builder = ensureSubagentBuilder(parentToolCallId); subagentTurnStates.set(parentToolCallId, TurnState.Error); terminatedSubagentTurns.add(parentToolCallId); - builder.error = buildChatErrorInfoFromCopilotSdkFields(e.data); + builder.responseParts.push(createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data))); touch(builder); break; } if (parentBuilder && !parentTurnTerminated) { rootAssistantTurnActive = false; + rootRequestActive = false; parentTurnState = TurnState.Error; parentTurnTerminated = true; - parentBuilder.error = buildChatErrorInfoFromCopilotSdkFields(e.data); + parentBuilder.responseParts.push(createErrorResponsePart(buildChatErrorInfoFromCopilotSdkFields(e.data), true)); + parentBuilder.waitingStartedAt = currentEventTimestamp; touch(parentBuilder); } break; @@ -648,6 +684,10 @@ export async function mapSessionEvents( } toolInfoByCallId.delete(d.toolCallId); const parentToolCallId = resolveParentToolCallId(e.agentId, d.parentToolCallId); + if ((!parentToolCallId && parentTurnTerminated && parentTurnState === TurnState.Error) + || (parentToolCallId && terminatedSubagentTurns.has(parentToolCallId) && subagentTurnStates.get(parentToolCallId) === TurnState.Error)) { + break; + } if (isTaskCompleteTool(info.toolName)) { const builder = targetBuilderFor(parentToolCallId); if (!builder) { @@ -663,6 +703,7 @@ export async function mapSessionEvents( } if (!parentToolCallId && d.success && builder === parentBuilder && !parentTurnTerminated) { parentTurnState = TurnState.Complete; + rootRequestActive = false; } continue; } @@ -711,6 +752,7 @@ export async function mapSessionEvents( } } else { rootAssistantTurnActive = false; + rootRequestActive = false; if (parentBuilder && !parentTurnTerminated) { parentTurnState = TurnState.Cancelled; parentTurnTerminated = true; @@ -719,11 +761,18 @@ export async function mapSessionEvents( } break; } + case 'session.idle': + rootRequestActive = false; + break; default: break; } } + if (options && !(options instanceof URI) && options.interruptedTurnError && parentBuilder && rootRequestActive && parentTurnState !== TurnState.Error) { + parentBuilder.responseParts.push(createErrorResponsePart(options.interruptedTurnError, true)); + parentTurnState = TurnState.Error; + } flushParent(); for (const parentToolCallId of [...subagentBuilders.keys()]) { flushSubagent(parentToolCallId); @@ -753,6 +802,7 @@ export async function mapSessionEvents( } if (!parentToolCallId && completion?.success && builder === parentBuilder && !parentTurnTerminated) { parentTurnState = TurnState.Complete; + rootRequestActive = false; } continue; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 89c5fea3049dea..cee2be510001e2 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -79,7 +79,6 @@ const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; const UNSUPPORTED_CLIENT_ACTION_TYPES: ReadonlySet = new Set([ ActionType.ChatWorkingDirectorySet, ActionType.ChatWorkingDirectoryRemoved, - ActionType.ChatTurnResume, ]); /** A client tool call in any of these statuses is still awaiting its result. */ diff --git a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts index 43e75a71bcb490..3bfb0b04cdcd2f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostInputRequestTracker.test.ts @@ -177,7 +177,7 @@ suite('AgentHostInputRequestTracker', () => { }]); }); - test('decline, cancellation, non-ask purposes, missing active turns, and duplicate completion do not emit', () => { + test('decline, cancellation, non-ask requests, missing active turns, and duplicate completion do not emit', () => { const { telemetry, tracker } = createTracker(); const ask: ChatInputRequest = withChatInputRequestPurpose({ id: 'ask', questions: [] }, ChatInputRequestPurpose.AskUser); const state = completedState(rootChat, 'turn-1', ask); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 1aad43a8c80dbc..fc376767808d61 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { ChatInputQuestionKind, ChatInputResponseKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; +import { ChatInputQuestionKind, ChatInputResponseKind, MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, createErrorResponsePart, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, readSessionEhcliAdoptable, withSessionEhcliAdoptable, type ChatState, type MarkdownResponsePart, type SessionState, type Turn } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; @@ -585,7 +585,7 @@ suite('AgentHostStateManager', () => { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, - part: { kind: ResponsePartKind.Error, error: { errorType: 'failed', message: 'boom' } }, + part: createErrorResponsePart({ errorType: 'failed', message: 'boom' }), }); assert.deepStrictEqual(events, [ diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 206a35d81abb58..a3de1b5ed7870d 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -22,7 +22,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import type { SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ActionType, type ChatAction, type ChatUsageAction } from '../../common/state/sessionActions.js'; -import { buildDefaultChatUri, buildSubagentChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, buildSubagentChatUri, createErrorResponsePart, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostChatContributions } from '../../common/agentHostChatContributionsService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; @@ -294,7 +294,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { devDeviceId: 'client-dev-device-id', }; startTurn('t-client', 'hello', undefined, defaultChatUri, clientContext); - fire({ type: ActionType.ChatError, turnId: 't-client', duration: 100, part: { kind: ResponsePartKind.Error, error: { errorType: 'providerFailed', message: 'failed' } } }); + fire({ type: ActionType.ChatError, turnId: 't-client', duration: 100, part: createErrorResponsePart({ errorType: 'providerFailed', message: 'failed' }) }); assert.deepStrictEqual([completedEvents()[0], failedEvents()[0]].map(event => { const data = event.data as Record; @@ -560,7 +560,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { startTurn('turn-success'); fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-success', duration: 1000 }); startTurn('turn-error'); - fire({ type: ActionType.ChatError, turnId: 'turn-error', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'oops', message: 'fail' } } }); + fire({ type: ActionType.ChatError, turnId: 'turn-error', duration: 1000, part: createErrorResponsePart({ errorType: 'oops', message: 'fail' }) }); startTurn('turn-cancelled'); fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-cancelled', duration: 1000 }); @@ -754,7 +754,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits result=error on ChatError', () => { setupSession(); startTurn('turn-1'); - fire({ type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'oops', message: 'fail' } } }); + fire({ type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, part: createErrorResponsePart({ errorType: 'oops', message: 'fail' }) }); const events = completedEvents(); assert.strictEqual(events.length, 1); @@ -769,21 +769,18 @@ suite('AgentSideEffects — turn tracker telemetry', () => { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, - part: { - kind: ResponsePartKind.Error, - error: { - errorType: 'quota', - message: 'quota exceeded', - _meta: { - chatError: { - fetchError: { - requestId: 'provider-request-id', - serverRequestId: 'service-request-id', - }, + part: createErrorResponsePart({ + errorType: 'quota', + message: 'quota exceeded', + _meta: { + chatError: { + fetchError: { + requestId: 'provider-request-id', + serverRequestId: 'service-request-id', }, }, }, - }, + }), }); assert.deepStrictEqual(failedEvents().map(event => { @@ -814,7 +811,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { startTurn('subagent-complete', 'hello', undefined, subagentChatUri); fire({ type: ActionType.ChatTurnComplete, turnId: 'subagent-complete', duration: 1000 }, subagentChatUri); startTurn('subagent-failed', 'hello', undefined, subagentChatUri); - fire({ type: ActionType.ChatError, turnId: 'subagent-failed', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'oops', message: 'fail' } } }, subagentChatUri); + fire({ type: ActionType.ChatError, turnId: 'subagent-failed', duration: 1000, part: createErrorResponsePart({ errorType: 'oops', message: 'fail' }) }, subagentChatUri); assert.deepStrictEqual({ completed: completedEvents().map(event => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index a21c681f8809c5..73a27bb88043b5 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -41,7 +41,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType, type INotification } from '../../common/state/sessionActions.js'; -import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_CREATED_BY_SESSION_DB_KEY, AH_META_IS_READ_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, readSessionEhcliAdopted, AH_META_IS_ARCHIVED_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, createErrorResponsePart, customizationId, isDefaultChatUri, isMessageHiddenFromTranscript, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionExternal, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { isHostSnapshotAttachment, toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { readAgentMessageDelegationMeta } from '../../common/meta/agentMessageDelegationMeta.js'; @@ -552,6 +552,253 @@ suite('AgentService (node dispatcher)', () => { // No throw - success }); + suite('failed turn resume', () => { + async function createErroredTurn(): Promise<{ session: URI; chat: string }> { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const chat = buildDefaultChatUri(session.toString()); + const stateManager = getStateManager(service); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2026-08-11T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatUsage, + turnId: 'turn-1', + usage: { + inputTokens: 10, + outputTokens: 5, + model: 'model-1', + _meta: { + cost: 1, + copilotUsage: { totalNanoAiu: 2 }, + turnTokenTotals: [{ model: 'model-1', inputTokens: 10, cachedTokens: 1, outputTokens: 5 }], + }, + }, + }); + stateManager.dispatchServerAction(chat, { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + }); + return { session, chat }; + } + + test('rejects resumable state when the provider cannot continue', async () => { + const { chat } = await createErroredTurn(); + const envelopePromise = Event.toPromise(Event.filter(service.onDidAction, envelope => envelope.origin?.clientSeq === 1)); + + service.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, 'client-1', 1); + + const envelope = await envelopePromise; + assert.deepStrictEqual({ + rejectionReason: envelope.rejectionReason, + activeTurn: getStateManager(service).getChatState(chat)?.activeTurn, + }, { + rejectionReason: 'The session provider does not support turn resume.', + activeTurn: undefined, + }); + }); + + test('rejects resume after the session is archived', async () => { + const { session, chat } = await createErroredTurn(); + copilotAgent.chats.resumeTurn = async () => { }; + getStateManager(service).dispatchServerAction(session.toString(), { + type: ActionType.SessionIsArchivedChanged, + isArchived: true, + }); + const envelopePromise = Event.toPromise(Event.filter(service.onDidAction, envelope => envelope.origin?.clientSeq === 1)); + + service.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, 'client-1', 1); + + const envelope = await envelopePromise; + assert.strictEqual(envelope.rejectionReason, 'Cannot resume a read-only or archived chat.'); + }); + + test('accepts only one racing resume before provider side effects', async () => { + const { chat } = await createErroredTurn(); + const calls: Array<{ chat: string; turnId: string }> = []; + copilotAgent.chats.resumeTurn = async (resource, turnId) => { + calls.push({ chat: resource.toString(), turnId }); + }; + const envelopes: ActionEnvelope[] = []; + disposables.add(service.onDidAction(envelope => { + if (envelope.origin?.clientSeq === 1 || envelope.origin?.clientSeq === 2) { + envelopes.push(envelope); + } + })); + + service.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, 'client-1', 1); + service.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, 'client-2', 2); + await timeout(0); + + assert.deepStrictEqual({ + calls, + envelopes: envelopes.map(envelope => ({ clientSeq: envelope.origin?.clientSeq, rejectionReason: envelope.rejectionReason })), + activeTurnId: getStateManager(service).getChatState(chat)?.activeTurn?.id, + }, { + calls: [{ chat, turnId: 'turn-1' }], + envelopes: [ + { clientSeq: 1, rejectionReason: undefined }, + { clientSeq: 2, rejectionReason: 'Cannot resume while a turn is active.' }, + ], + activeTurnId: 'turn-1', + }); + }); + + test('preserves cumulative logical-turn duration and usage', async () => { + const { chat } = await createErroredTurn(); + copilotAgent.chats.resumeTurn = async () => { }; + service.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, 'client-1', 1); + copilotAgent.fireProgress({ + kind: 'action', + resource: URI.parse(chat), + action: { + type: ActionType.ChatUsage, + turnId: 'turn-1', + usage: { + inputTokens: 20, + outputTokens: 8, + model: 'model-1', + _meta: { + cost: 3, + copilotUsage: { totalNanoAiu: 4 }, + turnTokenTotals: [{ model: 'model-1', inputTokens: 20, cachedTokens: 2, outputTokens: 8 }], + }, + }, + }, + }); + copilotAgent.fireProgress({ + kind: 'action', + resource: URI.parse(chat), + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 50 }, + }); + + const turn = getStateManager(service).getChatState(chat)?.turns.at(-1); + assert.deepStrictEqual({ + id: turn?.id, + duration: turn?.duration, + usage: turn?.usage, + }, { + id: 'turn-1', + duration: 150, + usage: { + inputTokens: 20, + outputTokens: 8, + model: 'model-1', + _meta: { + cost: 4, + copilotUsage: { totalNanoAiu: 6 }, + turnTokenTotals: [{ model: 'model-1', inputTokens: 30, cachedTokens: 3, outputTokens: 13 }], + }, + }, + }); + }); + + test('accumulates duration and usage across repeated failed continuations', async () => { + const { chat } = await createErroredTurn(); + copilotAgent.chats.resumeTurn = async () => { }; + const failContinuation = (clientSeq: number, duration: number, usage: { inputTokens: number; outputTokens: number; cost: number; nanoAiu: number; cachedTokens: number }, message: string) => { + service.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, `client-${clientSeq}`, clientSeq); + copilotAgent.fireProgress({ + kind: 'action', + resource: URI.parse(chat), + action: { + type: ActionType.ChatUsage, + turnId: 'turn-1', + usage: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + model: 'model-1', + _meta: { + cost: usage.cost, + copilotUsage: { totalNanoAiu: usage.nanoAiu }, + turnTokenTotals: [{ + model: 'model-1', + inputTokens: usage.inputTokens, + cachedTokens: usage.cachedTokens, + outputTokens: usage.outputTokens, + }], + }, + }, + }, + }); + copilotAgent.fireProgress({ + kind: 'action', + resource: URI.parse(chat), + action: { + type: ActionType.ChatError, + turnId: 'turn-1', + duration, + part: createErrorResponsePart({ errorType: 'requestFailed', message }, true), + }, + }); + }; + + failContinuation(1, 50, { inputTokens: 20, outputTokens: 8, cost: 3, nanoAiu: 4, cachedTokens: 2 }, 'failed again'); + failContinuation(2, 25, { inputTokens: 30, outputTokens: 10, cost: 5, nanoAiu: 6, cachedTokens: 3 }, 'failed a third time'); + + const state = getStateManager(service).getChatState(chat); + const turn = state?.turns.at(-1); + assert.deepStrictEqual({ + turnCount: state?.turns.length, + id: turn?.id, + duration: turn?.duration, + errorMessages: turn?.responseParts + .filter(part => part.kind === ResponsePartKind.Error) + .map(part => part.error.message), + usage: turn?.usage, + }, { + turnCount: 1, + id: 'turn-1', + duration: 175, + errorMessages: ['failed', 'failed again', 'failed a third time'], + usage: { + inputTokens: 30, + outputTokens: 10, + model: 'model-1', + _meta: { + cost: 9, + copilotUsage: { totalNanoAiu: 12 }, + turnTokenTotals: [{ model: 'model-1', inputTokens: 60, cachedTokens: 6, outputTokens: 23 }], + }, + }, + }); + }); + + test('finalizes the same turn with another resumable error when continuation fails immediately', async () => { + const { chat } = await createErroredTurn(); + copilotAgent.chats.resumeTurn = async () => { + throw new Error('continuation failed'); + }; + service.dispatchAction(chat, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, 'client-1', 1); + await Event.toPromise(Event.filter(service.onDidAction, envelope => + envelope.action.type === ActionType.ChatError && !envelope.origin)); + + const state = getStateManager(service).getChatState(chat); + const turn = state?.turns.at(-1); + assert.deepStrictEqual({ + turnCount: state?.turns.length, + id: turn?.id, + state: turn?.state, + errors: turn?.responseParts.filter(part => part.kind === ResponsePartKind.Error), + durationAtLeastInitial: (turn?.duration ?? 0) >= 100, + }, { + turnCount: 1, + id: 'turn-1', + state: TurnState.Error, + errors: [ + createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + createErrorResponsePart({ errorType: 'sendFailed', message: 'Error: continuation failed' }, true), + ], + durationAtLeastInitial: true, + }); + }); + }); + test('forwards the exact chat URI encoded in an MCP channel', async () => { const provider: IAgent = copilotAgent; const calls: Array<{ chat: string; serverName: string; method: string; params: Record | undefined }> = []; diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index de138011244d15..65cf137bfda07c 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -28,7 +28,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; import { ChangesSummary, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, AuthRequiredReason, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ChatInputRequest, type ClientPluginCustomization, type Customization, type ISessionGitHubState, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInteractivity, createErrorResponsePart, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, readUsageInfoMeta, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ChatInputRequest, type ClientPluginCustomization, type Customization, type ISessionGitHubState, type PluginCustomization, type Turn } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -353,6 +353,424 @@ suite('AgentSideEffects', () => { // ---- handleAction: session/turnStarted ------------------------------ + test('tracks a resumed turn as a new provider execution', () => { + setupSession(); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + }); + const resumedTurn = stateManager.getChatState(defaultChatUri)?.turns.at(-1); + assert.ok(resumedTurn); + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }); + agent.chats.resumeTurn = async () => { }; + const startedProviders: string[] = []; + disposables.add(sideEffects.onDidStartTurn(provider => startedProviders.push(provider))); + + sideEffects.handleAction( + defaultChatUri, + { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, + 'client-1', + AgentHostClientType.EditorWindow, + resumedTurn, + ); + + assert.deepStrictEqual(startedProviders, ['mock']); + }); + + test('reports only the resumed attempt usage to turn telemetry', () => { + setupSession(); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatUsage, + turnId: 'turn-1', + usage: { + _meta: { + copilotUsage: { totalNanoAiu: 2 }, + directCopilotUsage: { totalNanoAiu: 1 }, + directTurnTokenTotals: [{ model: 'model-1', inputTokens: 10, cachedTokens: 2, outputTokens: 3 }], + }, + }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + }); + const resumedTurn = stateManager.getChatState(defaultChatUri)?.turns.at(-1); + assert.ok(resumedTurn); + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }); + agent.chats.resumeTurn = async () => { }; + + sideEffects.handleAction( + defaultChatUri, + { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, + 'client-1', + AgentHostClientType.EditorWindow, + resumedTurn, + ); + disposables.add(sideEffects.registerProgressListener(agent)); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatUsage, + turnId: 'turn-1', + usage: { + _meta: { + copilotUsage: { totalNanoAiu: 4 }, + directCopilotUsage: { totalNanoAiu: 3 }, + directTurnTokenTotals: [{ model: 'model-1', inputTokens: 20, cachedTokens: 4, outputTokens: 6 }], + }, + }, + }, + }); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 50 }, + }); + + const completedEvent = telemetryService.events.find(event => event.eventName === 'agentHost.turnCompleted'); + const completedEventData = completedEvent?.data as Record | undefined; + const persistedUsage = readUsageInfoMeta(stateManager.getChatState(defaultChatUri)?.turns.at(-1)?.usage); + assert.deepStrictEqual({ + billedNanoAiu: completedEventData?.billedNanoAiu, + directBilledNanoAiu: completedEventData?.directBilledNanoAiu, + directPromptTokenCount: completedEventData?.directPromptTokenCount, + directPromptCacheTokenCount: completedEventData?.directPromptCacheTokenCount, + directCompletionTokenCount: completedEventData?.directCompletionTokenCount, + persistedNanoAiu: persistedUsage.copilotUsage?.totalNanoAiu, + persistedDirectNanoAiu: persistedUsage.directCopilotUsage?.totalNanoAiu, + persistedDirectTurnTokenTotals: persistedUsage.directTurnTokenTotals, + }, { + billedNanoAiu: 4, + directBilledNanoAiu: 3, + directPromptTokenCount: 20, + directPromptCacheTokenCount: 4, + directCompletionTokenCount: 6, + persistedNanoAiu: 6, + persistedDirectNanoAiu: 4, + persistedDirectTurnTokenTotals: [{ model: 'model-1', inputTokens: 30, cachedTokens: 6, outputTokens: 9 }], + }); + }); + + test('preserves the original turn-start checkpoint identity across resume and completion', async () => { + const workingDirectory = URI.file('/wd'); + setupSession(workingDirectory.toString()); + const checkpointCalls: Array<{ kind: 'start' | 'end' | 'discard'; session: string; chat: string; turnId: string; startKeys?: readonly string[] }> = []; + const turnStartKeys = new Set(); + const finalCapture = new DeferredPromise(); + const checkpointService: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + captureTurnStartCheckpoint: async (session, chat, turnId) => { + const key = `${chat.toString()}\0${turnId}`; + turnStartKeys.add(key); + checkpointCalls.push({ kind: 'start', session: session.toString(), chat: chat.toString(), turnId }); + }, + captureTurnCheckpoint: async (session, chat, turnId) => { + const key = `${chat.toString()}\0${turnId}`; + checkpointCalls.push({ + kind: 'end', + session: session.toString(), + chat: chat.toString(), + turnId, + startKeys: [...turnStartKeys], + }); + turnStartKeys.delete(key); + finalCapture.complete(); + }, + discardTurnStartCheckpoint: async (session, chat, turnId) => { + turnStartKeys.delete(`${chat.toString()}\0${turnId}`); + checkpointCalls.push({ kind: 'discard', session: session.toString(), chat: chat.toString(), turnId }); + }, + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + resolveWorkingDirectoryBeforeSend: async () => [workingDirectory], + }, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpointService); + disposables.add(localSideEffects.registerProgressListener(agent)); + + const turnStarted = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + } as const; + stateManager.dispatchServerAction(defaultChatUri, turnStarted); + localSideEffects.handleAction(defaultChatUri, turnStarted); + await waitForSendMessageCalls(1); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + }, + }); + await timeout(0); + + const resumedTurn = stateManager.getChatState(defaultChatUri)?.turns.at(-1); + assert.ok(resumedTurn); + const resumeCalls: Array<{ chat: string; turnId: string }> = []; + agent.chats.resumeTurn = async (chat, turnId) => { + resumeCalls.push({ chat: chat.toString(), turnId }); + }; + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }); + localSideEffects.handleAction( + defaultChatUri, + { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, + 'client-1', + AgentHostClientType.EditorWindow, + resumedTurn, + ); + await timeout(0); + const callsAfterResume = checkpointCalls.map(call => ({ ...call })); + const keysAfterResume = [...turnStartKeys]; + + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 50 }, + }); + await finalCapture.p; + const finalKeys = [...turnStartKeys]; + + const checkpointKey = `${defaultChatUri}\0turn-1`; + assert.deepStrictEqual({ + resumeCalls, + callsAfterResume, + keysAfterResume, + finalCheckpointCall: checkpointCalls.at(-1), + finalKeys, + finalTurn: stateManager.getChatState(defaultChatUri)?.turns.at(-1)?.id, + }, { + resumeCalls: [{ chat: defaultChatUri, turnId: 'turn-1' }], + callsAfterResume: [ + { kind: 'start', session: sessionUri.toString(), chat: defaultChatUri, turnId: 'turn-1' }, + ], + keysAfterResume: [checkpointKey], + finalCheckpointCall: { kind: 'end', session: sessionUri.toString(), chat: defaultChatUri, turnId: 'turn-1', startKeys: [checkpointKey] }, + finalKeys: [], + finalTurn: 'turn-1', + }); + }); + + test('discarding an abandoned resumable checkpoint survives replacement cancellation', async () => { + const workingDirectory = URI.file('/wd'); + setupSession(workingDirectory.toString()); + const turn2ResolutionStarted = new DeferredPromise(); + const turn2Resolution = new DeferredPromise(); + const turn1Discarded = new DeferredPromise(); + const turn3Captured = new DeferredPromise(); + const checkpointCalls: Array<{ kind: 'start' | 'discard'; turnId: string }> = []; + const turnStartKeys = new Set(); + const checkpointService: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + captureTurnStartCheckpoint: async (_session, chat, turnId) => { + turnStartKeys.add(`${chat.toString()}\0${turnId}`); + checkpointCalls.push({ kind: 'start', turnId }); + if (turnId === 'turn-3') { + turn3Captured.complete(); + } + }, + discardTurnStartCheckpoint: async (_session, chat, turnId) => { + turnStartKeys.delete(`${chat.toString()}\0${turnId}`); + checkpointCalls.push({ kind: 'discard', turnId }); + if (turnId === 'turn-1') { + turn1Discarded.complete(); + } + }, + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + resolveWorkingDirectoryBeforeSend: async ({ turnId }) => { + if (turnId === 'turn-2') { + turn2ResolutionStarted.complete(); + await turn2Resolution.p; + } + return [workingDirectory]; + }, + }, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpointService); + disposables.add(localSideEffects.registerProgressListener(agent)); + const startTurn = (turnId: string) => { + const action = { + type: ActionType.ChatTurnStarted, + turnId, + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + } as const; + stateManager.dispatchServerAction(defaultChatUri, action); + localSideEffects.handleAction(defaultChatUri, action); + }; + + startTurn('turn-1'); + await waitForSendMessageCalls(1); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + }, + }); + await timeout(0); + startTurn('turn-2'); + await turn2ResolutionStarted.p; + const cancellation = { type: ActionType.ChatTurnCancelled, turnId: 'turn-2', duration: 0 } as const; + stateManager.dispatchServerAction(defaultChatUri, cancellation); + localSideEffects.handleAction(defaultChatUri, cancellation); + await turn1Discarded.p; + startTurn('turn-3'); + await turn3Captured.p; + turn2Resolution.complete(); + await timeout(0); + + assert.deepStrictEqual({ + checkpointCalls, + turnStartKeys: [...turnStartKeys], + }, { + checkpointCalls: [ + { kind: 'start', turnId: 'turn-1' }, + { kind: 'discard', turnId: 'turn-1' }, + { kind: 'discard', turnId: 'turn-2' }, + { kind: 'discard', turnId: 'turn-2' }, + { kind: 'start', turnId: 'turn-3' }, + ], + turnStartKeys: [`${defaultChatUri}\0turn-3`], + }); + }); + + test('provider-created turns discard an abandoned resumable checkpoint', async () => { + setupSession(URI.file('/wd').toString()); + const checkpointCalls: Array<{ kind: 'start' | 'discard'; turnId: string }> = []; + const discarded = new DeferredPromise(); + const checkpointService: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + captureTurnStartCheckpoint: async (_session, _chat, turnId) => { + checkpointCalls.push({ kind: 'start', turnId }); + }, + discardTurnStartCheckpoint: async (_session, _chat, turnId) => { + checkpointCalls.push({ kind: 'discard', turnId }); + discarded.complete(); + }, + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + resolveWorkingDirectoryBeforeSend: async () => [URI.file('/wd')], + }, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpointService); + disposables.add(localSideEffects.registerProgressListener(agent)); + const turnStarted = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + } as const; + stateManager.dispatchServerAction(defaultChatUri, turnStarted); + localSideEffects.handleAction(defaultChatUri, turnStarted); + await waitForSendMessageCalls(1); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + }, + }); + await timeout(0); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'system-turn', + startedAt: '2025-01-01T00:01:00.000Z', + message: { text: 'Background work completed', origin: { kind: MessageKind.SystemNotification } }, + }); + await discarded.p; + + assert.deepStrictEqual(checkpointCalls, [ + { kind: 'start', turnId: 'turn-1' }, + { kind: 'discard', turnId: 'turn-1' }, + ]); + }); + + test('a rejected replacement keeps the resumable turn checkpoint', async () => { + setupSession(URI.file('/wd').toString()); + const checkpointCalls: Array<{ kind: 'start' | 'discard'; turnId: string }> = []; + const checkpointService: IAgentHostCheckpointService = { + ...NULL_CHECKPOINT_SERVICE, + captureTurnStartCheckpoint: async (_session, _chat, turnId) => { + checkpointCalls.push({ kind: 'start', turnId }); + }, + discardTurnStartCheckpoint: async (_session, _chat, turnId) => { + checkpointCalls.push({ kind: 'discard', turnId }); + }, + }; + const localSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + resolveWorkingDirectoryBeforeSend: async () => [URI.file('/wd')], + }, undefined, NullTelemetryService, new FakeChangesetService(), undefined, checkpointService); + disposables.add(localSideEffects.registerProgressListener(agent)); + const turnStarted = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + } as const; + stateManager.dispatchServerAction(defaultChatUri, turnStarted); + localSideEffects.handleAction(defaultChatUri, turnStarted); + await waitForSendMessageCalls(1); + agent.fireProgress({ + kind: 'action', + resource: URI.parse(defaultChatUri), + action: { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: createErrorResponsePart({ errorType: 'requestFailed', message: 'failed' }, true), + }, + }); + await timeout(0); + + stateManager.rejectClientAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'rejected-turn', + startedAt: '2025-01-01T00:01:00.000Z', + message: { text: 'rejected', origin: { kind: MessageKind.User } }, + }, { clientId: 'client-1', clientSeq: 2 }, 'Rejected for test'); + await timeout(0); + + assert.deepStrictEqual(checkpointCalls, [ + { kind: 'start', turnId: 'turn-1' }, + ]); + }); + test('records customization toggles in the enablement service', () => { const calls: { session: string; target: string; enablement: unknown }[] = []; customizationEnablementService.replaceEnablement = (session, target, enablement) => { @@ -1153,9 +1571,11 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual({ sendMessageCalls: agent.sendMessageCalls.length, errorType: envelope.action.type === ActionType.ChatError ? envelope.action.part.error.errorType : undefined, + resumable: envelope.action.type === ActionType.ChatError ? envelope.action.part.resumable : undefined, }, { sendMessageCalls: 0, errorType: 'sendFailed', + resumable: undefined, }); }); @@ -1550,7 +1970,7 @@ suite('AgentSideEffects', () => { await originalSendMessage(...args); agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1, part: { kind: ResponsePartKind.Error, error: { errorType: 'CodexMaterializeFailed', message: 'workspace root rejected' } } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1, part: createErrorResponsePart({ errorType: 'CodexMaterializeFailed', message: 'workspace root rejected' }) }, }); agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), @@ -2332,7 +2752,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, part: { kind: ResponsePartKind.Error, error: { errorType: 'Error', message: 'boom' } } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, part: createErrorResponsePart({ errorType: 'Error', message: 'boom' }) }, }); assert.deepStrictEqual({ @@ -7059,7 +7479,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, part: { kind: ResponsePartKind.Error, error: { errorType: 'test', message: 'failed' } } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, part: createErrorResponsePart({ errorType: 'test', message: 'failed' }) }, }); agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), @@ -7087,7 +7507,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, part: { kind: ResponsePartKind.Error, error: { errorType: 'terminal', message: 'failed' } } }, + action: { type: ActionType.ChatError, turnId: 'turn-1', duration: 100, part: createErrorResponsePart({ errorType: 'terminal', message: 'failed' }) }, }); await captured.p; diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 764de8abad309d..ea02009a22c8a9 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -916,6 +916,38 @@ suite('AgentHostChatContributions', () => { }); }); + test('queue drain defers stale queued actions until a resumable turn completes', () => { + const queue = createQueueDrainContributions(disposables); + queue.stateManager.dispatchServerAction(queue.chat, { + type: ActionType.ChatTurnStarted, + turnId: 'resumable-turn', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'running', origin: { kind: MessageKind.User } }, + }); + queue.stateManager.dispatchServerAction(queue.chat, { + type: ActionType.ChatError, + turnId: 'resumable-turn', + duration: 1, + part: { kind: ResponsePartKind.Error, error: { errorType: 'requestFailed', message: 'failed' }, resumable: true }, + }); + const queued = queuedMessage('queued', 'queued'); + queue.stateManager.dispatchServerAction(queue.chat, queued); + queue.service.action(observedAction(queue.chat, queue.session, queued)); + const admittedWhileFailed = queue.admitted.map(admission => admission.message.text); + + queue.stateManager.dispatchServerAction(queue.chat, { type: ActionType.ChatTurnResume, turnId: 'resumable-turn' }); + queue.stateManager.dispatchServerAction(queue.chat, { type: ActionType.ChatTurnComplete, turnId: 'resumable-turn', duration: 2 }); + queue.service.turnEnd({ session: queue.session, channel: queue.chat, turnId: 'resumable-turn', reason: { kind: 'success' } }); + + assert.deepStrictEqual({ + admittedWhileFailed, + admittedAfterCompletion: queue.admitted.map(admission => admission.message.text), + }, { + admittedWhileFailed: [], + admittedAfterCompletion: ['queued'], + }); + }); + test('queue drain falls back after chat-memento eviction', () => { const queue = createQueueDrainContributions(disposables); queue.stateManager.dispatchServerAction(queue.chat, { @@ -1013,6 +1045,26 @@ suite('AgentHostChatContributions', () => { assert.deepStrictEqual(observed, ['checkpointAndChangeset', 'queueDrain', 'githubReferences', 'sessionTitle', 'markUnread']); }); + test('resumable errors defer checkpoint capture until the logical turn ends', () => { + const observed: string[] = []; + const contributions = createBuiltInContributions(disposables, observed); + contributions.service.turnEnd(turnEnd('resumable-error', { + kind: 'error', + error: { errorType: 'requestFailed', message: 'failed' }, + resumable: true, + })); + + assert.deepStrictEqual({ + checkpointAndChangeset: observed.includes('checkpointAndChangeset'), + queueDrain: observed.includes('queueDrain'), + markUnread: observed.includes('markUnread'), + }, { + checkpointAndChangeset: false, + queueDrain: false, + markUnread: true, + }); + }); + test('drains the queue but skips other turn-end contributions for local commands', () => { const observed: string[] = []; const contributions = createBuiltInContributions(disposables, observed); @@ -1400,7 +1452,7 @@ suite('AgentHostChatContributions', () => { test('injects context after failed or cancelled first side-chat attempts', async () => { const reasons: readonly ITurnEnd['reason'][] = [ - { kind: 'error', error: { errorType: 'test', message: 'failed' } }, + { kind: 'error', error: { errorType: 'test', message: 'failed' }, resumable: false }, { kind: 'cancelled' }, ]; for (const reason of reasons) { @@ -1423,7 +1475,10 @@ suite('AgentHostChatContributions', () => { type: ActionType.ChatError, turnId: 'first-turn', duration: 1, - part: { kind: ResponsePartKind.Error, error: reason.error }, + part: { + kind: ResponsePartKind.Error, + error: reason.error, + }, }); } else { sideChat.stateManager.dispatchServerAction(sideChat.sideChat, { diff --git a/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts index 598916212256de..0bbfeffae779e9 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexReplayMapper.test.ts @@ -475,9 +475,14 @@ suite('codexReplayMapper', () => { startedAt: null, completedAt: null, durationMs: null, }], } as never); - assert.deepStrictEqual(turns.map(turn => ({ state: turn.state, error: getTurnError(turn) })), [{ + assert.deepStrictEqual(turns.map(turn => ({ + state: turn.state, + error: getTurnError(turn), + errorPartCount: turn.responseParts.filter(part => part.kind === ResponsePartKind.Error).length, + })), [{ state: TurnState.Error, error: { errorType: 'CodexError', message: 'oops' }, + errorPartCount: 1, }]); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 3c4ccc8f9f92d0..0c3a3df7f7640d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -2708,6 +2708,55 @@ suite('CopilotAgent', () => { } }); + test('recovers a closed connection while resuming without duplicating the turn failure', async () => { + const client = new TestCopilotClient([]); + const telemetryService = new RecordingTelemetryService(); + const agent = createTestAgent(disposables, { copilotClient: client, telemetryService }); + const session = AgentSession.uri('copilotcli', 'resume-failure'); + const chat = defaultChatUri(session); + let active = true; + let resumeCalls = 0; + let failureCalls = 0; + setDefaultSessionStub(agent, 'resume-failure', { + sessionId: 'resume-failure', + sessionUri: session, + chatUri: chat, + get hasActiveTurn() { return active; }, + currentTurnClientContext: undefined, + resume: async () => { + resumeCalls++; + throw new Error('Connection is closed.'); + }, + failActiveTurn: () => { + if (!active) { + return undefined; + } + active = false; + failureCalls++; + return 'turn-1'; + }, + dispose: () => { }, + }, chat); + try { + await agent.listChatsToMigrate(); + await agent.chats.resumeTurn!(chat, 'turn-1', exactChatContext(session, chat)); + + assert.deepStrictEqual({ + resumeCalls, + failureCalls, + remainingSessions: chatEntriesBySdkId(agent).size, + operation: (telemetryService.errorEvents.find(event => event.eventName === 'agentHost.copilotClientFailure')?.data as Record | undefined)?.operation, + }, { + resumeCalls: 1, + failureCalls: 1, + remainingSessions: 0, + operation: 'resumeTurn', + }); + } finally { + await disposeAgent(agent); + } + }); + test('reports but does not recover or discard for another classified abort failure', async () => { const telemetryService = new RecordingTelemetryService(); const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]), telemetryService }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 4978a4d47c8d01..755a985fd06388 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -85,6 +85,9 @@ import { createTestGitHubEndpointService } from './testGitHubEndpointService.js' class MockCopilotSession { readonly sessionId = 'test-session-1'; readonly sendRequests: unknown[] = []; + readonly sendMessagesRequests: unknown[] = []; + sendMessagesError: Error | undefined; + sendMessagesGate: Promise | undefined; sendGate: Promise | undefined; readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = []; readonly permissionModeSetCalls: PermissionAllowAllMode[] = []; @@ -266,6 +269,13 @@ class MockCopilotSession { } readonly rpc = { + sendMessages: async (request: unknown) => { + this.sendMessagesRequests.push(request); + if (this.sendMessagesError) { + throw this.sendMessagesError; + } + await this.sendMessagesGate; + }, debug: { collectLogs: async (params: Parameters[0]) => { this.collectLogsCalls.push(params); @@ -702,6 +712,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { isLaunchTokenCurrent?: () => boolean; onTurnEnded?: () => void; modelId?: string; + enableDevelopmentErrorInjection?: boolean; resume?: boolean; initializeEnablementSession?: (session: string) => Promise; beforeLaunch?: () => void; @@ -959,6 +970,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { platform: options?.platform ?? 'linux', isLaunchTokenCurrent: options?.isLaunchTokenCurrent, onTurnEnded: options?.onTurnEnded, + enableDevelopmentErrorInjection: options?.enableDevelopmentErrorInjection ?? true, }, )); @@ -1436,6 +1448,26 @@ suite('CopilotAgentSession', () => { assert.strictEqual(getEventsCalls, 3, 'memo should be invalidated after a session error'); }); + test('describes an interrupted restored request without exposing Agent Host terminology', async () => { + const { session, mockSession } = await createAgentSession(disposables, { resume: true }); + mockSession.messages = [ + { type: 'user.message', id: 'interrupted-turn', data: { interactionId: 'message-1', content: 'Keep working' } }, + { type: 'assistant.turn_start', data: { turnId: 'sdk-turn' } }, + { type: 'assistant.message', data: { messageId: 'message-2', content: 'Partial response' } }, + ] as SessionEvent[]; + + const turn = (await session.getMessages())[0]; + + assert.deepStrictEqual(turn.responseParts.at(-1), { + kind: ResponsePartKind.Error, + error: { + errorType: 'executionInterrupted', + message: 'The agent was interrupted before this request finished.', + }, + resumable: true, + }); + }); + test('falls back to file reference when reading a symbol Resource attachment fails', async () => { const symbolUri = URI.file('/workspace/missing.ts'); const { session, mockSession } = await createAgentSession(disposables, { @@ -5496,6 +5528,410 @@ suite('CopilotAgentSession', () => { }); }); + suite('failed turn resume', () => { + + test('the development $error path uses raw sendMessages even with attachments', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('$error', [{ + type: MessageAttachmentKind.Simple, + label: 'context', + modelRepresentation: 'attached context', + }], 'turn-error'); + + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + sendMessagesRequests: mockSession.sendMessagesRequests, + }, { + sendRequests: [], + sendMessagesRequests: [{ + messages: [{ prompt: '$error' }], + requestHeaders: { Authorization: '******' }, + }], + }); + }); + + test('the development $error-ui path emits a resumable error even with attachments', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + await session.send('$error-ui', [{ + type: MessageAttachmentKind.Simple, + label: 'context', + modelRepresentation: 'attached context', + }], 'turn-error'); + + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + sendMessagesRequests: mockSession.sendMessagesRequests, + actions: getActions(signals).map(action => action.type === ActionType.ChatError ? { ...action, duration: 0 } : action), + }, { + sendRequests: [], + sendMessagesRequests: [], + actions: [{ + type: ActionType.ChatError, + turnId: 'turn-error', + duration: 0, + part: { + kind: ResponsePartKind.Error, + error: { + errorType: 'developmentRecoverableError', + message: 'Injected recoverable development error (1/1).', + }, + resumable: true, + }, + }], + }); + }); + + test('the development $error-ui path can repeat failures before succeeding in the same turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + await session.send('$error-ui:2', undefined, 'turn-error'); + await session.resume('turn-error'); + await session.resume('turn-error'); + + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + sendMessagesRequests: mockSession.sendMessagesRequests, + actions: getActions(signals).map(action => ({ + type: action.type, + turnId: action.type === ActionType.ChatError || action.type === ActionType.ChatResponsePart || action.type === ActionType.ChatTurnComplete ? action.turnId : undefined, + error: action.type === ActionType.ChatError ? action.part.error.message : undefined, + content: action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown ? action.part.content : undefined, + })), + }, { + sendRequests: [], + sendMessagesRequests: [], + actions: [ + { type: ActionType.ChatError, turnId: 'turn-error', error: 'Injected recoverable development error (1/2).', content: undefined }, + { type: ActionType.ChatError, turnId: 'turn-error', error: 'Injected recoverable development error (2/2).', content: undefined }, + { type: ActionType.ChatResponsePart, turnId: 'turn-error', error: undefined, content: 'Recovered after 2 injected failure(s).' }, + { type: ActionType.ChatTurnComplete, turnId: 'turn-error', error: undefined, content: undefined }, + ], + }); + }); + + test('the development $error-ui-tool path preserves a completed tool call across failure and resume', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + await session.send('$error-ui-tool', undefined, 'turn-error'); + await session.resume('turn-error'); + + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + sendMessagesRequests: mockSession.sendMessagesRequests, + actions: getActions(signals).map(action => ({ + type: action.type, + toolCallId: action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallReady || action.type === ActionType.ChatToolCallComplete ? action.toolCallId : undefined, + error: action.type === ActionType.ChatError ? action.part.error.message : undefined, + content: action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown ? action.part.content : undefined, + })), + }, { + sendRequests: [], + sendMessagesRequests: [], + actions: [ + { type: ActionType.ChatToolCallStart, toolCallId: 'turn-error-development-tool', error: undefined, content: undefined }, + { type: ActionType.ChatToolCallReady, toolCallId: 'turn-error-development-tool', error: undefined, content: undefined }, + { type: ActionType.ChatToolCallComplete, toolCallId: 'turn-error-development-tool', error: undefined, content: undefined }, + { type: ActionType.ChatError, toolCallId: undefined, error: 'Injected recoverable development error (1/1).', content: undefined }, + { type: ActionType.ChatResponsePart, toolCallId: undefined, error: undefined, content: 'Recovered after 1 injected failure(s).' }, + { type: ActionType.ChatTurnComplete, toolCallId: undefined, error: undefined, content: undefined }, + ], + }); + }); + + test('development error helpers can be disabled for product builds', async () => { + const disabled = await createAgentSession(disposables, { enableDevelopmentErrorInjection: false }); + + await disabled.session.send('$error-ui-tool', undefined, 'turn-error'); + + assert.deepStrictEqual({ + actions: getActions(disabled.signals), + sendRequests: disabled.mockSession.sendRequests, + sendMessagesRequests: disabled.mockSession.sendMessagesRequests, + }, { + actions: [], + sendRequests: [{ prompt: '$error-ui-tool', attachments: undefined }], + sendMessagesRequests: [], + }); + }); + + test('resumes the same turn with zero SDK messages', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.resume('turn-1', 'plan', 'client-1'); + + assert.deepStrictEqual({ + sendRequests: mockSession.sendRequests, + sendMessagesRequests: mockSession.sendMessagesRequests, + modeSetCalls: mockSession.modeSetCalls, + }, { + sendRequests: [], + sendMessagesRequests: [{ messages: [] }], + modeSetCalls: [{ mode: 'plan' }], + }); + }); + + test('clears the active turn when the continuation connection closes', async () => { + const { session, mockSession } = await createAgentSession(disposables); + mockSession.sendMessagesError = new Error('Connection closed during continuation'); + + await assert.rejects(() => session.resume('turn-1'), /Connection closed/); + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + sendMessagesRequests: mockSession.sendMessagesRequests, + }, { + active: false, + sendMessagesRequests: [{ messages: [] }], + }); + }); + + for (const timing of ['before', 'after'] as const) { + test(`ignores a stale idle ${timing} zero-message continuation resolves`, async () => { + const gate = new DeferredPromise(); + const { session, mockSession, signals } = await createAgentSession(disposables); + if (timing === 'before') { + mockSession.sendMessagesGate = gate.p; + } + + const resumePromise = session.resume('turn-1'); + await timeout(0); + if (timing === 'before') { + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + gate.complete(); + } + await resumePromise; + if (timing === 'after') { + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + } + const beforeProviderStart = { + active: session.hasActiveTurn, + terminalActions: getActions(signals).filter(action => action.type === ActionType.ChatTurnComplete || action.type === ActionType.ChatError), + }; + + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + mockSession.fire('assistant.message', { + messageId: 'm2', + content: 'Recovered response', + toolRequests: [], + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + beforeProviderStart, + active: session.hasActiveTurn, + actions: getActions(signals).filter(action => action.type === ActionType.ChatResponsePart || action.type === ActionType.ChatTurnComplete).map(action => action.type), + }, { + beforeProviderStart: { active: true, terminalActions: [] }, + active: false, + actions: [ActionType.ChatResponsePart, ActionType.ChatTurnComplete], + }); + }); + } + + test('cancellation before the provider turn starts clears the resumed turn', async () => { + const abortGate = new DeferredPromise(); + const { session, mockSession, signals } = await createAgentSession(disposables); + await session.resume('turn-1'); + mockSession.abortGate = abortGate.p; + + const abortPromise = session.abort(); + await timeout(0); + mockSession.fire('abort', { reason: 'user_abort' } as SessionEventPayload<'abort'>['data']); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + const activeAfterIdle = session.hasActiveTurn; + abortGate.complete(); + await abortPromise; + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + activeAfterIdle, + abortCalls: mockSession.abortCalls, + actions: getActions(signals), + }, { + active: false, + activeAfterIdle: false, + abortCalls: 1, + actions: [], + }); + }); + + test('cancellation after provider start but before content clears without completing', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + await session.resume('turn-1'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + + await session.abort(); + mockSession.fire('abort', { reason: 'user_abort' } as SessionEventPayload<'abort'>['data']); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + actions: getActions(signals), + }, { + active: false, + actions: [], + }); + }); + + test('quarantines late cancelled events until the next provider turn starts', async () => { + const abortGate = new DeferredPromise(); + const logService = new CapturingLogService(); + const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); + await session.resume('turn-1'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + mockSession.abortGate = abortGate.p; + const abortPromise = session.abort(); + await timeout(0); + + mockSession.fire('assistant.message_delta', { + deltaContent: 'Late response delta before idle', + } as SessionEventPayload<'assistant.message_delta'>['data']); + mockSession.fire('assistant.message', { + messageId: 'late-message-before-idle', + content: 'Late response before idle', + toolRequests: [], + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + abortGate.complete(); + await abortPromise; + const fireLateTurnEvents = (suffix: string) => { + mockSession.fire('assistant.message', { + messageId: `late-message-${suffix}`, + content: `Late response ${suffix}`, + toolRequests: [], + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('assistant.tool_call_delta', { + toolCallId: `late-tool-${suffix}`, + toolName: 'bash', + inputDelta: '{"command":"echo late"}', + }); + mockSession.fire('tool.execution_start', { + toolCallId: `late-tool-${suffix}`, + toolName: 'bash', + arguments: { command: 'echo late' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + mockSession.fire('session.error', { + errorType: 'LateError', + message: `Late error ${suffix}`, + } as SessionEventPayload<'session.error'>['data']); + mockSession.fire('subagent.started', { + toolCallId: `late-subagent-${suffix}`, + agentName: 'late-agent', + agentDisplayName: 'Late Agent', + agentDescription: 'Late cancelled subagent', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: `late-agent-${suffix}` }); + }; + fireLateTurnEvents('after-idle'); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + session.resetTurnState('turn-2'); + fireLateTurnEvents('after-next-turn-reset'); + const beforeProviderStart = { + active: session.hasActiveTurn, + actions: getActions(signals), + }; + + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-3' } as SessionEventPayload<'assistant.turn_start'>['data']); + mockSession.fire('assistant.message', { + messageId: 'valid-message', + content: 'Valid next response', + toolRequests: [], + } as SessionEventPayload<'assistant.message'>['data']); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + beforeProviderStart, + activeAfterCompletion: session.hasActiveTurn, + actionsAfterCompletion: getActions(signals).map(action => action.type), + subagentSignals: signals.filter(signal => signal.kind === 'subagent_started' || signal.kind === 'subagent_resumed'), + droppedResponseLogged: logService.errors.some(error => /after cancellation/i.test(String(error.first))), + }, { + beforeProviderStart: { active: true, actions: [] }, + activeAfterCompletion: false, + actionsAfterCompletion: [ActionType.ChatResponsePart, ActionType.ChatTurnComplete], + subagentSignals: [], + droppedResponseLogged: true, + }); + }); + + test('inline commands complete while cancelled provider events remain quarantined', async () => { + const logService = new CapturingLogService(); + const { session, mockSession, signals } = await createAgentSession(disposables, { logService }); + await session.resume('turn-1'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + await session.abort(); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + + await session.send('/compact', undefined, 'turn-compact-after-cancel'); + mockSession.fire('assistant.message', { + messageId: 'late-cancelled-message', + content: 'Late cancelled response', + toolRequests: [], + } as SessionEventPayload<'assistant.message'>['data']); + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + actions: getActions(signals).map(action => action.type), + droppedResponseLogged: logService.errors.some(error => /after cancellation/i.test(String(error.first))), + }, { + active: false, + actions: [ActionType.ChatResponsePart, ActionType.ChatTurnComplete], + droppedResponseLogged: true, + }); + }); + + test('turn-starting system notifications establish a trusted post-cancellation boundary', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + await session.resume('turn-1'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + await session.abort(); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + + mockSession.fire('system.notification', { + content: '\nAgent "agent-a" has finished processing and is now idle.\n', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose', description: 'Investigate the issue' }, + } as SessionEventPayload<'system.notification'>['data']); + mockSession.fire('assistant.message_delta', { + deltaContent: 'Reading the background agent result now.', + } as SessionEventPayload<'assistant.message_delta'>['data']); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + actions: getActions(signals).map(action => action.type), + }, { + active: false, + actions: [ActionType.ChatTurnStarted, ActionType.ChatResponsePart, ActionType.ChatTurnComplete], + }); + }); + + test('a root user-message echo establishes the boundary for a no-op replacement turn', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + await session.resume('turn-1'); + mockSession.fire('assistant.turn_start', { turnId: 'sdk-turn-2' } as SessionEventPayload<'assistant.turn_start'>['data']); + await session.abort(); + mockSession.fire('session.idle', { aborted: true } as SessionEventPayload<'session.idle'>['data']); + + await session.send('next request', undefined, 'turn-2'); + mockSession.fire('user.message', { + content: 'next request', + interactionId: 'interaction-turn-2', + source: 'user', + } as SessionEventPayload<'user.message'>['data']); + mockSession.fire('session.idle', {} as SessionEventPayload<'session.idle'>['data']); + + assert.deepStrictEqual({ + active: session.hasActiveTurn, + actions: getActions(signals).map(action => action.type), + }, { + active: false, + actions: [ActionType.ChatTurnComplete], + }); + }); + }); + // ---- system.notification ---- suite('system.notification', () => { @@ -7405,23 +7841,20 @@ suite('CopilotAgentSession', () => { assert.ok(isAction(signals[0], ActionType.ChatError)); if (isAction(signals[0], ActionType.ChatError)) { const action = signals[0].action as ChatErrorAction; - assert.deepStrictEqual(action.part, { - kind: ResponsePartKind.Error, - error: { - errorType: 'TestError', - message: 'something went wrong', - stack: 'Error: something went wrong', - _meta: { - chatError: { - fetchError: { - type: 'failed', - reason: 'something went wrong', - requestId: 'provider-request-id', - serverRequestId: 'service-request-id', - capiError: { - code: 'test-code', - message: 'something went wrong', - }, + assert.deepStrictEqual(action.part.error, { + errorType: 'TestError', + message: 'something went wrong', + stack: 'Error: something went wrong', + _meta: { + chatError: { + fetchError: { + type: 'failed', + reason: 'something went wrong', + requestId: 'provider-request-id', + serverRequestId: 'service-request-id', + capiError: { + code: 'test-code', + message: 'something went wrong', }, }, }, diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index df5a52c9bfb3f7..0b7058903624b0 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -861,6 +861,23 @@ Use the affected provider command with `--grep ""` and tempora --grep "accepted steering followed by abort" ``` +### Mid-turn host shutdown recovery is record-only + +A user can lose the Agent Host process while a model response is still streaming. Reopening the session should restore the unfinished request as a resumable error, and retrying should continue that same turn without adding another user message. + +- Test: `restores and resumes a turn interrupted by host shutdown`. +- Scope: deterministic replay for Copilot. +- Expected: the host dies after streaming starts but before any terminal turn action; restoration synthesizes a resumable `executionInterrupted` error, and a zero-message continuation completes the same turn. +- Observed: replay serves the full recorded response immediately, leaving no active streaming window in which to kill the host before turn completion. +- Gate: direct `AGENT_HOST_REPLAY_RECORD=1` mode only. +- Run: + + ```bash + AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts \ + --grep "restores and resumes a turn interrupted by host shutdown" + ``` + ### Codex model-backed multiple-chat recording - Tests: the model-backed peer-chat and fork scenarios in `multiChatSuite.ts`, and `side chat receives bounded source context without copied history`. diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-resumes-a-failed-turn-in-place.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-resumes-a-failed-turn-in-place.yaml new file mode 100644 index 00000000000000..c5df9c37fa9a67 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-resumes-a-failed-turn-in-place.yaml @@ -0,0 +1,19 @@ +version: 1 +dialect: anthropic +exchanges: + - method: POST + path: /v1/messages + response: + status: 400 + headers: + content-type: application/json + body: '{"error":{"message":"Injected recoverable E2E failure.","type":"invalid_request_error","code":"invalid_request_error"}}' + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: $error + response: + content: It looks like your message came through empty (just "$error" with no actual content). Could you let me know what you'd like help with? + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-resumes-the-same-turn-after-repeated-failures.yaml b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-resumes-the-same-turn-after-repeated-failures.yaml new file mode 100644 index 00000000000000..cfab180e6628fe --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/captures/copilotcli-resumes-the-same-turn-after-repeated-failures.yaml @@ -0,0 +1,26 @@ +version: 1 +dialect: anthropic +exchanges: + - method: POST + path: /v1/messages + response: + status: 400 + headers: + content-type: application/json + body: '{"error":{"message":"Injected recoverable E2E failure.","type":"invalid_request_error","code":"invalid_request_error"}}' + - method: POST + path: /v1/messages + response: + status: 400 + headers: + content-type: application/json + body: '{"error":{"message":"Injected second recoverable E2E failure.","type":"invalid_request_error","code":"invalid_request_error"}}' + - request: + model: claude-sonnet-5 + system: ${system} + messages: + - role: user + content: $error + response: + content: It looks like your message came through empty (just a placeholder "$error" with no actual content). Could you let me know what task or issue you'd like help with? + stopReason: end_turn diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts index 1a820aa77efddc..cfba89701aa380 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostE2ETestHarness.ts @@ -33,7 +33,7 @@ import { CopilotCliConfigKey } from '../../../../common/copilotCliConfig.js'; import { AgentHostSessionResidencyLimitEnvVar } from '../../../../common/agentService.js'; import { CapiReplayMode, type ICapiReplayResponse } from './capiReplayProxy.js'; import { - fetchSessionWithChat, getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification, IServerHandle, stopServer, TestProtocolClient, + fetchSessionWithChat, getActionEnvelope, getAgentHostE2ETestTimeout, isActionNotification, IServerHandle, killServer, stopServer, TestProtocolClient, } from '../../serverIntegrationTestHelpers.js'; import { defaultAgentHostTarget, type IAgentHostTarget } from './agentHostTarget.js'; import { createProviderSession, dispatchTurn, dispatchTurnWithAttachments } from '../../providerIntegrationTestHelpers.js'; @@ -195,6 +195,19 @@ const STALE_RECORDED_REQUEST_EXCEPTIONS = new Set([ 'claude:side chat receives bounded source context without copied history', ]); +const RECOVERABLE_RECORDING_MODEL_RESPONSE: ICapiReplayResponse = { + status: 400, + headers: { + 'content-type': 'application/json', + }, + body: '{"error":{"message":"Injected recoverable E2E failure.","type":"invalid_request_error","code":"invalid_request_error"}}', +}; + +const RECORDING_MODEL_RESPONSES = new Map([ + ['copilotcli:resumes a failed turn in place', RECOVERABLE_RECORDING_MODEL_RESPONSE], + ['copilotcli:resumes the same turn after repeated failures', RECOVERABLE_RECORDING_MODEL_RESPONSE], +]); + /** Identifies one provider's capture of a test, matching `fixturePathFor`. */ function captureKey(provider: string, testTitle: string): string { return `${provider}:${testTitle}`; @@ -206,14 +219,14 @@ function captureKey(provider: string, testTitle: string): string { * `AGENT_HOST_REPLAY_RECORD=1` or `AGENT_HOST_UPDATE_SNAPSHOTS=1`. Tests that * declare no model traffic always use the strict shared empty replay fixture. */ -export function capiReplayFor(provider: string, testTitle: string, modelTraffic: AgentHostE2EModelTraffic = 'recorded'): { fixturePath: string; real: true; mode: CapiReplayMode; allowPosixCommands: boolean; allowStaleRecordedRequest: boolean } { +export function capiReplayFor(provider: string, testTitle: string, modelTraffic: AgentHostE2EModelTraffic = 'recorded'): { fixturePath: string; real: true; mode: CapiReplayMode; allowPosixCommands: boolean; allowStaleRecordedRequest: boolean; recordingModelResponse?: ICapiReplayResponse } { const key = captureKey(provider, testTitle); const allowPosixCommands = POSIX_COMMAND_EXCEPTIONS.has(key); const allowStaleRecordedRequest = STALE_RECORDED_REQUEST_EXCEPTIONS.has(key); if (modelTraffic === 'none') { return { fixturePath: EMPTY_CAPTURE_PATH, real: true, mode: 'replay', allowPosixCommands, allowStaleRecordedRequest }; } - return { fixturePath: fixturePathFor(provider, testTitle), real: true, mode: REPLAY_MODE, allowPosixCommands, allowStaleRecordedRequest }; + return { fixturePath: fixturePathFor(provider, testTitle), real: true, mode: REPLAY_MODE, allowPosixCommands, allowStaleRecordedRequest, recordingModelResponse: RECORDING_MODEL_RESPONSES.get(key) }; } // #endregion @@ -933,6 +946,15 @@ export class AgentHostE2EServerLease { * uninitialized client for the caller to initialize with a new client id. */ async restart(): Promise { + return this._restart(false); + } + + /** Crash the target without graceful shutdown, then restart it over the same persisted state and replay proxy. */ + async crashAndRestart(): Promise { + return this._restart(true); + } + + private async _restart(crash: boolean): Promise { const server = this._server; const proxy = server?.capiReplay; const capiReplay = this._currentCapiReplay; @@ -940,9 +962,14 @@ export class AgentHostE2EServerLease { throw new Error('[agent-host-e2e] no replay-backed server to restart'); } - this._client?.close(); + if (crash) { + await killServer(server); + this._client?.close(); + } else { + this._client?.close(); + await stopServer(server); + } this._client = undefined; - await stopServer(server); this._server = undefined; try { @@ -967,12 +994,12 @@ export class AgentHostE2EServerLease { return client; } - setRecordingModelResponse(response: ICapiReplayResponse): void { + setRecordingModelResponse(response: ICapiReplayResponse, path?: string): void { const proxy = this._server?.capiReplay; if (!proxy) { throw new Error('[agent-host-e2e] no replay-backed server'); } - proxy.setRecordingModelResponse(response); + proxy.setRecordingModelResponse(response, path); } /** diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostTarget.ts b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostTarget.ts index fba79c4db83095..c52f4e13eab4e4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/agentHostTarget.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/agentHostTarget.ts @@ -21,7 +21,7 @@ */ import { startRealServer, type IServerHandle } from '../../serverIntegrationTestHelpers.js'; -import type { CapiReplayMode, CapiReplayProxy } from './capiReplayProxy.js'; +import type { CapiReplayMode, CapiReplayProxy, ICapiReplayResponse } from './capiReplayProxy.js'; export interface IAgentHostTargetLaunchOptions { /** Absolute path to a home directory the implementation must confine provider config to. */ @@ -31,7 +31,7 @@ export interface IAgentHostTargetLaunchOptions { /** Absolute path to the Codex home directory. */ readonly codexHomeDir: string; /** Record/replay proxy configuration fronting the model boundary. */ - readonly capiReplay: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly real?: boolean }; + readonly capiReplay: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly real?: boolean; readonly recordingModelResponse?: ICapiReplayResponse }; /** Existing replay proxy whose consumed exchange sequence must survive a target restart. */ readonly existingCapiReplay?: CapiReplayProxy; /** Optional dev override for a locally installed Claude SDK root. */ diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts b/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts index e19d56645134a6..149a5189938639 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/ahpSnapshot.ts @@ -425,12 +425,17 @@ function projectAction( return profile === 'behavior' ? { type: action.type, turnId: normalizeIdentifier(action.turnId, 'turn', turns), - error: { - errorType: action.part.error.errorType, - message: action.part.error.message, + part: { + kind: action.part.kind, + error: { + errorType: action.part.error.errorType, + message: action.part.error.message, + }, + ...(action.part.resumable ? { resumable: true } : {}), }, } : { type: action.type }; case ActionType.ChatUsage: + case ActionType.ChatTurnResume: case ActionType.ChatTurnComplete: return { type: action.type, turnId: normalizeIdentifier(action.turnId, 'turn', turns) }; default: diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts index ea72ef4ab6c7f8..185dd1f2649be4 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/capiReplayProxy.ts @@ -223,6 +223,8 @@ export interface ICapiReplayProxyOptions { * `STALE_RECORDED_REQUEST_EXCEPTIONS` in `agentHostE2ETestHarness.ts`. */ readonly allowStaleRecordedRequest?: boolean; + /** Synthetic first model response used by deterministic provider-error recordings. */ + readonly recordingModelResponse?: ICapiReplayResponse; } /** A replayable item: raw bytes (ancillary) or a model reply to regenerate. */ @@ -255,7 +257,7 @@ export class CapiReplayProxy { private readonly _replayPlaceholderValues = new Map(); private _modelTurnCount = 0; private _workingDirectory: string | undefined; - private _recordingModelResponse: ICapiReplayResponse | undefined; + private _recordingModelResponse: { readonly response: ICapiReplayResponse; readonly path?: string } | undefined; /** * Fixture currently being replayed. Mutable so a single long-lived proxy can @@ -279,6 +281,7 @@ export class CapiReplayProxy { const fixtureExists = existsSync(this._fixturePath); this._mode = _options.mode ?? 'replay'; this._strict = _options.strict ?? true; + this._recordingModelResponse = _options.recordingModelResponse ? { response: _options.recordingModelResponse } : undefined; if (this._mode === 'replay' && !fixtureExists) { throw new Error(`[capi-replay] replay mode requires a fixture but none exists at ${this._fixturePath}`); @@ -373,11 +376,11 @@ export class CapiReplayProxy { this._workingDirectory = workingDirectory; } - setRecordingModelResponse(response: ICapiReplayResponse): void { + setRecordingModelResponse(response: ICapiReplayResponse, path?: string): void { if (this._isReplaying) { throw new Error('[capi-replay] setRecordingModelResponse is only valid in record mode'); } - this._recordingModelResponse = response; + this._recordingModelResponse = { response, path }; } get observedModelRequestBodies(): readonly string[] { @@ -571,8 +574,9 @@ export class CapiReplayProxy { if (MODEL_ENDPOINTS.has(path)) { this._observedModelRequestBodies.push(this._normalize(body)); } - if (MODEL_ENDPOINTS.has(path) && this._recordingModelResponse) { - const response = this._recordingModelResponse; + if (MODEL_ENDPOINTS.has(path) && this._recordingModelResponse && (!this._recordingModelResponse.path || this._recordingModelResponse.path === path)) { + const response = this._recordingModelResponse.response; + this._recordingModelResponse = undefined; res.writeHead(response.status, response.headers); res.end(response.body); this._recorded.push({ diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml new file mode 100644 index 00000000000000..0b73a2a9cf24d4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/e2e/providers/__snapshots__/Agent_Host_E2E___Copilot__Copilot-specific__resumes_a_failed_turn_in_place.traffic.ahp.yaml @@ -0,0 +1,65 @@ +version: 1 +rounds: + - clientToServer: + - channel: ${session_0} + action: + type: session/titleChanged + - channel: ${chat_0} + action: + type: chat/turnStarted + turnId: ${turn_0} + message: + text: $error + origin: + kind: user + serverToClient: + - channel: ${session_0} + action: + type: session/titleChanged + - channel: ${chat_0} + action: + type: chat/turnStarted + turnId: ${turn_0} + message: + text: $error + origin: + kind: user + - method: root/sessionAdded + - channel: ${session_0} + action: + type: session/ready + - channel: ${chat_0} + action: + type: chat/error + turnId: ${turn_0} + part: + kind: error + error: + errorType: query + message: 'Execution failed: CAPIError: 400 Injected recoverable E2E failure.' + resumable: true + - clientToServer: + - channel: ${chat_0} + action: + type: chat/turnResume + turnId: ${turn_0} + serverToClient: + - channel: ${chat_0} + action: + type: chat/turnResume + turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/turnResume + turnId: ${turn_0} + - channel: ${chat_0} + action: + type: chat/responsePart + turnId: ${turn_0} + part: + kind: markdown + content: It looks like your message came through empty (just "$error" with no actual content). Could you let me know what you'd like help with? + - channel: ${chat_0} + action: + type: chat/turnComplete + turnId: ${turn_0} diff --git a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts index 4cb37e8e9f3100..fcf2e3c519d64c 100644 --- a/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/e2e/providers/copilotAgentHostE2E.integrationTest.ts @@ -31,14 +31,15 @@ import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CollectAgentHostDebugLogsExtensionMethod, type IAgentHostExtensionCommandMap } from '../../../../common/agentHostExtensionProtocol.js'; import { readToolCallMeta } from '../../../../common/meta/agentToolCallMeta.js'; -import { MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildDefaultChatUri, getInlineToolInput, getTurnError, type MessageAttachment } from '../../../../common/state/sessionState.js'; -import { ActionType, type ChatErrorAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatUsageAction } from '../../../../common/state/sessionActions.js'; +import { MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildDefaultChatUri, getErrorResponsePart, getInlineToolInput, type MessageAttachment } from '../../../../common/state/sessionState.js'; +import { ActionType, type ChatErrorAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction } from '../../../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import { AgentHostE2EServerLease, assertToolCallCompleteText, createRealSession, dispatchTurn, driveTurnToCompletion, driveTurnWithAttachmentsToCompletion, removeTempDirs, resolveGitHubToken, runAhpSnapshotTest, } from '../harness/agentHostE2ETestHarness.js'; import { assertRecordedAhpSnapshot } from '../harness/ahpSnapshot.js'; +import { summarizeAnthropicRequest, summarizeResponsesRequest } from '../harness/capiWireCodec.js'; import { defineAgentHostE2ETests } from '../suites/agentHostE2ESuites.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification, TestProtocolClient } from '../../serverIntegrationTestHelpers.js'; import { COPILOT_CONFIG } from './copilotTestConfiguration.js'; @@ -179,7 +180,8 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { && getActionEnvelope(notification).channel === chatUri, 90_000, ); - const liveError = (getActionEnvelope(liveNotification).action as ChatErrorAction).part.error; + const liveErrorPart = (getActionEnvelope(liveNotification).action as ChatErrorAction).part; + assert.strictEqual(liveErrorPart.resumable, true); client = await lease.restart(); client.setWorkingDirectory(workingDirectory); @@ -194,10 +196,325 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { const restoredTurn = reopened.turns.find(turn => turn.message.text === prompt); assert.deepStrictEqual({ state: restoredTurn?.state, - error: getTurnError(restoredTurn), + error: getErrorResponsePart(restoredTurn)?.error, + resumable: getErrorResponsePart(restoredTurn)?.resumable, }, { state: TurnState.Error, - error: liveError, + error: liveErrorPart.error, + resumable: true, + }); + }); + + test('resumes a failed turn in place', async function () { + this.timeout(180_000); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-failed-turn-resume-')); + tempDirs.push(workingDirectory); + const prompt = '$error'; + if (!lease) { + throw new Error('Agent Host E2E server lease was not initialized.'); + } + await lease.release([], true); + await lease.dispose(); + lease = new AgentHostE2EServerLease(COPILOT_CONFIG); + ({ client } = await lease.acquire(this.test!.title)); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'copilot-failed-turn-resume', createdSessions, URI.file(workingDirectory)); + const chatUri = buildDefaultChatUri(sessionUri); + const turnId = 'turn-failed-resume'; + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { type: ActionType.SessionTitleChanged, title: 'Recovery test' }, + }); + await client.waitForNotification(notification => + isActionNotification(notification, ActionType.SessionTitleChanged) + && getActionEnvelope(notification).channel === sessionUri, + 30_000, + ); + + client.beginAhpSnapshotRound(); + dispatchTurn(client, sessionUri, turnId, prompt, 2); + const errorNotification = await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatError) + && getActionEnvelope(notification).channel === chatUri, + 90_000, + ); + const errorAction = getActionEnvelope(errorNotification).action as ChatErrorAction; + assert.strictEqual(errorAction.part.resumable, true); + + const peerClientId = 'copilot-failed-turn-resume-peer'; + const peer = await lease.connectClient(); + await peer.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: peerClientId }, 30_000); + await peer.call('subscribe', { channel: chatUri }, 30_000); + const modelRequestCountBeforeResume = lease.observedModelRequestBodies.length; + + try { + client.beginAhpSnapshotRound(); + const primaryResumeObserved = client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatTurnResume) + && getActionEnvelope(notification).channel === chatUri + && getActionEnvelope(notification).origin?.clientId === 'copilot-failed-turn-resume' + && getActionEnvelope(notification).origin?.clientSeq === 3, + 30_000, + ); + const peerResumeObserved = client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatTurnResume) + && getActionEnvelope(notification).channel === chatUri + && getActionEnvelope(notification).origin?.clientId === peerClientId + && getActionEnvelope(notification).origin?.clientSeq === 1, + 30_000, + ); + client.dispatch({ + channel: chatUri, + clientSeq: 3, + action: { type: ActionType.ChatTurnResume, turnId }, + }); + peer.dispatch({ + channel: chatUri, + clientSeq: 1, + action: { type: ActionType.ChatTurnResume, turnId }, + }); + await Promise.all([ + primaryResumeObserved, + peerResumeObserved, + ...[client, peer].map(resumeClient => resumeClient.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatTurnComplete) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as ChatTurnCompleteAction).turnId === turnId, + 90_000, + )), + ]); + await assertRecordedAhpSnapshot(this.test!, client, { profile: 'behavior' }); + + const [finalState, peerFinalState] = await Promise.all([ + fetchSessionWithChat(client, sessionUri), + fetchSessionWithChat(peer, sessionUri), + ]); + const resumeEnvelopes = client.receivedNotifications(notification => + isActionNotification(notification, ActionType.ChatTurnResume) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as { readonly turnId: string }).turnId === turnId, + ).map(getActionEnvelope); + const acceptedResumes = resumeEnvelopes.filter(envelope => envelope.rejectionReason === undefined); + const rejectedResumes = resumeEnvelopes.filter(envelope => envelope.rejectionReason !== undefined); + const resumedRequest = lease.observedModelRequestBodies.at(-1); + assert.ok(resumedRequest); + const summarizedRequest = summarizeAnthropicRequest(resumedRequest) ?? summarizeResponsesRequest(resumedRequest); + assert.ok(summarizedRequest); + const promptOccurrences = summarizedRequest.messages + .filter(message => message.role === 'user') + .reduce((count, message) => count + (JSON.stringify(message.content).split(prompt).length - 1), 0); + const summarizeTurns = (turns: typeof finalState.turns) => turns.map(turn => ({ + id: turn.id, + message: turn.message.text, + state: turn.state, + errorCount: turn.responseParts.filter(part => part.kind === ResponsePartKind.Error).length, + })); + + assert.deepStrictEqual({ + acceptedResumeCount: acceptedResumes.length, + rejectedResumeCount: rejectedResumes.length, + resumeOriginClientIds: resumeEnvelopes.map(envelope => envelope.origin?.clientId).sort(), + continuationModelRequestCount: lease.observedModelRequestBodies.length - modelRequestCountBeforeResume, + promptOccurrences, + activeTurns: [finalState.activeTurn, peerFinalState.activeTurn], + clientTurns: summarizeTurns(finalState.turns), + peerTurns: summarizeTurns(peerFinalState.turns), + }, { + acceptedResumeCount: 1, + rejectedResumeCount: 1, + resumeOriginClientIds: ['copilot-failed-turn-resume', peerClientId], + continuationModelRequestCount: 1, + promptOccurrences: 1, + activeTurns: [undefined, undefined], + clientTurns: [{ + id: turnId, + message: prompt, + state: TurnState.Complete, + errorCount: 1, + }], + peerTurns: [{ + id: turnId, + message: prompt, + state: TurnState.Complete, + errorCount: 1, + }], + }); + } finally { + peer.close(); + } + }); + + test('resumes the same turn after repeated failures', async function () { + this.timeout(180_000); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-repeated-failed-turn-resume-')); + tempDirs.push(workingDirectory); + const prompt = '$error'; + if (!lease) { + throw new Error('Agent Host E2E server lease was not initialized.'); + } + await lease.release([], true); + await lease.dispose(); + lease = new AgentHostE2EServerLease(COPILOT_CONFIG); + ({ client } = await lease.acquire(this.test!.title)); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'copilot-repeated-failed-turn-resume', createdSessions, URI.file(workingDirectory)); + const chatUri = buildDefaultChatUri(sessionUri); + const turnId = 'turn-repeated-failed-resume'; + + dispatchTurn(client, sessionUri, turnId, prompt, 1); + const firstErrorNotification = await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatError) + && getActionEnvelope(notification).channel === chatUri, + 90_000, + ); + const firstErrorEnvelope = getActionEnvelope(firstErrorNotification); + assert.strictEqual((firstErrorEnvelope.action as ChatErrorAction).part.resumable, true); + + if (RECORD) { + lease.setRecordingModelResponse({ + status: 400, + headers: { + 'content-type': 'application/json', + }, + body: '{"error":{"message":"Injected second recoverable E2E failure.","type":"invalid_request_error","code":"invalid_request_error"}}', + }); + } + client.dispatch({ + channel: chatUri, + clientSeq: 2, + action: { type: ActionType.ChatTurnResume, turnId }, + }); + const secondErrorNotification = await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatError) + && getActionEnvelope(notification).channel === chatUri + && getActionEnvelope(notification).serverSeq > firstErrorEnvelope.serverSeq, + 90_000, + ); + assert.strictEqual((getActionEnvelope(secondErrorNotification).action as ChatErrorAction).part.resumable, true); + + client.dispatch({ + channel: chatUri, + clientSeq: 3, + action: { type: ActionType.ChatTurnResume, turnId }, + }); + await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatTurnComplete) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as ChatTurnCompleteAction).turnId === turnId, + 90_000, + ); + + const finalState = await fetchSessionWithChat(client, sessionUri); + assert.deepStrictEqual({ + modelRequestCount: lease.observedModelRequestBodies.length, + activeTurn: finalState.activeTurn, + turns: finalState.turns.map(turn => ({ + id: turn.id, + message: turn.message.text, + state: turn.state, + errorCount: turn.responseParts.filter(part => part.kind === ResponsePartKind.Error).length, + })), + }, { + modelRequestCount: 3, + activeTurn: undefined, + turns: [{ + id: turnId, + message: prompt, + state: TurnState.Complete, + errorCount: 2, + }], + }); + }); + + // Replay serves the full recorded response immediately, so it has no active streaming window to terminate. + (RECORD_ONLY ? test : test.skip)('restores and resumes a turn interrupted by host shutdown', async function () { + this.timeout(240_000); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-host-shutdown-resume-')); + tempDirs.push(workingDirectory); + const clientId = 'copilot-host-shutdown-resume'; + const prompt = 'Reply with exactly the numbers 1 through 40, separated by spaces.'; + if (!lease) { + throw new Error('Agent Host E2E server lease was not initialized.'); + } + const sessionUri = await createRealSession(client, COPILOT_CONFIG, clientId, createdSessions, URI.file(workingDirectory)); + const chatUri = buildDefaultChatUri(sessionUri); + const turnId = 'turn-host-shutdown-resume'; + + dispatchTurn(client, sessionUri, turnId, prompt, 1); + await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatResponsePart) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as { readonly turnId: string }).turnId === turnId, + 90_000, + ); + const interruptedClient = client; + client = await lease.crashAndRestart(); + const terminalActionsBeforeHostDeath = interruptedClient.receivedNotifications(notification => + (isActionNotification(notification, ActionType.ChatError) + || isActionNotification(notification, ActionType.ChatTurnComplete) + || isActionNotification(notification, ActionType.ChatTurnCancelled)) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as { readonly turnId: string }).turnId === turnId, + ); + assert.deepStrictEqual(terminalActionsBeforeHostDeath, []); + + client.setWorkingDirectory(workingDirectory); + await client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: `${clientId}-reopened` }, 30_000); + await client.call('authenticate', { + channel: ROOT_STATE_URI, + resource: 'https://api.github.com', + token: COPILOT_CONFIG.githubToken ?? resolveGitHubToken(), + }, 30_000); + + const restoredState = await fetchSessionWithChat(client, sessionUri); + const restoredTurn = restoredState.turns.find(turn => turn.message.text === prompt); + assert.ok(restoredTurn); + const restoredError = getErrorResponsePart(restoredTurn); + assert.deepStrictEqual({ + activeTurn: restoredState.activeTurn, + turnCount: restoredState.turns.length, + turnState: restoredTurn.state, + errorType: restoredError?.error.errorType, + resumable: restoredError?.resumable, + }, { + activeTurn: undefined, + turnCount: 1, + turnState: TurnState.Error, + errorType: 'executionInterrupted', + resumable: true, + }); + + const modelRequestCountBeforeResume = lease.observedModelRequestBodies.length; + client.dispatch({ + channel: chatUri, + clientSeq: 2, + action: { type: ActionType.ChatTurnResume, turnId: restoredTurn.id }, + }); + await client.waitForNotification(notification => + isActionNotification(notification, ActionType.ChatTurnComplete) + && getActionEnvelope(notification).channel === chatUri + && (getActionEnvelope(notification).action as ChatTurnCompleteAction).turnId === restoredTurn.id, + 90_000, + ); + const finalState = await fetchSessionWithChat(client, sessionUri); + + assert.deepStrictEqual({ + continuationModelRequestCount: lease.observedModelRequestBodies.length - modelRequestCountBeforeResume, + activeTurn: finalState.activeTurn, + turns: finalState.turns.map(turn => ({ + id: turn.id, + message: turn.message.text, + state: turn.state, + errorCount: turn.responseParts.filter(part => part.kind === ResponsePartKind.Error).length, + })), + }, { + continuationModelRequestCount: 1, + activeTurn: undefined, + turns: [{ + id: restoredTurn.id, + message: prompt, + state: TurnState.Complete, + errorCount: 1, + }], }); }); diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts index a580514aa758c6..c441de291373e7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/coreSuite.ts @@ -9,6 +9,7 @@ import { tmpdir } from 'os'; import { join } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; +import type { ChatErrorAction } from '../../../../common/state/protocol/actions.js'; import { CompletionItemKind, type CompletionsResult, type ResolveSessionConfigResult, type SessionConfigCompletionsResult, SubscribeResult } from '../../../../common/state/protocol/commands.js'; import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js'; import type { RootState } from '../../../../common/state/protocol/state.js'; @@ -707,11 +708,7 @@ export function defineCoreTests(context: IAgentHostE2ETestContext): void { && (getActionEnvelope(n).action as { readonly turnId: string }).turnId === turnId, 30_000, ); - const action = getActionEnvelope(failed).action; - assert.strictEqual(action.type, ActionType.ChatError); - if (action.type !== ActionType.ChatError) { - return; - } + const action = getActionEnvelope(failed).action as ChatErrorAction; assert.deepStrictEqual({ errorType: action.part.error.errorType, diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index cd75e5a93f8387..62f0012199a6eb 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { AgentSession } from '../../common/agent.js'; -import { getTurnError, MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; +import { getErrorResponsePart, getTurnError, MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart, type ToolResultContent } from '../../common/state/sessionState.js'; import { appendSdkToolResultContent, mapSessionEvents as mapSessionEventsWithRouting, type IMapSessionEventsOptions } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; @@ -92,6 +92,182 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('restored completed task_complete is not marked interrupted', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'turn-task-complete', data: { interactionId: 'm1', content: 'finish the task' } }, + { type: 'assistant.turn_start', data: { turnId: 'sdk-turn' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: 'All done.', toolRequests: [{ toolCallId: 'tc-1', name: 'task_complete' }] } }, + { type: 'assistant.turn_end', data: { turnId: 'sdk-turn' } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'task_complete', arguments: {} } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events), { + interruptedTurnError: { errorType: 'executionInterrupted', message: 'interrupted' }, + }); + + assert.deepStrictEqual({ + state: turns[0].state, + error: getErrorResponsePart(turns[0]), + }, { + state: TurnState.Complete, + error: undefined, + }); + }); + + test('restores an unfinished request as a resumable error on the same turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'interrupted-turn', data: { interactionId: 'm1', content: 'Keep working' } }, + { type: 'assistant.turn_start', data: { turnId: 'sdk-turn' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: 'Partial response' } }, + ]; + const interruptedTurnError = { + errorType: 'executionInterrupted', + message: 'The agent was interrupted before this request finished.', + }; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events), { interruptedTurnError }); + + assert.deepStrictEqual({ + turnCount: turns.length, + id: turns[0].id, + state: turns[0].state, + errorPart: getErrorResponsePart(turns[0]), + }, { + turnCount: 1, + id: 'interrupted-turn', + state: TurnState.Error, + errorPart: { + kind: ResponsePartKind.Error, + error: interruptedTurnError, + resumable: true, + }, + }); + }); + + test('restores a continued failed request as one completed turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'turn-1', timestamp: '2026-08-11T00:00:00.000Z', data: { interactionId: 'm1', content: 'Keep working' } }, + { type: 'assistant.turn_start', timestamp: '2026-08-11T00:00:00.100Z', data: { turnId: 'sdk-turn-1' } }, + { type: 'session.error', timestamp: '2026-08-11T00:00:02.000Z', data: { errorType: 'requestFailed', message: 'First failure' } }, + { type: 'assistant.turn_start', timestamp: '2026-08-11T00:10:00.000Z', data: { turnId: 'sdk-turn-2' } }, + { type: 'assistant.message', timestamp: '2026-08-11T00:10:03.000Z', data: { messageId: 'm2', content: 'Finished response' } }, + { type: 'assistant.turn_end', timestamp: '2026-08-11T00:10:03.000Z', data: { turnId: 'sdk-turn-2' } }, + { type: 'session.idle', timestamp: '2026-08-11T00:10:03.000Z', data: {} }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + state: turn.state, + duration: turn.duration, + parts: partKinds(turn.responseParts), + })), [{ + id: 'turn-1', + state: TurnState.Complete, + duration: 5000, + parts: [ + { kind: ResponsePartKind.Error }, + { kind: ResponsePartKind.Markdown, content: 'Finished response' }, + ], + }]); + }); + + test('excludes host downtime when an interrupted execution resumes and is interrupted again', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'turn-1', timestamp: '2026-08-11T00:00:00.000Z', data: { interactionId: 'm1', content: 'Keep working' } }, + { type: 'assistant.turn_start', timestamp: '2026-08-11T00:00:00.100Z', data: { turnId: 'sdk-turn-1' } }, + { type: 'assistant.message', timestamp: '2026-08-11T00:00:02.000Z', data: { messageId: 'm2', content: 'First segment' } }, + { type: 'assistant.turn_start', timestamp: '2026-08-11T00:10:00.000Z', data: { turnId: 'sdk-turn-2' } }, + { type: 'assistant.message', timestamp: '2026-08-11T00:10:03.000Z', data: { messageId: 'm3', content: 'Second segment' } }, + ]; + const interruptedTurnError = { + errorType: 'executionInterrupted', + message: 'The agent was interrupted before this request finished.', + }; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events), { interruptedTurnError }); + + assert.deepStrictEqual({ + duration: turns[0].duration, + state: turns[0].state, + parts: partKinds(turns[0].responseParts), + resumable: getErrorResponsePart(turns[0])?.resumable, + }, { + duration: 5000, + state: TurnState.Error, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'First segment' }, + { kind: ResponsePartKind.Markdown, content: 'Second segment' }, + { kind: ResponsePartKind.Error }, + ], + resumable: true, + }); + }); + + test('keeps a resumable error terminal when a later notification starts another turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'failed-turn', timestamp: '2026-08-11T00:00:00.000Z', data: { interactionId: 'm1', content: 'Start the background agent' } }, + { type: 'assistant.turn_start', timestamp: '2026-08-11T00:00:00.100Z', data: { turnId: 'sdk-turn-1' } }, + { type: 'session.error', timestamp: '2026-08-11T00:00:02.000Z', data: { errorType: 'requestFailed', message: 'First failure' } }, + { + type: 'system.notification', + id: 'notification-turn', + timestamp: '2026-08-11T00:10:00.000Z', + data: { + content: '\nAgent completed\n', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose' }, + }, + }, + { type: 'assistant.turn_start', timestamp: '2026-08-11T00:10:00.100Z', data: { turnId: 'sdk-turn-2' } }, + { type: 'assistant.message', timestamp: '2026-08-11T00:10:01.000Z', data: { messageId: 'm2', content: 'The background agent finished.' } }, + { type: 'assistant.turn_end', timestamp: '2026-08-11T00:10:01.000Z', data: { turnId: 'sdk-turn-2' } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message, + state: turn.state, + parts: partKinds(turn.responseParts), + })), [{ + id: 'failed-turn', + message: { text: 'Start the background agent', origin: { kind: MessageKind.User } }, + state: TurnState.Error, + parts: [{ kind: ResponsePartKind.Error }], + }, { + id: 'notification-turn', + message: { text: 'Background agent agent-a is complete', origin: { kind: MessageKind.SystemNotification } }, + state: TurnState.Complete, + parts: [{ kind: ResponsePartKind.Markdown, content: 'The background agent finished.' }], + }]); + assert.strictEqual(getErrorResponsePart(turns[0])?.resumable, true); + }); + + test('keeps a resumable error as the final part when a late tool completion arrives', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'failed-turn', data: { interactionId: 'm1', content: 'Run a command' } }, + { type: 'assistant.turn_start', data: { turnId: 'sdk-turn-1' } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'echo hi' } } }, + { type: 'session.error', data: { errorType: 'requestFailed', message: 'First failure' } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true, result: { content: 'hi\n' } } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual({ + state: turns[0].state, + parts: partKinds(turns[0].responseParts), + resumable: getErrorResponsePart(turns[0])?.resumable, + }, { + state: TurnState.Error, + parts: [{ kind: ResponsePartKind.Error }], + resumable: true, + }); + }); + test('fallback task_complete marks the turn complete', async () => { const events: ISessionEvent[] = [ { type: 'user.message', data: { interactionId: 'm1', content: 'finish the task' } }, @@ -779,7 +955,6 @@ suite('mapSessionEvents — history replay', () => { }, parts: [ { kind: ResponsePartKind.Markdown, content: 'Working on it.' }, - { kind: ResponsePartKind.Markdown, content: 'Late completion.' }, { kind: ResponsePartKind.Error }, ], }]); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index ff06d4df86890b..db07d28c39be7e 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -16,7 +16,7 @@ import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryReco import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType, type AuthRequiredParams } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, buildDefaultChatUri, isAhpChatChannel, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, buildDefaultChatUri, createErrorResponsePart, isAhpChatChannel, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { hasKey } from '../../../../base/common/types.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -1219,7 +1219,7 @@ function _idle(session: URI, sessionStr: string, turnId: string): IAgentActionSi /** Creates a {@link ActionType.ChatError} signal. */ function _error(session: URI, sessionStr: string, turnId: string, errorType: string, message: string, stack?: string): IAgentActionSignal { - return _action(session, { type: ActionType.ChatError, turnId, duration: 1, part: { kind: ResponsePartKind.Error, error: { errorType, message, stack } } }); + return _action(session, { type: ActionType.ChatError, turnId, duration: 1, part: createErrorResponsePart({ errorType, message, stack }) }); } /** Creates a {@link ActionType.SessionTitleChanged} signal. */ diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 6b58d448fb2a5c..22742c9e5830d3 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -895,7 +895,6 @@ suite('ProtocolServerHandler', () => { const cases: readonly { readonly action: ChatAction; readonly channel: string }[] = [ { action: { type: ActionType.ChatWorkingDirectorySet, directory: 'file:///tmp/extra-root' }, channel: defaultChatUri }, { action: { type: ActionType.ChatWorkingDirectoryRemoved, directory: 'file:///tmp/extra-root' }, channel: defaultChatUri }, - { action: { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, channel: defaultChatUri }, ]; for (const [index, { action, channel }] of cases.entries()) { @@ -927,6 +926,21 @@ suite('ProtocolServerHandler', () => { } }); + test('turn resume reaches the agent service', () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); + const transport = connectClient('resume-client', [sessionUri, defaultChatUri]); + transport.sent.length = 0; + + transport.simulateMessage(notification('dispatchAction', { + channel: defaultChatUri, + clientSeq: 1, + action: { type: ActionType.ChatTurnResume, turnId: 'turn-1' }, + })); + + assert.deepStrictEqual(agentService.handledActions.at(-1), { type: ActionType.ChatTurnResume, turnId: 'turn-1' }); + }); + test('session working-directory actions reach the agent service', () => { stateManager.createSession(makeSessionSummary()); stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index 805a20ec8eef36..9271ce7dd705ca 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -107,6 +107,53 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ }); }); + test('resumes and completes one turn while preserving durable errors', () => { + let state = chatReducer(makeChat(), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + state = chatReducer(state, { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 100, + part: { kind: ResponsePartKind.Error, error: { errorType: 'first', message: 'failed' }, resumable: true }, + }); + state = chatReducer(state, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }); + state = chatReducer(state, { + type: ActionType.ChatError, + turnId: 'turn-1', + duration: 200, + part: { kind: ResponsePartKind.Error, error: { errorType: 'second', message: 'failed again' }, resumable: true }, + }); + state = chatReducer(state, { type: ActionType.ChatTurnResume, turnId: 'turn-1' }); + state = chatReducer(state, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 300 }); + + assert.deepStrictEqual({ + activeTurn: state.activeTurn, + turns: state.turns.map(turn => ({ + id: turn.id, + message: turn.message.text, + state: turn.state, + duration: turn.duration, + errors: turn.responseParts.filter(part => part.kind === ResponsePartKind.Error), + })), + }, { + activeTurn: undefined, + turns: [{ + id: 'turn-1', + message: 'hello', + state: TurnState.Complete, + duration: 300, + errors: [ + { kind: ResponsePartKind.Error, error: { errorType: 'first', message: 'failed' }, resumable: true }, + { kind: ResponsePartKind.Error, error: { errorType: 'second', message: 'failed again' }, resumable: true }, + ], + }], + }); + }); + test('Chat status is InputNeeded when a tool call is PendingConfirmation', () => { let state = withActiveTurnAndToolCall(makeChat()); diff --git a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts index 1f626bee574505..4355b3dbe4c6e5 100644 --- a/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/serverIntegrationTestHelpers.ts @@ -12,7 +12,7 @@ import { mkdirSync } from 'fs'; import { userInfo } from 'os'; import { fileURLToPath } from 'url'; import { WebSocket } from 'ws'; -import { CapiReplayProxy, type CapiReplayMode } from './e2e/harness/capiReplayProxy.js'; +import { CapiReplayProxy, type CapiReplayMode, type ICapiReplayResponse } from './e2e/harness/capiReplayProxy.js'; import { dirname, resolve as resolvePath } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { @@ -688,6 +688,35 @@ export async function stopServer(server: IServerHandle | undefined): Promise { + const serverProcess = server?.process; + if (!serverProcess || serverProcess.exitCode !== null || serverProcess.signalCode !== null) { + return; + } + const pid = serverProcess.pid; + if (pid === undefined) { + throw new Error('Agent Host test server has no process id'); + } + + const serverExit = new Promise(resolve => { + const onExit = () => resolve(); + serverProcess.once('exit', onExit); + if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) { + serverProcess.removeListener('exit', onExit); + resolve(); + } + }); + try { + await killTree(pid, true); + } catch (error) { + if (serverProcess.exitCode === null && serverProcess.signalCode === null) { + throw error; + } + } + await serverExit; +} + interface IMockLlmServerHandle { readonly url: string; requestCount(): number; @@ -805,7 +834,7 @@ export async function startServer(options?: { readonly quiet?: boolean; readonly * Start the agent host server with the Copilot SDK agent with either a real or mocked LLM. * The server is started with logging enabled so the CopilotAgent is registered. */ -export async function startRealServer(options: { readonly homeDir: string; readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly codexHomeDir?: string; readonly codexAgentEnabled?: boolean; readonly mockLlm?: boolean; readonly userDataDir?: string; readonly logLevel?: string; readonly env?: NodeJS.ProcessEnv; readonly capiReplay?: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly workDir?: string; readonly real?: boolean; readonly allowPosixCommands?: boolean; readonly allowStaleRecordedRequest?: boolean }; readonly existingCapiReplay?: CapiReplayProxy; readonly mockScenarios?: readonly IMockScenario[] }): Promise { +export async function startRealServer(options: { readonly homeDir: string; readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly codexHomeDir?: string; readonly codexAgentEnabled?: boolean; readonly mockLlm?: boolean; readonly userDataDir?: string; readonly logLevel?: string; readonly env?: NodeJS.ProcessEnv; readonly capiReplay?: { readonly fixturePath: string; readonly mode?: CapiReplayMode; readonly workDir?: string; readonly real?: boolean; readonly allowPosixCommands?: boolean; readonly allowStaleRecordedRequest?: boolean; readonly recordingModelResponse?: ICapiReplayResponse }; readonly existingCapiReplay?: CapiReplayProxy; readonly mockScenarios?: readonly IMockScenario[] }): Promise { // `capiReplay` records/replays in front of the mock LLM server, so it implies // a mock upstream even when `mockLlm` was not explicitly requested — unless // `real` is set, in which case the proxy forwards to real CAPI/GitHub. @@ -822,6 +851,7 @@ export async function startRealServer(options: { readonly homeDir: string; reado workDir: options.capiReplay.workDir, allowPosixCommands: options.capiReplay.allowPosixCommands, allowStaleRecordedRequest: options.capiReplay.allowStaleRecordedRequest, + recordingModelResponse: options.capiReplay.recordingModelResponse, homeDir: options.homeDir, userName: userInfo().username, // Real hosts (consumer defaults); override for Enterprise/Business accounts. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 97f349319151e5..09813d17a8df4f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -48,7 +48,7 @@ import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuth import { compareProtocolVersions } from '../../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ClientChatAction, type ClientSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, AHP_NOT_FOUND, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChatOriginKind, getErrorResponsePart, getInlineToolInput, getToolSubagentContent, getTurnError, isChatReadOnly, isDefaultChatUri, isMessageHiddenFromTranscript, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionStatus, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, readSessionWorkspaceless, readUsageInfoMeta, withMessageHiddenFromTranscript, type ChatState, type ISessionWithDefaultChat, type ICompletedToolCall, type InputRequestResponsePart, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageChatAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type PendingMessage, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputQuestion, type ChatInputRequest, type ChatSummary, type SessionState, type StringOrMarkdown, type ToolCallPendingConfirmationState, type ToolCallResponsePart, type ToolCallRunningState, type ToolCallState, type ToolInput, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -79,7 +79,7 @@ import { type IImageVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; -import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestionAnswers, IChatService, IChatToolInvocation, IRemotePendingRequest, ToolConfirmKind, type IChatAutoModeResolutionPart, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData, type IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { ChatErrorLevel, ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestionAnswers, IChatService, IChatToolInvocation, IRemotePendingRequest, ToolConfirmKind, type IChatAutoModeResolutionPart, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData, type IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { isInConversationModelChoice } from '../../../common/modelSelection.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, isTerminalCommandPrompt, SessionType, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; @@ -223,6 +223,8 @@ interface IObserveTurnOptions { readonly snapshotToolCalls?: ReadonlyMap; readonly seedEmittedLengths?: ReadonlyMap; readonly initialResponsePartCount?: number; + /** Do not complete from an already-historical turn until this observer sees it active. */ + readonly requireActiveTurn?: boolean; readonly onTurnEnded?: (lastTurn: Turn | undefined) => void; readonly onFileEdits?: (tc: ToolCallState, fileEdits: IToolCallFileEdit[]) => void; /** @@ -256,6 +258,17 @@ interface IObserveTurnOptions { readonly subAgentModelObservable?: ISettableObservable; } +interface IResumeTurnConfirmationData { + readonly agentHostResumeTurn: true; +} + +function isResumeTurnConfirmationData(value: unknown): value is IResumeTurnConfirmationData { + return typeof value === 'object' + && value !== null + && 'agentHostResumeTurn' in value + && value.agentHostResumeTurn === true; +} + /** * Shared context for subagent observation within a parent turn. Tracks which * subagent tool calls already have observers so they aren't double-subscribed. @@ -302,6 +315,7 @@ interface IStartServerRequestOptions { readonly isHidden?: boolean; readonly timestamp?: number; readonly isTerminalRequest?: boolean; + readonly resume?: boolean; readonly origin?: IChatSessionServerRequest['origin']; } @@ -785,6 +799,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { isHidden: options?.isHidden, timestamp: options?.timestamp, isTerminalRequest: options?.isTerminalRequest, + resume: options?.resume, origin: options?.origin, }); } @@ -1446,6 +1461,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // treatment first rather than baking in a guess. await this._hideAutoExplainabilityReady; const lookup = this._createTurnModelLookup(sessionResource, fallbackRawModelId); + const allowTurnResume = !this._isChatReadOnly(resolvedSession.toString(), chatURI); history.push(...turnsToHistory( resolvedSession, sessionState.turns, @@ -1456,6 +1472,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.initializeResult.get()?.terminalCommandPrefix, this._config.connection.resourceUris, this._config.provider, + turn => this._getTurnErrorDetails(turn, allowTurnResume), )); this._logService.trace(`[AgentHost] provideChatSessionContent: converted ${sessionState.turns.length} turn(s) into ${history.length} history item(s) for ${resolvedSession.toString()}`); @@ -1899,13 +1916,39 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC * non-error turns. Falls back to the raw error when no structured chat * error was forwarded in `_meta`. */ - private _getTurnErrorDetails(turn: Turn | undefined): IChatResponseErrorDetails | undefined { + private _getTurnErrorDetails(turn: Turn | undefined, allowResume = true): IChatResponseErrorDetails | undefined { + const errorPart = getErrorResponsePart(turn); const error = getTurnError(turn); if (!error) { return undefined; } - return getChatErrorDetailsFromMeta(error, this._chatErrorContext()) - ?? { message: localize('agentHost.turnError', "Error: ({0}) {1}", error.errorType, error.message) }; + const isExecutionInterrupted = error.errorType === 'executionInterrupted'; + const forwardedDetails = getChatErrorDetailsFromMeta(error, this._chatErrorContext()); + const details: IChatResponseErrorDetails = isExecutionInterrupted + ? { + ...forwardedDetails, + message: error.message, + isExpectedError: true, + level: ChatErrorLevel.Warning, + } + : forwardedDetails ?? { message: localize('agentHost.turnError', "Error: ({0}) {1}", error.errorType, error.message) }; + if (!allowResume || errorPart?.resumable !== true || details.responseIsFiltered) { + return details; + } + return { + ...details, + confirmationButtons: [ + ...(details.confirmationButtons ?? []), + { + data: { agentHostResumeTurn: true } satisfies IResumeTurnConfirmationData, + label: isExecutionInterrupted + ? localize('agentHost.continueInterruptedTurn', "Keep Going") + : localize('agentHost.resumeTurn', "Try Again"), + resend: true, + preserveRequestId: true, + }, + ], + }; } /** @@ -2260,6 +2303,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let previousQueuedIds: Set | undefined; let previousSteeringId: string | undefined = currentState?.steeringMessage?.id; let previousTitle: string | undefined = currentState ? getChatTitle(currentState, chatURI) : undefined; + let previousTurnIds = new Set(currentState?.turns.map(turn => turn.id) ?? []); const disposables = new DisposableStore(); @@ -2295,21 +2339,32 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC previousTitle = currentTitle; const activeTurn = e.state.activeTurn; - if (!activeTurn || activeTurn.id === lastSeenTurnId) { + const currentTurnIds = new Set(e.state.turns.map(turn => turn.id)); + if (!activeTurn) { + lastSeenTurnId = undefined; + previousQueuedIds = currentQueuedIds; + previousTurnIds = currentTurnIds; + return; + } + if (activeTurn.id === lastSeenTurnId) { previousQueuedIds = currentQueuedIds; + previousTurnIds = currentTurnIds; return; } + const resumedTurn = previousTurnIds.has(activeTurn.id); lastSeenTurnId = activeTurn.id; // If we dispatched this turn, the existing _handleTurn flow handles it if (this._clientDispatchedTurnIds.has(activeTurn.id)) { previousQueuedIds = currentQueuedIds; + previousTurnIds = currentTurnIds; return; } const chatSession = this._activeSessions.get(sessionResource); if (!chatSession) { previousQueuedIds = currentQueuedIds; + previousTurnIds = currentTurnIds; return; } @@ -2324,6 +2379,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } previousQueuedIds = currentQueuedIds; + previousTurnIds = currentTurnIds; // Signal the session to create a new request+response pair chatSession.startServerRequest( @@ -2335,6 +2391,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC isHidden: isMessageHiddenFromTranscript(activeTurn.message), timestamp: parseTimestamp(activeTurn.startedAt), isTerminalRequest: isTerminalCommandPrompt(activeTurn.message.text, this._config.connection.initializeResult.get()?.terminalCommandPrefix), + resume: resumedTurn, origin: messageToRequestOrigin(backendSession, activeTurn.message, this._config.agentId, this._config.provider), }, ); @@ -2434,10 +2491,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const initial = request$.get(); const chatURI = initial.chat.toString(); - if (initial.kind === SessionInputRequestKind.ChatInput) { - return; - } - if (initial.kind !== SessionInputRequestKind.ToolClientExecution || initial.clientId !== this._config.connection.clientId) { + if (!this._isOwnedClientToolRequest(initial)) { return; } @@ -2476,7 +2530,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC itemStore.add(autorun(reader => { const request = request$.read(reader); const claimant = this._renderedRequests.read(reader).get(key); - if (request.kind !== SessionInputRequestKind.ToolClientExecution || request.clientId !== this._config.connection.clientId) { + if (!this._isOwnedClientToolRequest(request)) { generation++; observedRequest = undefined; startedRequest = undefined; @@ -2484,6 +2538,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC unobservedTimer.clear(); return; } + if (startedClientToolCalls.has(key)) { startedRequest = request; unobservedTimer.clear(); @@ -2550,6 +2605,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC })); } + private _isOwnedClientToolRequest(request: ObservedSessionInputRequest): request is ClientToolExecutionRequest { + return request.kind === SessionInputRequestKind.ToolClientExecution && request.clientId === this._config.connection.clientId; + } + /** * Releases this resource's reference to the shared per-backend-session * {@link _watchForSessionInputNeeded} watcher, disposing it only once the @@ -2810,6 +2869,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC chatSession: AgentHostChatSession, turnDisposables: DisposableStore, ): void { + const chatURI = this._getChatURI(chatSession.sessionResource); const cts = new CancellationTokenSource(); turnDisposables.add(toDisposable(() => cts.dispose(true))); turnDisposables.add(this._observeTurn({ @@ -2819,7 +2879,18 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnId, sink: parts => chatSession.appendProgress(parts), cancellationToken: cts.token, - onTurnEnded: () => chatSession.isCompleteObs.set(true, undefined), + suppressErrorMarkdown: true, + onTurnEnded: lastTurn => { + const errorDetails = this._getTurnErrorDetails(lastTurn, !this._isChatReadOnly(backendSession.toString(), chatURI)); + if (errorDetails) { + const response = this._chatService.getSession(chatSession.sessionResource) + ?.getRequests() + .find(request => request.id === turnId) + ?.response; + response?.setResult({ ...response.result, errorDetails }); + } + chatSession.isCompleteObs.set(true, undefined); + }, })); } @@ -2860,6 +2931,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } onFailureStage('prepareTurn'); + if (request.acceptedConfirmationData?.some(isResumeTurnConfirmationData)) { + return this._handleResumedTurn(session, request, progress, cancellationToken); + } // This waits only for local trust checks and ordered optimistic dispatch; // working-directory action envelopes are not a turn-start barrier. await this._workingDirectorySynchronizer.reconcile(session, cancellationToken); @@ -2978,6 +3052,96 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }); } + private _handleResumedTurn( + session: URI, + request: IChatAgentRequest, + progress: (parts: IChatProgress[]) => void, + cancellationToken: CancellationToken, + ): Promise { + if (cancellationToken.isCancellationRequested) { + return Promise.resolve(undefined); + } + const turnId = request.requestId; + const chatURI = this._getChatURI(request.sessionResource); + const state = this._getSessionState(session.toString(), chatURI); + const latestTurn = state?.turns.at(-1); + const activeTurn = state?.activeTurn?.id === turnId ? state.activeTurn : undefined; + const resumableTurn = latestTurn?.id === turnId && latestTurn.state === TurnState.Error && getErrorResponsePart(latestTurn)?.resumable === true + ? latestTurn + : undefined; + const completedResumedTurn = latestTurn?.id === turnId && latestTurn.state !== TurnState.Error + ? latestTurn + : undefined; + const turn = activeTurn ?? resumableTurn ?? completedResumedTurn; + if (!turn) { + throw new Error(localize('agentHost.resumeTurnUnavailable', "This failed request can no longer be resumed.")); + } + const shouldDispatchResume = resumableTurn !== undefined; + + this._clientDispatchedTurnIds.add(turnId); + this._ensureActiveClient(request.sessionResource, session); + + return new Promise((resolve, reject) => { + const store = new DisposableStore(); + const chatSubscription = this._ensureChatSubscription(session.toString(), chatURI); + if (shouldDispatchResume) { + let acceptedConcurrentResume = false; + store.add(chatSubscription.onDidApplyAction(envelope => { + if (envelope.action.type !== ActionType.ChatTurnResume + || envelope.action.turnId !== turnId) { + return; + } + if (!envelope.rejectionReason) { + acceptedConcurrentResume ||= envelope.origin?.clientId !== this._config.connection.clientId; + return; + } + if (envelope.origin?.clientId !== this._config.connection.clientId || acceptedConcurrentResume) { + return; + } + store.dispose(); + this._clientDispatchedTurnIds.delete(turnId); + reject(new Error(localize('agentHost.resumeTurnRejected', "This failed request could not be resumed: {0}", envelope.rejectionReason))); + })); + } + const cancelSub = store.add(cancellationToken.onCancellationRequested(() => { + cancelSub.dispose(); + this._config.connection.dispatch(chatURI, { + type: ActionType.ChatTurnCancelled, + turnId, + duration: 0, + }); + })); + store.add(this._observeTurn({ + backendSession: session, + sessionResource: request.sessionResource, + chatURI, + turnId, + sink: progress, + cancellationToken, + suppressErrorMarkdown: true, + requireActiveTurn: shouldDispatchResume, + onTurnEnded: lastTurn => { + store.dispose(); + this._clientDispatchedTurnIds.delete(turnId); + this._activeSessions.get(request.sessionResource)?.isCompleteObs.set(true, undefined); + resolve(lastTurn); + }, + onFileEdits: toolCall => { + const editParts = this._hydrateFileEdits(request.sessionResource, turnId, toolCall); + if (editParts.length > 0) { + progress(editParts); + } + }, + })); + if (shouldDispatchResume) { + this._config.connection.dispatch(chatURI, { + type: ActionType.ChatTurnResume, + turnId, + }); + } + }); + } + // ---- Tool confirmation -------------------------------------------------- /** @@ -3162,6 +3326,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } break; + case ResponsePartKind.Error: + break; } }, )); @@ -3396,7 +3562,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // "having seen it", so reconnect / server-initiated paths that // install us against an already-completed turn still finish. const lastTurn = state.turns.find(t => t.id === opts.turnId); - if (lastTurn) { + if (lastTurn && !opts.requireActiveTurn) { seenActive = true; } if (!seenActive) { @@ -6514,6 +6680,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (!value) { return undefined; } + const defaultChat = value.defaultChat?.toString(); const chatState = chatUri && chatUri !== defaultChat ? this._getAdditionalChatState(chatUri) @@ -6521,6 +6688,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return mergeSessionWithDefaultChat(value, chatState); } + private _isChatReadOnly(sessionUri: string, chatUri: string): boolean { + const sessionState = this._getRawSessionState(sessionUri); + if (!sessionState) { + return true; + } + const chatState = chatUri === sessionState.defaultChat?.toString() + ? this._getDefaultChatState(sessionUri) + : this._getAdditionalChatState(chatUri); + return !chatState || isChatReadOnly(chatState.interactivity, (sessionState.status & SessionStatus.IsArchived) === SessionStatus.IsArchived); + } + private _getRawSessionState(sessionUri: string): SessionState | undefined { const ref = this._sessionSubscriptions.get(sessionUri); const value = ref?.object.value; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 907f8e3377bb1c..e8617523ff7ee3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -862,7 +862,7 @@ export function usageInfoToQuotas(usage: UsageInfo | undefined): IAgentHostQuota * The `lookup` callback is responsible for any session-level fallback (e.g. * `summary.model?.id` when usage hasn't reported a model yet). */ -export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string, lookup?: TurnModelLookup, errorContext?: IChatErrorContext, terminalCommandPrefix?: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority), logicalSessionScheme: string = backendSession.scheme): IChatSessionHistoryItem[] { +export function turnsToHistory(backendSession: URI, turns: readonly Turn[], participantId: string, connectionAuthority: string, lookup?: TurnModelLookup, errorContext?: IChatErrorContext, terminalCommandPrefix?: string, resourceUris: IAgentHostResourceUriMapper = createAgentHostResourceUriMapper(connectionAuthority), logicalSessionScheme: string = backendSession.scheme, errorDetailsProvider?: (turn: Turn) => IChatResponseErrorDetails | undefined): IChatSessionHistoryItem[] { const history: IChatSessionHistoryItem[] = []; for (const turn of turns) { const rawModelId = turn.usage?.model; @@ -947,6 +947,8 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part parts.push(inputRequestResponsePartToProgress(rp, connectionAuthority, resourceUris)); break; } + case ResponsePartKind.Error: + break; } } @@ -957,7 +959,8 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part let errorDetails: IChatResponseErrorDetails | undefined; const turnError = getTurnError(turn); if (turnError) { - errorDetails = getChatErrorDetailsFromMeta(turnError, errorContext) + errorDetails = errorDetailsProvider?.(turn) + ?? getChatErrorDetailsFromMeta(turnError, errorContext) ?? { message: `Error: (${turnError.errorType}) ${turnError.message}` }; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts index df87c9d74214c7..566c6e62b9d584 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatErrorConfirmationPart.ts @@ -44,23 +44,47 @@ export class ChatErrorConfirmationContentPart extends Disposable implements ICha const buttonOptions: IButtonOptions = { ...defaultButtonStyles }; const buttonContainer = dom.append(this.domNode, $('.chat-buttons-container')); + const buttons: Button[] = []; + let isRunning = false; confirmationButtons.forEach(buttonData => { const button = this._register(new Button(buttonContainer, buttonOptions)); + buttons.push(button); button.label = buttonData.label; this._register(button.onDidClick(async () => { + if (isRunning) { + return; + } + isRunning = true; + buttons.forEach(button => button.enabled = false); const prompt = buttonData.label; const options: IChatSendRequestOptions = buttonData.isSecondary ? { rejectedConfirmationData: [buttonData.data] } : { acceptedConfirmationData: [buttonData.data] }; - options.agentId = element.agent?.id; - options.slashCommand = element.slashCommand?.name; - options.confirmation = buttonData.label; - const widget = chatWidgetService.getWidgetBySessionResource(element.sessionResource); - Object.assign(options, widget?.getSelectedModelRequestOptions()); - Object.assign(options, widget?.getModeRequestOptions()); - this.chatAccessibilityService.acceptRequest(element.sessionResource); - await chatService.sendRequest(element.sessionResource, prompt, options); + try { + options.agentId = element.agent?.id; + options.slashCommand = element.slashCommand?.name; + if (!buttonData.resend) { + options.confirmation = buttonData.label; + } + const widget = chatWidgetService.getWidgetBySessionResource(element.sessionResource); + Object.assign(options, widget?.getSelectedModelRequestOptions()); + Object.assign(options, widget?.getModeRequestOptions()); + this.chatAccessibilityService.acceptRequest(element.sessionResource); + if (buttonData.resend) { + const request = chatService.getSession(element.sessionResource)?.getRequests().find(request => request.id === element.requestId); + if (!request) { + throw new Error(`Cannot resend missing chat request: ${element.requestId}`); + } + await chatService.resendRequest(request, options, buttonData.preserveRequestId); + } else { + await chatService.sendRequest(element.sessionResource, prompt, options); + } + } catch (error) { + isRunning = false; + buttons.forEach(button => button.enabled = true); + throw error; + } })); }); } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 7d02bff5822484..ecd82a05dadd65 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -52,6 +52,10 @@ export interface IChatResponseErrorDetailsConfirmationButton { data: any; label: string; isSecondary?: boolean; + /** Replace and resend the request associated with this response instead of adding a new request. */ + resend?: boolean; + /** Reuse the existing request model and identifier when resending. */ + preserveRequestId?: boolean; } export interface IChatResponseErrorDetails { @@ -2026,7 +2030,7 @@ export interface IChatService { setSessionTitle(sessionResource: URI, title: string): void; appendProgress(request: IChatRequestModel, progress: IChatProgress): void; - resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise; + resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions, preserveRequestId?: boolean): Promise; adoptRequest(sessionResource: URI, request: IChatRequestModel): Promise; removeRequest(sessionResource: URI, requestId: string): Promise; cancelCurrentRequestForSession(sessionResource: URI, source?: string): Promise; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index f09b4906f18c80..08ba8836c7265a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -976,7 +976,18 @@ export class ChatService extends Disposable implements IChatService { // Handle server-initiated requests (e.g. consumed queued messages). if (providedSession.onDidStartServerRequest) { - disposables.add(providedSession.onDidStartServerRequest(({ id, prompt, variableData, timestamp, isSystemInitiated, isHidden, systemInitiatedLabel, isTerminalRequest, origin }) => { + disposables.add(providedSession.onDidStartServerRequest(({ id, prompt, variableData, timestamp, isSystemInitiated, isHidden, systemInitiatedLabel, isTerminalRequest, resume, origin }) => { + if (resume) { + const request = model.getRequests().find(request => request.id === id); + if (!request?.response) { + throw new Error(`Cannot resume missing chat request: ${id}`); + } + request.response.reopen(); + lastRequest = request; + lastProgressLength = 0; + ensureCancellationTracking(); + return; + } // Complete any in-flight request if (lastRequest?.response && !lastRequest.response.isComplete) { completeLastResponse(); @@ -1098,7 +1109,7 @@ export class ChatService extends Disposable implements IChatService { return modelRef; } - async resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise { + async resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions, preserveRequestId = false): Promise { const model = this._sessionModels.get(request.session.sessionResource); if (!model && model !== request.session) { throw new Error(`Unknown session: ${request.session.sessionResource}`); @@ -1116,16 +1127,24 @@ export class ChatService extends Disposable implements IChatService { const location = options?.location ?? model.initialLocation; const attempt = options?.attempt ?? 0; const enableCommandDetection = !options?.noCommandDetection; - const defaultAgent = this.chatAgentService.getDefaultAgent(location, options?.modeInfo?.kind)!; + const requestedAgentId = options?.agentId ?? options?.agentIdSilent; + const requestedAgent = requestedAgentId ? this.chatAgentService.getAgent(requestedAgentId) : undefined; + if (requestedAgentId && !requestedAgent) { + throw new Error('Unknown agent: ' + requestedAgentId); + } + const defaultAgent = requestedAgent ?? this.chatAgentService.getDefaultAgent(location, options?.modeInfo?.kind)!; - model.removeRequest(request.id, ChatRequestRemovalReason.Resend); + const preservedRequest = preserveRequestId && request instanceof ChatRequestModel ? request : undefined; + if (!preservedRequest) { + model.removeRequest(request.id, ChatRequestRemovalReason.Resend); + } const resendOptions: IChatSendRequestOptions = { ...options, locationData: request.locationData, attachedContext: request.attachedContext, }; - await this._sendRequestAsync(model, model.sessionResource, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise; + await this._sendRequestAsync(model, model.sessionResource, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions, preservedRequest, preserveRequestId ? request.id : undefined).responseCompletePromise; } private queuePendingRequest(model: ChatModel, sessionResource: URI, request: string, options: IChatSendRequestOptions): ChatSendResultQueued { @@ -1408,13 +1427,13 @@ export class ChatService extends Disposable implements IChatService { return newTokenSource.token; } - private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions): IChatSendRequestResponseState { + private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions, preservedRequest?: ChatRequestModel, requestId?: string): IChatSendRequestResponseState { const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionResource); let request: ChatRequestModel | undefined; const agentPart = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); const commandPart = parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart); - const requests = [...model.getRequests()]; + const requests = model.getRequests().filter(request => request !== preservedRequest); const isTerminalCommand = isTerminalCommandPrompt(parsedRequest.text, this.chatSessionService.getCapabilitiesForSessionType(getChatSessionType(sessionResource))?.terminalCommandPrefix); const requestTelemetry = this.instantiationService.createInstance(ChatRequestTelemetry, { agent: agentPart?.agent ?? defaultAgent, @@ -1595,12 +1614,13 @@ export class ChatService extends Disposable implements IChatService { let rawResult: IChatAgentResult | null | undefined; let agentOrCommandFollowups: Promise | undefined = undefined; if (agentPart || (defaultAgent && !commandPart)) { - // --- Step 1: Create the request model immediately (before any awaits) --- - // This fires RequestUiUpdated synchronously so the user sees their message right away. + // --- Step 1: Create or reuse the request model immediately (before any awaits) --- + // New requests become visible immediately; preserved requests remain mounted and reopen synchronously. const initialAgent = agentPart?.agent ?? defaultAgent; const initialCommand = agentSlashCommandPart?.command; const initVariableData: IChatRequestVariableData = { variables: [] }; - request = model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, initialAgent, initialCommand, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), undefined, options?.isSystemInitiated, options?.systemInitiatedLabel, options?.terminalExecutionId, isTerminalCommand, undefined, options?.hideFromTranscript); + request = preservedRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, initialAgent, initialCommand, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), requestId, options?.isSystemInitiated, options?.systemInitiatedLabel, options?.terminalExecutionId, isTerminalCommand, undefined, options?.hideFromTranscript); + preservedRequest?.response?.reopen(); const thisRequest = request; completeResponseCreated(); @@ -1700,6 +1720,7 @@ export class ChatService extends Disposable implements IChatService { location !== ChatAgentLocation.EditorInline && options?.modeInfo?.kind !== ChatModeKind.Agent && options?.modeInfo?.kind !== ChatModeKind.Edit && + !options?.agentId && !options?.agentIdSilent ) { // We have no agent or command to scope history with, pass the full history to the participant detection provider diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index cd031faf26fef3..b9fb0fa5c2da7a 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -345,6 +345,8 @@ export interface IChatSessionServerRequest { readonly isHidden?: boolean; readonly systemInitiatedLabel?: string; readonly isTerminalRequest?: boolean; + /** Reopen the existing request with this id instead of adding another request. */ + readonly resume?: boolean; readonly origin?: IChatRequestOrigin; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 8e3b394b0c7654..1bd29c295afb91 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -15,7 +15,7 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; -import { IObservable, autorun, constObservable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts, registerAutorunSelfDisposable } from '../../../../../base/common/observable.js'; +import { IObservable, autorun, constObservable, derived, observableFromEvent, observableSignal, observableSignalFromEvent, observableValue, observableValueOpts, registerAutorunSelfDisposable } from '../../../../../base/common/observable.js'; import { basename, isEqual } from '../../../../../base/common/resources.js'; import { hasKey, WithDefinedProps } from '../../../../../base/common/types.js'; import { URI, UriDto } from '../../../../../base/common/uri.js'; @@ -335,6 +335,7 @@ export interface IChatResponseModel { setVote(vote: ChatAgentVoteDirection): void; setUsage(usage: IChatUsage): void; setElapsedMs(elapsedMs: number): void; + setResult(result: IChatAgentResult): void; setEditApplied(edit: IChatTextEditGroup, editCount: number): boolean; resolveInlineReference(resolveId: string, resolvedReference: IChatContentInlineReference): boolean; updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatTask | IChatExternalToolInvocationUpdate, quiet?: boolean): void; @@ -1210,6 +1211,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel private _completionTimestamp: number | undefined; private _timeSpentWaitingAccumulator: number; private _elapsedMs: number | undefined; + private readonly _timingChanged = observableSignal(this); public confirmationAdjustedTimestamp: IObservable; @@ -1481,6 +1483,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel let lastStartedWaitingAt: number | undefined = undefined; this.confirmationAdjustedTimestamp = derived(reader => { + this._timingChanged.read(reader); const pending = this.isPendingConfirmation.read(reader); if (pending) { this._modelState.set({ value: ResponseModelState.NeedsInput }, undefined); @@ -1649,6 +1652,25 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel this._complete(Date.now(), undefined); } + reopen(): void { + if (!this.isComplete) { + return; + } + this._response.clear(); + if (this._result?.errorDetails) { + const { errorDetails: _errorDetails, ...result } = this._result; + this._result = result; + } + if (this.completedAt !== undefined) { + this._timeSpentWaitingAccumulator += Math.max(0, Date.now() - this.completedAt); + this._timingChanged.trigger(undefined); + } + this._completionTimestamp = undefined; + this._elapsedMs = undefined; + this._modelState.set({ value: ResponseModelState.Pending }, undefined); + this._onDidChange.fire(defaultChatResponseModelChangeReason); + } + private _complete(completedAt: number, completionTimestamp: number | undefined): void { // No-op if it's already complete if (this.isComplete) { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 556c668bcedcb0..1d60683a3470e8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -50,7 +50,7 @@ import { IAuthenticationMcpUsageService } from '../../../../../services/authenti import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { CHAT_SUBAGENT_RESOURCE_QUERY_PARAM, ChatAIDisabledSettingId, ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { ChatRequestQueueKind, ElicitationState, IChatService, IRemotePendingRequest, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatProgress, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { ChatErrorLevel, ChatRequestQueueKind, ElicitationState, IChatService, IRemotePendingRequest, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatProgress, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatDebugService } from '../../../common/chatDebugService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js'; @@ -118,7 +118,7 @@ import { IAgentHostEnablementService } from '../../../../../../platform/agentHos type ILegacyTimedChatAction = | { type: 'chat/turnComplete'; turnId: string; endedAt: string } | { type: 'chat/turnCancelled'; turnId: string; endedAt: string } - | { type: 'chat/error'; turnId: string; endedAt: string; error: { errorType: string; message: string; stack?: string } }; + | { type: 'chat/error'; turnId: string; endedAt: string; part: { kind: ResponsePartKind.Error; error: { errorType: string; message: string; stack?: string }; resumable?: true } }; type ChatAction = AgentHostChatAction | ILegacyTimedChatAction; type TestActionEnvelope = Omit & { action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction }; @@ -126,7 +126,7 @@ type TestActionEnvelope = Omit & { action: SessionActi function normalizeTestAction(action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): SessionAction | AgentHostChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction { if (hasKey(action, { endedAt: true })) { if (action.type === 'chat/error') { - return { type: ActionType.ChatError, turnId: action.turnId, duration: 1000, part: { kind: ResponsePartKind.Error, error: action.error } }; + return { type: ActionType.ChatError, turnId: action.turnId, duration: 1000, part: action.part }; } return { type: action.type === 'chat/turnComplete' ? ActionType.ChatTurnComplete : ActionType.ChatTurnCancelled, @@ -1094,7 +1094,7 @@ function createByokLanguageModelTestData(groupName?: string): { languageModels: }; } -function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentId: string; requestId: string }> = {}): IChatAgentRequest { +function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record; agentHostSessionConfig: Record; agentId: string; requestId: string; acceptedConfirmationData: unknown[] }> = {}): IChatAgentRequest { return upcastPartial({ sessionResource: overrides.sessionResource ?? URI.from({ scheme: 'untitled', path: '/chat-1' }), requestId: overrides.requestId ?? 'req-1', @@ -1105,6 +1105,7 @@ function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; userSelectedModelId: overrides.userSelectedModelId, modelConfiguration: overrides.modelConfiguration, agentHostSessionConfig: overrides.agentHostSessionConfig, + acceptedConfirmationData: overrides.acceptedConfirmationData, }); } @@ -6255,7 +6256,7 @@ suite('AgentHostChatContribution', () => { action: { type: 'chat/error', endedAt: '2025-01-01T00:00:00.000Z', turnId, - error: { errorType: 'test_error', message: 'Something went wrong' }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'test_error', message: 'Something went wrong' } }, } as ChatAction, serverSeq: 99, origin: undefined, @@ -6269,6 +6270,365 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(result.errorDetails?.message, 'Error: (test_error) Something went wrong'); assert.ok(!collected.flat().some(p => p.kind === 'markdownContent' && (p as IChatMarkdownContent).content.value.includes('Something went wrong')), 'Error should not be duplicated as a markdown progress part'); })); + + test('resumable error offers Try Again and resumes the same turn', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const languageModels = new Map([ + ['agent-host-copilot:opus-4.7', upcastPartial({ name: 'Opus 4.7', pricing: '15x' })], + ]); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { languageModels }); + agentHostService.setRootState({ + agents: [{ + provider: 'copilot', + displayName: 'Agent Host - Copilot', + description: 'test', + models: [], + }], + activeSessions: 1, + }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/retry-turn' }); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + fire({ + type: ActionType.ChatResponsePart, + turnId, + part: { kind: ResponsePartKind.Markdown, id: 'old-part', content: 'partial response' }, + }); + fire({ + type: ActionType.ChatResponsePart, + turnId, + part: { kind: ResponsePartKind.SystemNotification, content: 'prior notice' }, + }); + fire({ + type: ActionType.ChatUsage, + turnId, + usage: { + inputTokens: 10, + outputTokens: 5, + model: 'opus-4.7', + _meta: { + copilotUsage: { totalNanoAiu: 2_000_000_000 }, + turnTokenTotals: [{ model: 'opus-4.7', inputTokens: 10, cachedTokens: 1, outputTokens: 5 }], + }, + }, + }); + fire({ + type: ActionType.ChatError, + turnId, + duration: 100, + part: { kind: ResponsePartKind.Error, error: { errorType: 'requestFailed', message: 'failed' }, resumable: true }, + }); + const failedResult = await turnPromise; + const retryButton = failedResult.errorDetails?.confirmationButtons?.at(-1); + assert.deepStrictEqual(retryButton, { + data: { agentHostResumeTurn: true }, + label: 'Try Again', + resend: true, + preserveRequestId: true, + }); + + agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot'); + assert.ok(registered); + const retryProgress: IChatProgress[][] = []; + const retryPromise = registered.impl.invoke( + makeRequest({ + sessionResource, + requestId: turnId, + message: 'original request', + acceptedConfirmationData: [retryButton!.data], + }), + parts => retryProgress.push(parts), + [], + CancellationToken.None, + ); + await timeout(10); + + const resumeDispatch = agentHostService.dispatchedActions.find(entry => entry.action.type === ActionType.ChatTurnResume); + assert.ok(resumeDispatch?.action.type === ActionType.ChatTurnResume); + assert.strictEqual(resumeDispatch.action.turnId, turnId); + agentHostService.fireAction({ + channel: resumeDispatch.channel.toString(), + action: resumeDispatch.action, + serverSeq: 100, + origin: { clientId: agentHostService.clientId, clientSeq: resumeDispatch.clientSeq }, + }); + agentHostService.fireAction({ + channel: session, + action: { + type: ActionType.ChatUsage, + turnId, + usage: { + inputTokens: 20, + outputTokens: 8, + model: 'opus-4.7', + _meta: { + copilotUsage: { totalNanoAiu: 6_000_000_000 }, + turnTokenTotals: [{ model: 'opus-4.7', inputTokens: 30, cachedTokens: 3, outputTokens: 13 }], + }, + }, + }, + serverSeq: 101, + origin: undefined, + }); + agentHostService.fireAction({ + channel: session, + action: { + type: ActionType.ChatResponsePart, + turnId, + part: { kind: ResponsePartKind.Markdown, id: 'new-part', content: 'continued response' }, + }, + serverSeq: 102, + origin: undefined, + }); + agentHostService.fireAction({ + channel: session, + action: { type: ActionType.ChatTurnComplete, turnId, duration: 200 }, + serverSeq: 103, + origin: undefined, + }); + + const retryResult = await retryPromise; + const retryUsage = retryProgress.flat().filter((part): part is IChatUsage => part.kind === 'usage').at(-1); + assert.deepStrictEqual({ + details: retryResult.details, + errorDetails: retryResult.errorDetails, + resumeDispatch: resumeDispatch.action, + progress: retryProgress.flat().filter(part => part.kind === 'markdownContent').map(part => (part as IChatMarkdownContent).content.value), + systemNotifications: retryProgress.flat().filter(part => part.kind === 'systemNotification').map(part => part.content.value), + usage: retryUsage ? { + promptTokens: retryUsage.promptTokens, + completionTokens: retryUsage.completionTokens, + copilotCredits: retryUsage.copilotCredits, + modelTotals: retryUsage.modelTotals, + } : undefined, + }, { + details: 'Opus 4.7 • 6 credits', + errorDetails: undefined, + resumeDispatch: { type: ActionType.ChatTurnResume, turnId }, + progress: ['partial response', 'continued response'], + systemNotifications: ['prior notice'], + usage: { + promptTokens: 20, + completionTokens: 8, + copilotCredits: 6, + modelTotals: [{ model: 'Opus 4.7', inputTokens: 30, cachedTokens: 3, outputTokens: 13 }], + }, + }); + })); + + test('interrupted turn offers Keep Going as a warning', async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + agentHostService.setRootState({ + agents: [{ + provider: 'copilot', + displayName: 'Agent Host - Copilot', + description: 'test', + models: [], + }], + activeSessions: 1, + }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/interrupted-turn' }); + const { turnPromise, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + fire({ + type: ActionType.ChatError, + turnId, + duration: 100, + part: { + kind: ResponsePartKind.Error, + error: { + errorType: 'executionInterrupted', + message: 'The agent was interrupted before this request finished.', + }, + resumable: true, + }, + }); + + assert.deepStrictEqual((await turnPromise).errorDetails, { + message: 'The agent was interrupted before this request finished.', + isExpectedError: true, + level: ChatErrorLevel.Warning, + confirmationButtons: [{ + data: { agentHostResumeTurn: true }, + label: 'Keep Going', + resend: true, + preserveRequestId: true, + }], + }); + }); + + test('a local retry joins a turn concurrently resumed by another client', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + agentHostService.setRootState({ + agents: [{ + provider: 'copilot', + displayName: 'Agent Host - Copilot', + description: 'test', + models: [], + }], + activeSessions: 1, + }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/racing-retry' }); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + fire({ + type: ActionType.ChatResponsePart, + turnId, + part: { kind: ResponsePartKind.Markdown, id: 'old-part', content: 'partial response' }, + }); + fire({ + type: ActionType.ChatError, + turnId, + duration: 100, + part: { kind: ResponsePartKind.Error, error: { errorType: 'requestFailed', message: 'failed' }, resumable: true }, + }); + const retryButton = (await turnPromise).errorDetails?.confirmationButtons?.at(-1); + assert.ok(retryButton); + + agentHostService.fireAction({ + channel: session, + action: { type: ActionType.ChatTurnResume, turnId }, + serverSeq: 100, + origin: { clientId: 'other-client', clientSeq: 1 }, + }); + agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot'); + assert.ok(registered); + const retryProgress: IChatProgress[][] = []; + const retryPromise = registered.impl.invoke( + makeRequest({ + sessionResource, + requestId: turnId, + acceptedConfirmationData: [retryButton.data], + }), + parts => retryProgress.push(parts), + [], + CancellationToken.None, + ); + await timeout(10); + fire({ + type: ActionType.ChatResponsePart, + turnId, + part: { kind: ResponsePartKind.Markdown, id: 'new-part', content: 'continued response' }, + }); + fire({ type: ActionType.ChatTurnComplete, turnId, duration: 200 }); + + const retryResult = await retryPromise; + assert.deepStrictEqual({ + errorDetails: retryResult.errorDetails, + resumeDispatches: agentHostService.dispatchedActions.filter(entry => entry.action.type === ActionType.ChatTurnResume).length, + progress: retryProgress.flat().filter(part => part.kind === 'markdownContent').map(part => (part as IChatMarkdownContent).content.value), + }, { + errorDetails: undefined, + resumeDispatches: 0, + progress: ['partial response', 'continued response'], + }); + })); + + test('a rejected local retry keeps observing a concurrently accepted resume', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + agentHostService.setRootState({ + agents: [{ provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'test', models: [] }], + activeSessions: 1, + }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/rejected-racing-retry' }); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + fire({ + type: ActionType.ChatError, + turnId, + duration: 100, + part: { kind: ResponsePartKind.Error, error: { errorType: 'requestFailed', message: 'failed' }, resumable: true }, + }); + const retryButton = (await turnPromise).errorDetails?.confirmationButtons?.at(-1); + assert.ok(retryButton); + + agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot'); + assert.ok(registered); + const retryProgress: IChatProgress[][] = []; + const retryPromise = registered.impl.invoke( + makeRequest({ + sessionResource, + requestId: turnId, + acceptedConfirmationData: [retryButton.data], + }), + parts => retryProgress.push(parts), + [], + CancellationToken.None, + ); + await timeout(10); + const resumeDispatch = agentHostService.dispatchedActions.find(entry => entry.action.type === ActionType.ChatTurnResume); + assert.ok(resumeDispatch?.action.type === ActionType.ChatTurnResume); + agentHostService.fireAction({ + channel: session, + action: { type: ActionType.ChatTurnResume, turnId }, + serverSeq: 100, + origin: { clientId: 'other-client', clientSeq: 1 }, + }); + agentHostService.fireAction({ + channel: resumeDispatch.channel.toString(), + action: resumeDispatch.action, + serverSeq: 101, + origin: { clientId: agentHostService.clientId, clientSeq: resumeDispatch.clientSeq }, + rejectionReason: 'Already resumed', + }); + fire({ + type: ActionType.ChatResponsePart, + turnId, + part: { kind: ResponsePartKind.Markdown, id: 'new-part', content: 'continued response' }, + }); + fire({ type: ActionType.ChatTurnComplete, turnId, duration: 200 }); + + const retryResult = await retryPromise; + assert.deepStrictEqual({ + errorDetails: retryResult.errorDetails, + progress: retryProgress.flat().filter(part => part.kind === 'markdownContent').map(part => (part as IChatMarkdownContent).content.value), + }, { + errorDetails: undefined, + progress: ['continued response'], + }); + })); + + test('rejected resume resolves the retry invocation with an error', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + agentHostService.setRootState({ + agents: [{ provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'test', models: [] }], + activeSessions: 1, + }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/rejected-retry' }); + const { turnPromise, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); + fire({ + type: ActionType.ChatError, + turnId, + duration: 100, + part: { kind: ResponsePartKind.Error, error: { errorType: 'requestFailed', message: 'failed' }, resumable: true }, + }); + const retryButton = (await turnPromise).errorDetails?.confirmationButtons?.at(-1); + assert.ok(retryButton); + + agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot'); + assert.ok(registered); + const retryPromise = registered.impl.invoke( + makeRequest({ + sessionResource, + requestId: turnId, + acceptedConfirmationData: [retryButton.data], + }), + () => { }, + [], + CancellationToken.None, + ); + await timeout(10); + const resumeDispatch = agentHostService.dispatchedActions.find(entry => entry.action.type === ActionType.ChatTurnResume); + assert.ok(resumeDispatch?.action.type === ActionType.ChatTurnResume); + agentHostService.fireAction({ + channel: resumeDispatch.channel.toString(), + action: resumeDispatch.action, + serverSeq: 100, + origin: { clientId: agentHostService.clientId, clientSeq: resumeDispatch.clientSeq }, + rejectionReason: 'Already resumed', + }); + + await assert.rejects(retryPromise, /Already resumed/); + })); }); // ---- Permission requests ----------------------------------------------- @@ -8199,7 +8559,7 @@ suite('AgentHostChatContribution', () => { action: { type: 'chat/error', endedAt: '2025-01-01T00:00:00.000Z', turnId, - error: { errorType: 'connection_error', message: 'connection lost' }, + part: { kind: ResponsePartKind.Error, error: { errorType: 'connection_error', message: 'connection lost' } }, } as ChatAction, serverSeq: 99, origin: undefined, diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts index 4311ef3edffc68..a1651d15857549 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts @@ -73,10 +73,15 @@ suite('importedTurnsFromChatModel', () => { text: turn.message.text, state: turn.state, error: getTurnError(turn), - parts: turn.responseParts.filter(part => part.kind !== ResponsePartKind.Error).map(part => - part.kind === ResponsePartKind.Markdown || part.kind === ResponsePartKind.Reasoning - ? { kind: part.kind, content: part.content } - : { kind: part.kind, subagent: subagentOf(part) }), + parts: turn.responseParts.map(part => { + if (part.kind === ResponsePartKind.Markdown || part.kind === ResponsePartKind.Reasoning) { + return { kind: part.kind, content: part.content }; + } + if (part.kind === ResponsePartKind.Error) { + return { kind: part.kind, error: part.error }; + } + return { kind: part.kind, subagent: subagentOf(part) }; + }), })); } @@ -198,7 +203,7 @@ suite('importedTurnsFromChatModel', () => { text: 'q', state: TurnState.Error, error: { errorType: 'E1', message: 'boom' }, - parts: [], + parts: [{ kind: ResponsePartKind.Error, error: { errorType: 'E1', message: 'boom' } }], }]); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 70915e4e832d0f..06081c230b081f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -15,7 +15,7 @@ import { toAgentMessageDelegationMeta } from '../../../../../../platform/agentHo import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; import { McpAuthRequiredReason } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { createAgentHostResourceUriMapper, fromAgentHostUri, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, createErrorResponsePart, MessageAttachmentKind, MessageKind, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, readUsageInfoMeta, withMessageHiddenFromTranscript, type ActiveTurn, type ICompletedToolCall, type ToolCallPendingConfirmationState, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message, type ToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatTranscriptContextAttachmentDisplayKind, IChatRequestTranscriptContextVariableEntry, toChatTranscriptContextAttachmentMeta } from '../../../common/attachments/chatVariableEntries.js'; import { ChatRequestOriginKind } from '../../../common/chatRequestOrigin.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatMarkdownContent, type IChatTerminalToolInvocationData, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; @@ -1167,7 +1167,7 @@ suite('stateToProgressAdapter', () => { test('error turn produces error details in history', () => { const turn = createTurn({ state: TurnState.Error, - responseParts: [{ kind: ResponsePartKind.Error, error: { errorType: 'test', message: 'boom' } }], + responseParts: [createErrorResponsePart({ errorType: 'test', message: 'boom' })], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); @@ -1178,17 +1178,32 @@ suite('stateToProgressAdapter', () => { assert.ok(!response.parts.some(p => p.kind === 'markdownContent' && (p as IChatMarkdownContent).content.value.includes('boom')), 'Error should not be duplicated as a markdown part'); }); + test('historical resumable errors can restore Try Again without rendering completed errors', () => { + const resumableError = createErrorResponsePart({ errorType: 'test', message: 'boom' }, true); + const errorTurn = createTurn({ state: TurnState.Error, responseParts: [resumableError] }); + const completeTurn = createTurn({ state: TurnState.Complete, responseParts: [resumableError] }); + const errorDetails = { + message: 'boom', + confirmationButtons: [{ data: { resume: true }, label: 'Try Again' }], + }; + + const history = rawTurnsToHistory(URI.file('/'), [errorTurn, completeTurn], 'p', '', undefined, undefined, undefined, createAgentHostResourceUriMapper(''), undefined, () => errorDetails); + const responses = history.filter(item => item.type === 'response'); + + assert.deepStrictEqual(responses.map(response => response.type === 'response' ? response.errorDetails : undefined), [ + errorDetails, + undefined, + ]); + }); + test('forwarded quota error turn produces quota-exceeded error details', () => { const turn = createTurn({ state: TurnState.Error, - responseParts: [{ - kind: ResponsePartKind.Error, - error: { - errorType: 'quota', - message: 'raw', - _meta: { chatError: { fetchError: { type: 'quotaExceeded', capiError: { code: 'quota_exceeded' } } } }, - }, - }], + responseParts: [createErrorResponsePart({ + errorType: 'quota', + message: 'raw', + _meta: { chatError: { fetchError: { type: 'quotaExceeded', capiError: { code: 'quota_exceeded' } } } }, + })], }); const history = turnsToHistory(URI.file('/'), [turn], 'p'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatErrorConfirmationPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatErrorConfirmationPart.test.ts new file mode 100644 index 00000000000000..27fa9e5e64d7a9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatErrorConfirmationPart.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { DeferredPromise } from '../../../../../../../base/common/async.js'; +import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { IChatAccessibilityService, IChatWidgetService } from '../../../../browser/chat.js'; +import { ChatErrorConfirmationContentPart } from '../../../../browser/widget/chatContentParts/chatErrorConfirmationPart.js'; +import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; +import { ChatErrorLevel, IChatSendRequestOptions, IChatService } from '../../../../common/chatService/chatService.js'; +import { IChatModel, IChatRequestModel } from '../../../../common/model/chatModel.js'; +import { IChatErrorDetailsPart, IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; +import { IChatAgentData } from '../../../../common/participants/chatAgents.js'; + +suite('ChatErrorConfirmationContentPart', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('Try Again resends the same request through its selected agent', async () => { + const sessionResource = URI.parse('test://session'); + const request = upcastPartial({ id: 'turn-1' }); + const resend = new DeferredPromise(); + let resendCallCount = 0; + let resendCall: { requestId: string; options: IChatSendRequestOptions | undefined; preserveRequestId: boolean | undefined } | undefined; + let acceptedSession: URI | undefined; + const chatService = new class extends mock() { + override getSession(): IChatModel { + return upcastPartial({ getRequests: () => [request] }); + } + + override async resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions, preserveRequestId?: boolean): Promise { + resendCallCount++; + resendCall = { requestId: request.id, options, preserveRequestId }; + resend.complete(); + } + }; + const instantiationService = workbenchInstantiationService(undefined, store); + instantiationService.stub(IChatService, chatService); + instantiationService.stub(IChatWidgetService, new class extends mock() { + override getWidgetBySessionResource() { + return undefined; + } + }); + instantiationService.stub(IChatAccessibilityService, new class extends mock() { + override acceptRequest(resource: URI): void { + acceptedSession = resource; + } + }); + const renderer = upcastPartial({ + render: markdown => { + const element = mainWindow.document.createElement('div'); + element.textContent = markdown.value; + return { element, dispose() { } }; + }, + }); + const element = upcastPartial({ + setVote() { }, + sessionResource, + requestId: request.id, + agent: upcastPartial({ id: 'agent-host-copilot' }), + }); + const errorDetails = upcastPartial({ + kind: 'errorDetails', + errorDetails: { message: 'Failed' }, + isLast: true, + }); + const part = store.add(instantiationService.createInstance( + ChatErrorConfirmationContentPart, + ChatErrorLevel.Error, + new MarkdownString('Failed'), + errorDetails, + [{ + label: 'Try Again', + data: { agentHostResumeTurn: true }, + resend: true, + preserveRequestId: true, + }, { + label: 'Try Another Way', + data: { agentHostResumeTurn: true }, + resend: true, + preserveRequestId: true, + }], + renderer, + upcastPartial({ element }), + )); + mainWindow.document.body.appendChild(part.domNode); + store.add(toDisposable(() => part.domNode.remove())); + + const buttons = [...part.domNode.querySelectorAll('.monaco-button')]; + assert.strictEqual(buttons.length, 2); + buttons[0].click(); + buttons[0].click(); + buttons[1].click(); + await resend.p; + + assert.deepStrictEqual({ + labels: buttons.map(button => button.textContent), + roles: buttons.map(button => button.getAttribute('role')), + acceptedSession: acceptedSession?.toString(), + resendCallCount, + resendCall, + }, { + labels: ['Try Again', 'Try Another Way'], + roles: ['button', 'button'], + acceptedSession: sessionResource.toString(), + resendCallCount: 1, + resendCall: { + requestId: request.id, + options: { + acceptedConfirmationData: [{ agentHostResumeTurn: true }], + agentId: 'agent-host-copilot', + slashCommand: undefined, + }, + preserveRequestId: true, + }, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 326f9df1e4722f..c28a15177038b7 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -9,7 +9,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { constObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mockObject } from '../../../../../../base/test/common/mock.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; @@ -1152,19 +1152,62 @@ suite('ChatService', () => { const firstRequest = model.getRequests()[0]; assert.ok(firstRequest, 'Expected the initial request to exist before resend'); + const structuralChanges: string[] = []; + testDisposables.add(model.onDidChange(event => { + if (event.kind === 'removeRequest' || event.kind === 'addRequest') { + structuralChanges.push(event.kind); + } + })); // Resend the original request: now disabled hooks are present (simulates resend after setup) - await testService.resendRequest(firstRequest); + await testService.resendRequest(firstRequest, undefined, true); // Now the flag should be set and the hint shown assert.strictEqual(storageService.getBoolean(disabledHintsKey, StorageScope.WORKSPACE), true, 'Flag should be set after showing the hint'); const requests = model.getRequests(); assert.strictEqual(requests.length, 1, 'Resend should replace the original request'); + assert.strictEqual(requests[0].id, firstRequest.id, 'Preserved resend should keep the original request id'); + assert.strictEqual(requests[0], firstRequest, 'Preserved resend should reuse the original request model'); + assert.deepStrictEqual(structuralChanges, [], 'Preserved resend should not remove and recreate the transcript row'); const responseParts2 = requests[0].response?.response.value ?? []; const hasHookHint2 = responseParts2.some(part => part.kind === 'disabledClaudeHooks'); assert.ok(hasHookHint2, 'Response should contain the disabledClaudeHooks hint on second request'); }); + + test('resendRequest honors an agent selected outside the parsed request', async () => { + const retryAgentId = 'retryAgent'; + const invokedRequestIds: string[] = []; + testDisposables.add(chatAgentService.registerAgent(retryAgentId, getAgentData(retryAgentId))); + testDisposables.add(chatAgentService.registerAgentImplementation(retryAgentId, { + async invoke(request) { + invokedRequestIds.push(request.requestId); + return {}; + }, + })); + testDisposables.add(chatAgentService.registerChatParticipantDetectionProvider(1, { + provideParticipantDetection: async () => ({ participant: 'testAgent' }), + })); + + const testService = createChatService(); + const modelRef = testDisposables.add(startSessionModel(testService)); + const model = modelRef.object; + const response = await testService.sendRequest(model.sessionResource, 'retry me', { agentIdSilent: retryAgentId }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + const firstRequest = model.getRequests()[0]; + + await testService.resendRequest(firstRequest, { agentId: retryAgentId }, true); + + assert.deepStrictEqual({ + invokedRequestIds, + requestIds: model.getRequests().map(request => request.id), + }, { + invokedRequestIds: [firstRequest.id, firstRequest.id], + requestIds: [firstRequest.id], + }); + }); + test('cancelCurrentRequestForSession waits for response completion', async () => { const requestStarted = new DeferredPromise(); const completeRequest = new DeferredPromise(); @@ -2797,6 +2840,52 @@ suite('ChatService', () => { ]); }); + test('remote resume reopens the existing request without duplicating it', async () => { + const onDidStartServerRequest = testDisposables.add(new Emitter()); + const progressObs = observableValue('progress', []); + const isCompleteObs = observableValue('complete', true); + const { resource } = setupRemoteProvider({ + history: [ + { id: 'turn-1', type: 'request', prompt: 'hello', participant: remoteScheme }, + { type: 'response', parts: [{ kind: 'markdownContent', content: new MarkdownString('partial') }], participant: remoteScheme, errorDetails: { message: 'failed' } }, + ], + progressObs, + isCompleteObs, + interruptActiveResponseCallback: async () => true, + onDidStartServerRequest: onDidStartServerRequest.event, + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + transaction(tx => { + isCompleteObs.set(false, tx); + onDidStartServerRequest.fire({ id: 'turn-1', prompt: 'hello', resume: true }); + }); + + const request = ref.object.getRequests()[0]; + assert.deepStrictEqual({ + requestCount: ref.object.getRequests().length, + id: request.id, + state: request.response?.state, + errorDetails: request.response?.result?.errorDetails, + content: request.response?.response.value, + }, { + requestCount: 1, + id: 'turn-1', + state: ResponseModelState.Pending, + errorDetails: undefined, + content: [], + }); + + progressObs.set([{ kind: 'markdownContent', content: new MarkdownString('continued') }], undefined); + isCompleteObs.set(true, undefined); + + assert.deepStrictEqual(request.response?.response.value.map(part => part.kind === 'markdownContent' ? part.content.value : part.kind), ['continued']); + }); + test('already-complete session at load time: no initial pending request, response is completed via autorun', async () => { const progressObs = observableValue('progress', []); const isCompleteObs = observableValue('isComplete', true); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 9eb9cdea44aee6..dc611e8fe6615b 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -122,7 +122,7 @@ export class MockChatService implements IChatService { throw new Error('Method not implemented.'); } - resendRequest(_request: IChatRequestModel, _options?: IChatSendRequestOptions): Promise { + resendRequest(_request: IChatRequestModel, _options?: IChatSendRequestOptions, _preserveRequestId?: boolean): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index 10e47aabc830b2..ead9433d794223 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -1660,6 +1660,59 @@ suite('ChatResponseModel', () => { } }); + test('reopen clears terminal error state and keeps the request pending', () => { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const text = 'hello'; + const request = model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('partial') }); + model.setResponse(request, { errorDetails: { message: 'failed' } }); + request.response!.complete(); + + request.response!.reopen(); + + assert.deepStrictEqual({ + state: request.response!.state, + isIncomplete: request.response!.isIncomplete.get(), + errorDetails: request.response!.result?.errorDetails, + response: request.response!.response.value, + }, { + state: ResponseModelState.Pending, + isIncomplete: true, + errorDetails: undefined, + response: [], + }); + }); + + test('reopen excludes time spent failed from cumulative elapsed generation time', () => { + const clock = sinon.useFakeTimers({ now: 1000 }); + try { + const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); + const text = 'hello'; + const request = model.addRequest({ text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, { variables: [] }, 0); + const response = request.response!; + + clock.tick(1000); + model.setResponse(request, { errorDetails: { message: 'failed' } }); + response.complete(); + const firstElapsedMs = response.elapsedMs; + + clock.tick(5000); + response.reopen(); + clock.tick(2000); + response.complete(); + + assert.deepStrictEqual({ + firstElapsedMs, + finalElapsedMs: response.elapsedMs, + }, { + firstElapsedMs: 1000, + finalElapsedMs: 3000, + }); + } finally { + clock.restore(); + } + }); + test('MCP tool authentication marks the response as needing input', () => { const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); const text = 'hello';