mirror of
https://github.com/microsoft/vscode.git
synced 2026-08-15 18:37:00 +01:00
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:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent
d4bfecdaa8
commit
de6ea36c9e
@@ -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());
|
||||
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+44
@@ -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
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
+175
-156
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -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), {
|
||||
|
||||
+18
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user