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>
This commit is contained in:
Justin Chen
2026-07-14 17:18:52 +00:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent d4bfecdaa8
commit de6ea36c9e
56 changed files with 1525 additions and 305 deletions
@@ -1 +1 @@
870dbfda
3377734
@@ -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;
/**
@@ -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 };
@@ -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;
/**
@@ -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,
@@ -672,7 +672,7 @@ export class AgentService extends Disposable implements IAgentService {
*/
private async _startSessionPrompt(session: URI, chat: URI, prompt: string): Promise<void> {
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);
}
@@ -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<void> {
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');
@@ -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<SDKMessage, { type: 'result' }>,
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}`;
}
@@ -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<void>;
readonly steeringPendingId?: string;
}
@@ -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,
@@ -58,7 +58,7 @@ export class ClaudeSdkMessageRouter extends Disposable {
this._clientToolOwner = clientToolOwner;
}
async handle(message: SDKMessage, turnId: string | undefined): Promise<void> {
async handle(message: SDKMessage, turnId: string | undefined, turnDuration?: number): Promise<void> {
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);
@@ -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<void>(),
};
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<void>(),
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()),
},
});
}
@@ -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<string, string>(),
codexTurnIdByHostTurnId: new Map<string, string>(),
@@ -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<string, string>(),
codexTurnIdByHostTurnId: new Map<string, string>(),
@@ -2262,6 +2271,7 @@ export class CodexAgent extends Disposable implements IAgent {
firstTurnSent: true,
model,
currentTurnId: undefined,
turnStopWatch: undefined,
currentAppTurnId: undefined,
hostTurnIdByAppTurnId: new Map<string, string>(),
codexTurnIdByHostTurnId: new Map<string, string>(),
@@ -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<void> {
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
@@ -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 }];
}
/**
@@ -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),
@@ -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<void>, request: ILocalChatCommandRequest): Promise<void> {
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);
}
@@ -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,
));
@@ -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 } },
});
@@ -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();
@@ -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);
});
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
});
@@ -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,
);
@@ -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<void> {
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
@@ -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');
@@ -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<void>(),
steeringPendingId: opts?.steeringPendingId,
};
@@ -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'),
@@ -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;
}
@@ -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;
}
@@ -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', () => {
@@ -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. */
@@ -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] },
},
});
@@ -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 },
@@ -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' } },
},
});
@@ -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 } },
},
});
@@ -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, {
@@ -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());
@@ -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,
@@ -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,
@@ -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<Set<string>>();
/** Turn IDs dispatched by this client, used to distinguish server-originated turns. */
private readonly _clientDispatchedTurnIds = new Set<string>();
private readonly _turnStopWatches = new Map<string, StopWatch>();
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
@@ -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;
}
@@ -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',
@@ -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<Ch
// Insert the details container into the toolbar's internal element structure
const footerDetailsContainer = dom.append(footerToolbar.getElement(), $('.chat-footer-details'));
footerDetailsContainer.tabIndex = 0;
let responseTimingBounds: DOMRect | undefined;
templateDisposables.add(dom.addDisposableListener(footerDetailsContainer, dom.EventType.MOUSE_OVER, e => {
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<Ch
}
templateData.footerToolbar.context = element;
// Render result details in footer if available
if (isResponseVM(element) && element.result?.details) {
templateData.footerDetailsContainer.textContent = element.result.details;
templateData.footerDetailsContainer.classList.remove('hidden');
} else {
templateData.footerDetailsContainer.classList.add('hidden');
}
const updateResponseDetails = () => {
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<boolean>(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<Ch
// so child content parts can use CSS descendant selectors instead of each subscribing individually.
const updateContainerCheckmarks = () => templateData.rowContainer.classList.toggle('show-checkmarks', !!this.configService.getValue<boolean>(AccessibilityWorkbenchSettingId.ShowChatCheckmarks));
updateContainerCheckmarks();
const updateVerboseDetails = () => templateData.rowContainer.classList.toggle('show-verbose-details', !!this.configService.getValue<boolean>(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<Ch
}
const reqData = this.templateDataByRequestId.get(requestId);
const resData = this.responseTemplateDataByRequestId.get(requestId);
reqData?.rowContainer.classList.toggle('group-hovered', hovered);
reqData?.checkpointContainer.classList.toggle('group-hovered', hovered);
resData?.rowContainer.classList.toggle('group-hovered', hovered);
};
@@ -1704,6 +1808,43 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch
}
templateData.elementDisposables.add(newPart);
}
if (!element.pendingKind && !element.confirmation && this.rendererOptions.renderStyle !== 'minimal' && templateData.value.childElementCount > 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) {
@@ -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,
@@ -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 ');
}
@@ -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<ChatPendingRequestChangeEvent, ChatPendingRequestChangeClassification>(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();
}
}
@@ -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<IChatSessionHistoryItem, {
export interface IChatSessionServerRequest {
readonly prompt: string;
readonly variableData?: IChatRequestVariableData;
readonly timestamp?: number;
readonly isSystemInitiated?: boolean;
readonly systemInitiatedLabel?: string;
readonly isTerminalRequest?: boolean;
@@ -69,6 +69,7 @@ export enum ChatConfiguration {
ChatViewSessionsOrientation = 'chat.viewSessions.orientation',
ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled',
ChatContextUsageEnabled = 'chat.contextUsage.enabled',
Verbose = 'chat.verbose',
ChatPersistentProgressEnabled = 'chat.persistentProgress.enabled',
ProgressBorder = 'chat.progressBorder.enabled',
SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled',
@@ -116,6 +116,7 @@ export namespace IChatRequestVariableData {
export interface IChatRequestModel {
readonly id: string;
readonly timestamp: number;
readonly requestTimestamp: number | undefined;
readonly version: number;
readonly modeInfo?: IChatRequestModeInfo;
readonly session: IChatModel;
@@ -280,6 +281,8 @@ export interface IChatResponseModel {
readonly timestamp: number;
/** Milliseconds timestamp when this chat response was completed or cancelled. */
readonly completedAt?: number;
/** Known completion timestamp for display. Undefined for legacy responses whose completion time was synthesized during restore. */
readonly completionTimestamp?: number;
/** The state of this response */
readonly state: ResponseModelState;
/** @internal */
@@ -321,6 +324,7 @@ export interface IChatResponseModel {
addUndoStop(undoStop: IChatUndoStop): void;
setVote(vote: ChatAgentVoteDirection): void;
setUsage(usage: IChatUsage): void;
setElapsedMs(elapsedMs: number): void;
setEditApplied(edit: IChatTextEditGroup, editCount: number): boolean;
resolveInlineReference(resolveId: string, resolvedReference: IChatContentInlineReference): boolean;
updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatTask | IChatExternalToolInvocationUpdate, quiet?: boolean): void;
@@ -364,7 +368,8 @@ export interface IChatRequestModelParameters {
session: ChatModel;
message: IParsedChatRequest;
variableData: IChatRequestVariableData;
timestamp: number;
timestamp?: number;
fallbackTimestamp?: number;
attempt?: number;
modeInfo?: IChatRequestModeInfo;
confirmation?: string;
@@ -387,6 +392,7 @@ export class ChatRequestModel implements IChatRequestModel {
public response: ChatResponseModel | undefined;
public shouldBeRemovedOnSend: IChatRequestDisablement | undefined;
public readonly timestamp: number;
public readonly requestTimestamp: number | undefined;
public readonly message: IParsedChatRequest;
public readonly isCompleteAddedRequest: boolean;
public readonly modelId?: string;
@@ -456,7 +462,8 @@ export class ChatRequestModel implements IChatRequestModel {
this._session = params.session;
this.message = params.message;
this._variableData = params.variableData;
this.timestamp = params.timestamp;
this.requestTimestamp = params.timestamp;
this.timestamp = params.timestamp ?? params.fallbackTimestamp ?? Date.now();
this._attempt = params.attempt ?? 0;
this.modeInfo = params.modeInfo;
this._confirmation = params.confirmation;
@@ -1109,6 +1116,7 @@ export interface IChatResponseModelParameters {
shouldBeBlocked?: boolean;
restoredId?: string;
modelState?: ResponseModelStateT;
completionTimestamp?: number | null;
timeSpentWaiting?: number;
elapsedMs?: number;
/**
@@ -1140,6 +1148,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel
public readonly isCompleteAddedRequest: boolean;
private readonly _shouldBeBlocked = observableValue<boolean>(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<ISerializableChatResponseData, 'timestamp'> {
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<ISerializableChatResponseData>;
} satisfies WithDefinedProps<Omit<ISerializableChatResponseData, 'timestamp'>>;
}
}
@@ -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,
@@ -128,7 +128,7 @@ const chatVariableSchema = Adapt.object<IChatRequestVariableData, IChatRequestVa
const requestSchema = Adapt.object<IChatRequestModel, ISerializableChatRequestData>({
// 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<IChatRequestModel, ISerializableChatRequestDa
response: Adapt.t(m => m.response?.entireResponse.value.filter((p): p is Exclude<IChatProgressResponseContent, IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow> => 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 })),
@@ -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;
}
@@ -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), {
@@ -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,
@@ -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({
@@ -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<IChatProgress[]>;
readonly isCompleteObs?: ISettableObservable<boolean>;
readonly interruptActiveResponseCallback?: () => Promise<boolean>;
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<IChatProgress[]>('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<IChatProgress[]>('progress', []);
const isCompleteObs = observableValue<boolean>('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: {
@@ -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 () => {