From de6ea36c9e3ed055e2ff0dcd00759d672c2f4e96 Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:18:52 -0700 Subject: [PATCH] chat: add timestamps and elapsed time (#325061) * chat: add timestamps and elapsed time * put behind setting! * fix tests * ah timestamp fixes and better animations! * switch to use new protocol changes * bump ahp version --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../common/state/protocol/.ahp-version | 2 +- .../state/protocol/channels-chat/actions.ts | 23 ++ .../state/protocol/channels-chat/reducer.ts | 12 +- .../state/protocol/channels-chat/state.ts | 6 + .../agentHost/common/state/sessionState.ts | 3 +- .../platform/agentHost/node/agentService.ts | 2 +- .../agentHost/node/agentSideEffects.ts | 24 +- .../node/claude/claudeMapSessionEvents.ts | 6 +- .../node/claude/claudePromptQueue.ts | 2 + .../node/claude/claudeReplayMapper.ts | 72 ++-- .../node/claude/claudeSdkMessageRouter.ts | 3 +- .../node/claude/claudeSdkPipeline.ts | 7 +- .../agentHost/node/codex/codexAgent.ts | 47 ++- .../node/codex/codexMapAppServerEvents.ts | 15 +- .../node/copilot/copilotAgentSession.ts | 9 + .../node/localCommands/localChatCommand.ts | 4 +- .../test/common/agentSubscription.test.ts | 5 +- ...agentHostChangesetOperationService.test.ts | 1 + .../test/node/agentHostStateManager.test.ts | 43 +++ .../node/agentHostToolCallTelemetry.test.ts | 5 +- .../test/node/agentHostTurnTelemetry.test.ts | 21 +- .../agentHost/test/node/agentService.test.ts | 27 +- .../test/node/agentSideEffects.test.ts | 55 ++- .../test/node/claudeMapSessionEvents.test.ts | 5 +- .../test/node/claudePromptQueue.test.ts | 2 + .../test/node/claudeReplayMapper.test.ts | 61 +++- .../test/node/claudeSubagentRegistry.test.ts | 4 +- .../test/node/claudeSubagentResolver.test.ts | 4 +- .../codex/codexMapAppServerEvents.test.ts | 44 ++- .../platform/agentHost/test/node/mockAgent.ts | 4 +- .../node/protocol/agentHostE2ETestHelpers.ts | 2 + .../copilotAgentHostE2E.integrationTest.ts | 1 + .../sessionFeatures.integrationTest.ts | 1 + .../test/node/protocol/testHelpers.ts | 1 + .../test/node/protocolServerHandler.test.ts | 14 + .../agentHost/test/node/reducers.test.ts | 53 ++- .../localAgentHostSessionsProvider.test.ts | 3 + .../remoteAgentHostSessionsProvider.test.ts | 5 + .../agentHost/agentHostSessionHandler.ts | 44 +++ .../agentHost/stateToProgressAdapter.ts | 7 +- .../chat/browser/chat.shared.contribution.ts | 5 + .../chat/browser/widget/chatListRenderer.ts | 155 +++++++- .../chat/browser/widget/media/chat.css | 151 +++++++- .../chat/common/chatProgressFormatting.ts | 47 +++ .../common/chatService/chatServiceImpl.ts | 31 +- .../chat/common/chatSessionsService.ts | 4 + .../contrib/chat/common/constants.ts | 1 + .../contrib/chat/common/model/chatModel.ts | 75 +++- .../common/model/chatSessionOperationLog.ts | 3 +- .../chat/common/model/chatViewModel.ts | 5 + .../agentHostChatContribution.test.ts | 331 +++++++++--------- .../agentHostClientTools.test.ts | 19 +- .../stateToProgressAdapter.test.ts | 18 + .../browser/widget/chatListRenderer.test.ts | 131 ++++++- .../common/chatService/chatService.test.ts | 102 +++++- .../chat/test/common/model/chatModel.test.ts | 103 +++++- 56 files changed, 1525 insertions(+), 305 deletions(-) diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 7bba668e510..a25dd129286 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -870dbfda +3377734 diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 93093464ae4..c889855343d 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -51,6 +51,8 @@ export interface ChatTurnStartedAction { type: ActionType.ChatTurnStarted; /** Turn identifier */ turnId: string; + /** ISO 8601 timestamp when this turn started. */ + startedAt: string; /** The new message */ message: Message; /** If this turn was auto-started from a queued message, the ID of that message */ @@ -329,6 +331,13 @@ export interface ChatTurnCompleteAction { type: ActionType.ChatTurnComplete; /** Turn identifier */ turnId: string; + /** + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. + */ + duration: number; /** * Additional provider-specific metadata for this action. * @@ -352,6 +361,13 @@ export interface ChatTurnCancelledAction { type: ActionType.ChatTurnCancelled; /** Turn identifier */ turnId: string; + /** + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. + */ + duration: number; /** * Additional provider-specific metadata for this action. * @@ -374,6 +390,13 @@ export interface ChatErrorAction { type: ActionType.ChatError; /** Turn identifier */ turnId: string; + /** + * Elapsed turn duration in milliseconds, measured by the producer's own + * clock. Clients MUST NOT derive this by subtracting timestamps — cross- + * client clocks may differ — and MUST treat it as opaque, producer-supplied + * data. + */ + duration: number; /** Error details */ error: ErrorInfo; /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 16043346271..399b90af0e9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -100,6 +100,7 @@ function endTurn( state: ChatState, turnId: string, turnState: TurnState, + duration: number, terminalStatus?: SessionStatus.Error, error?: { errorType: string; message: string; stack?: string }, ): ChatState { @@ -131,6 +132,10 @@ function endTurn( const turn: Turn = { id: active.id, + startedAt: active.startedAt, + // Defensive clamp: the duration is producer-supplied and opaque to this + // reducer, but a negative value would be nonsensical to display. + duration: Math.max(0, duration), message: active.message, responseParts, usage: active.usage, @@ -259,6 +264,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st ...state, activeTurn: { id: action.turnId, + startedAt: action.startedAt, message: action.message, responseParts: [], usage: undefined, @@ -305,13 +311,13 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st }; case ActionType.ChatTurnComplete: - return endTurn(state, action.turnId, TurnState.Complete); + return endTurn(state, action.turnId, TurnState.Complete, action.duration); case ActionType.ChatTurnCancelled: - return endTurn(state, action.turnId, TurnState.Cancelled); + return endTurn(state, action.turnId, TurnState.Cancelled, action.duration); case ActionType.ChatError: - return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + return endTurn(state, action.turnId, TurnState.Error, action.duration, SessionStatus.Error, action.error); case ActionType.ChatActivityChanged: return { ...state, activity: action.activity }; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index 7c8488d8aa3..1cc9af4268f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -496,6 +496,10 @@ export const enum MessageAttachmentKind { export interface Turn { /** Turn identifier */ id: string; + /** ISO 8601 timestamp when this turn started. */ + startedAt?: string; + /** Turn duration in milliseconds. */ + duration?: number; /** The message that initiated the turn */ message: Message; /** @@ -521,6 +525,8 @@ export interface Turn { export interface ActiveTurn { /** Turn identifier */ id: string; + /** ISO 8601 timestamp when this turn started. */ + startedAt: string; /** The message that initiated the turn */ message: Message; /** diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index c4a83fd426a..75a0b3d0564 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -716,9 +716,10 @@ export function isChatReadOnly(interactivity: ChatInteractivity | undefined, ses return effectiveChatInteractivity(interactivity, sessionArchived) === ChatInteractivity.ReadOnly; } -export function createActiveTurn(id: string, message: Message): ActiveTurn { +export function createActiveTurn(id: string, message: Message, startedAt: string): ActiveTurn { return { id, + startedAt, message, responseParts: [], usage: undefined, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2bd3811ce32..a7111cd5aeb 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -672,7 +672,7 @@ export class AgentService extends Disposable implements IAgentService { */ private async _startSessionPrompt(session: URI, chat: URI, prompt: string): Promise { const message: Message = { text: prompt, origin: { kind: MessageKind.User } }; - const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), message } as const; + const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message } as const; this._stateManager.dispatchServerAction(chat.toString(), action); this._sideEffects.handleAction(chat.toString(), action); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5b2f17036ec..eec54cbed24 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -7,6 +7,7 @@ import { Disposable, DisposableStore, IDisposable } from '../../../base/common/l import { NKeyMap } from '../../../base/common/map.js'; import { equals } from '../../../base/common/objects.js'; import { autorun, IObservable, IReader } from '../../../base/common/observable.js'; +import { StopWatch } from '../../../base/common/stopwatch.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; @@ -109,6 +110,7 @@ interface ISubagentSessionRef { readonly toolCallId: string; readonly sessionUri: ProtocolURI; readonly chatUri: ProtocolURI; + readonly turnStopWatch: StopWatch; } /** @@ -779,10 +781,11 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(subagentChatUri, { type: ActionType.ChatTurnStarted, turnId, + startedAt: new Date().toISOString(), message: { text: '', origin: { kind: MessageKind.User } }, }); - this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri }, chatURI, toolCallId); + this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri, turnStopWatch: StopWatch.create(false) }, chatURI, toolCallId); // Dispatch content on the spawning tool call so clients discover the // subagent. The tool call lives in the immediate parent chat, which is @@ -836,6 +839,11 @@ export class AgentSideEffects extends Disposable { return []; } + private _turnDuration(stopWatch: StopWatch | undefined): number { + const elapsed = stopWatch?.elapsed(); + return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0; + } + /** * Cancels all active subagent sessions for a given parent session. */ @@ -846,6 +854,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnCancelled, turnId, + duration: this._turnDuration(subagent.turnStopWatch), }); this._turnTracker.turnCompleted(subagent.chatUri, turnId, 'cancelled'); } @@ -880,6 +889,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(subagent.chatUri, { type: ActionType.ChatTurnComplete, turnId, + duration: this._turnDuration(subagent.turnStopWatch), }); } this._subagentChats.delete(parentChatURI, toolCallId); @@ -996,6 +1006,7 @@ export class AgentSideEffects extends Disposable { if (!chatChannel) { throw new Error(`ChatTurnStarted must be handled on an AHP chat channel: ${channel}`); } + const turnStopWatch = StopWatch.create(false); // Per-turn streaming part tracking is owned by the agent // (e.g. CopilotAgentSession) and reset on its `send()` call. @@ -1017,6 +1028,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(channel, { type: ActionType.ChatError, turnId: action.turnId, + duration: this._turnDuration(turnStopWatch), error: { errorType: 'noAgent', message: 'No agent found for session' }, }); return; @@ -1033,6 +1045,7 @@ export class AgentSideEffects extends Disposable { message: action.message, turnId: action.turnId, senderClientId: clientId, + turnStopWatch, }); break; } @@ -1326,9 +1339,11 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(session, { type: ActionType.ChatTurnStarted, turnId, + startedAt: new Date().toISOString(), message: msg.message, queuedMessageId: msg.id, }); + const turnStopWatch = StopWatch.create(false); // Generic host commands (`/rename`, `!command`, …) are intercepted by // the local-command dispatcher (see the ChatTurnStarted handler) and @@ -1346,6 +1361,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(session, { type: ActionType.ChatError, turnId, + duration: this._turnDuration(turnStopWatch), error: { errorType: 'noAgent', message: 'No agent found for session' }, }); return; @@ -1364,6 +1380,7 @@ export class AgentSideEffects extends Disposable { message: msg.message, turnId, senderClientId: undefined, + turnStopWatch, }); } @@ -1392,8 +1409,9 @@ export class AgentSideEffects extends Disposable { message: Message; turnId: string; senderClientId: string | undefined; + turnStopWatch: StopWatch; }): Promise { - const { agent, sessionChannel, turnChannel, chat, message, turnId, senderClientId } = options; + const { agent, sessionChannel, turnChannel, chat, message, turnId, senderClientId, turnStopWatch } = options; // Read-only chats reject user-dispatched turns. `interactivity` is the // general signal (e.g. subagent worker chats are `ReadOnly`), and an @@ -1410,6 +1428,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(turnChannel, { type: ActionType.ChatError, turnId, + duration: this._turnDuration(turnStopWatch), error: sessionArchived ? { errorType: 'archived', message: 'This session is archived and read-only. Restore the session to continue the conversation.' } : { errorType: 'readOnly', message: 'This chat is read-only.' }, @@ -1447,6 +1466,7 @@ export class AgentSideEffects extends Disposable { this._stateManager.dispatchServerAction(turnChannel, { type: ActionType.ChatError, turnId, + duration: this._turnDuration(turnStopWatch), error: buildSendFailedError(err), }); this._turnTracker.turnCompleted(turnChannel, turnId, 'error'); diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index 8a239922f29..f8fa0250179 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -221,6 +221,7 @@ export function mapSDKMessageToAgentSignals( logService: ILogService, registry: SubagentRegistry, clientToolOwner?: (toolName: string) => string | undefined, + turnDuration?: number, ): AgentSignal[] { if (logService.getLevel() <= LogLevel.Trace) { try { @@ -239,7 +240,7 @@ export function mapSDKMessageToAgentSignals( registry, ); case 'result': - return mapResult(message, chat, turnId, state, logService, registry); + return mapResult(message, chat, turnId, turnDuration, state, logService, registry); case 'assistant': return tagWithParent( mapAssistantCanonical(message, chat, turnId, state, message.parent_tool_use_id, registry), @@ -423,6 +424,7 @@ function mapResult( message: Extract, session: URI, turnId: string, + turnDuration: number | undefined, state: ClaudeMapperState, logService: ILogService, registry: SubagentRegistry, @@ -469,6 +471,7 @@ function mapResult( action: { type: ActionType.ChatError, turnId, + duration: typeof turnDuration === 'number' && Number.isFinite(turnDuration) ? Math.max(0, turnDuration) : 0, error: { errorType: message.subtype, ...extractForwardedErrorInfo(errorText), @@ -720,4 +723,3 @@ function makeContentBlockPartId( } return `${turnId}#${messageId}#${index}`; } - diff --git a/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts b/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts index c29f14bffbb..f0463060a40 100644 --- a/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts +++ b/src/vs/platform/agentHost/node/claude/claudePromptQueue.ts @@ -6,6 +6,7 @@ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { ILogService } from '../../../log/common/log.js'; /** @@ -22,6 +23,7 @@ export interface IPendingSdkMessage { readonly sdkMessage: SDKUserMessage; readonly sdkUuid: string; readonly turnId: string; + readonly stopWatch: StopWatch; readonly deferred: DeferredPromise; readonly steeringPendingId?: string; } diff --git a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts index 75825a6c050..9ca1fab63b6 100644 --- a/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts +++ b/src/vs/platform/agentHost/node/claude/claudeReplayMapper.ts @@ -110,21 +110,30 @@ interface AssistantBlock { readonly type: string; readonly text?: string; readon * stateful reduction (the {@link ReplayBuilder}) — see CONTEXT M7. */ type ParsedSessionMessage = - | { readonly kind: 'user-text'; readonly uuid: string; readonly text: string } - | { readonly kind: 'user-tool-results'; readonly uuid: string; readonly results: readonly UserToolResultBlock[] } - | { readonly kind: 'assistant'; readonly uuid: string; readonly blocks: readonly AssistantBlock[]; readonly isInner: boolean } - | { readonly kind: 'system-notification'; readonly uuid: string; readonly subtype: string; readonly text: string }; + | { readonly kind: 'user-text'; readonly uuid: string; readonly text: string; readonly timestamp?: string } + | { readonly kind: 'user-tool-results'; readonly uuid: string; readonly results: readonly UserToolResultBlock[]; readonly timestamp?: string } + | { readonly kind: 'assistant'; readonly uuid: string; readonly blocks: readonly AssistantBlock[]; readonly isInner: boolean; readonly timestamp?: string } + | { readonly kind: 'system-notification'; readonly uuid: string; readonly subtype: string; readonly text: string; readonly timestamp?: string }; function parseSessionMessage(msg: SessionMessage): ParsedSessionMessage | undefined { + const timestamp = readTimestamp(msg); switch (msg.type) { - case 'user': return parseUserMessage(msg); - case 'assistant': return parseAssistantMessage(msg); - case 'system': return parseSystemMessage(msg); + case 'user': return parseUserMessage(msg, timestamp); + case 'assistant': return parseAssistantMessage(msg, timestamp); + case 'system': return parseSystemMessage(msg, timestamp); default: return undefined; } } -function parseUserMessage(msg: SessionMessage): ParsedSessionMessage | undefined { +function readTimestamp(msg: SessionMessage & { readonly timestamp?: unknown }): string | undefined { + if (typeof msg.timestamp !== 'string') { + return undefined; + } + const timestamp = Date.parse(msg.timestamp); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined; +} + +function parseUserMessage(msg: SessionMessage, timestamp: string | undefined): ParsedSessionMessage | undefined { const content = readUserContent(msg.message); if (content === undefined) { return undefined; @@ -133,19 +142,19 @@ function parseUserMessage(msg: SessionMessage): ParsedSessionMessage | undefined return undefined; } if (typeof content === 'string') { - return { kind: 'user-text', uuid: msg.uuid, text: content }; + return { kind: 'user-text', uuid: msg.uuid, text: content, timestamp }; } const textBlocks = content.filter((b): b is UserTextBlock => b.type === 'text'); if (textBlocks.length === 0) { const results = content.filter((b): b is UserToolResultBlock => b.type === 'tool_result'); - return results.length > 0 ? { kind: 'user-tool-results', uuid: msg.uuid, results } : undefined; + return results.length > 0 ? { kind: 'user-tool-results', uuid: msg.uuid, results, timestamp } : undefined; } // Mixed or text-only: text wins — matches prior behavior where tool_results // in a text-bearing envelope are dropped (they should already have been delivered). - return { kind: 'user-text', uuid: msg.uuid, text: textBlocks.map(b => b.text).join('\n') }; + return { kind: 'user-text', uuid: msg.uuid, text: textBlocks.map(b => b.text).join('\n'), timestamp }; } -function parseAssistantMessage(msg: SessionMessage): ParsedSessionMessage | undefined { +function parseAssistantMessage(msg: SessionMessage, timestamp: string | undefined): ParsedSessionMessage | undefined { const blocks = readAssistantBlocks(msg.message); if (blocks === undefined || blocks.length === 0) { return undefined; @@ -154,16 +163,16 @@ function parseAssistantMessage(msg: SessionMessage): ParsedSessionMessage | unde // `parent_tool_use_id` on every envelope and have no synthetic spawning // user prompt, so they legitimately open with an assistant message — // `isInner` lets the builder synthesize a turn instead of dropping it. - return { kind: 'assistant', uuid: msg.uuid, blocks, isInner: msg.parent_tool_use_id !== null }; + return { kind: 'assistant', uuid: msg.uuid, blocks, isInner: msg.parent_tool_use_id !== null, timestamp }; } -function parseSystemMessage(msg: SessionMessage): ParsedSessionMessage | undefined { +function parseSystemMessage(msg: SessionMessage, timestamp: string | undefined): ParsedSessionMessage | undefined { const subtype = readSystemSubtype(msg.message); if (subtype === undefined || !ALLOWED_SYSTEM_SUBTYPES.has(subtype)) { return undefined; } const text = readSystemText(msg.message) ?? `[${subtype}]`; - return { kind: 'system-notification', uuid: msg.uuid, subtype, text }; + return { kind: 'system-notification', uuid: msg.uuid, subtype, text, timestamp }; } // #endregion @@ -198,6 +207,8 @@ const CLI_ECHO_MARKER_PATTERN = /^<(command-name|command-message|command-args|lo interface InProgressTurn { readonly id: string; readonly userText: string; + readonly startedAt?: string; + lastResponseAt?: string; readonly responseParts: ResponsePart[]; /** * `tool_use_id`s announced by THIS turn. Drained when the matching @@ -238,16 +249,22 @@ class ReplayBuilder { this._active = { id: msg.uuid, userText: msg.text, + startedAt: msg.timestamp, responseParts: [], pendingToolUseIds: new Set(), toolCallParts: new Map(), }; return; - case 'user-tool-results': + case 'user-tool-results': { + let updatesActiveTurn = false; for (const block of msg.results) { - this._attachToolResult(block); + updatesActiveTurn = this._attachToolResult(block) === this._active?.id || updatesActiveTurn; + } + if (updatesActiveTurn && this._active && msg.timestamp) { + this._active.lastResponseAt = msg.timestamp; } return; + } case 'assistant': this._consumeAssistant(msg); return; @@ -260,6 +277,9 @@ class ReplayBuilder { kind: ResponsePartKind.SystemNotification, content: msg.text, }); + if (msg.timestamp) { + this._active.lastResponseAt = msg.timestamp; + } return; } } @@ -286,6 +306,7 @@ class ReplayBuilder { this._active = { id: msg.uuid, userText: '', + startedAt: msg.timestamp, responseParts: [], pendingToolUseIds: new Set(), toolCallParts: new Map(), @@ -315,6 +336,9 @@ class ReplayBuilder { } // Other block types (server_tool_use, etc.) are dropped silently per M7. } + if (msg.timestamp) { + this._active.lastResponseAt = msg.timestamp; + } } private _openToolUse(toolUseId: string, toolName: string, input: unknown): void { @@ -345,17 +369,17 @@ class ReplayBuilder { this._toolUses.set(toolUseId, { turnId: this._active.id, parsedInput }); } - private _attachToolResult(block: UserToolResultBlock): void { + private _attachToolResult(block: UserToolResultBlock): string | undefined { const entry = this._toolUses.get(block.tool_use_id); if (entry === undefined) { this._logService.warn(`[claudeReplayMapper] tool_result for unknown tool_use_id ${block.tool_use_id}`); - return; + return undefined; } const announcingTurnId = entry.turnId; // Find the part — it lives on the announcing turn (which may be `_active` or one already pushed to `_turns`). const part = this._findToolCallPart(announcingTurnId, block.tool_use_id); if (part === undefined) { - return; + return undefined; } const isError = block.is_error; const previousState = part.toolCall; @@ -394,6 +418,7 @@ class ReplayBuilder { if (this._active?.id === announcingTurnId) { this._active.pendingToolUseIds.delete(block.tool_use_id); } + return announcingTurnId; } private _findToolCallPart(turnId: string, toolUseId: string): ToolCallResponsePart | undefined { @@ -421,8 +446,15 @@ class ReplayBuilder { } const a = this._active; const state = a.pendingToolUseIds.size === 0 ? TurnState.Complete : TurnState.Cancelled; + const startedAt = a.startedAt === undefined ? undefined : Date.parse(a.startedAt); + const endedAt = a.lastResponseAt === undefined ? undefined : Date.parse(a.lastResponseAt); + const duration = startedAt !== undefined && endedAt !== undefined && Number.isFinite(startedAt) && Number.isFinite(endedAt) + ? Math.max(0, endedAt - startedAt) + : undefined; const turn: Turn = { id: a.id, + startedAt: a.startedAt, + duration, message: { text: a.userText, origin: { kind: MessageKind.User } }, responseParts: a.responseParts, usage: undefined, diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts index c84a49dd5a9..5a56073d1c6 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkMessageRouter.ts @@ -58,7 +58,7 @@ export class ClaudeSdkMessageRouter extends Disposable { this._clientToolOwner = clientToolOwner; } - async handle(message: SDKMessage, turnId: string | undefined): Promise { + async handle(message: SDKMessage, turnId: string | undefined, turnDuration?: number): Promise { if (message.type === 'assistant') { this._editObserver.observeAssistant(message); } else if (message.type === 'user' && turnId !== undefined) { @@ -76,6 +76,7 @@ export class ClaudeSdkMessageRouter extends Disposable { this._logService, this._subagents, this._clientToolOwner, + turnDuration, ); for (const signal of signals) { this._onDidProduceSignal.fire(signal); diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts index 2323adcfb83..39ac360ca8a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts @@ -7,6 +7,7 @@ import type { AgentInfo, McpServerStatus, PermissionMode, Query, SDKUserMessage, import { CancellationError, isCancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; @@ -397,6 +398,7 @@ export class ClaudeSdkPipeline extends Disposable { sdkMessage: prompt, sdkUuid: typeof prompt.uuid === 'string' ? prompt.uuid : turnId, turnId, + stopWatch: StopWatch.create(false), deferred: new DeferredPromise(), }; return this._queue.push(entry); @@ -432,6 +434,7 @@ export class ClaudeSdkPipeline extends Disposable { sdkMessage: prompt, sdkUuid, turnId: parent.turnId, + stopWatch: parent.stopWatch, deferred: new DeferredPromise(), steeringPendingId: pendingMessageId, }).catch(() => { /* expected on abort/crash */ }); @@ -630,8 +633,9 @@ export class ClaudeSdkPipeline extends Disposable { } } const turnId = this._queue.peekParent()?.turnId; + const turnDuration = this._queue.peekParent()?.stopWatch.elapsed(); try { - await this._router.handle(message, turnId); + await this._router.handle(message, turnId, turnDuration); } catch (handlerErr) { this._logService.warn(`[ClaudeSdkPipeline:${this.sessionId}] router threw, skipping: ${handlerErr}`); } @@ -648,6 +652,7 @@ export class ClaudeSdkPipeline extends Disposable { action: { type: ActionType.ChatTurnComplete, turnId: completed.turnId, + duration: Math.max(0, completed.stopWatch.elapsed()), }, }); } diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 1a69ef7a4b9..45677156ada 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -11,6 +11,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { type IObservable, observableValue } from '../../../../base/common/observable.js'; import { basename, dirname, isAbsolute, join, resolve, sep } from '../../../../base/common/path.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; @@ -453,6 +454,8 @@ interface ICodexSession { model: ModelSelection | undefined; /** Workbench-facing turn id for the active turn. */ currentTurnId: string | undefined; + /** Local monotonic timer for the active workbench-facing turn. */ + turnStopWatch: StopWatch | undefined; /** Codex app-server turn id for the active turn. */ currentAppTurnId: string | undefined; /** Codex app-server turn id -> workbench-facing turn id. */ @@ -1401,7 +1404,7 @@ export class CodexAgent extends Disposable implements IAgent { private _handleTurnCompletedNotification(session: ICodexSession, params: TurnCompletedNotification): (SessionAction | ChatAction)[] { const appTurnId = params.turn.id; const hostTurnId = this._hostTurnId(session, appTurnId); - const out = mapTurnCompleted(session.mapState, this._withHostTurn(session, params)); + const out = mapTurnCompleted(session.mapState, this._withHostTurn(session, params), this._clearTurnStopWatch(session)); // Remember which codex (app-server) turn each workbench turn maps to so // truncateSession can translate a host turn id to a thread rollback even // after the live correlation below is cleared. @@ -1488,7 +1491,7 @@ export class CodexAgent extends Disposable implements IAgent { const appTurnId = session.currentAppTurnId; const previousHostTurnId = session.currentTurnId ?? (appTurnId ? this._hostTurnId(session, appTurnId) : undefined); if (previousHostTurnId) { - actions.push({ type: ActionType.ChatTurnComplete, turnId: previousHostTurnId }); + actions.push({ type: ActionType.ChatTurnComplete, turnId: previousHostTurnId, duration: this._clearTurnStopWatch(session) }); } const newHostTurnId = generateUuid(); if (appTurnId) { @@ -1499,9 +1502,11 @@ export class CodexAgent extends Disposable implements IAgent { actions.push({ type: ActionType.ChatTurnStarted, turnId: newHostTurnId, + startedAt: new Date().toISOString(), message: steering.message, queuedMessageId: steering.id, }); + this._startTurnStopWatch(session); return actions; } @@ -1715,6 +1720,7 @@ export class CodexAgent extends Disposable implements IAgent { firstTurnSent: true, model: parent.model, currentTurnId: undefined, + turnStopWatch: undefined, currentAppTurnId: undefined, hostTurnIdByAppTurnId: new Map(), codexTurnIdByHostTurnId: new Map(), @@ -2070,12 +2076,14 @@ export class CodexAgent extends Disposable implements IAgent { session.hostTurnIdByAppTurnId.delete(appTurnId); } if (turnId) { + const duration = this._clearTurnStopWatch(session); this._fire(session.sessionUri, { type: ActionType.ChatError, turnId, + duration, error: { errorType: 'CodexDisconnected', message: 'Codex app-server disconnected; session must restart.' }, }); - this._fire(session.sessionUri, { type: ActionType.ChatTurnComplete, turnId }); + this._fire(session.sessionUri, { type: ActionType.ChatTurnComplete, turnId, duration }); } } // Release resources. The proxy handle is refcounted and drops @@ -2212,6 +2220,7 @@ export class CodexAgent extends Disposable implements IAgent { firstTurnSent: false, model: effectiveModel, currentTurnId: undefined, + turnStopWatch: undefined, currentAppTurnId: undefined, hostTurnIdByAppTurnId: new Map(), codexTurnIdByHostTurnId: new Map(), @@ -2262,6 +2271,7 @@ export class CodexAgent extends Disposable implements IAgent { firstTurnSent: true, model, currentTurnId: undefined, + turnStopWatch: undefined, currentAppTurnId: undefined, hostTurnIdByAppTurnId: new Map(), codexTurnIdByHostTurnId: new Map(), @@ -2600,6 +2610,18 @@ export class CodexAgent extends Disposable implements IAgent { } } + private _startTurnStopWatch(session: ICodexSession): StopWatch { + const stopWatch = StopWatch.create(false); + session.turnStopWatch = stopWatch; + return stopWatch; + } + + private _clearTurnStopWatch(session: ICodexSession): number { + const elapsed = session.turnStopWatch?.elapsed(); + session.turnStopWatch = undefined; + return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0; + } + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, workingDirectory?: URI): Promise { const sessionUri = this._sessionUriFromChat(chat); this._logService.info(`[Codex DEBUG] sendMessage session=${sessionUri.toString()} prompt=${JSON.stringify(prompt).slice(0, 60)}`); @@ -2626,12 +2648,14 @@ export class CodexAgent extends Disposable implements IAgent { } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.error(`[Codex:${sessionId}] materialize failed: ${message}`); + const duration = this._clearTurnStopWatch(session); this._fire(sessionUri, { type: ActionType.ChatError, turnId: effectiveTurnId, + duration, error: { errorType: 'CodexMaterializeFailed', message }, }); - this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId }); + this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; } // Codex registers client tools only at `thread/start`. If the thread @@ -2645,12 +2669,14 @@ export class CodexAgent extends Disposable implements IAgent { } catch (err) { const message = err instanceof Error ? err.message : String(err); this._logService.error(`[Codex:${sessionId}] tool re-materialize failed: ${message}`); + const duration = this._clearTurnStopWatch(session); this._fire(sessionUri, { type: ActionType.ChatError, turnId: effectiveTurnId, + duration, error: { errorType: 'CodexMaterializeFailed', message }, }); - this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId }); + this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; } } @@ -2662,15 +2688,17 @@ export class CodexAgent extends Disposable implements IAgent { }); session.needsResume = false; } catch (err) { + const duration = this._clearTurnStopWatch(session); this._fire(sessionUri, { type: ActionType.ChatError, turnId: effectiveTurnId, + duration, error: { errorType: 'CodexResumeFailed', message: err instanceof Error ? err.message : String(err), }, }); - this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId }); + this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); return; } } @@ -2679,6 +2707,7 @@ export class CodexAgent extends Disposable implements IAgent { // Buffer the prompt text for `turn/started`'s userMessage fallback. session.lastPromptText = prompt; session.currentTurnId = effectiveTurnId; + this._startTurnStopWatch(session); try { const model = await this._resolveModel(session); const turnOptions = this._turnStartOptions(session, model.id); @@ -2695,17 +2724,19 @@ export class CodexAgent extends Disposable implements IAgent { // stream emits ChatTurnComplete asynchronously. } catch (err) { if (err instanceof CancellationError) { - this._fire(sessionUri, { type: ActionType.ChatTurnCancelled, turnId: effectiveTurnId }); + this._fire(sessionUri, { type: ActionType.ChatTurnCancelled, turnId: effectiveTurnId, duration: this._clearTurnStopWatch(session) }); return; } const message = err instanceof Error ? err.message : String(err); this._logService.error(`[Codex:${sessionId}] turn/start error: ${message}`); + const duration = this._clearTurnStopWatch(session); this._fire(sessionUri, { type: ActionType.ChatError, turnId: effectiveTurnId, + duration, error: { errorType: 'CodexTurnError', ...extractForwardedErrorInfo(message) }, }); - this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId }); + this._fire(sessionUri, { type: ActionType.ChatTurnComplete, turnId: effectiveTurnId, duration }); } finally { // Best-effort temp-file cleanup. Image-on-localImage will be // re-read by codex synchronously during the turn so this is diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index 95168646098..bbb40b81ac0 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -347,6 +347,7 @@ export function mapTurnStarted( { type: ActionType.ChatTurnStarted, turnId: params.turn.id, + startedAt: typeof params.turn.startedAt === 'number' ? new Date(params.turn.startedAt * 1000).toISOString() : new Date().toISOString(), message: { text: userText, origin: { kind: MessageKind.User } }, }, ]; @@ -983,6 +984,7 @@ export function mapItemCompleted( export function mapTurnCompleted( state: ICodexSessionMapState, params: TurnCompletedNotification, + fallbackDuration?: number, ): (SessionAction | ChatAction)[] { state.currentTurnId = undefined; state.itemToPartId.clear(); @@ -995,6 +997,13 @@ export function mapTurnCompleted( state.itemToToolCall.clear(); const turnId = params.turn.id; const status = params.turn.status; + const duration = typeof params.turn.durationMs === 'number' && Number.isFinite(params.turn.durationMs) && params.turn.durationMs >= 0 + ? params.turn.durationMs + : typeof params.turn.startedAt === 'number' && typeof params.turn.completedAt === 'number' + ? Math.max(0, (params.turn.completedAt - params.turn.startedAt) * 1000) + : typeof fallbackDuration === 'number' && Number.isFinite(fallbackDuration) + ? Math.max(0, fallbackDuration) + : 0; const orphanedToolCallActions: (SessionAction | ChatAction)[] = orphanedToolCalls.map(entry => ({ type: ActionType.ChatToolCallComplete, turnId: entry.turnId, @@ -1014,6 +1023,7 @@ export function mapTurnCompleted( { type: ActionType.ChatError, turnId, + duration, error: { errorType: 'CodexError', ...extractForwardedErrorInfo(errMessage), @@ -1022,13 +1032,14 @@ export function mapTurnCompleted( { type: ActionType.ChatTurnComplete, turnId, + duration, }, ]; } if (status === 'interrupted') { - return [...preflightFlush, ...orphanedToolCallActions, { type: ActionType.ChatTurnCancelled, turnId }]; + return [...preflightFlush, ...orphanedToolCallActions, { type: ActionType.ChatTurnCancelled, turnId, duration }]; } - return [...preflightFlush, ...orphanedToolCallActions, { type: ActionType.ChatTurnComplete, turnId }]; + return [...preflightFlush, ...orphanedToolCallActions, { type: ActionType.ChatTurnComplete, turnId, duration }]; } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index e48d2f85cda..750f5e66ce7 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -15,6 +15,7 @@ import { isAuthorizationProtectedResourceMetadata } from '../../../../base/commo import { safeStringify } from '../../../../base/common/objects.js'; import { isAbsolute, join } from '../../../../base/common/path.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { splitLinesIncludeSeparators } from '../../../../base/common/strings.js'; import { hasKey, isDefined, isObject, isString, type Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; @@ -408,6 +409,7 @@ interface UsageContext { class CopilotTurn { private _state: CopilotTurnState = 'pending'; + private readonly _stopWatch = StopWatch.create(false); /** * Accumulated Copilot usage for this turn, in nano-AIU, keyed by scope. @@ -456,6 +458,7 @@ class CopilotTurn { get state(): CopilotTurnState { return this._state; } get isPending(): boolean { return this._state === 'pending'; } get isRunning(): boolean { return this._state === 'running'; } + get duration(): number { return Math.max(0, this._stopWatch.elapsed()); } /** Transition `pending → running` on the first SDK event. No-op once running/finished. */ markRunning(): void { @@ -755,15 +758,18 @@ export class CopilotAgentSession extends Disposable { private _beginSteeringTurn(steering: PendingMessage): string { const previousTurnId = this._turnId; if (previousTurnId) { + const previousDuration = this._currentTurn?.duration ?? 0; this._emitAction({ type: ActionType.ChatTurnComplete, turnId: previousTurnId, + duration: previousDuration, }); } const newTurnId = generateUuid(); this._emitAction({ type: ActionType.ChatTurnStarted, turnId: newTurnId, + startedAt: new Date().toISOString(), message: steering.message, queuedMessageId: steering.id, }); @@ -868,6 +874,7 @@ export class CopilotAgentSession extends Disposable { this._emitAction({ type: ActionType.ChatTurnComplete, turnId: turn.id, + duration: turn.duration, }); this._currentTurn = undefined; } @@ -2484,6 +2491,7 @@ export class CopilotAgentSession extends Disposable { this._emitAction({ type: ActionType.ChatTurnStarted, turnId, + startedAt: new Date().toISOString(), message: { text: notification.messageText, origin: { kind: MessageKind.SystemNotification }, @@ -2934,6 +2942,7 @@ export class CopilotAgentSession extends Disposable { this._emitAction({ type: ActionType.ChatError, turnId: this._turnId, + duration: this._currentTurn?.duration ?? 0, error: { errorType: e.data.errorType, message: stripProxyErrorMarker(e.data.message), diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts index b4ae872ba44..6517a8cd0d2 100644 --- a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { ILogService } from '../../../log/common/log.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType, StateAction } from '../../common/state/sessionActions.js'; @@ -152,6 +153,7 @@ export class AgentHostLocalCommands extends Disposable { } private async _run(command: ILocalChatCommand, work: () => Promise, request: ILocalChatCommandRequest): Promise { + const stopWatch = StopWatch.create(false); try { await work(); } catch (err) { @@ -161,7 +163,7 @@ export class AgentHostLocalCommands extends Disposable { // reducer opened, optionally persist it as a local turn (so it // survives reload and anchors fork/truncate), then let the owner // drain any messages queued behind it. - this._stateManager.dispatchServerAction(request.turnChannel, { type: ActionType.ChatTurnComplete, turnId: request.turnId }); + this._stateManager.dispatchServerAction(request.turnChannel, { type: ActionType.ChatTurnComplete, turnId: request.turnId, duration: Math.max(0, stopWatch.elapsed()) }); if (command.recordsLocalTurn) { this._recordLocalTurn(request.turnChannel, request.turnId); } diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index 7501d0015f3..cfa245fbe4f 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -363,7 +363,7 @@ suite('SessionStateSubscription', () => { sub.handleSnapshot(state, 0); sub.receiveEnvelope(makeEnvelope( - { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 1, undefined, )); @@ -484,13 +484,14 @@ suite('ChatStateSubscription', () => { sub.applyOptimistic({ type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual((sub.value as ChatState | undefined)?.activeTurn?.id, 'turn-1'); sub.receiveEnvelope(makeEnvelope( - { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 1, undefined, )); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts index 1da3a92b2ad..2d44169b9bd 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetOperationService.test.ts @@ -183,6 +183,7 @@ suite('AgentHostChangesetOperationService', () => { stateManager.dispatchServerAction(buildDefaultChatUri(sessionKey), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hi', origin: { kind: MessageKind.User } }, }); diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 03ba03b3418..9a82dcb4c4f 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -248,6 +248,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -272,6 +273,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -287,6 +289,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -296,6 +299,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', + duration: 1000, }); const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); @@ -314,11 +318,13 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 2); @@ -326,12 +332,14 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', + duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 1); manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnComplete, turnId: 'turn-2', + duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 0); }); @@ -342,6 +350,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -383,6 +392,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -390,6 +400,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', + duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -405,11 +416,13 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-2', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); @@ -418,6 +431,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-2', + duration: 1000, }); assert.strictEqual(manager.rootState.activeSessions, 0); @@ -433,15 +447,18 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'stale-turn', + duration: 1000, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatError, turnId: 'turn-1', + duration: 1000, error: { errorType: 'failed', message: 'boom' }, }); @@ -463,15 +480,18 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', + duration: 1000, }); manager.dispatchServerAction(buildDefaultChatUri(session2Uri), { type: ActionType.ChatTurnStarted, turnId: 'turn-2', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hi', origin: { kind: MessageKind.User } }, }); manager.removeSession(session2Uri); @@ -615,6 +635,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -629,6 +650,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', + duration: 1000, }); // Simulate eviction within the 100 ms debounce window. @@ -993,6 +1015,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); const afterStart = manager.hasActiveTurn(sessionUri); @@ -1000,6 +1023,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', + duration: 1000, }); const afterComplete = manager.hasActiveTurn(sessionUri); @@ -1023,11 +1047,13 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(sessionChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', + duration: 1000, }); assert.deepStrictEqual(observed, [ @@ -1047,6 +1073,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-default', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); const afterDefaultStart = manager.hasActiveTurn(sessionUri); @@ -1054,6 +1081,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const afterBothStart = manager.hasActiveTurn(sessionUri); @@ -1062,6 +1090,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-default', + duration: 1000, }); const afterDefaultComplete = manager.hasActiveTurn(sessionUri); @@ -1069,6 +1098,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', + duration: 1000, }); const afterBothComplete = manager.hasActiveTurn(sessionUri); @@ -1089,6 +1119,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const whilePeerRuns = manager.getSessionState(sessionUri)?.status; @@ -1097,6 +1128,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', + duration: 1000, }); const afterPeerComplete = manager.getSessionState(sessionUri)?.status; @@ -1131,6 +1163,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const runningCatalog = peerCatalogStatus(); @@ -1139,6 +1172,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', + duration: 1000, }); assert.deepStrictEqual( @@ -1168,11 +1202,13 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-default', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const activeWhileBothRun = manager.rootState.activeSessions; @@ -1180,12 +1216,14 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-default', + duration: 1000, }); const activeAfterFirstCompletes = manager.rootState.activeSessions; manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-peer', + duration: 1000, }); assert.deepStrictEqual( @@ -1217,11 +1255,13 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-default', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'a', origin: { kind: MessageKind.User } }, }); manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const activeWhileBothRun = manager.hasActiveTurn(sessionUri); @@ -1235,6 +1275,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(defaultChat, { type: ActionType.ChatTurnComplete, turnId: 'turn-default', + duration: 1000, }); assert.deepStrictEqual( @@ -1266,6 +1307,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const activeWhilePeerRuns = manager.hasActiveTurn(sessionUri); @@ -1440,6 +1482,7 @@ suite('AgentHostStateManager', () => { manager.dispatchServerAction(peerChat, { type: ActionType.ChatTurnStarted, turnId: 'turn-peer', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'b', origin: { kind: MessageKind.User } }, }); const runningRollup = summaryHasInProgress(); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index 99f28c33064..deec21e2b0e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -112,6 +112,7 @@ suite('AgentSideEffects — tool call telemetry', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId, + startedAt: '2025-01-01T00:00:00.000Z', message: { text, origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); @@ -294,7 +295,7 @@ suite('AgentSideEffects — tool call telemetry', () => { startTurn('turn-1'); toolStart('turn-1', 'tc-inflight', 'bash'); - fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 }); // A late completion after the turn ended must not emit: the start entry // was cleared, so there is no timing to report. toolComplete('turn-1', 'tc-inflight', { success: true, pastTenseMessage: 'ran' }); @@ -390,7 +391,7 @@ suite('AgentSideEffects — tool call telemetry', () => { invocationMessage: 'Write file', confirmationTitle: 'Write file', }); - fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 }); await timeout(5 * 60 * 1000); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 8bc7f39cacb..9f7d0247551 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -126,6 +126,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId, + startedAt: '2025-01-01T00:00:00.000Z', message: { text, origin: { kind: MessageKind.User }, model: modelId ? { id: modelId } : undefined }, }; // Dispatch into the state manager so `getActiveTurnId` returns the @@ -187,7 +188,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { startTurn('turn-1', 'hello', 'gpt-5.5'); fire({ type: ActionType.ChatResponsePart, turnId: 'turn-1', part: { kind: ResponsePartKind.Markdown, id: 'p1', content: 'hi' } }); - fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }); const events = completedEvents(); assert.strictEqual(events.length, 1); @@ -207,7 +208,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { // Usage is not a "visible progress" action — it should not mark first progress. fire({ type: ActionType.ChatUsage, turnId: 'turn-1', usage: { inputTokens: 1, outputTokens: 1 } }); - fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }); const data = completedEvents()[0].data as Record; assert.strictEqual(data.timeToFirstProgress, undefined); @@ -216,7 +217,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits result=cancelled on ChatTurnCancelled', () => { setupSession(); startTurn('turn-1'); - fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 }); const events = completedEvents(); assert.strictEqual(events.length, 1); @@ -226,7 +227,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits result=error on ChatError', () => { setupSession(); startTurn('turn-1'); - fire({ type: ActionType.ChatError, turnId: 'turn-1', error: { errorType: 'oops', message: 'fail' } }); + fire({ type: ActionType.ChatError, turnId: 'turn-1', duration: 1000, error: { errorType: 'oops', message: 'fail' } }); const events = completedEvents(); assert.strictEqual(events.length, 1); @@ -236,10 +237,10 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('emits a single turnCompleted per turn even when followed by duplicate completions', () => { setupSession(); startTurn('turn-1'); - fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }); // A duplicate turn-complete should not produce a second telemetry event because the tracker // drops its per-turn state on the first completion. - fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }); assert.strictEqual(completedEvents().length, 1); }); @@ -252,7 +253,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { // Change config mid-turn — should not affect the recorded event. setAutoApprove('autopilot'); - fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }); const data = completedEvents()[0].data as Record; assert.strictEqual(data.permissionLevel, 'default'); @@ -261,7 +262,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { test('model and permissionLevel are undefined when never set', () => { setupSession(); startTurn('turn-1'); - fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }); const data = completedEvents()[0].data as Record; assert.strictEqual(data.model, undefined); @@ -280,6 +281,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', + duration: 1000, }); await new Promise(r => setTimeout(r, 10)); @@ -332,8 +334,9 @@ suite('AgentSideEffects — turn tracker telemetry', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', + duration: 1000, }); - fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 }); assert.strictEqual(completedEvents().length, 1); }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 3e9cb09c5cf..737f3efa62c 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -193,7 +193,7 @@ suite('AgentService (node dispatcher)', () => { // Start a turn so there's an active turn to map events to service.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -447,7 +447,7 @@ suite('AgentService (node dispatcher)', () => { svc.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Please help me fix the TypeScript compile errors', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Please help me fix the TypeScript compile errors', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -474,7 +474,7 @@ suite('AgentService (node dispatcher)', () => { svc.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Explain workspace search indexing', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Explain workspace search indexing', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); @@ -498,7 +498,7 @@ suite('AgentService (node dispatcher)', () => { svc.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Create tests for terminal persistence', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Create tests for terminal persistence', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'title generation should be in flight'); @@ -528,7 +528,7 @@ suite('AgentService (node dispatcher)', () => { svc.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'Investigate flaky terminal tests', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Investigate flaky terminal tests', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); await waitForCondition(() => copilotApiService.utilityCalls.length === 1, 'title generation should be in flight'); @@ -555,13 +555,13 @@ suite('AgentService (node dispatcher)', () => { svc.dispatchAction( buildDefaultChatUri(sourceSession.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'source-turn', message: { text: 'Seed fork title', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'source-turn', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Seed fork title', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); await waitForCondition(() => svc.stateManager.getSessionState(sourceSession.toString())?.title === 'Source generated title', 'source generated title should be applied'); svc.dispatchAction( buildDefaultChatUri(sourceSession.toString()), - { type: ActionType.ChatTurnComplete, turnId: 'source-turn' }, + { type: ActionType.ChatTurnComplete, turnId: 'source-turn', duration: 1000 }, 'test-client', 2, ); await waitForCondition(() => (svc.stateManager.getSessionState(sourceSession.toString())?.turns.length ?? 0) === 1, 'source turn should be complete before forking'); @@ -635,6 +635,7 @@ suite('AgentService (node dispatcher)', () => { { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, attachments: attachments as never }, }, 'test-client', 1, @@ -1070,7 +1071,7 @@ suite('AgentService (node dispatcher)', () => { // renderer-side caches don't evict the in-flight session. service.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'test-client', 1, ); const activeListed = await service.listSessions(); @@ -1086,7 +1087,7 @@ suite('AgentService (node dispatcher)', () => { // session, reintroducing #321269's sibling eviction bug). service.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 'test-client', 2, ); const stateAfterTurn = service.stateManager.getSessionState(session.toString()); @@ -3182,7 +3183,7 @@ suite('AgentService (node dispatcher)', () => { function startParentTurn(session: URI, turnId: string): void { service.dispatchAction( buildDefaultChatUri(session.toString()), - { type: ActionType.ChatTurnStarted, turnId, message: { text: 'go', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'go', origin: { kind: MessageKind.User } } }, 'client-test', 1, ); } @@ -3757,7 +3758,7 @@ suite('AgentService (node dispatcher)', () => { // mid-response. service.dispatchAction( buildDefaultChatUri(sessionResource.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); @@ -4143,12 +4144,12 @@ suite('AgentService (node dispatcher)', () => { service.addSubscriber(sessionResource, 'client-1'); service.dispatchAction( buildDefaultChatUri(sessionResource.toString()), - { type: ActionType.ChatTurnStarted, turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, 'client-1', 1, ); service.dispatchAction( buildDefaultChatUri(sessionResource.toString()), - { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, 'client-1', 2, ); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 1f7c66e87b5..43a5a613d04 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -173,7 +173,7 @@ suite('AgentSideEffects', () => { } function startTurn(turnId: string, channel = defaultChatUri): void { - stateManager.dispatchClientAction(channel, { type: ActionType.ChatTurnStarted, turnId, message: { text: 'hello', origin: { kind: MessageKind.User } } }, + stateManager.dispatchClientAction(channel, { type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } } }, { clientId: 'test', clientSeq: 1 }, ); } @@ -267,6 +267,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello world', origin: { kind: MessageKind.User } }, }; sideEffects.handleAction(defaultChatUri, action); @@ -281,6 +282,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello world', origin: { kind: MessageKind.User } }, }; sideEffects.handleAction(defaultChatUri, action, 'client-B'); @@ -312,6 +314,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'direct.ts', displayKind: 'document' }] }, }); @@ -337,6 +340,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello world', origin: { kind: MessageKind.User }, attachments: [{ type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'test.ts', displayKind: 'document' }] }, }; @@ -357,6 +361,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello world', origin: { kind: MessageKind.User }, @@ -413,6 +418,7 @@ suite('AgentSideEffects', () => { noAgentSideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -429,6 +435,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, + startedAt: '2025-01-01T00:00:00.000Z', turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -450,6 +457,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(readOnlyChat, { type: ActionType.ChatTurnStarted, + startedAt: '2025-01-01T00:00:00.000Z', turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -489,6 +497,7 @@ suite('AgentSideEffects', () => { // the side effects that drive `sendMessage`. const turnStarted = { type: ActionType.ChatTurnStarted, + startedAt: '2025-01-01T00:00:00.000Z', turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, } as const; @@ -531,6 +540,7 @@ suite('AgentSideEffects', () => { 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.dispatchClientAction(defaultChatUri, turnStarted, { clientId: 'test', clientSeq: 1 }); @@ -563,6 +573,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, + startedAt: '2025-01-01T00:00:00.000Z', turnId: 'turn-1', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -603,6 +614,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: '/rename Renamed Session', origin: { kind: MessageKind.User } }, }; // Mirror production: the reducer applies the turn, then side effects run. @@ -625,6 +637,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: '/rename', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); @@ -643,6 +656,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: '/renamed thing', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); @@ -673,6 +687,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: '!echo hi', origin: { kind: MessageKind.User } }, }; // Mirror production: the reducer opens the turn, then side effects run. @@ -708,6 +723,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: '!', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); @@ -735,6 +751,7 @@ suite('AgentSideEffects', () => { const action: ChatAction = { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: '!echo hi', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); @@ -769,14 +786,14 @@ suite('AgentSideEffects', () => { /** Drives a normal (SDK-backed) turn into `turns[]` via the reducer. */ function seedRealTurn(turnId: string, text: string): void { stateManager.dispatchClientAction(defaultChatUri, { - type: ActionType.ChatTurnStarted, turnId, message: { text, origin: { kind: MessageKind.User } }, + type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text, origin: { kind: MessageKind.User } }, }, { clientId: 'test', clientSeq: ++clientSeq }); - stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId }); + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId, duration: 1000 }); } async function runBang(se: AgentSideEffects, terminalManager: TestAgentHostTerminalManager, turnId: string): Promise { const action: ChatAction = { - type: ActionType.ChatTurnStarted, turnId, message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + type: ActionType.ChatTurnStarted, turnId, startedAt: '2025-01-01T00:00:00.000Z', message: { text: '!echo hi', origin: { kind: MessageKind.User } }, }; stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: ++clientSeq }); se.handleAction(defaultChatUri, action); @@ -881,6 +898,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Fix the login bug', origin: { kind: MessageKind.User } }, }); @@ -900,6 +918,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: ' ', origin: { kind: MessageKind.User } }, }); @@ -917,6 +936,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: longMessage, origin: { kind: MessageKind.User } }, }); @@ -938,6 +958,7 @@ suite('AgentSideEffects', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId: 'turn-1', + duration: 1000, }); const envelopes: ActionEnvelope[] = []; @@ -946,6 +967,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-2', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'second message', origin: { kind: MessageKind.User } }, }); @@ -972,6 +994,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); @@ -987,6 +1010,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', + duration: 1000, }); await new Promise(r => setTimeout(r, 10)); @@ -1004,6 +1028,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, }); @@ -1030,6 +1055,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, }); await Promise.resolve(); @@ -1054,6 +1080,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(chatChannel, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'gpt-5' } }, }); @@ -1072,6 +1099,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } }, }); @@ -1086,6 +1114,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(chatChannel, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, agent: { uri: 'file:///agents/reviewer.md' } }, }); @@ -1497,7 +1526,7 @@ suite('AgentSideEffects', () => { // Fire idle → turn completes → queued message should be consumed agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, }); const turnComplete = envelopes.find(e => e.action.type === ActionType.ChatTurnComplete); @@ -1539,7 +1568,7 @@ suite('AgentSideEffects', () => { assert.strictEqual(agent.sendMessageCalls.length, 0); // Cancel the active turn (client abort). - const cancelAction = { type: ActionType.ChatTurnCancelled as const, turnId: 'turn-1' }; + const cancelAction = { type: ActionType.ChatTurnCancelled as const, turnId: 'turn-1', duration: 1000 }; stateManager.dispatchClientAction(defaultChatUri, cancelAction, { clientId: 'test', clientSeq: 2 }); sideEffects.handleAction(defaultChatUri, cancelAction); @@ -1583,7 +1612,7 @@ suite('AgentSideEffects', () => { // then the message queued behind it must be drained to the agent. agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, }); // The `/rename` must not reach the agent; only the message behind it does @@ -1605,7 +1634,7 @@ suite('AgentSideEffects', () => { // Start a turn on the peer chat, then queue a message behind it. stateManager.dispatchClientAction(chatUri.toString(), - { type: ActionType.ChatTurnStarted, turnId: 'pturn-1', message: { text: 'hi', origin: { kind: MessageKind.User } } }, + { type: ActionType.ChatTurnStarted, turnId: 'pturn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hi', origin: { kind: MessageKind.User } } }, { clientId: 'test', clientSeq: 1 }); const setAction = { type: ActionType.ChatPendingMessageSet as const, @@ -1623,7 +1652,7 @@ suite('AgentSideEffects', () => { // so the harness routes it to the right peer SDK chat. agent.fireProgress({ kind: 'action', resource: chatUri, - action: { type: ActionType.ChatTurnComplete, turnId: 'pturn-1' }, + action: { type: ActionType.ChatTurnComplete, turnId: 'pturn-1', duration: 1000 }, }); await waitForSendMessageCalls(1); @@ -1899,6 +1928,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello world', origin: { kind: MessageKind.User } }, }); @@ -2331,7 +2361,7 @@ suite('AgentSideEffects', () => { }); agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, }); // Verify no active turn @@ -3458,6 +3488,7 @@ suite('AgentSideEffects', () => { sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', + duration: 1000, }); // Both subagent chats should have their turns completed (cancelled) @@ -3807,7 +3838,7 @@ suite('AgentSideEffects', () => { }); assert.strictEqual(sessionInputNeeded().length, 1); - stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1', duration: 1000 }); assert.deepStrictEqual(sessionInputNeeded(), []); }); @@ -4128,7 +4159,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), - action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1' }, + action: { type: ActionType.ChatTurnComplete, turnId: 'turn-1', duration: 1000 }, }); // `_runTurnCompleteSideEffects` now defers the diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index f0d3ac51c4e..85991b81bb2 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -84,7 +84,7 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(signals, []); }); - test('error_during_execution result with a proxy marker emits a ChatError carrying _meta', () => { + test('error_during_execution result emits a ChatError carrying duration and _meta', () => { const marker = encodeForwardedChatError({ fetchError: { type: 'quotaExceeded', capiError: { code: 'quota_exceeded', message: 'You have exceeded your monthly quota' } } }); const signals = mapSDKMessageToAgentSignals( makeResultError(SESSION_ID, [`CAPI request failed: 402 Payment Required \u2014 quota ${marker}`]), @@ -93,10 +93,13 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { new ClaudeMapperState(), new NullLogService(), r(), + undefined, + 123, ); const errorSignal = signals.find(s => s.kind === 'action' && s.action.type === ActionType.ChatError); assert.ok(errorSignal && errorSignal.kind === 'action' && errorSignal.action.type === ActionType.ChatError); + assert.strictEqual(errorSignal.action.duration, 123); const error = errorSignal.action.error; const meta = error._meta as { chatError?: { fetchError?: { type?: string } } } | undefined; assert.strictEqual(meta?.chatError?.fetchError?.type, 'quotaExceeded'); diff --git a/src/vs/platform/agentHost/test/node/claudePromptQueue.test.ts b/src/vs/platform/agentHost/test/node/claudePromptQueue.test.ts index c34486720a6..2b6c9db01ae 100644 --- a/src/vs/platform/agentHost/test/node/claudePromptQueue.test.ts +++ b/src/vs/platform/agentHost/test/node/claudePromptQueue.test.ts @@ -7,6 +7,7 @@ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import assert from 'assert'; import { DeferredPromise } from '../../../../base/common/async.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; @@ -45,6 +46,7 @@ function makeEntry(id: string, opts?: { steeringPendingId?: string; turnId?: str sdkMessage, sdkUuid: id, turnId: opts?.turnId ?? 'turn-1', + stopWatch: StopWatch.create(false), deferred: new DeferredPromise(), steeringPendingId: opts?.steeringPendingId, }; diff --git a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts index 1798bcffcdb..8b0327f65dc 100644 --- a/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeReplayMapper.test.ts @@ -17,28 +17,31 @@ suite('claudeReplayMapper', () => { const logService = new NullLogService(); const session = URI.parse('claude:/sess-1'); + type TimestampedSessionMessage = SessionMessage & { readonly timestamp?: string }; - function makeUser(uuid: string, text: string): SessionMessage { + function makeUser(uuid: string, text: string, timestamp?: string): TimestampedSessionMessage { return { type: 'user', uuid, session_id: 'sess-1', parent_tool_use_id: null, message: { role: 'user', content: [{ type: 'text', text }] }, + timestamp, }; } - function makeAssistantText(uuid: string, text: string): SessionMessage { + function makeAssistantText(uuid: string, text: string, timestamp?: string): TimestampedSessionMessage { return { type: 'assistant', uuid, session_id: 'sess-1', parent_tool_use_id: null, message: { id: `msg_${uuid}`, role: 'assistant', content: [{ type: 'text', text }] }, + timestamp, }; } - function makeAssistantToolUse(uuid: string, toolUseId: string, name: string, input: unknown = {}): SessionMessage { + function makeAssistantToolUse(uuid: string, toolUseId: string, name: string, input: unknown = {}, timestamp?: string): TimestampedSessionMessage { return { type: 'assistant', uuid, @@ -49,10 +52,11 @@ suite('claudeReplayMapper', () => { role: 'assistant', content: [{ type: 'tool_use', id: toolUseId, name, input }], }, + timestamp, }; } - function makeUserToolResult(uuid: string, toolUseId: string, text: string, isError = false): SessionMessage { + function makeUserToolResult(uuid: string, toolUseId: string, text: string, isError = false, timestamp?: string): TimestampedSessionMessage { return { type: 'user', uuid, @@ -62,6 +66,7 @@ suite('claudeReplayMapper', () => { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: text, ...(isError ? { is_error: true } : {}) }], }, + timestamp, }; } @@ -96,6 +101,40 @@ suite('claudeReplayMapper', () => { } }); + test('restores turn timing from persisted message timestamps', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'hello', '2026-07-09T18:00:00.000Z'), + makeAssistantText('a1', 'world', '2026-07-09T18:00:02.500Z'), + ]; + + const turns = mapSessionMessagesToTurns(messages, session, logService); + + assert.deepStrictEqual({ + startedAt: turns[0].startedAt, + duration: turns[0].duration, + }, { + startedAt: '2026-07-09T18:00:00.000Z', + duration: 2_500, + }); + }); + + test('leaves turn timing unknown when persisted timestamps are missing or invalid', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'hello', 'invalid'), + makeAssistantText('a1', 'world'), + ]; + + const turns = mapSessionMessagesToTurns(messages, session, logService); + + assert.deepStrictEqual({ + startedAt: turns[0].startedAt, + duration: turns[0].duration, + }, { + startedAt: undefined, + duration: undefined, + }); + }); + test('Fixture 2: tool_use + tool_result is one Turn with one Completed ToolCall', () => { const messages: SessionMessage[] = [ makeUser('u1', 'list files'), @@ -219,6 +258,20 @@ suite('claudeReplayMapper', () => { assert.strictEqual(turns[1].state, TurnState.Complete, 'turn 2 has no orphan'); }); + test('late tool results do not extend the active turn duration', () => { + const messages: SessionMessage[] = [ + makeUser('u1', 'first', '2026-07-09T18:00:00.000Z'), + makeAssistantToolUse('a1', 'tu-late', 'Bash', {}, '2026-07-09T18:00:01.000Z'), + makeUser('u2', 'second', '2026-07-09T18:00:10.000Z'), + makeAssistantText('a2', 'clean reply', '2026-07-09T18:00:12.000Z'), + makeUserToolResult('late-result', 'tu-late', 'done', false, '2026-07-09T18:00:20.000Z'), + ]; + + const turns = mapSessionMessagesToTurns(messages, session, logService); + + assert.deepStrictEqual(turns.map(turn => turn.duration), [1_000, 2_000]); + }); + test('Fixture 7: non-allowlisted system subtypes are dropped', () => { const messages: SessionMessage[] = [ makeUser('u1', 'go'), diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts index c0189005f2c..a231b4a33dd 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentRegistry.test.ts @@ -27,8 +27,8 @@ function makeAgentToolCallTurn(toolCallId: string, opts: { suffixText?: string; }, }], state: 0 as unknown as Turn['state'], - startedAt: 1, - endedAt: 2, + startedAt: '1970-01-01T00:00:00.001Z', + duration: 2, usage: undefined, } as Turn; } diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts index 378cd8319a0..ed7ce251ff2 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts @@ -87,8 +87,8 @@ function makeAgentToolCallTurn(toolCallId: string, opts: { prompt?: string; suff }, }], state: 0 as unknown as Turn['state'], - startedAt: 1, - endedAt: 2, + startedAt: '1970-01-01T00:00:00.001Z', + duration: 2, usage: undefined, } as Turn; } diff --git a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts index 042d6cf9d36..fbfb9473fef 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -29,7 +29,7 @@ suite('codexMapAppServerEvents', () => { itemsView: { type: 'full' } as never, status: 'inProgress' as never, error: null, - startedAt: null, + startedAt: 1_752_012_321, completedAt: null, durationMs: null, }, @@ -38,6 +38,7 @@ suite('codexMapAppServerEvents', () => { assert.deepStrictEqual(actions, [{ type: ActionType.ChatTurnStarted, turnId: 'turn_a', + startedAt: '2025-07-08T22:05:21.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }]); }); @@ -60,6 +61,26 @@ suite('codexMapAppServerEvents', () => { assert.strictEqual((actions[0] as { message: { text: string } }).message.text, 'the prompt'); }); + test('turn/started uses a current timestamp when Codex omits startedAt', () => { + const before = new Date().toISOString(); + const actions = mapTurnStarted(createCodexSessionMapState(), { + threadId: 'thr_1', + turn: { + id: 'turn_c', + items: [], + itemsView: { type: 'full' } as never, + status: 'inProgress' as never, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + }, + }, 'prompt'); + + const startedAt = actions[0].type === ActionType.ChatTurnStarted ? actions[0].startedAt : undefined; + assert.ok(typeof startedAt === 'string' && startedAt >= before && startedAt <= new Date().toISOString()); + }); + test('item/started for agentMessage seeds a markdown part', () => { const state = createCodexSessionMapState(); const actions = mapItemStarted(state, { @@ -846,10 +867,10 @@ suite('codexMapAppServerEvents', () => { id: 'turn_a', items: [], itemsView: { type: 'full' } as never, status: 'completed' as never, - error: null, startedAt: null, completedAt: null, durationMs: null, + error: null, startedAt: 1_752_012_321, completedAt: 1_752_012_323.5, durationMs: 2500, }, }); - assert.deepStrictEqual(actions, [{ type: ActionType.ChatTurnComplete, turnId: 'turn_a' }]); + assert.deepStrictEqual(actions, [{ type: ActionType.ChatTurnComplete, turnId: 'turn_a', duration: 2500 }]); assert.strictEqual(state.currentTurnId, undefined); }); @@ -863,14 +884,17 @@ suite('codexMapAppServerEvents', () => { status: 'completed' as never, error: null, startedAt: null, completedAt: null, durationMs: null, }, - }); - assert.deepStrictEqual({ actions, remainingToolCalls: state.itemToToolCall.size }, { + }, 321); + const completeAction = actions[1] as { type: ActionType; turnId: string; duration: number }; + const { duration: completeDuration, ...completeRest } = completeAction; + assert.deepStrictEqual({ actions: [actions[0], completeRest], remainingToolCalls: state.itemToToolCall.size }, { actions: [ { type: ActionType.ChatToolCallComplete, turnId: 'turn_a', toolCallId: 'tc_1', result: { success: false, pastTenseMessage: 'Stopped shell', content: [{ type: ToolResultContentType.Text, text: 'partial output' }], error: { message: 'Turn completed before the tool reported completion' } } }, { type: ActionType.ChatTurnComplete, turnId: 'turn_a' }, ], remainingToolCalls: 0, }); + assert.strictEqual(completeDuration, 321); }); test('turn/completed with status=failed emits ChatError + ChatTurnComplete', () => { @@ -884,9 +908,10 @@ suite('codexMapAppServerEvents', () => { startedAt: null, completedAt: null, durationMs: null, }, }); - assert.strictEqual(actions.length, 2); - assert.strictEqual((actions[0] as { type: ActionType }).type, ActionType.ChatError); - assert.strictEqual((actions[1] as { type: ActionType }).type, ActionType.ChatTurnComplete); + assert.deepStrictEqual(actions, [ + { type: ActionType.ChatError, turnId: 'turn_a', duration: 0, error: { errorType: 'CodexError', message: 'boom' } }, + { type: ActionType.ChatTurnComplete, turnId: 'turn_a', duration: 0 }, + ]); }); test('turn/completed with status=interrupted emits ChatTurnCancelled', () => { @@ -899,8 +924,7 @@ suite('codexMapAppServerEvents', () => { error: null, startedAt: null, completedAt: null, durationMs: null, }, }); - assert.strictEqual(actions.length, 1); - assert.strictEqual((actions[0] as { type: ActionType }).type, ActionType.ChatTurnCancelled); + assert.deepStrictEqual(actions, [{ type: ActionType.ChatTurnCancelled, turnId: 'turn_a', duration: 0 }]); }); test('turnStateFromStatus maps strings correctly', () => { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 76d1deeb44e..bfda4b0557b 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -1027,12 +1027,12 @@ function _reasoning(session: URI, sessionStr: string, turnId: string, content: s /** Creates a {@link ActionType.ChatTurnComplete} signal. */ function _idle(session: URI, sessionStr: string, turnId: string): IAgentActionSignal { - return _action(session, { type: ActionType.ChatTurnComplete, turnId }); + return _action(session, { type: ActionType.ChatTurnComplete, turnId, duration: 1 }); } /** 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, error: { errorType, message, stack } }); + return _action(session, { type: ActionType.ChatError, turnId, duration: 1, error: { errorType, message, stack } }); } /** Creates a {@link ActionType.SessionTitleChanged} signal. */ diff --git a/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts index 76c0472efa0..3d7de65e085 100644 --- a/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/agentHostE2ETestHelpers.ts @@ -260,6 +260,7 @@ export function dispatchTurn(c: TestProtocolClient, session: string, turnId: str action: { type: ActionType.ChatTurnStarted, turnId, + startedAt: '2025-01-01T00:00:00.000Z', message: { text, origin: { kind: MessageKind.User } }, }, }); @@ -273,6 +274,7 @@ export function dispatchTurnWithAttachments(c: TestProtocolClient, session: stri action: { type: ActionType.ChatTurnStarted, turnId, + startedAt: '2025-01-01T00:00:00.000Z', message: { text, origin: { kind: MessageKind.User }, attachments: [...attachments] }, }, }); diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts index 07882a0c059..c69aca13b0a 100644 --- a/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/copilotAgentHostE2E.integrationTest.ts @@ -117,6 +117,7 @@ suite('Agent Host E2E — Copilot (Copilot-specific)', function () { action: { type: ActionType.ChatTurnStarted, turnId, + startedAt: new Date().toISOString(), message: { text: 'Call the get_magic_word tool and then tell me the exact magic word it returned.', origin: { kind: MessageKind.User }, diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts index 4a3136a81e3..04eaf90a02c 100644 --- a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -171,6 +171,7 @@ suite('Protocol WebSocket — Session Features', function () { action: { type: ActionType.ChatTurnStarted, turnId: 'turn-model', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'mock-model' } }, }, }); diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts index dc922282c57..24dbb5ba5a4 100644 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -516,6 +516,7 @@ export function dispatchTurnStarted(c: TestProtocolClient, session: string, turn action: { type: ActionType.ChatTurnStarted, turnId, + startedAt: '2025-01-01T00:00:00.000Z', message: { text, origin: { kind: MessageKind.User } }, }, }); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 5d685dc8dd8..30c2bd8ed60 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -451,6 +451,7 @@ suite('ProtocolServerHandler', () => { action: { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }, })); @@ -975,6 +976,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1037,6 +1039,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1085,6 +1088,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1127,6 +1131,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1176,6 +1181,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1234,6 +1240,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1286,6 +1293,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1339,6 +1347,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(chatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); // Tool call stamped for a clientId that never connected (e.g. a @@ -1378,6 +1387,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1407,6 +1417,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); // First orphaned tool call (owner never connected) arms the grace timer. @@ -1458,6 +1469,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1512,6 +1524,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { @@ -1576,6 +1589,7 @@ suite('ProtocolServerHandler', () => { stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run it', origin: { kind: MessageKind.User } }, }); stateManager.dispatchServerAction(defaultChatUri, { diff --git a/src/vs/platform/agentHost/test/node/reducers.test.ts b/src/vs/platform/agentHost/test/node/reducers.test.ts index de824401d3d..ff44d03965d 100644 --- a/src/vs/platform/agentHost/test/node/reducers.test.ts +++ b/src/vs/platform/agentHost/test/node/reducers.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { changesetReducer, chatReducer, sessionReducer } from '../../common/state/protocol/reducers.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, ChangesetOperationStatus, CustomizationLoadStatus, MessageKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ResponsePartKind, ToolCallStatus, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type ChatState, type SessionState } from '../../common/state/sessionState.js'; +import { ChangesetStatus, ChangesetOperationStatus, CustomizationLoadStatus, MessageKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ResponsePartKind, ToolCallStatus, TurnState, type AgentCustomization, type ChangesetState, type Customization, type PluginCustomization, type ChatState, type SessionState } from '../../common/state/sessionState.js'; import { CustomizationType } from '../../common/state/protocol/state.js'; function makeSession(): SessionState { @@ -39,6 +39,7 @@ function withActiveTurnAndToolCall(state: ChatState): ChatState { state = chatReducer(state, { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User } }, }); state = chatReducer(state, { @@ -55,6 +56,56 @@ suite('chatReducer – summaryStatus with tool call confirmations and input requ ensureNoDisposablesAreLeakedInTestSuite(); + test('preserves turn start timestamp and duration after completion', () => { + let state = chatReducer(makeChat(), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + const activeStartedAt = state.activeTurn?.startedAt; + state = chatReducer(state, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: 150_000, + }); + + assert.deepStrictEqual({ + activeStartedAt, + completedStartedAt: state.turns[0].startedAt, + duration: state.turns[0].duration, + }, { + activeStartedAt: '2025-01-01T00:00:00.000Z', + completedStartedAt: '2025-01-01T00:00:00.000Z', + duration: 150_000, + }); + }); + + test('clamps negative terminal duration', () => { + const active = chatReducer(makeChat(), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + const afterNegativeDuration = chatReducer(active, { + type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: -5, + }); + + assert.deepStrictEqual(afterNegativeDuration.turns[0], { + id: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + duration: 0, + message: { text: 'hello', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + error: undefined, + }); + }); + test('Chat status is InputNeeded when a tool call is PendingConfirmation', () => { let state = withActiveTurnAndToolCall(makeChat()); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index fb610b23fe9..f5ec3cae330 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -2976,6 +2976,7 @@ suite('LocalAgentHostSessionsProvider', () => { action: { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'new-model' } }, }, serverSeq: 1, @@ -3005,6 +3006,8 @@ suite('LocalAgentHostSessionsProvider', () => { channel: buildDefaultChatUri(AgentSession.uri('copilotcli', 'turn-sess').toString()), action: { type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: 1000, }, serverSeq: 1, origin: undefined, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 37ae211eed5..9295326918f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -773,6 +773,7 @@ suite('RemoteAgentHostSessionsProvider', () => { action: { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'hello', origin: { kind: MessageKind.User }, model: { id: 'new-model' } }, }, serverSeq: 1, @@ -804,6 +805,8 @@ suite('RemoteAgentHostSessionsProvider', () => { channel: buildDefaultChatUri(AgentSession.uri('copilotcli', 'persist-sess').toString()), action: { type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: 1000, }, serverSeq: 1, origin: undefined, @@ -1056,6 +1059,8 @@ suite('RemoteAgentHostSessionsProvider', () => { channel: buildDefaultChatUri(AgentSession.uri('copilotcli', 'turn-sess').toString()), action: { type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: 1000, }, serverSeq: 1, origin: undefined, 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 844a4fe1730..7d946557200 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -188,9 +188,15 @@ interface ISubagentContext { interface IStartServerRequestOptions { readonly isSystemInitiated?: boolean; + readonly timestamp?: number; readonly isTerminalRequest?: boolean; } +function parseTimestamp(value: string): number | undefined { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + function userOriginMessage(text: string, attachments: readonly MessageAttachment[] | undefined): Message { return attachments?.length ? { text, origin: { kind: MessageKind.User }, attachments: [...attachments] } @@ -552,6 +558,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { prompt, variableData, isSystemInitiated: options?.isSystemInitiated, + timestamp: options?.timestamp, isTerminalRequest: options?.isTerminalRequest, }); } @@ -637,6 +644,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private readonly _surfacedMcpAuthServers = new ResourceMap>(); /** Turn IDs dispatched by this client, used to distinguish server-originated turns. */ private readonly _clientDispatchedTurnIds = new Set(); + private readonly _turnStopWatches = new Map(); private readonly _config: IAgentHostSessionHandlerConfig; /** Active session subscriptions, keyed by backend session URI string. */ @@ -969,6 +977,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC prompt: sessionState.activeTurn.message.text, participant: this._config.agentId, modelId: lookup.toLanguageModelId(activeRawModelId), + timestamp: parseTimestamp(sessionState.activeTurn.startedAt), variableData: messageToVariableData(sessionState.activeTurn.message, this._config.connectionAuthority), isSystemInitiated: sessionState.activeTurn.message.origin.kind === MessageKind.SystemNotification, }); @@ -1079,6 +1088,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.dispatch(chatURI, { type: ActionType.ChatTurnCancelled, turnId, + duration: this._turnDuration(chatURI, turnId), }); return true; }, @@ -1609,6 +1619,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC messageToVariableData(activeTurn.message, this._config.connectionAuthority), { isSystemInitiated: activeTurn.message.origin.kind === MessageKind.SystemNotification, + timestamp: parseTimestamp(activeTurn.startedAt), isTerminalRequest: isTerminalCommandPrompt(activeTurn.message.text, this._config.connection.initializeResult.get()?.terminalCommandPrefix), }, ); @@ -1649,6 +1660,29 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC })); } + private _turnStopWatchKey(chatURI: string, turnId: string): string { + return `${chatURI}\0${turnId}`; + } + + private _ensureTurnStopWatch(chatURI: string, turnId: string): StopWatch { + const key = this._turnStopWatchKey(chatURI, turnId); + let stopWatch = this._turnStopWatches.get(key); + if (!stopWatch) { + stopWatch = StopWatch.create(false); + this._turnStopWatches.set(key, stopWatch); + } + return stopWatch; + } + + private _turnDuration(chatURI: string, turnId: string): number { + const elapsed = this._turnStopWatches.get(this._turnStopWatchKey(chatURI, turnId))?.elapsed(); + return typeof elapsed === 'number' && Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0; + } + + private _clearTurnStopWatch(chatURI: string, turnId: string): void { + this._turnStopWatches.delete(this._turnStopWatchKey(chatURI, turnId)); + } + // ---- Turn handling (state-driven) --------------------------------------- private async _handleTurn( @@ -1714,12 +1748,14 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const turnAction: ChatTurnStartedAction = { type: ActionType.ChatTurnStarted, turnId, + startedAt: new Date().toISOString(), message: { ...userOriginMessage(request.message, messageAttachments), ...(selectedModel ? { model: selectedModel } : {}), ...(requestedAgentUri ? { agent: { uri: requestedAgentUri } } : {}), }, }; + this._ensureTurnStopWatch(turnChannel, turnId); this._config.connection.dispatch(turnChannel, turnAction); // Ensure the snapshot controller records a sentinel checkpoint for this @@ -1741,6 +1777,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config.connection.dispatch(turnChannel, { type: ActionType.ChatTurnCancelled, turnId, + duration: this._turnDuration(turnChannel, turnId), }); })); @@ -1842,6 +1879,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private _observeTurn(opts: IObserveTurnOptions): IDisposable { const sessionKey = opts.backendSession.toString(); const store = new DisposableStore(); + this._ensureTurnStopWatch(opts.chatURI, opts.turnId); // `_ensureSessionSubscription` returns a process-shared, non-refcounted // subscription owned by the chat session lifecycle. Do NOT release it // from here — other callers (the server-turn watcher, reconnect, the @@ -1874,6 +1912,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const responseParts$ = derived(reader => turn$.read(reader)?.responseParts ?? []); const inputRequests$ = derived(reader => mergedState$.read(reader)?.inputRequests ?? []); const usage$ = derived(reader => turn$.read(reader)?.usage); + store.add(autorun(reader => { + const state = mergedState$.read(reader); + if (state?.turns.some(turn => turn.id === opts.turnId)) { + this._clearTurnStopWatch(opts.chatURI, opts.turnId); + } + })); const mcpAuthRequired$ = derivedOpts({ equalsFn: equals }, reader => { const state = mergedState$.read(reader); const servers = state?.customizations?.flatMap(c => c.type === CustomizationType.McpServer 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 d7cca563fa5..9e8863101c2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -553,6 +553,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part prompt: turn.message.text, participant: participantId, modelId, + ...(turn.startedAt !== undefined && Number.isFinite(Date.parse(turn.startedAt)) ? { timestamp: Date.parse(turn.startedAt) } : {}), variableData, ...(isSystemInitiated ? { isSystemInitiated: true, @@ -621,7 +622,11 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part ?? { message: `Error: (${turn.error.errorType}) ${turn.error.message}` }; } - history.push({ type: 'response', parts, participant: participantId, details, ...(errorDetails ? { errorDetails } : {}) }); + const startedAt = turn.startedAt === undefined ? undefined : Date.parse(turn.startedAt); + const completedAt = startedAt !== undefined && Number.isFinite(startedAt) && typeof turn.duration === 'number' && Number.isFinite(turn.duration) && turn.duration >= 0 + ? startedAt + turn.duration + : undefined; + history.push({ type: 'response', parts, participant: participantId, details, elapsedMs: turn.duration, completedAt, ...(errorDetails ? { errorDetails } : {}) }); } return history; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 1c705c4f027..70ae948987c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -795,6 +795,11 @@ configurationRegistry.registerConfiguration({ default: true, description: nls.localize('chat.contextUsage.enabled', "Show the context window usage indicator in the chat input."), }, + [ChatConfiguration.Verbose]: { + type: 'boolean', + default: false, + description: nls.localize('chat.verbose', "Show request and completion timestamps. Hover over a completion timestamp to show the elapsed response time."), + }, [ChatConfiguration.ChatPersistentProgressEnabled]: { type: 'boolean', default: product.quality !== 'stable', diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index cf5e74e1546..0e66d2f15cb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -62,6 +62,7 @@ import { getExplicitFileOrImageAttachmentSummary, IChatRequestVariableEntry, isE import { getStickyScrollTargetItem, IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM, IChatTurnPillsPart } from '../../common/model/chatViewModel.js'; import { getNWords } from '../../common/model/chatWordCounter.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; +import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../common/chatProgressFormatting.js'; import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js'; import { MarkHelpfulActionId } from '../actions/chatTitleActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from '../chat.js'; @@ -234,6 +235,68 @@ export function shouldScheduleInitialHeightChange(normalizedHeight: number, allo return typeof allocatedHeight !== 'number' || normalizedHeight > allocatedHeight; } +export function renderChatResponseDetails(container: HTMLElement, details: string | undefined, completedAt: number | undefined, elapsedMs: number | undefined, verbose: boolean): void { + dom.clearNode(container); + + const completion = verbose ? formatChatRequestTimestamp(completedAt) : undefined; + const elapsed = completion && typeof elapsedMs === 'number' && elapsedMs >= 1000 + ? formatElapsedTime(elapsedMs) + : undefined; + const alternate = completion?.isRelative + ? formatChatResponseDetails(elapsed, completion.fullText) + : elapsed; + const responseDetails = formatChatResponseDetails(details, completion?.text); + + if (completion) { + const timing = dom.append(container, $('span.chat-response-timing')); + dom.append(timing, $('time.chat-response-completed-at', { datetime: completion.dateTime }, completion.text)); + if (alternate) { + dom.append(timing, $('span.chat-response-alternate', undefined, alternate)); + } + timing.classList.toggle('has-alternate', !!alternate); + } + if (completion && details) { + dom.append(container, $('span.chat-response-details-separator', { 'aria-hidden': 'true' }, '\u2022')); + } + if (details) { + dom.append(container, $('span.chat-response-model-details', undefined, details)); + } + + const accessibleTiming = completion + ? localize('chatResponseCompletedAt', "Completed {0}", completion.fullText) + : undefined; + const accessibleElapsed = elapsed + ? localize('chatResponseElapsed', "Elapsed time {0}", elapsed) + : undefined; + container.ariaLabel = [accessibleTiming, accessibleElapsed, details].filter(Boolean).join(', '); + container.classList.toggle('hidden', !responseDetails); + container.tabIndex = responseDetails ? 0 : -1; +} + +export function renderChatRequestTimestamp(container: HTMLElement, timestamp: number | undefined): { readonly element: HTMLElement; readonly hoverText?: string } | undefined { + const formatted = formatChatRequestTimestamp(timestamp); + if (!formatted) { + return undefined; + } + + if (!formatted.isRelative) { + const element = dom.append(container, $('time.chat-request-timestamp', { + datetime: formatted.dateTime, + 'aria-label': localize('chatRequestSentAt', "Sent {0}", formatted.fullText), + }, formatted.text)); + return { element, hoverText: formatted.fullText }; + } + + const element = dom.append(container, $('span.chat-request-timestamp', { + 'aria-label': localize('chatRequestSentAt', "Sent {0}", formatted.fullText), + tabindex: 0, + })); + const timing = dom.append(element, $('span.chat-request-timing.has-alternate')); + dom.append(timing, $('time.chat-request-relative', { datetime: formatted.dateTime }, formatted.text)); + dom.append(timing, $('time.chat-request-full-date', { datetime: formatted.dateTime }, formatted.fullText)); + return { element }; +} + export function shouldRenderInitialProgressiveContentImmediately(isComplete: boolean, hasMarkdownParts: boolean, hasRenderData: boolean): boolean { return !isComplete && hasMarkdownParts && !hasRenderData; } @@ -699,6 +762,35 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const target = dom.isHTMLElement(e.target) ? e.target.closest('.chat-response-completed-at') : undefined; + if (!dom.isHTMLElement(target) || !footerDetailsContainer.contains(target)) { + return; + } + const bounds = target.getBoundingClientRect(); + responseTimingBounds = bounds; + footerDetailsContainer.classList.add('chat-response-flip-reset'); + footerDetailsContainer.classList.remove('chat-response-flip-active'); + footerDetailsContainer.classList.toggle('chat-response-flip-down', e.clientY < bounds.top + bounds.height / 2); + void footerDetailsContainer.offsetWidth; + footerDetailsContainer.classList.remove('chat-response-flip-reset'); + void footerDetailsContainer.offsetWidth; + footerDetailsContainer.classList.add('chat-response-flip-active'); + })); + templateDisposables.add(dom.addDisposableListener(footerDetailsContainer, dom.EventType.MOUSE_MOVE, e => { + if (responseTimingBounds && (e.clientX < responseTimingBounds.left || e.clientX > responseTimingBounds.right || e.clientY < responseTimingBounds.top || e.clientY > responseTimingBounds.bottom)) { + responseTimingBounds = undefined; + footerDetailsContainer.classList.remove('chat-response-flip-active'); + } + })); + templateDisposables.add(dom.addDisposableListener(footerDetailsContainer, dom.EventType.MOUSE_LEAVE, () => { + responseTimingBounds = undefined; + footerDetailsContainer.classList.remove('chat-response-flip-active'); + })); + templateDisposables.add(dom.addDisposableListener(footerDetailsContainer, dom.EventType.FOCUS, () => { + footerDetailsContainer.classList.remove('chat-response-flip-active', 'chat-response-flip-down'); + })); const checkpointRestoreContainer = dom.append(rowContainer, $('.checkpoint-restore-container')); dom.append(checkpointRestoreContainer, $('.checkpoint-line-left')); @@ -907,13 +999,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const detailsContainer = templateData.footerDetailsContainer; + const details = isResponseVM(element) ? element.result?.details : undefined; + renderChatResponseDetails( + detailsContainer, + details, + isResponseVM(element) ? element.model.completionTimestamp : undefined, + isResponseVM(element) ? element.model.elapsedMs : undefined, + isResponseVM(element) && this.configService.getValue(ChatConfiguration.Verbose), + ); + }; + updateResponseDetails(); ChatContextKeys.responseHasError.bindTo(templateData.contextKeyService).set(isResponseVM(element) && !!element.errorDetails); const isFiltered = !!(isResponseVM(element) && element.errorDetails?.responseIsFiltered); @@ -931,10 +1028,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer templateData.rowContainer.classList.toggle('show-checkmarks', !!this.configService.getValue(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)); updateContainerCheckmarks(); + const updateVerboseDetails = () => templateData.rowContainer.classList.toggle('show-verbose-details', !!this.configService.getValue(ChatConfiguration.Verbose)); + updateVerboseDetails(); templateData.elementDisposables.add(this.configService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)) { updateContainerCheckmarks(); } + if (e.affectsConfiguration(ChatConfiguration.Verbose)) { + updateVerboseDetails(); + updateResponseDetails(); + } })); if (!this.rendererOptions.noHeader) { @@ -983,6 +1086,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer 0) { + const timestamp = renderChatRequestTimestamp(templateData.value, element.requestTimestamp); + if (timestamp?.hoverText) { + templateData.elementDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), timestamp.element, timestamp.hoverText)); + } else if (timestamp) { + let requestTimingBounds: DOMRect | undefined; + templateData.elementDisposables.add(dom.addDisposableListener(timestamp.element, dom.EventType.MOUSE_OVER, e => { + const target = dom.isHTMLElement(e.target) ? e.target.closest('.chat-request-relative') : undefined; + if (!dom.isHTMLElement(target) || !timestamp.element.contains(target)) { + return; + } + const bounds = target.getBoundingClientRect(); + requestTimingBounds = bounds; + timestamp.element.classList.add('chat-request-flip-reset'); + timestamp.element.classList.remove('chat-request-flip-active'); + timestamp.element.classList.toggle('chat-request-flip-down', e.clientY < bounds.top + bounds.height / 2); + void timestamp.element.offsetWidth; + timestamp.element.classList.remove('chat-request-flip-reset'); + void timestamp.element.offsetWidth; + timestamp.element.classList.add('chat-request-flip-active'); + })); + templateData.elementDisposables.add(dom.addDisposableListener(timestamp.element, dom.EventType.MOUSE_MOVE, e => { + if (requestTimingBounds && (e.clientX < requestTimingBounds.left || e.clientX > requestTimingBounds.right || e.clientY < requestTimingBounds.top || e.clientY > requestTimingBounds.bottom)) { + requestTimingBounds = undefined; + timestamp.element.classList.remove('chat-request-flip-active'); + } + })); + templateData.elementDisposables.add(dom.addDisposableListener(timestamp.element, dom.EventType.MOUSE_LEAVE, () => { + requestTimingBounds = undefined; + timestamp.element.classList.remove('chat-request-flip-active'); + })); + templateData.elementDisposables.add(dom.addDisposableListener(timestamp.element, dom.EventType.FOCUS, () => { + timestamp.element.classList.remove('chat-request-flip-active', 'chat-request-flip-down'); + })); + } + } } private renderSystemInitiatedRequest(element: IChatRequestViewModel, templateData: IChatListItemTemplate) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index a8cb974ec54..c8c509bc2a9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -319,8 +319,128 @@ white-space: nowrap; } -.interactive-item-container .chat-footer-details.hidden { - display: none !important; +.interactive-item-container.interactive-response:not(.chat-response-loading) .chat-footer-toolbar .chat-footer-details:not(.hidden) { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size40); + min-width: 0; +} + +.interactive-item-container .chat-footer-details:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.interactive-item-container .chat-response-timing { + display: inline-grid; + flex-shrink: 0; + overflow: hidden; +} + +.interactive-item-container .chat-response-timing > * { + grid-area: 1 / 1; + justify-self: end; + text-align: right; + transition: opacity 160ms ease, transform 160ms ease; +} + +.interactive-item-container .chat-response-flip-reset .chat-response-timing > * { + transition: none; +} + +.interactive-item-container .chat-response-timing .chat-response-alternate { + opacity: 0; + transform: translateY(100%); +} + +.interactive-item-container .chat-footer-details.chat-response-flip-down .chat-response-timing .chat-response-alternate { + transform: translateY(-100%); +} + +.interactive-item-container .chat-footer-details.chat-response-flip-active .chat-response-timing.has-alternate .chat-response-completed-at, +.interactive-item-container .chat-footer-details:focus-visible .chat-response-timing.has-alternate .chat-response-completed-at { + opacity: 0; + transform: translateY(-100%); +} + +.interactive-item-container .chat-footer-details.chat-response-flip-down.chat-response-flip-active .chat-response-timing.has-alternate .chat-response-completed-at { + transform: translateY(100%); +} + +.interactive-item-container .chat-footer-details.chat-response-flip-active .chat-response-timing.has-alternate .chat-response-alternate, +.interactive-item-container .chat-footer-details:focus-visible .chat-response-timing.has-alternate .chat-response-alternate { + opacity: 1; + transform: translateY(0); +} + +.interactive-item-container .chat-request-timing { + display: inline-grid; + overflow: hidden; +} + +.interactive-item-container .chat-request-timestamp:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: var(--vscode-spacing-size20); +} + +.interactive-item-container .chat-request-timing > * { + grid-area: 1 / 1; + justify-self: end; + text-align: right; + transition: opacity 160ms ease, transform 160ms ease; +} + +.interactive-item-container .chat-request-flip-reset .chat-request-timing > * { + transition: none; +} + +.interactive-item-container .chat-request-timing .chat-request-full-date { + opacity: 0; + transform: translateY(100%); +} + +.interactive-item-container .chat-request-timestamp.chat-request-flip-down .chat-request-timing .chat-request-full-date { + transform: translateY(-100%); +} + +.interactive-item-container .chat-request-timestamp.chat-request-flip-active .chat-request-timing.has-alternate .chat-request-relative, +.interactive-item-container .chat-request-timestamp:focus-visible .chat-request-timing.has-alternate .chat-request-relative { + opacity: 0; + transform: translateY(-100%); +} + +.interactive-item-container .chat-request-timestamp.chat-request-flip-down.chat-request-flip-active .chat-request-timing.has-alternate .chat-request-relative { + transform: translateY(100%); +} + +.interactive-item-container .chat-request-timestamp.chat-request-flip-active .chat-request-timing.has-alternate .chat-request-full-date, +.interactive-item-container .chat-request-timestamp:focus-visible .chat-request-timing.has-alternate .chat-request-full-date { + opacity: 1; + transform: translateY(0); +} + +.interactive-item-container .chat-response-model-details { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.interactive-item-container.interactive-response .chat-footer-toolbar .chat-footer-details.hidden { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .interactive-item-container .chat-response-timing > * { + transition: none; + } + + .interactive-item-container .chat-request-timing > * { + transition: none; + } +} + +.interactive-item-container.interactive-request:not(.show-verbose-details) .value .chat-request-timestamp { + display: none; } .interactive-item-container .value { @@ -3837,6 +3957,27 @@ have to be updated for changes to the rules above, or to support more deeply nes width: fit-content; } + .interactive-item-container.interactive-request .chat-request-timestamp { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-label3); + line-height: var(--vscode-fontSize-label3); + opacity: 0.7; + visibility: visible; + transition: opacity 0.1s ease-in-out, visibility 0s linear 0s; + } + + .interactive-item-container.interactive-request:not(.group-hovered) .chat-request-timestamp { + opacity: 0; + visibility: hidden; + transition: opacity 0.1s ease-in-out, visibility 0s linear 0.1s; + } + + .interactive-item-container.interactive-request:not(.group-hovered) .chat-request-timestamp:focus-visible { + opacity: 0.7; + visibility: visible; + transition: opacity 0.1s ease-in-out, visibility 0s linear 0s; + } + .interactive-item-container.interactive-request .chat-attached-context { max-width: 100%; width: fit-content; @@ -4309,6 +4450,12 @@ have to be updated for changes to the rules above, or to support more deeply nes .checkpoint-container { opacity: 1; } + + .chat-request-timestamp { + opacity: 0.7; + visibility: visible; + transition: opacity 0.1s ease-in-out, visibility 0s linear 0s; + } } .interactive-request.editing .rendered-markdown, diff --git a/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts b/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts index 6d75061cb15..c026e816d5b 100644 --- a/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts +++ b/src/vs/workbench/contrib/chat/common/chatProgressFormatting.ts @@ -4,6 +4,29 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from '../../../../nls.js'; +import { safeIntl } from '../../../../base/common/date.js'; + +const dayInMilliseconds = 24 * 60 * 60 * 1000; + +const chatRequestTimeFormatter = safeIntl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', +}); + +const chatRequestFullDateTimeFormatter = safeIntl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', +}); + +export interface IFormattedChatRequestTimestamp { + readonly text: string; + readonly fullText: string; + readonly dateTime: string; + readonly isRelative: boolean; +} /** * Format a millisecond duration as a human-readable elapsed time string. @@ -19,4 +42,28 @@ export function formatElapsedTime(ms: number): string { return localize('minutesSeconds', "{0}m {1}s", minutes, seconds); } +export function formatChatRequestTimestamp(timestamp: number | undefined): IFormattedChatRequestTimestamp | undefined { + if (timestamp === undefined || !Number.isFinite(timestamp) || timestamp <= 0) { + return undefined; + } + const date = new Date(timestamp); + const age = Date.now() - timestamp; + const isRelative = age > dayInMilliseconds; + return { + text: isRelative + ? localize('chatTimestampDays', "{0}d", Math.floor(age / dayInMilliseconds)) + : chatRequestTimeFormatter.value.format(date), + fullText: chatRequestFullDateTimeFormatter.value.format(date), + dateTime: date.toISOString(), + isRelative, + }; +} + +export function formatChatResponseDetails(details: string | undefined, timing: string | undefined): string { + const parts: string[] = timing ? [timing] : []; + if (details) { + parts.push(details); + } + return parts.join(' \u2022 '); +} diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index dab9f26e223..6e95f2aa093 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -798,10 +798,19 @@ export class ChatService extends Disposable implements IChatService { }; let lastRequest: ChatRequestModel | undefined; + let lastResponseCompletedAt: number | undefined; + const completeLastResponse = () => { + if (Number.isFinite(lastResponseCompletedAt)) { + lastRequest?.response?.complete(lastResponseCompletedAt); + } else { + lastRequest?.response?.completeWithoutTimestamp(); + } + lastResponseCompletedAt = undefined; + }; for (const message of providedSession.history) { if (message.type === 'request') { if (lastRequest) { - lastRequest.response?.complete(); + completeLastResponse(); } const requestText = message.prompt; @@ -833,7 +842,8 @@ export class ChatService extends Disposable implements IChatService { message.isSystemInitiated, message.systemInitiatedLabel, undefined, // terminalExecutionId - message.isTerminalRequest + message.isTerminalRequest, + message.timestamp ?? null, ); } else { // response @@ -847,6 +857,10 @@ export class ChatService extends Disposable implements IChatService { ...(message.errorDetails ? { errorDetails: message.errorDetails } : {}), }); } + if (lastRequest.response && typeof message.elapsedMs === 'number') { + lastRequest.response.setElapsedMs(message.elapsedMs); + } + lastResponseCompletedAt = message.completedAt; } } } @@ -889,10 +903,10 @@ export class ChatService extends Disposable implements IChatService { // Handle server-initiated requests (e.g. consumed queued messages). if (providedSession.onDidStartServerRequest) { - disposables.add(providedSession.onDidStartServerRequest(({ prompt, variableData, isSystemInitiated, systemInitiatedLabel, isTerminalRequest }) => { + disposables.add(providedSession.onDidStartServerRequest(({ prompt, variableData, timestamp, isSystemInitiated, systemInitiatedLabel, isTerminalRequest }) => { // Complete any in-flight request if (lastRequest?.response && !lastRequest.response.isComplete) { - lastRequest.response.complete(); + completeLastResponse(); } // Create a new request in the model @@ -914,7 +928,8 @@ export class ChatService extends Disposable implements IChatService { isSystemInitiated, systemInitiatedLabel, undefined, // terminalExecutionId - isTerminalRequest + isTerminalRequest, + timestamp, ); // Reset progress tracking for the new turn @@ -987,21 +1002,21 @@ export class ChatService extends Disposable implements IChatService { if (isComplete && lastRequest) { this._pendingRequests.deleteAndDispose(model.sessionResource); cancellationListener.clear(); - lastRequest.response?.complete(); + completeLastResponse(); // Flush any message queued/steered during the streamed turn (no-op if none, or server-managed). this.processPendingRequests(model.sessionResource); } })); } else { if (providedSession.isCompleteObs?.get()) { - lastRequest?.response?.complete(); + completeLastResponse(); } this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'notCancelable', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); if (lastRequest && model.editingSession) { // wait for timeline to load so that a 'changes' part is added when the response completes await chatEditingSessionIsReady(model.editingSession); - lastRequest.response?.complete(); + completeLastResponse(); } } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 9ca1c96ac7c..8ea6f0577a2 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -283,6 +283,7 @@ export type IChatSessionHistoryItem = { command?: string; variableData?: IChatRequestVariableData; modelId?: string; + timestamp?: number; modeInstructions?: IChatRequestModeInstructions; isSystemInitiated?: boolean; systemInitiatedLabel?: string; @@ -292,6 +293,8 @@ export type IChatSessionHistoryItem = { parts: IChatProgress[]; participant: string; details?: string; + elapsedMs?: number; + completedAt?: number; /** * Error details for a failed response. Rendered as a proper chat error * (including the quota-exceeded upgrade affordance), mirroring the live @@ -305,6 +308,7 @@ export type IChatSessionRequestHistoryItem = Extract(this, false); private readonly _timestamp: number; + private _completionTimestamp: number | undefined; private _timeSpentWaitingAccumulator: number; private _elapsedMs: number | undefined; @@ -1190,6 +1199,10 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return undefined; } + public get completionTimestamp(): number | undefined { + return this._completionTimestamp; + } + public get state(): ResponseModelState { const state = this._modelState.get().value; if (state === ResponseModelState.Complete && !!this._result?.errorDetails && this.result?.errorDetails?.code !== 'canceled') { @@ -1329,6 +1342,9 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel if (params.modelState) { this._modelState.set(params.modelState, undefined); } + this._completionTimestamp = params.completionTimestamp === null + ? undefined + : params.completionTimestamp ?? (params.modelState && 'completedAt' in params.modelState ? params.modelState.completedAt : undefined); this._timeSpentWaitingAccumulator = params.timeSpentWaiting || 0; this._elapsedMs = params.elapsedMs; this._vote = params.vote; @@ -1495,6 +1511,10 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel this._onDidChange.fire(defaultChatResponseModelChangeReason); } + setElapsedMs(elapsedMs: number): void { + this._elapsedMs = Math.max(0, elapsedMs); + } + private isSameUsage(usage: IChatUsage): boolean { const currentUsage = this._usageObs.get(); return !!currentUsage @@ -1505,7 +1525,15 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel && equals(currentUsage.promptTokenDetails, usage.promptTokenDetails); } - complete(): void { + complete(completedAt = Date.now()): void { + this._complete(completedAt, completedAt); + } + + completeWithoutTimestamp(): void { + this._complete(Date.now(), undefined); + } + + private _complete(completedAt: number, completionTimestamp: number | undefined): void { // No-op if it's already complete if (this.isComplete) { return; @@ -1516,11 +1544,12 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel this._response.finalizeReasoningDuration(); // Compute elapsed generation time before setting terminal state - this._elapsedMs = Math.max(0, Date.now() - this.confirmationAdjustedTimestamp.get()); + this._elapsedMs ??= Math.max(0, completedAt - this.confirmationAdjustedTimestamp.get()); // Canceled sessions can be considered 'Complete' const state = !!this._result?.errorDetails && this._result.errorDetails.code !== 'canceled' ? ResponseModelState.Failed : ResponseModelState.Complete; - this._modelState.set({ value: state, completedAt: Date.now() }, undefined); + this._completionTimestamp = completionTimestamp; + this._modelState.set({ value: state, completedAt }, undefined); this._onDidChange.fire({ reason: 'completedRequest' }); } @@ -1540,7 +1569,10 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel } } - this._modelState.set({ value: ResponseModelState.Cancelled, completedAt: Date.now() }, undefined); + const completedAt = Date.now(); + this._elapsedMs ??= Math.max(0, completedAt - this.confirmationAdjustedTimestamp.get()); + this._completionTimestamp = completedAt; + this._modelState.set({ value: ResponseModelState.Cancelled, completedAt }, undefined); this._onDidChange.fire({ reason: 'completedRequest' }); } @@ -1586,7 +1618,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel } } - toJSON(): ISerializableChatResponseData { + toJSON(): Omit { const modelState = this._modelState.get(); const pendingConfirmation = this.isPendingConfirmation.get(); @@ -1601,7 +1633,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel usedContext: this.usedContext, contentReferences: this.contentReferences, codeCitations: this.codeCitations, - timestamp: this._timestamp, + responseTimestamp: this._timestamp, timeSpentWaiting: (pendingConfirmation ? Date.now() - pendingConfirmation.startedWaitingAt : 0) + this._timeSpentWaitingAccumulator, promptTokens: this.usage?.promptTokens, completionTokens: this.completionTokenCount, @@ -1609,7 +1641,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel promptTokenDetails: this.usage?.promptTokenDetails, copilotCredits: this.usage?.copilotCredits, elapsedMs: this.elapsedMs ?? (this.completedAt ? Math.max(0, this.completedAt - this.confirmationAdjustedTimestamp.get()) : undefined), - } satisfies WithDefinedProps; + } satisfies WithDefinedProps>; } } @@ -1698,6 +1730,7 @@ interface ISerializableChatResponseData { modelState?: ResponseModelStateT; vote?: ChatAgentVoteDirection; timestamp?: number; + responseTimestamp?: number; slashCommand?: IChatAgentCommand; /** For backward compat: should be optional */ usedContext?: IChatUsedContext; @@ -2496,8 +2529,8 @@ export class ChatModel extends Disposable implements IChatModel { this._disableBackgroundKeepAlive = initialModelProps.disableBackgroundKeepAlive ?? false; - this._requests = initialData ? this._deserialize(initialData) : []; this._timestamp = (isValidFullData && initialData.creationDate) || Date.now(); + this._requests = initialData ? this._deserialize(initialData) : []; this._customTitle = isValidFullData ? initialData.customTitle : undefined; // Initialize input model from serialized data (undefined for new chats) @@ -2650,11 +2683,13 @@ export class ChatModel extends Disposable implements IChatModel { // Old messages don't have variableData, or have it in the wrong (non-array) shape const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); + const requestTimestamp = typeof raw.timestamp === 'number' && raw.timestamp > 0 ? raw.timestamp : undefined; const request = new ChatRequestModel({ session: this, message: parsedRequest, variableData, - timestamp: raw.timestamp ?? -1, + timestamp: requestTimestamp, + fallbackTimestamp: this._timestamp, restoredId: raw.requestId, confirmation: raw.confirmation, editedFileEvents: raw.editedFileEvents, @@ -2697,8 +2732,11 @@ export class ChatModel extends Disposable implements IChatModel { slashCommand: raw.slashCommand, requestId: request.id, modelState, + completionTimestamp: raw.modelState && 'completedAt' in raw.modelState && Number.isFinite(raw.modelState.completedAt) && raw.modelState.completedAt > 0 + ? raw.modelState.completedAt + : null, vote: raw.vote, - timestamp: raw.timestamp, + timestamp: typeof raw.responseTimestamp === 'number' && raw.responseTimestamp > 0 ? raw.responseTimestamp : requestTimestamp, result, followups: raw.followups, restoredId: raw.responseId, @@ -2859,16 +2897,23 @@ export class ChatModel extends Disposable implements IChatModel { isSystemInitiated?: boolean, systemInitiatedLabel?: string, terminalExecutionId?: string, - isTerminalCommand?: boolean + isTerminalCommand?: boolean, + timestamp?: number | null, ): ChatRequestModel { const editedFileEvents = [...this.currentEditedFileEvents.values()]; this.currentEditedFileEvents.clear(); + const requestTimestamp = timestamp === undefined + ? Date.now() + : typeof timestamp === 'number' && Number.isFinite(timestamp) && timestamp > 0 + ? timestamp + : undefined; const request = new ChatRequestModel({ restoredId: id, session: this, message, variableData, - timestamp: Date.now(), + timestamp: requestTimestamp, + fallbackTimestamp: this._timestamp, attempt, modeInfo, confirmation, @@ -3036,7 +3081,7 @@ export class ChatModel extends Disposable implements IChatModel { : undefined, shouldBeRemovedOnSend: r.shouldBeRemovedOnSend, agent: agentJson, - timestamp: r.timestamp, + timestamp: r.requestTimestamp, confirmation: r.confirmation, editedFileEvents: r.editedFileEvents, modelId: r.modelId, diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 8d2b2276bd5..f9d24949f4f 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -128,7 +128,7 @@ const chatVariableSchema = Adapt.object({ // request parts requestId: Adapt.t(m => m.id, Adapt.key()), - timestamp: Adapt.v(m => m.timestamp), + timestamp: Adapt.v(m => m.requestTimestamp), confirmation: Adapt.v(m => m.confirmation), message: Adapt.t(m => m.message, messageSchema), shouldBeRemovedOnSend: Adapt.v(m => m.shouldBeRemovedOnSend, objectsEqual), @@ -141,6 +141,7 @@ const requestSchema = Adapt.object m.response?.entireResponse.value.filter((p): p is Exclude => p.kind !== 'mcpAuthenticationRequired' && p.kind !== 'mcpServersStartingSlow'), Adapt.array(responsePartSchema)), responseId: Adapt.v(m => m.response?.id), + responseTimestamp: Adapt.v(m => m.response?.timestamp), result: Adapt.v(m => m.response?.result, objectsEqual), responseMarkdownInfo: Adapt.v( m => m.response?.codeBlockInfos?.map(info => ({ suggestionId: info.suggestionId })), diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 3ff8edd13c4..c27338100cc 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -123,6 +123,7 @@ export interface IChatRequestViewModel { readonly modelId?: string; readonly resolvedModelId?: string; readonly timestamp: number; + readonly requestTimestamp: number | undefined; /** The kind of pending request, or undefined if not pending */ readonly pendingKind?: ChatRequestQueueKind; readonly isSystemInitiated?: boolean; @@ -547,6 +548,10 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.timestamp; } + get requestTimestamp() { + return this._model.requestTimestamp; + } + get pendingKind() { return this._pendingKind; } 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 ad8bcf1b3b4..64cab6a41b7 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 @@ -8,6 +8,7 @@ import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js' import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IDisposable, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { hasKey } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { constObservable, derived, ISettableObservable, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; @@ -24,7 +25,7 @@ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, Ag import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; import { BrowserViewAttachmentDisplayKind, BrowserViewAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; -import { ActionType, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type ClientPluginCustomization, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -94,6 +95,22 @@ import { messageAttachmentsToVariableData } from '../../../browser/agentSessions import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferenceAttachmentMetadataKey, AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, toSessionReferenceModelRepresentation } from '../../../browser/agentSessions/agentHost/agentHostSessionReferenceAttachment.js'; import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +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 ChatAction = AgentHostChatAction | ILegacyTimedChatAction; +type TestActionEnvelope = Omit & { action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction }; + +function normalizeTestAction(action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): SessionAction | AgentHostChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction { + if (hasKey(action, { endedAt: true })) { + const { endedAt: _endedAt, ...rest } = action as ILegacyTimedChatAction; + return { ...rest, duration: 1000 } as AgentHostChatAction; + } + return action as SessionAction | AgentHostChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction; +} + // ---- Mock agent host service ------------------------------------------------ /** @@ -403,7 +420,7 @@ class MockAgentHostService extends mock() { return this.inflightCreates.get(resource.toString()); } - override dispatch(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { + override dispatch(channel: string, action: SessionAction | AgentHostChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): void { this.dispatchedActions.push({ channel, action, clientId: this.clientId, clientSeq: this._nextSeq++ }); // Apply state-management actions optimistically so state-dependent // logic (e.g. customization re-dispatch) sees the correct activeClient. @@ -423,8 +440,9 @@ class MockAgentHostService extends mock() { } // Test helpers - fireAction(envelope: ActionEnvelope): void { - this._onDidAction.fire(envelope); + fireAction(envelope: TestActionEnvelope): void { + const normalized = { ...envelope, action: normalizeTestAction(envelope.action) } satisfies ActionEnvelope; + this._onDidAction.fire(normalized); // Route the action to the matching live subscription, applying the // appropriate reducer. Chat actions (turns/tools/input) belong to the // session's default-chat channel: producers may address them to either @@ -432,22 +450,22 @@ class MockAgentHostService extends mock() { // and emit there — mirroring the server's behaviour. Session actions // target the session channel directly. const noop = () => { }; - if (isChatAction(envelope.action)) { - const chatUri = isAhpChatChannel(envelope.channel) ? envelope.channel : buildDefaultChatUri(envelope.channel); + if (isChatAction(normalized.action)) { + const chatUri = isAhpChatChannel(normalized.channel) ? normalized.channel : buildDefaultChatUri(normalized.channel); const entry = this._liveSubscriptions.get(chatUri); if (entry) { - entry.onWillApply.fire(envelope); - entry.state = chatReducer(entry.state as ChatState, envelope.action as Parameters[1], noop); + entry.onWillApply.fire(normalized); + entry.state = chatReducer(entry.state as ChatState, normalized.action as Parameters[1], noop); entry.emitter.fire(entry.state); - entry.onDidApply.fire(envelope); + entry.onDidApply.fire(normalized); } - } else if (isSessionAction(envelope.action)) { - const entry = this._liveSubscriptions.get(envelope.channel); + } else if (isSessionAction(normalized.action)) { + const entry = this._liveSubscriptions.get(normalized.channel); if (entry) { - entry.onWillApply.fire(envelope); - entry.state = sessionReducer(entry.state as SessionState, envelope.action as Parameters[1], noop); + entry.onWillApply.fire(normalized); + entry.state = sessionReducer(entry.state as SessionState, normalized.action as Parameters[1], noop); entry.emitter.fire(entry.state); - entry.onDidApply.fire(envelope); + entry.onDidApply.fire(normalized); } } } @@ -1133,7 +1151,7 @@ suite('AgentHostChatContribution', () => { }, ]); - fire({ type: 'chat/turnComplete', session: session!, turnId: turnId! } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session: session!, turnId: turnId! } as ChatAction); await turnPromise; }); @@ -1163,7 +1181,7 @@ suite('AgentHostChatContribution', () => { modelRepresentation: 'Transcript text', }]); - fire({ type: 'chat/turnComplete', session: session!, turnId: turnId! } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session: session!, turnId: turnId! } as ChatAction); await turnPromise; }); @@ -1203,7 +1221,7 @@ suite('AgentHostChatContribution', () => { }], }); - fire({ type: 'chat/turnComplete', session: session!, turnId: turnId! } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session: session!, turnId: turnId! } as ChatAction); await turnPromise; }); @@ -1284,7 +1302,7 @@ suite('AgentHostChatContribution', () => { }, }]); - fire({ type: 'chat/turnComplete', session: session!, turnId: turnId! } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session: session!, turnId: turnId! } as ChatAction); await turnPromise; }); @@ -1336,7 +1354,7 @@ suite('AgentHostChatContribution', () => { }, }]); - fire({ type: 'chat/turnComplete', session: session!, turnId: turnId! } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session: session!, turnId: turnId! } as ChatAction); await turnPromise; }); @@ -2403,7 +2421,7 @@ suite('AgentHostChatContribution', () => { message: 'Hello from controller', sessionResource: item.resource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -2546,7 +2564,7 @@ suite('AgentHostChatContribution', () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hello' }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -2579,7 +2597,7 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; // Echo the turnStarted to clear pending write-ahead agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: { type: 'chat/turnComplete', turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Second turn @@ -2591,7 +2609,7 @@ suite('AgentHostChatContribution', () => { const dispatch2 = agentHostService.turnActions[1]; const action2 = dispatch2.action as ITurnStartedAction; agentHostService.fireAction({ channel: dispatch2.channel.toString(), action: dispatch2.action, serverSeq: 3, origin: { clientId: agentHostService.clientId, clientSeq: dispatch2.clientSeq } }); - agentHostService.fireAction({ channel: dispatch2.channel.toString(), action: { type: 'chat/turnComplete', turnId: action2.turnId } as ChatAction, serverSeq: 4, origin: undefined }); + agentHostService.fireAction({ channel: dispatch2.channel.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: action2.turnId } as ChatAction, serverSeq: 4, origin: undefined }); await turn2Promise; assert.strictEqual(agentHostService.turnActions.length, 2); @@ -2608,7 +2626,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/existing-session-42' }), }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const parentSession = parseDefaultChatUri(session); @@ -2636,7 +2654,7 @@ suite('AgentHostChatContribution', () => { const dispatch = agentHostService.turnActions[0]; const action = dispatch.action as ITurnStartedAction; agentHostService.fireAction({ channel: dispatch.channel.toString(), action: dispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch.clientSeq } }); - agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: action.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: action.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turnPromise; assert.deepStrictEqual(agentHostService.turnActions.map(d => (d.action as ITurnStartedAction).message.text), ['Recovered']); @@ -2704,7 +2722,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -2719,7 +2737,7 @@ suite('AgentHostChatContribution', () => { userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', modelConfiguration: { thinkingLevel: 'high', contextSize: 272000 }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -2733,7 +2751,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', userSelectedModelId: 'gpt-4o', }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -2753,7 +2771,7 @@ suite('AgentHostChatContribution', () => { message: 'Hi', userSelectedModelId: 'copilotcli/claude-sonnet-4.6', }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -2799,7 +2817,7 @@ suite('AgentHostChatContribution', () => { const { sessionHandler, agentHostService, chatAgentService, trustController } = createContribution(disposables); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi' }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -2832,7 +2850,7 @@ suite('AgentHostChatContribution', () => { await timeout(600); assert.strictEqual(preparingStatusCount(collected), 0, 'normal session must never emit "Preparing session…"'); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(preparingStatusCount(collected), 0); })); @@ -2850,7 +2868,7 @@ suite('AgentHostChatContribution', () => { await timeout(30); assert.strictEqual(preparingStatusCount(collected), 1, 'must emit once the 500ms threshold elapses'); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -2865,7 +2883,7 @@ suite('AgentHostChatContribution', () => { await timeout(600); assert.strictEqual(preparingStatusCount(collected), 0, 'first real progress must cancel the pending status'); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -2901,7 +2919,7 @@ suite('AgentHostChatContribution', () => { fire({ type: 'chat/responsePart', session, turnId, part: { kind: 'markdown', id: 'md-1', content: 'hello ' } } as ChatAction); fire({ type: 'chat/delta', session, turnId, partId: 'md-1', content: 'world' } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -2921,7 +2939,7 @@ suite('AgentHostChatContribution', () => { turnId, part: { kind: ResponsePartKind.SystemNotification, content: 'Background command completed' }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const notifications = collected.flat().filter(part => part.kind === 'systemNotification'); @@ -2950,7 +2968,7 @@ suite('AgentHostChatContribution', () => { }, }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.deepStrictEqual(collected.flat().filter(part => part.kind === 'autoModeResolution'), [{ @@ -2967,7 +2985,7 @@ suite('AgentHostChatContribution', () => { const { turnPromise, chatSession, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(chatSession.isCompleteObs?.get(), true, 'should be complete after turn finishes'); @@ -2982,7 +3000,7 @@ suite('AgentHostChatContribution', () => { const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'chat/usage', session, turnId, usage: { model: 'opus-4.7', _meta: { cost: 1.5 } } } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); const result = await turnPromise; @@ -3018,7 +3036,7 @@ suite('AgentHostChatContribution', () => { const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'chat/usage', session, turnId, usage: { model: 'opus-4.7', _meta: { cost: 0 } } } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); const result = await turnPromise; @@ -3036,7 +3054,7 @@ suite('AgentHostChatContribution', () => { const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'chat/usage', session, turnId, usage: { model: 'claude-sonnet-4-6', _meta: { cost: 1 } } } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); const result = await turnPromise; @@ -3055,7 +3073,7 @@ suite('AgentHostChatContribution', () => { { userSelectedModelId: 'agent-host-copilot:auto' }); fire({ type: 'chat/usage', session, turnId, usage: { model: 'raptor-mini', _meta: { cost: 1 } } } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); const result = await turnPromise; @@ -3068,7 +3086,7 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'chat/usage', session, turnId, usage: { inputTokens: 1200, outputTokens: 300, model: 'gpt-5' } } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -3122,7 +3140,7 @@ suite('AgentHostChatContribution', () => { agentHostService.fireAction({ channel: childSessionUri, action, serverSeq: 1000, origin: undefined }); }; fireChild({ - type: 'chat/turnStarted', + type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', turnId: childTurnId, message: { text: '', origin: { kind: MessageKind.User } }, } as ChatAction); @@ -3133,7 +3151,7 @@ suite('AgentHostChatContribution', () => { await timeout(50); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; // The parent session cost is emitted as-is — the client does not re-add the @@ -3164,7 +3182,7 @@ suite('AgentHostChatContribution', () => { fire({ type: 'chat/toolCallStart', session, turnId, toolCallId: 'tc-1', toolName: 'read_file', displayName: 'Read File' } as ChatAction); fire({ type: 'chat/toolCallReady', session, turnId, toolCallId: 'tc-1', invocationMessage: 'Reading file', confirmed: 'not-needed' } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -3183,7 +3201,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-2', result: { success: true, pastTenseMessage: 'Ran Bash command' }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -3205,7 +3223,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-3', result: { success: false, pastTenseMessage: '"Bash" failed', content: [{ type: 'text', text: 'command not found' }], error: { message: 'command not found' } }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -3222,7 +3240,7 @@ suite('AgentHostChatContribution', () => { fire({ type: 'chat/toolCallStart', session, turnId, toolCallId: 'tc-bad', toolName: 'bash', displayName: 'Bash' } as ChatAction); fire({ type: 'chat/toolCallReady', session, turnId, toolCallId: 'tc-bad', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -3238,7 +3256,7 @@ suite('AgentHostChatContribution', () => { // tool_start without tool_complete fire({ type: 'chat/toolCallStart', session, turnId, toolCallId: 'tc-orphan', toolName: 'bash', displayName: 'Bash' } as ChatAction); fire({ type: 'chat/toolCallReady', session, turnId, toolCallId: 'tc-orphan', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -3261,7 +3279,7 @@ suite('AgentHostChatContribution', () => { origin: undefined, }); fire({ type: 'chat/responsePart', turnId, part: { kind: 'markdown', id: 'md-1', content: 'right' } } as ChatAction); - fire({ type: 'chat/turnComplete', turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId } as ChatAction); await turnPromise; @@ -3309,7 +3327,7 @@ suite('AgentHostChatContribution', () => { { id: 'autopilot', label: 'Implement with Autopilot', permissionLevel: 'autopilot' }, ]); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3350,7 +3368,7 @@ suite('AgentHostChatContribution', () => { }, }); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3391,7 +3409,7 @@ suite('AgentHostChatContribution', () => { }, }); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3439,7 +3457,7 @@ suite('AgentHostChatContribution', () => { ]); assert.strictEqual(agentHostService.dispatchedActions.some(dispatched => dispatched.action.type === ActionType.ChatInputCompleted), false); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3468,7 +3486,7 @@ suite('AgentHostChatContribution', () => { review.dismiss(); assert.strictEqual(review.isUsed, true); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; assert.deepStrictEqual(chatWidgetService.clearPlanReviewCalls.map(call => ({ responseId: call.responseId, resolveId: call.resolveId })), [ @@ -3540,7 +3558,7 @@ suite('AgentHostChatContribution', () => { }); assert.strictEqual(agentHostService.dispatchedActions.some(dispatched => dispatched.action.type === ActionType.ChatInputCompleted), false); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3594,7 +3612,7 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(chatWidgetService.clearQuestionCarouselCalls, []); assert.strictEqual(agentHostService.dispatchedActions.some(dispatched => dispatched.action.type === ActionType.ChatInputCompleted), false); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3646,7 +3664,7 @@ suite('AgentHostChatContribution', () => { ]); assert.strictEqual(agentHostService.dispatchedActions.some(dispatched => dispatched.action.type === ActionType.ChatInputCompleted), false); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3676,7 +3694,7 @@ suite('AgentHostChatContribution', () => { assert.ok(part.acceptButtonLabel.includes('example.com'), 'accept button should reference the URL authority'); assert.strictEqual(collected.flat().some(p => p.kind === 'questionCarousel'), false, 'url-style requests must not also render a question carousel'); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3710,7 +3728,7 @@ suite('AgentHostChatContribution', () => { response: (completions[0].action as { response: ChatInputResponseKind }).response, }, { requestId: 'url-1', response: ChatInputResponseKind.Accept }); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3740,7 +3758,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual((completions[0].action as { response: ChatInputResponseKind }).response, ChatInputResponseKind.Decline); assert.strictEqual(part.state.get(), ElicitationState.Rejected); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3768,7 +3786,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual((completions[0].action as { response: ChatInputResponseKind }).response, ChatInputResponseKind.Decline); assert.strictEqual(part.state.get(), ElicitationState.Rejected); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3796,7 +3814,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual((completions[0].action as { response: ChatInputResponseKind }).response, ChatInputResponseKind.Decline); assert.strictEqual(part.state.get(), ElicitationState.Rejected); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; })); @@ -3812,7 +3830,7 @@ suite('AgentHostChatContribution', () => { await timeout(10); agentHostService.dispatchedActions.length = 0; - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; const completions = agentHostService.dispatchedActions.filter(d => d.action.type === ActionType.ChatInputCompleted); @@ -3847,7 +3865,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(part.state.get(), ElicitationState.Accepted); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.dispatchedActions.some(d => d.action.type === ActionType.ChatInputCompleted), false); @@ -3877,7 +3895,7 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(part.state.get(), ElicitationState.Rejected); - fire({ type: ActionType.ChatTurnComplete, turnId } as ChatAction); + fire({ type: ActionType.ChatTurnComplete, turnId, endedAt: '2025-01-01T00:00:00.000Z' } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.dispatchedActions.some(d => d.action.type === ActionType.ChatInputCompleted), false); @@ -3972,7 +3990,7 @@ suite('AgentHostChatContribution', () => { }); // Turn completes naturally on its own. - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; // Now the request's cancellation token fires (e.g. ChatService @@ -3997,7 +4015,7 @@ suite('AgentHostChatContribution', () => { agentHostService.fireAction({ channel: session, action: { - type: 'chat/error', + type: 'chat/error', endedAt: '2025-01-01T00:00:00.000Z', turnId, error: { errorType: 'test_error', message: 'Something went wrong' }, } as ChatAction, @@ -4057,7 +4075,7 @@ suite('AgentHostChatContribution', () => { } )); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -4091,7 +4109,7 @@ suite('AgentHostChatContribution', () => { } )); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -4115,7 +4133,7 @@ suite('AgentHostChatContribution', () => { IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); await timeout(10); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -4135,7 +4153,7 @@ suite('AgentHostChatContribution', () => { IChatToolInvocation.confirmWith(permInvocation, { type: ToolConfirmKind.UserAction }); await timeout(10); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -4207,7 +4225,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-race', result: { success: true, pastTenseMessage: 'Ran echo hi', content: [{ type: 'text', text: 'hi\n' }] }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; // Final invariant: still the same number of invocations as right @@ -4289,7 +4307,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-recon', result: { success: true, pastTenseMessage: 'Done', content: [{ type: 'text', text: 'hi\n' }] }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); }); @@ -4612,6 +4630,7 @@ suite('AgentHostChatContribution', () => { ], activeTurn: { id: 'turn-active', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'Q3', origin: { kind: MessageKind.User }, model: { id: 'sonnet-4.6' } }, responseParts: [], usage: { _meta: { cost: 1 } }, @@ -4661,7 +4680,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-shell', result: { success: true, pastTenseMessage: 'Ran `echo hello`', content: [{ type: 'terminal', resource: 'agenthost-terminal:///tc-shell-term' }, { type: 'text', text: 'hello\n' }] }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -4699,7 +4718,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-fail', result: { success: false, pastTenseMessage: '"Bash" failed', content: [{ type: 'terminal', resource: 'agenthost-terminal:///tc-fail-term' }, { type: 'text', text: 'command not found: bad_cmd' }], error: { message: 'command not found: bad_cmd' } }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -4727,7 +4746,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-gen', result: { success: true, pastTenseMessage: 'Used "custom_tool"' }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -4754,7 +4773,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-noargs', result: { success: true, pastTenseMessage: 'Ran Bash command' }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -4781,7 +4800,7 @@ suite('AgentHostChatContribution', () => { type: 'chat/toolCallComplete', session, turnId, toolCallId: 'tc-view', result: { success: true, pastTenseMessage: 'Read /tmp/test.txt' }, } as ChatAction); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; @@ -4937,7 +4956,7 @@ suite('AgentHostChatContribution', () => { agentHostService.fireAction({ channel: session, action: { - type: 'chat/error', + type: 'chat/error', endedAt: '2025-01-01T00:00:00.000Z', turnId, error: { errorType: 'connection_error', message: 'connection lost' }, } as ChatAction, @@ -5112,7 +5131,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5134,7 +5153,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5169,7 +5188,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5207,7 +5226,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5237,7 +5256,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5291,7 +5310,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; @@ -5342,7 +5361,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; @@ -5372,7 +5391,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; @@ -5390,7 +5409,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5427,7 +5446,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5466,7 +5485,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5487,7 +5506,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5524,7 +5543,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5565,7 +5584,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5594,7 +5613,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5614,7 +5633,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5641,7 +5660,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5668,7 +5687,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5691,7 +5710,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5712,7 +5731,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5736,7 +5755,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5760,7 +5779,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this selection?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5783,7 +5802,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5806,7 +5825,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5828,7 +5847,7 @@ suite('AgentHostChatContribution', () => { message: 'what\'s in this file?', sessionResource, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5850,7 +5869,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5873,7 +5892,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5897,7 +5916,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5920,7 +5939,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5949,7 +5968,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5973,7 +5992,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -5998,7 +6017,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -6017,7 +6036,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -6039,7 +6058,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -6057,7 +6076,7 @@ suite('AgentHostChatContribution', () => { const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hello', }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -6115,7 +6134,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -6173,7 +6192,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -6199,7 +6218,7 @@ suite('AgentHostChatContribution', () => { ], }, }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.turnActions.length, 1); @@ -6336,7 +6355,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-test' }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -6358,7 +6377,7 @@ suite('AgentHostChatContribution', () => { const config = { isolation: 'worktree', branch: 'feature/config' }; const { turnPromise, session, turnId, fire } = await startDynamicAgentTurn(chatAgentService, agentHostService, 'config-test', { message: 'Add Agent Host session configuration flow', agentHostSessionConfig: config }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -6395,7 +6414,7 @@ suite('AgentHostChatContribution', () => { const turnDispatch = agentHostService.turnActions[0]; const turnAction = turnDispatch.action as ITurnStartedAction; agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: turnDispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: turnDispatch.clientSeq } }); - agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: turnAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: turnAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turnPromise; const configChanged = agentHostService.dispatchedActions.find(d => d.action.type === ActionType.SessionConfigChanged); @@ -6443,7 +6462,7 @@ suite('AgentHostChatContribution', () => { const turnDispatch = agentHostService.turnActions[0]; const turnAction = turnDispatch.action as ITurnStartedAction; agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: turnDispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: turnDispatch.clientSeq } }); - agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: turnAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: turnAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turnPromise; const configChanged = agentHostService.dispatchedActions.find(d => d.action.type === ActionType.SessionConfigChanged) as { action: { config: Record; replace?: boolean } } | undefined; @@ -6499,7 +6518,7 @@ suite('AgentHostChatContribution', () => { const turnDispatch = agentHostService.turnActions[0]; const turnAction = turnDispatch.action as ITurnStartedAction; agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: turnDispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: turnDispatch.clientSeq } }); - agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: turnAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: turnDispatch.channel.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: turnAction.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 0, 'no duplicate createSession should have been issued; eager-create branch should have been taken'); @@ -6525,7 +6544,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-resolver-test' }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -6558,7 +6577,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-agenthost-test' }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; assert.strictEqual(agentHostService.createSessionCalls.length, 1); @@ -6634,7 +6653,7 @@ suite('AgentHostChatContribution', () => { }); fire({ type: 'chat/delta', turnId, content: 'Response' } as ChatAction); - fire({ type: 'chat/turnComplete', turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId } as ChatAction); await turnPromise; // Turn dispatched via connection.dispatchAction @@ -6676,7 +6695,7 @@ suite('AgentHostChatContribution', () => { state: TurnState.Complete, }], activeTurn: { - ...createActiveTurn('turn-active', { text: 'Second message', origin: { kind: MessageKind.User } }), + ...createActiveTurn('turn-active', { text: 'Second message', origin: { kind: MessageKind.User } }, '2025-01-01T00:00:00.000Z'), responseParts: activeTurnParts, }, }; @@ -6819,7 +6838,7 @@ suite('AgentHostChatContribution', () => { // Fire turnComplete to finish the active turn agentHostService.fireAction({ - channel: sessionUri.toString(), action: { type: 'chat/turnComplete', turnId: 'turn-active' } as ChatAction, + channel: sessionUri.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: 'turn-active' } as ChatAction, serverSeq: 1, origin: undefined, }); @@ -6887,7 +6906,7 @@ suite('AgentHostChatContribution', () => { // Complete the turn so the awaitConfirmation promise and its internal // DisposableStore are cleaned up before test teardown. agentHostService.fireAction({ - channel: sessionUri.toString(), action: { type: 'chat/turnComplete', turnId: 'turn-active' } as ChatAction, + channel: sessionUri.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: 'turn-active' } as ChatAction, serverSeq: 1, origin: undefined, }); @@ -6970,7 +6989,7 @@ suite('AgentHostChatContribution', () => { modifiedAt: new Date().toISOString(), }), lifecycle: SessionLifecycle.Ready, - activeTurn: createActiveTurn('active-turn-1', { text: 'Working', origin: { kind: MessageKind.User } }), + activeTurn: createActiveTurn('active-turn-1', { text: 'Working', origin: { kind: MessageKind.User } }, '2025-01-01T00:00:00.000Z'), }); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/restored-pending-sync' }); @@ -7062,7 +7081,7 @@ suite('AgentHostChatContribution', () => { const session = dispatch1.channel.toString(); // Echo + complete the first turn agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Now simulate a server-initiated turn (e.g. from a consumed queued message) @@ -7073,7 +7092,7 @@ suite('AgentHostChatContribution', () => { agentHostService.fireAction({ channel: session, action: { - type: 'chat/turnStarted', + type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', turnId: serverTurnId, message: { text: 'queued message text', origin: { kind: MessageKind.User } }, } as ChatAction, @@ -7113,14 +7132,14 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = dispatch1.channel.toString(); agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Server-initiated turn const serverTurnId = 'server-turn-progress'; agentHostService.fireAction({ channel: session, - action: { type: 'chat/turnStarted', session, turnId: serverTurnId, message: { text: 'auto queued', origin: { kind: MessageKind.User } } } as ChatAction, + action: { type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', session, turnId: serverTurnId, message: { text: 'auto queued', origin: { kind: MessageKind.User } } } as ChatAction, serverSeq: 3, origin: undefined, }); await timeout(10); @@ -7147,7 +7166,7 @@ suite('AgentHostChatContribution', () => { // Complete the turn agentHostService.fireAction({ channel: session, - action: { type: 'chat/turnComplete', session, turnId: serverTurnId } as ChatAction, + action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: serverTurnId } as ChatAction, serverSeq: 6, origin: undefined, }); await timeout(10); @@ -7193,7 +7212,7 @@ suite('AgentHostChatContribution', () => { const dispatch = agentHostService.turnActions[0]; const action = dispatch.action as ITurnStartedAction; agentHostService.fireAction({ channel: dispatch.channel.toString(), action: dispatch.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch.clientSeq } }); - agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', turnId: action.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: dispatch.channel.toString(), action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: action.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turnPromise; assert.strictEqual(serverRequestEvents.length, 0, 'Client-dispatched turns should not trigger onDidStartServerRequest'); @@ -7221,14 +7240,14 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = dispatch1.channel.toString(); agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Server-initiated turn const serverTurnId = 'server-turn-tool-dedup'; agentHostService.fireAction({ channel: session, - action: { type: 'chat/turnStarted', session, turnId: serverTurnId, message: { text: 'queued', origin: { kind: MessageKind.User } } } as ChatAction, + action: { type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', session, turnId: serverTurnId, message: { text: 'queued', origin: { kind: MessageKind.User } } } as ChatAction, serverSeq: 3, origin: undefined, }); await timeout(10); @@ -7262,7 +7281,7 @@ suite('AgentHostChatContribution', () => { }); agentHostService.fireAction({ channel: session, - action: { type: 'chat/turnComplete', session, turnId: serverTurnId } as ChatAction, + action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: serverTurnId } as ChatAction, serverSeq: 8, origin: undefined, }); await timeout(50); @@ -7295,7 +7314,7 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = dispatch1.channel.toString(); agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Fire turnStarted followed immediately by a response part. @@ -7306,7 +7325,7 @@ suite('AgentHostChatContribution', () => { const serverTurnId = 'server-turn-md-initial'; agentHostService.fireAction({ channel: session, - action: { type: 'chat/turnStarted', session, turnId: serverTurnId, message: { text: 'queued', origin: { kind: MessageKind.User } } } as ChatAction, + action: { type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', session, turnId: serverTurnId, message: { text: 'queued', origin: { kind: MessageKind.User } } } as ChatAction, serverSeq: 3, origin: undefined, }); agentHostService.fireAction({ @@ -7325,7 +7344,7 @@ suite('AgentHostChatContribution', () => { // Complete the turn agentHostService.fireAction({ channel: session, - action: { type: 'chat/turnComplete', session, turnId: serverTurnId } as ChatAction, + action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: serverTurnId } as ChatAction, serverSeq: 5, origin: undefined, }); await timeout(10); @@ -7353,7 +7372,7 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = dispatch1.channel.toString(); agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Add a queued message to the protocol state so it's tracked. @@ -7369,7 +7388,7 @@ suite('AgentHostChatContribution', () => { chatService.removePendingRequestCalls.length = 0; agentHostService.fireAction({ channel: session, - action: { type: 'chat/turnStarted', session, turnId: 'server-turn-q', message: { text: 'will be consumed', origin: { kind: MessageKind.User } }, queuedMessageId: 'q-1' } as ChatAction, + action: { type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', session, turnId: 'server-turn-q', message: { text: 'will be consumed', origin: { kind: MessageKind.User } }, queuedMessageId: 'q-1' } as ChatAction, serverSeq: 4, origin: undefined, }); await timeout(10); @@ -7399,7 +7418,7 @@ suite('AgentHostChatContribution', () => { const action1 = dispatch1.action as ITurnStartedAction; const session = dispatch1.channel.toString(); agentHostService.fireAction({ channel: dispatch1.channel.toString(), action: dispatch1.action, serverSeq: 1, origin: { clientId: agentHostService.clientId, clientSeq: dispatch1.clientSeq } }); - agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); + agentHostService.fireAction({ channel: session, action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId: action1.turnId } as ChatAction, serverSeq: 2, origin: undefined }); await turn1Promise; // Set a steering message on the protocol state. @@ -7447,7 +7466,7 @@ suite('AgentHostChatContribution', () => { })); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; // The active-client claim is now threaded through createSession @@ -7478,7 +7497,7 @@ suite('AgentHostChatContribution', () => { // Create a session first const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; agentHostService.dispatchedActions.length = 0; @@ -7581,7 +7600,7 @@ suite('AgentHostChatContribution', () => { // Starting a turn claims active-client for this connection. const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const activeClientActions = agentHostService.dispatchedActions.filter(d => d.action.type === 'session/activeClientSet'); @@ -7635,7 +7654,7 @@ suite('AgentHostChatContribution', () => { // The fresh customization set is published on first turn. const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { sessionResource }); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const activeClientActions = agentHostService.dispatchedActions.filter(d => d.action.type === 'session/activeClientSet'); @@ -7674,7 +7693,7 @@ suite('AgentHostChatContribution', () => { confirmed: ToolCallConfirmationReason.NotNeeded, contributor, } as ToolCallState; - const activeTurn = createActiveTurn('child-turn-1', { text: 'do work', origin: { kind: MessageKind.User } }); + const activeTurn = createActiveTurn('child-turn-1', { text: 'do work', origin: { kind: MessageKind.User } }, '2025-01-01T00:00:00.000Z'); activeTurn.responseParts.push({ kind: ResponsePartKind.ToolCall, toolCall: innerTool }); return { ...createSessionState(summary), @@ -7734,14 +7753,14 @@ suite('AgentHostChatContribution', () => { agentHostService.fireAction({ channel: childSessionUri, - action: { type: 'chat/turnComplete', turnId: 'child-turn-1' } as ChatAction, + action: { type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', turnId: 'child-turn-1' } as ChatAction, serverSeq: 1001, origin: undefined, }); await timeout(50); assert.strictEqual((parent!.toolSpecificData as IChatSubagentToolInvocationData).isActive, false); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -7784,7 +7803,7 @@ suite('AgentHostChatContribution', () => { agentHostService.fireAction({ channel: childSessionUri, action, serverSeq: 1000, origin: undefined }); }; fireChild({ - type: 'chat/turnStarted', + type: 'chat/turnStarted', startedAt: '2025-01-01T00:00:00.000Z', turnId: childTurnId, message: { text: '', origin: { kind: MessageKind.User } }, } as ChatAction); @@ -7800,7 +7819,7 @@ suite('AgentHostChatContribution', () => { await timeout(50); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const allParts = collected.flat(); @@ -7857,7 +7876,7 @@ suite('AgentHostChatContribution', () => { await timeout(50); assert.strictEqual(capturedSubagentInvocationId, parentToolCallId); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; })); @@ -7903,7 +7922,7 @@ suite('AgentHostChatContribution', () => { await timeout(50); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); await turnPromise; const allParts = collected.flat(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 2de82ed1813..f6e1017632c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -590,6 +590,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -674,6 +675,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -747,6 +749,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -818,6 +821,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -866,6 +870,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'run the task', origin: { kind: MessageKind.User } }, } as ChatAction); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -925,6 +930,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { type: ActionType.ChatTurnStarted, turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'do work', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -956,6 +962,7 @@ suite('AgentHostClientTools', () => { connection.applySessionAction(URI.parse(subagentChat), { type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', + startedAt: '2025-01-01T00:00:00.000Z', message: { text: '', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(subagentChat), { @@ -1020,7 +1027,7 @@ suite('AgentHostClientTools', () => { // Default turn spawns the level-1 subagent. connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { - type: ActionType.ChatTurnStarted, turnId: 'turn-1', + type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'do work', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -1038,7 +1045,7 @@ suite('AgentHostClientTools', () => { // Level-1 subagent spawns the level-2 subagent. connection.applySessionAction(URI.parse(subagentChat1), { - type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: '', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(subagentChat1), { @@ -1056,7 +1063,7 @@ suite('AgentHostClientTools', () => { // Level-2 subagent runs a client-provided tool. connection.applySessionAction(URI.parse(subagentChat2), { - type: ActionType.ChatTurnStarted, turnId: 'sub-turn-2', + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-2', startedAt: '2025-01-01T00:00:00.000Z', message: { text: '', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(subagentChat2), { @@ -1110,7 +1117,7 @@ suite('AgentHostClientTools', () => { // Default turn spawns the level-1 subagent (no content block). connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { - type: ActionType.ChatTurnStarted, turnId: 'turn-1', + type: ActionType.ChatTurnStarted, turnId: 'turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: 'do work', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { @@ -1124,7 +1131,7 @@ suite('AgentHostClientTools', () => { // Level-1 subagent spawns the level-2 subagent (no content block). connection.applySessionAction(URI.parse(subagentChat1), { - type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', startedAt: '2025-01-01T00:00:00.000Z', message: { text: '', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(subagentChat1), { @@ -1138,7 +1145,7 @@ suite('AgentHostClientTools', () => { // Level-2 subagent runs a client-provided tool. connection.applySessionAction(URI.parse(subagentChat2), { - type: ActionType.ChatTurnStarted, turnId: 'sub-turn-2', + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-2', startedAt: '2025-01-01T00:00:00.000Z', message: { text: '', origin: { kind: MessageKind.User } }, }); connection.applySessionAction(URI.parse(subagentChat2), { 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 04d120a7aa7..646b8a823c8 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 @@ -430,6 +430,8 @@ suite('stateToProgressAdapter', () => { test('request history includes restored model id', () => { const turn = createTurn({ message: message('Use restored model'), + startedAt: '2025-07-08T22:05:21.000Z', + duration: 2_500, }); const lookup = makeLookup('agent-host-copilot:', {}, 'gpt-5'); @@ -441,8 +443,23 @@ suite('stateToProgressAdapter', () => { prompt: 'Use restored model', participant: 'participant-1', modelId: 'agent-host-copilot:gpt-5', + timestamp: 1_752_012_321_000, variableData: undefined, }); + assert.deepStrictEqual(history[1].type === 'response' ? { + elapsedMs: history[1].elapsedMs, + completedAt: history[1].completedAt, + } : undefined, { + elapsedMs: 2_500, + completedAt: 1_752_012_323_500, + }); + }); + + test('request history omits invalid restored timestamp', () => { + const turn = createTurn({ startedAt: 'invalid' }); + const history = turnsToHistory(URI.file('/'), [turn], 'participant-1'); + + assert.strictEqual(history[0].type === 'request' ? history[0].timestamp : undefined, undefined); }); test('terminal tool call in history has correct terminal data', () => { @@ -1373,6 +1390,7 @@ suite('stateToProgressAdapter', () => { function createActiveTurnState(responseParts?: ActiveTurn['responseParts']): ActiveTurn { return { id: 'turn-active', + startedAt: '2025-01-01T00:00:00.000Z', message: message('Do things'), responseParts: responseParts ?? [], usage: undefined, diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index f81845a0cd2..cbaf305fa8b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -6,8 +6,9 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { buildPlanReviewProgressContent, getWorkingProgressRelevantParts, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, getWorkingProgressRelevantParts, renderChatRequestTimestamp, renderChatResponseDetails, shouldCreateGroupedThinkingPart, shouldHideChatUserIdentity, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange, shouldStartNewCollapsedThinkingGroup } from '../../../browser/widget/chatListRenderer.js'; import { IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { formatChatRequestTimestamp, formatChatResponseDetails, formatElapsedTime } from '../../../common/chatProgressFormatting.js'; import { CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../../common/constants.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; @@ -109,6 +110,134 @@ suite('ChatListRenderer', () => { }); }); + suite('formatChatResponseDetails', () => { + test('formats completion metadata for the footer', () => { + assert.deepStrictEqual([ + formatChatResponseDetails('GPT-5.6 Sol \u2022 1.5 credits', '4:56 PM'), + formatChatResponseDetails('GPT-5.6 Sol', undefined), + formatChatResponseDetails(undefined, '4:56 PM'), + formatElapsedTime(83_000), + ], [ + '4:56 PM \u2022 GPT-5.6 Sol \u2022 1.5 credits', + 'GPT-5.6 Sol', + '4:56 PM', + '1m 23s', + ]); + }); + + test('renders completion time with elapsed-time alternate only in verbose mode', () => { + const container = document.createElement('div'); + container.className = 'chat-footer-details'; + const completedAt = Date.now() - 60 * 60 * 1000; + + renderChatResponseDetails(container, 'Claude Opus 4.8', completedAt, 24_000, false); + const compact = { + text: container.textContent, + timing: container.querySelector('.chat-response-timing'), + tabIndex: container.tabIndex, + }; + + renderChatResponseDetails(container, 'Claude Opus 4.8', completedAt, 24_000, true); + assert.deepStrictEqual({ + compact, + completionDateTime: container.querySelector('time')?.dateTime, + hasAlternate: container.querySelector('.chat-response-timing')?.classList.contains('has-alternate'), + duration: container.querySelector('.chat-response-alternate')?.textContent, + details: container.querySelector('.chat-response-model-details')?.textContent, + separatorHidden: container.querySelector('.chat-response-details-separator')?.getAttribute('aria-hidden'), + ariaIncludesElapsed: container.ariaLabel?.includes('24s') ?? false, + tabIndex: container.tabIndex, + }, { + compact: { + text: 'Claude Opus 4.8', + timing: null, + tabIndex: 0, + }, + completionDateTime: new Date(completedAt).toISOString(), + hasAlternate: true, + duration: '24s', + details: 'Claude Opus 4.8', + separatorHidden: 'true', + ariaIncludesElapsed: true, + tabIndex: 0, + }); + + renderChatResponseDetails(container, undefined, undefined, 24_000, true); + assert.deepStrictEqual({ + text: container.textContent, + timing: container.querySelector('.chat-response-timing'), + hidden: container.classList.contains('hidden'), + tabIndex: container.tabIndex, + }, { + text: '', + timing: null, + hidden: true, + tabIndex: -1, + }); + + const oldCompletion = Date.now() - 25 * 60 * 60 * 1000; + renderChatResponseDetails(container, undefined, oldCompletion, 24_000, true); + assert.deepStrictEqual({ + compact: container.querySelector('.chat-response-completed-at')?.textContent, + alternateEndsWithElapsed: container.querySelector('.chat-response-alternate')?.textContent?.endsWith(' \u2022 24s'), + hasAlternate: container.querySelector('.chat-response-timing')?.classList.contains('has-alternate'), + }, { + compact: '1d', + alternateEndsWithElapsed: true, + hasAlternate: true, + }); + }); + }); + + suite('formatChatRequestTimestamp', () => { + test('formats valid persisted timestamps and rejects legacy placeholders', () => { + const timestamp = Date.UTC(2026, 6, 8, 23, 18, 41); + const formatted = formatChatRequestTimestamp(timestamp); + assert.deepStrictEqual({ + hasText: !!formatted?.text, + hasFullText: !!formatted?.fullText, + dateTime: formatted?.dateTime, + invalid: formatChatRequestTimestamp(-1), + }, { + hasText: true, + hasFullText: true, + dateTime: '2026-07-08T23:18:41.000Z', + invalid: undefined, + }); + }); + + test('uses relative days after 24 hours', () => { + assert.deepStrictEqual([ + formatChatRequestTimestamp(Date.now() - 25 * 60 * 60 * 1000)?.text, + formatChatRequestTimestamp(Date.now() - 49 * 60 * 60 * 1000)?.text, + ], [ + '1d', + '2d', + ]); + }); + + test('renders compact days with an animated full date alternate', () => { + const container = document.createElement('div'); + const timestamp = Date.now() - 25 * 60 * 60 * 1000; + + const rendered = renderChatRequestTimestamp(container, timestamp); + + assert.deepStrictEqual({ + compact: container.querySelector('.chat-request-relative')?.textContent, + fullDate: container.querySelector('.chat-request-full-date')?.textContent, + hasAlternate: container.querySelector('.chat-request-timing')?.classList.contains('has-alternate'), + focusable: rendered?.element.tabIndex, + managedHoverText: rendered?.hoverText, + }, { + compact: '1d', + fullDate: formatChatRequestTimestamp(timestamp)?.fullText, + hasAlternate: true, + focusable: 0, + managedHoverText: undefined, + }); + }); + }); + suite('buildPlanReviewProgressContent', () => { test('keeps plan summary and full plan link after approval', () => { const content = buildPlanReviewProgressContent({ 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 90bf8553c8c..a0609be7240 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 @@ -6,7 +6,7 @@ import assert from 'assert'; import { DeferredPromise, timeout } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../../base/common/event.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'; @@ -2076,7 +2076,7 @@ suite('ChatService', () => { readonly progressObs?: ISettableObservable; readonly isCompleteObs?: ISettableObservable; readonly interruptActiveResponseCallback?: () => Promise; - readonly onDidStartServerRequest?: Event<{ prompt: string; variableData?: IChatRequestVariableData; isSystemInitiated?: boolean; systemInitiatedLabel?: string }>; + readonly onDidStartServerRequest?: Event<{ prompt: string; variableData?: IChatRequestVariableData; timestamp?: number; isSystemInitiated?: boolean; systemInitiatedLabel?: string }>; readonly history?: readonly IChatSessionHistoryItem[]; } @@ -2110,6 +2110,102 @@ suite('ChatService', () => { return `${Date.now()}-${idCounter++}`; } + test('restores request timestamps from remote session history', async () => { + const timestamp = 1_752_012_321_000; + const completedAt = timestamp + 2_500; + const { resource } = setupRemoteProvider({ + history: [ + { type: 'request', prompt: 'hello', participant: remoteScheme, timestamp }, + { type: 'response', parts: [], participant: remoteScheme, elapsedMs: 2_500, completedAt }, + ], + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + assert.deepStrictEqual({ + timestamp: ref.object.getRequests()[0].timestamp, + requestTimestamp: ref.object.getRequests()[0].requestTimestamp, + elapsedMs: ref.object.getRequests()[0].response?.elapsedMs, + completedAt: ref.object.getRequests()[0].response?.completedAt, + completionTimestamp: ref.object.getRequests()[0].response?.completionTimestamp, + }, { + timestamp, + requestTimestamp: timestamp, + elapsedMs: 2_500, + completedAt, + completionTimestamp: completedAt, + }); + }); + + test('keeps display time unknown when remote session history predates timestamps', async () => { + const before = Date.now(); + const { resource } = setupRemoteProvider({ + history: [{ type: 'request', prompt: 'hello', participant: remoteScheme }], + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const request = ref.object.getRequests()[0]; + assert.deepStrictEqual({ + hasCurrentRecencyFallback: request.timestamp >= before && request.timestamp <= Date.now(), + requestTimestamp: request.requestTimestamp, + completionTimestamp: request.response?.completionTimestamp, + }, { + hasCurrentRecencyFallback: true, + requestTimestamp: undefined, + completionTimestamp: undefined, + }); + }); + + test('normalizes legacy remote timestamp sentinels to unknown', async () => { + const { resource } = setupRemoteProvider({ + history: [{ type: 'request', prompt: 'hello', participant: remoteScheme, timestamp: -1 }], + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + assert.deepStrictEqual({ + requestTimestamp: ref.object.getRequests()[0].requestTimestamp, + serializedTimestamp: ref.object.toJSON().requests[0].timestamp, + }, { + requestTimestamp: undefined, + serializedTimestamp: undefined, + }); + }); + + test('uses the Agent Host timestamp for live server-initiated requests', async () => { + const onDidStartServerRequest = testDisposables.add(new Emitter<{ prompt: string; timestamp?: number }>()); + const timestamp = 1_752_012_321_000; + const { resource } = setupRemoteProvider({ + progressObs: observableValue('progress', []), + 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); + onDidStartServerRequest.fire({ prompt: 'server request', timestamp }); + + assert.deepStrictEqual({ + message: ref.object.lastRequest?.message.text, + timestamp: ref.object.lastRequest?.timestamp, + }, { + message: 'server request', + timestamp, + }); + }); + 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); @@ -2479,7 +2575,7 @@ function toSnapshotExportData(model: IChatModel) { ...exp, requests: exp.requests.map(r => { // Destructure properties after `vote` so we can insert `voteDownReason` in the correct position for snapshot compat - const { slashCommand, usedContext, contentReferences, codeCitations, timeSpentWaiting, isSystemInitiated: _isSystemInitiated, systemInitiatedLabel: _systemInitiatedLabel, elapsedMs: _elapsedMs, completionTokens: _completionTokens, promptTokens: _promptTokens, outputBuffer: _outputBuffer, promptTokenDetails: _promptTokenDetails, copilotCredits: _copilotCredits, ...rest } = r; + const { slashCommand, usedContext, contentReferences, codeCitations, timeSpentWaiting, isSystemInitiated: _isSystemInitiated, systemInitiatedLabel: _systemInitiatedLabel, responseTimestamp: _responseTimestamp, elapsedMs: _elapsedMs, completionTokens: _completionTokens, promptTokens: _promptTokens, outputBuffer: _outputBuffer, promptTokenDetails: _promptTokenDetails, copilotCredits: _copilotCredits, ...rest } = r; return { ...rest, modelState: { 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 6c3f6ad5960..ce2914e8023 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 @@ -92,6 +92,39 @@ suite('ChatModel', () => { assert.strictEqual(model.customTitle, 'My Chat'); }); + test('legacy requests without timestamps keep display time unknown', () => { + const creationDate = 1_752_012_321_000; + const serializableData: ISerializableChatData3 = { + version: 3, + sessionId: 'legacy-session', + creationDate, + customTitle: undefined, + initialLocation: ChatAgentLocation.Chat, + requests: [{ + requestId: 'req1', + message: { text: 'hello', parts: [] }, + variableData: { variables: [] }, + response: undefined, + }], + responderUsername: 'bot', + }; + const model = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: serializableData, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + + assert.deepStrictEqual({ + recencyTimestamp: model.getRequests()[0].timestamp, + requestTimestamp: model.getRequests()[0].requestTimestamp, + serializedTimestamp: model.toJSON().requests[0].timestamp, + }, { + recencyTimestamp: creationDate, + requestTimestamp: undefined, + serializedTimestamp: undefined, + }); + }); + test('initialization with invalid data', async () => { const invalidData = { // Missing required fields @@ -178,6 +211,65 @@ suite('ChatModel', () => { }); }); + test('response details, elapsed time, and tokens roundtrip through serialization', () => { + const completedAt = 1_752_012_405_000; + const serializableData: ISerializableChatData3 = { + version: 3, + sessionId: 'test-session', + creationDate: Date.now(), + customTitle: undefined, + initialLocation: ChatAgentLocation.Chat, + requests: [{ + requestId: 'req1', + message: { text: 'hello', parts: [] }, + variableData: { variables: [] }, + timestamp: 1_752_012_321_000, + response: [{ value: 'response', isTrusted: false }], + result: { details: 'GPT-5.6 Sol' }, + modelState: { value: ResponseModelState.Complete, completedAt }, + responseTimestamp: 1_752_012_322_000, + elapsedMs: 83_000, + completionTokens: 1_234, + }], + responderUsername: 'bot', + }; + const model = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: serializableData, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + + const response = model.getRequests()[0].response; + const serializedResponse = model.toJSON().requests[0]; + assert.deepStrictEqual({ + details: response?.result?.details, + requestTimestamp: model.getRequests()[0].timestamp, + visibleRequestTimestamp: model.getRequests()[0].requestTimestamp, + responseTimestamp: response?.timestamp, + completionTimestamp: response?.completionTimestamp, + elapsedMs: response?.elapsedMs, + completionTokens: response?.completionTokenCount, + serializedDetails: serializedResponse.result?.details, + serializedRequestTimestamp: serializedResponse.timestamp, + serializedResponseTimestamp: serializedResponse.responseTimestamp, + serializedElapsedMs: serializedResponse.elapsedMs, + serializedCompletionTokens: serializedResponse.completionTokens, + }, { + details: 'GPT-5.6 Sol', + requestTimestamp: 1_752_012_321_000, + visibleRequestTimestamp: 1_752_012_321_000, + responseTimestamp: 1_752_012_322_000, + completionTimestamp: completedAt, + elapsedMs: 83_000, + completionTokens: 1_234, + serializedDetails: 'GPT-5.6 Sol', + serializedRequestTimestamp: 1_752_012_321_000, + serializedResponseTimestamp: 1_752_012_322_000, + serializedElapsedMs: 83_000, + serializedCompletionTokens: 1_234, + }); + }); + test('persists reasoning duration when response progress moves on', () => { const clock = sinon.useFakeTimers({ now: 1000 }); try { @@ -1341,8 +1433,15 @@ suite('ChatResponseModel', () => { assert.strictEqual(response.isIncomplete.get(), true); model.cancelRequest(request); - assert.strictEqual(response.isIncomplete.get(), false); - assert.strictEqual(response.state, ResponseModelState.Cancelled); + assert.deepStrictEqual({ + isIncomplete: response.isIncomplete.get(), + state: response.state, + hasElapsedTime: typeof response.elapsedMs === 'number', + }, { + isIncomplete: false, + state: ResponseModelState.Cancelled, + hasElapsedTime: true, + }); }); test('cancellation transitions streaming tool invocations to Cancelled (issue #288701)', async () => {