diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index a8b33f048ab..8714a9b5e20 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -20,9 +20,11 @@ import type { IPermissionRequest, IResponsePart, ISessionSummary, - IToolCallState, + IToolCallResult, IUsageInfo, IUserMessage, + StringOrMarkdown, + ToolCallConfirmationReason, } from './sessionState.js'; // ---- Action envelope -------------------------------------------------------- @@ -113,27 +115,79 @@ export interface IResponsePartAction extends ISessionActionBase { readonly part: IResponsePart; } -// -- Tool calls (server-only) -- +// -- Tool calls -- -export interface IToolStartAction extends ISessionActionBase { - readonly type: 'session/toolStart'; - readonly turnId: string; - readonly toolCall: IToolCallState; -} - -export interface IToolCompleteAction extends ISessionActionBase { - readonly type: 'session/toolComplete'; +/** Base interface for all tool-call-scoped actions. */ +interface IToolCallActionBase extends ISessionActionBase { readonly turnId: string; readonly toolCallId: string; - readonly result: IToolCompleteResult; } -/** The data delivered with a tool completion event. */ -export interface IToolCompleteResult { - readonly success: boolean; - readonly pastTenseMessage: string; - readonly toolOutput?: string; - readonly error?: { readonly message: string; readonly code?: string }; +/** Server-only. A tool call begins — parameters are streaming from the LM. */ +export interface IToolCallStartAction extends IToolCallActionBase { + readonly type: 'session/toolCallStart'; + readonly toolName: string; + readonly displayName: string; + /** Hint for the renderer about how to display this tool. */ + readonly toolKind?: 'terminal'; + /** Language identifier for syntax highlighting. */ + readonly language?: string; +} + +/** Server-only. Streaming partial parameters for a tool call. */ +export interface IToolCallDeltaAction extends IToolCallActionBase { + readonly type: 'session/toolCallDelta'; + readonly content: string; + readonly invocationMessage?: StringOrMarkdown; +} + +/** + * Server-only. Tool call parameters are complete. Transitions to + * `pending-confirmation` or directly to `running` if `confirmed` is set. + */ +export interface IToolCallReadyAction extends IToolCallActionBase { + readonly type: 'session/toolCallReady'; + readonly invocationMessage: StringOrMarkdown; + readonly toolInput?: string; + /** If set, the tool was auto-confirmed and transitions directly to `running`. */ + readonly confirmed?: ToolCallConfirmationReason; +} + +/** Client-dispatchable. Approves a pending tool call → `running`. */ +export interface IToolCallApprovedAction extends IToolCallActionBase { + readonly type: 'session/toolCallConfirmed'; + readonly approved: true; + readonly confirmed: ToolCallConfirmationReason; +} + +/** Client-dispatchable. Denies a pending tool call → `cancelled`. */ +export interface IToolCallDeniedAction extends IToolCallActionBase { + readonly type: 'session/toolCallConfirmed'; + readonly approved: false; + readonly reason: 'denied' | 'skipped'; + readonly userSuggestion?: IUserMessage; + readonly reasonMessage?: StringOrMarkdown; +} + +/** Client-dispatchable. Confirms or denies a pending tool call. */ +export type IToolCallConfirmedAction = + | IToolCallApprovedAction + | IToolCallDeniedAction; + +/** + * Server-only. Tool execution finished. Transitions to `completed` or + * `pending-result-confirmation` if `requiresResultConfirmation` is true. + */ +export interface IToolCallCompleteAction extends IToolCallActionBase { + readonly type: 'session/toolCallComplete'; + readonly result: IToolCallResult; + readonly requiresResultConfirmation?: boolean; +} + +/** Client-dispatchable. Approves or denies a tool's result. */ +export interface IToolCallResultConfirmedAction extends IToolCallActionBase { + readonly type: 'session/toolCallResultConfirmed'; + readonly approved: boolean; } // -- Permissions -- @@ -208,8 +262,12 @@ export type ISessionAction = | ITurnStartedAction | IDeltaAction | IResponsePartAction - | IToolStartAction - | IToolCompleteAction + | IToolCallStartAction + | IToolCallDeltaAction + | IToolCallReadyAction + | IToolCallConfirmedAction + | IToolCallCompleteAction + | IToolCallResultConfirmedAction | IPermissionRequestAction | IPermissionResolvedAction | ITurnCompleteAction diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index 400f51a2d21..6dbe335f35c 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -20,11 +20,11 @@ import { type IErrorInfo, type IRootState, type ISessionState, + type IToolCallState, type ITurn, createActiveTurn, SessionLifecycle, SessionStatus, - ToolCallStatus, TurnState, } from './sessionState.js'; @@ -95,26 +95,10 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS }, }; } - case 'session/toolStart': { + case 'session/toolCallStart': { if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; } - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCall.toolCallId]: action.toolCall }, - }, - }; - } - case 'session/toolComplete': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const toolCall = state.activeTurn.toolCalls[action.toolCallId]; - if (!toolCall) { - return state; - } return { ...state, activeTurn: { @@ -122,16 +106,204 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: { - ...toolCall, - status: action.result.success ? ToolCallStatus.Completed : ToolCallStatus.Failed, - pastTenseMessage: action.result.pastTenseMessage, - toolOutput: action.result.toolOutput, - error: action.result.error, + status: 'streaming', + toolCallId: action.toolCallId, + toolName: action.toolName, + displayName: action.displayName, + toolKind: action.toolKind, + language: action.language, }, }, }, }; } + case 'session/toolCallDelta': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const tc = state.activeTurn.toolCalls[action.toolCallId]; + if (!tc || tc.status !== 'streaming') { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + toolCalls: { + ...state.activeTurn.toolCalls, + [action.toolCallId]: { + ...tc, + partialInput: (tc.partialInput ?? '') + action.content, + invocationMessage: action.invocationMessage ?? tc.invocationMessage, + }, + }, + }, + }; + } + case 'session/toolCallReady': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const tc = state.activeTurn.toolCalls[action.toolCallId]; + if (!tc) { + return state; + } + const updated: IToolCallState = action.confirmed + ? { + status: 'running', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + confirmed: action.confirmed, + } + : { + status: 'pending-confirmation', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + }; + return { + ...state, + activeTurn: { + ...state.activeTurn, + toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, + }, + }; + } + case 'session/toolCallConfirmed': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const tc = state.activeTurn.toolCalls[action.toolCallId]; + if (!tc || tc.status !== 'pending-confirmation') { + return state; + } + const updated: IToolCallState = action.approved + ? { + status: 'running', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: action.confirmed, + } + : { + status: 'cancelled', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: action.reason, + reasonMessage: action.reasonMessage, + userSuggestion: action.userSuggestion, + }; + return { + ...state, + activeTurn: { + ...state.activeTurn, + toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, + }, + }; + } + case 'session/toolCallComplete': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const tc = state.activeTurn.toolCalls[action.toolCallId]; + if (!tc || (tc.status !== 'running' && tc.status !== 'pending-confirmation')) { + return state; + } + const confirmed = tc.status === 'running' ? tc.confirmed : 'not-needed'; + const updated: IToolCallState = action.requiresResultConfirmation + ? { + status: 'pending-result-confirmation', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + } + : { + status: 'completed', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + }; + return { + ...state, + activeTurn: { + ...state.activeTurn, + toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, + }, + }; + } + case 'session/toolCallResultConfirmed': { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const tc = state.activeTurn.toolCalls[action.toolCallId]; + if (!tc || tc.status !== 'pending-result-confirmation') { + return state; + } + const updated: IToolCallState = action.approved + ? { + status: 'completed', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: tc.confirmed, + success: tc.success, + pastTenseMessage: tc.pastTenseMessage, + toolOutput: tc.toolOutput, + error: tc.error, + } + : { + status: 'cancelled', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: 'result-denied', + }; + return { + ...state, + activeTurn: { + ...state.activeTurn, + toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, + }, + }; + } case 'session/permissionRequest': { if (!state.activeTurn || state.activeTurn.id !== action.turnId) { return state; @@ -140,8 +312,15 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS let toolCalls = state.activeTurn.toolCalls; if (action.request.toolCallId) { const toolCall = toolCalls[action.request.toolCallId]; - if (toolCall) { - toolCalls = { ...toolCalls, [action.request.toolCallId]: { ...toolCall, status: ToolCallStatus.PendingPermission } }; + if (toolCall && (toolCall.status === 'running' || toolCall.status === 'streaming')) { + toolCalls = { + ...toolCalls, + [action.request.toolCallId]: { + ...toolCall, + status: 'pending-confirmation', + invocationMessage: toolCall.invocationMessage ?? '', + }, + }; } } return { @@ -158,16 +337,31 @@ export function sessionReducer(state: ISessionState, action: ISessionAction): IS let toolCalls = state.activeTurn.toolCalls; if (resolved?.toolCallId) { const toolCall = toolCalls[resolved.toolCallId]; - if (toolCall && toolCall.status === ToolCallStatus.PendingPermission) { - toolCalls = { - ...toolCalls, - [resolved.toolCallId]: { - ...toolCall, - status: action.approved ? ToolCallStatus.Running : ToolCallStatus.Cancelled, - confirmed: action.approved ? 'user-action' : 'denied', - cancellationReason: action.approved ? undefined : 'denied', - }, - }; + if (toolCall && toolCall.status === 'pending-confirmation') { + const updated: IToolCallState = action.approved + ? { + status: 'running', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + displayName: toolCall.displayName, + toolKind: toolCall.toolKind, + language: toolCall.language, + invocationMessage: toolCall.invocationMessage, + toolInput: toolCall.toolInput, + confirmed: 'user-action', + } + : { + status: 'cancelled', + toolCallId: toolCall.toolCallId, + toolName: toolCall.toolName, + displayName: toolCall.displayName, + toolKind: toolCall.toolKind, + language: toolCall.language, + invocationMessage: toolCall.invocationMessage, + toolInput: toolCall.toolInput, + reason: 'denied', + }; + toolCalls = { ...toolCalls, [resolved.toolCallId]: updated }; } } return { @@ -236,19 +430,26 @@ function finalizeTurn(state: ISessionState, turnId: string, turnState: TurnState const completedToolCalls: ICompletedToolCall[] = []; for (const tc of Object.values(active.toolCalls)) { - completedToolCalls.push({ - toolCallId: tc.toolCallId, - toolName: tc.toolName, - displayName: tc.displayName, - invocationMessage: tc.invocationMessage, - success: tc.status === ToolCallStatus.Completed, - pastTenseMessage: tc.pastTenseMessage ?? tc.invocationMessage, - toolInput: tc.toolInput, - toolKind: tc.toolKind, - language: tc.language, - toolOutput: tc.toolOutput, - error: tc.error, - }); + if (tc.status === 'completed') { + completedToolCalls.push(tc); + } else if (tc.status === 'cancelled') { + completedToolCalls.push(tc); + } else { + // For tool calls that are not in a terminal state when the turn + // finishes (e.g. still streaming or running), force them into + // a cancelled state so they are persisted properly. + completedToolCalls.push({ + status: 'cancelled', + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolKind: tc.toolKind, + language: tc.language, + invocationMessage: tc.status === 'streaming' ? (tc.invocationMessage ?? '') : tc.invocationMessage, + toolInput: tc.status === 'streaming' ? undefined : tc.toolInput, + reason: 'skipped', + }); + } } const finalizedTurn: ITurn = { diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index c6872559708..d965ad30c12 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -117,7 +117,8 @@ export interface ITurn { /** The final assistant response text (captured from streamingText on turn completion). */ readonly responseText: string; readonly responseParts: readonly IResponsePart[]; - readonly toolCalls: readonly ICompletedToolCall[]; + /** Tool invocations in terminal states (completed or cancelled). */ + readonly toolCalls: readonly (IToolCallCompletedState | IToolCallCancelledState)[]; readonly usage: IUsageInfo | undefined; readonly state: TurnState; /** Error info if the turn ended with {@link TurnState.Error}. */ @@ -169,63 +170,118 @@ export interface IContentRef { export type IResponsePart = IMarkdownResponsePart | IContentRef; +// ---- String/Markdown helper ------------------------------------------------- + +/** + * A string that may optionally be rendered as Markdown. + * Mirrors the protocol's `StringOrMarkdown` type. + */ +export type StringOrMarkdown = string | { readonly markdown: string }; + // ---- Tool calls ------------------------------------------------------------- -export const enum ToolCallStatus { - /** Tool is actively executing. */ - Running = 'running', - /** Waiting for user to approve before execution. */ - PendingPermission = 'pending-permission', - /** Tool finished successfully. */ - Completed = 'completed', - /** Tool failed with an error. */ - Failed = 'failed', - /** Tool was denied or skipped by the user. */ - Cancelled = 'cancelled', +/** + * How a tool call was confirmed for execution. + */ +export type ToolCallConfirmationReason = 'not-needed' | 'user-action' | 'setting'; + +/** + * Metadata common to all tool call states. + */ +interface IToolCallBase { + readonly toolCallId: string; + /** Internal tool name (for debugging/logging). */ + readonly toolName: string; + /** Human-readable tool name. */ + readonly displayName: string; + /** Hint for the renderer about how to display this tool (e.g., 'terminal' for shell commands). */ + readonly toolKind?: 'terminal'; + /** Language identifier for syntax highlighting. Used with toolKind 'terminal'. */ + readonly language?: string; } /** - * Represents the full lifecycle state of a tool invocation within an active turn. - * Modeled after {@link IChatToolInvocation.State} to enable direct mapping to the chat UI. + * Properties available once tool call parameters are fully received. */ -export interface IToolCallState { - readonly toolCallId: string; - readonly toolName: string; - readonly displayName: string; - readonly invocationMessage: string; +interface IToolCallParameterFields { + /** Message describing what the tool will do. */ + readonly invocationMessage: StringOrMarkdown; + /** A representative input string for display (e.g., the shell command). */ readonly toolInput?: string; - readonly toolKind?: 'terminal'; - readonly language?: string; - readonly toolArguments?: string; - readonly status: ToolCallStatus; - /** Parsed tool parameters (from toolArguments). */ - readonly parameters?: unknown; - /** How the tool was confirmed before execution (set after PendingPermission → Running). */ - readonly confirmed?: 'not-needed' | 'user-action' | 'setting' | 'denied' | 'skipped'; - /** Set when status transitions to Completed or Failed. */ - readonly pastTenseMessage?: string; - /** Set when status transitions to Completed or Failed. */ - readonly toolOutput?: string; - /** Set when status transitions to Failed. */ - readonly error?: { readonly message: string; readonly code?: string }; - /** Why the tool was cancelled (set when status is Cancelled). */ - readonly cancellationReason?: 'denied' | 'skipped'; } -export interface ICompletedToolCall { - readonly toolCallId: string; - readonly toolName: string; - readonly displayName: string; - readonly invocationMessage: string; +/** + * Tool execution result details, available after execution completes. + */ +export interface IToolCallResult { readonly success: boolean; - readonly pastTenseMessage: string; - readonly toolInput?: string; - readonly toolKind?: 'terminal'; - readonly language?: string; + readonly pastTenseMessage: StringOrMarkdown; readonly toolOutput?: string; readonly error?: { readonly message: string; readonly code?: string }; } +/** LM is streaming the tool call parameters. */ +export interface IToolCallStreamingState extends IToolCallBase { + readonly status: 'streaming'; + /** Partial parameters accumulated so far. */ + readonly partialInput?: string; + /** Progress message shown while parameters are streaming. */ + readonly invocationMessage?: StringOrMarkdown; +} + +/** Parameters are complete, waiting for client to confirm execution. */ +export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolCallParameterFields { + readonly status: 'pending-confirmation'; +} + +/** Tool is actively executing. */ +export interface IToolCallRunningState extends IToolCallBase, IToolCallParameterFields { + readonly status: 'running'; + readonly confirmed: ToolCallConfirmationReason; +} + +/** Tool finished executing, waiting for client to approve the result. */ +export interface IToolCallPendingResultConfirmationState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { + readonly status: 'pending-result-confirmation'; + readonly confirmed: ToolCallConfirmationReason; +} + +/** Tool completed successfully or with an error. */ +export interface IToolCallCompletedState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { + readonly status: 'completed'; + readonly confirmed: ToolCallConfirmationReason; +} + +/** Tool call was cancelled (denied, skipped, or result-denied). */ +export interface IToolCallCancelledState extends IToolCallBase, IToolCallParameterFields { + readonly status: 'cancelled'; + readonly reason: 'denied' | 'skipped' | 'result-denied'; + readonly reasonMessage?: StringOrMarkdown; + readonly userSuggestion?: IUserMessage; +} + +/** + * Discriminated union of all tool call lifecycle states. + * Modeled after {@link IChatToolInvocation.State} to enable direct mapping to the chat UI. + */ +export type IToolCallState = + | IToolCallStreamingState + | IToolCallPendingConfirmationState + | IToolCallRunningState + | IToolCallPendingResultConfirmationState + | IToolCallCompletedState + | IToolCallCancelledState; + +/** + * Derived status type for the tool call lifecycle. + */ +export type ToolCallStatus = IToolCallState['status']; + +/** + * A tool call in a terminal state, stored in completed turns. + */ +export type ICompletedToolCall = IToolCallCompletedState | IToolCallCancelledState; + // ---- Permission requests ---------------------------------------------------- export interface IPermissionRequest { diff --git a/src/vs/platform/agentHost/common/state/versions/v1.ts b/src/vs/platform/agentHost/common/state/versions/v1.ts index 1df83cd7f22..c5f7a1b3550 100644 --- a/src/vs/platform/agentHost/common/state/versions/v1.ts +++ b/src/vs/platform/agentHost/common/state/versions/v1.ts @@ -69,7 +69,7 @@ export interface IV1_Turn { readonly userMessage: IV1_UserMessage; readonly responseText: string; readonly responseParts: readonly IV1_ResponsePart[]; - readonly toolCalls: readonly IV1_CompletedToolCall[]; + readonly toolCalls: readonly (IV1_ToolCallCompletedState | IV1_ToolCallCancelledState)[]; readonly usage: IV1_UsageInfo | undefined; readonly state: 'complete' | 'cancelled' | 'error'; readonly error?: IV1_ErrorInfo; @@ -100,38 +100,72 @@ export interface IV1_ContentRef { export type IV1_ResponsePart = IV1_MarkdownResponsePart | IV1_ContentRef; -export interface IV1_ToolCallState { +export type IV1_StringOrMarkdown = string | { readonly markdown: string }; + +export type IV1_ToolCallConfirmationReason = 'not-needed' | 'user-action' | 'setting'; + +interface IV1_ToolCallBase { readonly toolCallId: string; readonly toolName: string; readonly displayName: string; - readonly invocationMessage: string; - readonly toolInput?: string; readonly toolKind?: 'terminal'; readonly language?: string; - readonly toolArguments?: string; - readonly status: 'running' | 'pending-permission' | 'completed' | 'failed' | 'cancelled'; - readonly parameters?: unknown; - readonly confirmed?: 'not-needed' | 'user-action' | 'setting' | 'denied' | 'skipped'; - readonly pastTenseMessage?: string; - readonly toolOutput?: string; - readonly error?: { readonly message: string; readonly code?: string }; - readonly cancellationReason?: 'denied' | 'skipped'; } -export interface IV1_CompletedToolCall { - readonly toolCallId: string; - readonly toolName: string; - readonly displayName: string; - readonly invocationMessage: string; - readonly success: boolean; - readonly pastTenseMessage: string; +interface IV1_ToolCallParameterFields { + readonly invocationMessage: IV1_StringOrMarkdown; readonly toolInput?: string; - readonly toolKind?: 'terminal'; - readonly language?: string; +} + +export interface IV1_ToolCallResult { + readonly success: boolean; + readonly pastTenseMessage: IV1_StringOrMarkdown; readonly toolOutput?: string; readonly error?: { readonly message: string; readonly code?: string }; } +export interface IV1_ToolCallStreamingState extends IV1_ToolCallBase { + readonly status: 'streaming'; + readonly partialInput?: string; + readonly invocationMessage?: IV1_StringOrMarkdown; +} + +export interface IV1_ToolCallPendingConfirmationState extends IV1_ToolCallBase, IV1_ToolCallParameterFields { + readonly status: 'pending-confirmation'; +} + +export interface IV1_ToolCallRunningState extends IV1_ToolCallBase, IV1_ToolCallParameterFields { + readonly status: 'running'; + readonly confirmed: IV1_ToolCallConfirmationReason; +} + +export interface IV1_ToolCallPendingResultConfirmationState extends IV1_ToolCallBase, IV1_ToolCallParameterFields, IV1_ToolCallResult { + readonly status: 'pending-result-confirmation'; + readonly confirmed: IV1_ToolCallConfirmationReason; +} + +export interface IV1_ToolCallCompletedState extends IV1_ToolCallBase, IV1_ToolCallParameterFields, IV1_ToolCallResult { + readonly status: 'completed'; + readonly confirmed: IV1_ToolCallConfirmationReason; +} + +export interface IV1_ToolCallCancelledState extends IV1_ToolCallBase, IV1_ToolCallParameterFields { + readonly status: 'cancelled'; + readonly reason: 'denied' | 'skipped' | 'result-denied'; + readonly reasonMessage?: IV1_StringOrMarkdown; + readonly userSuggestion?: IV1_UserMessage; +} + +export type IV1_ToolCallState = + | IV1_ToolCallStreamingState + | IV1_ToolCallPendingConfirmationState + | IV1_ToolCallRunningState + | IV1_ToolCallPendingResultConfirmationState + | IV1_ToolCallCompletedState + | IV1_ToolCallCancelledState; + +export type IV1_CompletedToolCall = IV1_ToolCallCompletedState | IV1_ToolCallCancelledState; + export interface IV1_PermissionRequest { readonly requestId: string; readonly permissionKind: 'shell' | 'write' | 'mcp' | 'read' | 'url'; @@ -200,24 +234,59 @@ export interface IV1_ResponsePartAction extends IV1_SessionActionBase { readonly part: IV1_ResponsePart; } -export interface IV1_ToolStartAction extends IV1_SessionActionBase { - readonly type: 'session/toolStart'; - readonly turnId: string; - readonly toolCall: IV1_ToolCallState; -} - -export interface IV1_ToolCompleteAction extends IV1_SessionActionBase { - readonly type: 'session/toolComplete'; +interface IV1_ToolCallActionBase extends IV1_SessionActionBase { readonly turnId: string; readonly toolCallId: string; - readonly result: IV1_ToolCompleteResult; } -export interface IV1_ToolCompleteResult { - readonly success: boolean; - readonly pastTenseMessage: string; - readonly toolOutput?: string; - readonly error?: { readonly message: string; readonly code?: string }; +export interface IV1_ToolCallStartAction extends IV1_ToolCallActionBase { + readonly type: 'session/toolCallStart'; + readonly toolName: string; + readonly displayName: string; + readonly toolKind?: 'terminal'; + readonly language?: string; +} + +export interface IV1_ToolCallDeltaAction extends IV1_ToolCallActionBase { + readonly type: 'session/toolCallDelta'; + readonly content: string; + readonly invocationMessage?: IV1_StringOrMarkdown; +} + +export interface IV1_ToolCallReadyAction extends IV1_ToolCallActionBase { + readonly type: 'session/toolCallReady'; + readonly invocationMessage: IV1_StringOrMarkdown; + readonly toolInput?: string; + readonly confirmed?: IV1_ToolCallConfirmationReason; +} + +export interface IV1_ToolCallApprovedAction extends IV1_ToolCallActionBase { + readonly type: 'session/toolCallConfirmed'; + readonly approved: true; + readonly confirmed: IV1_ToolCallConfirmationReason; +} + +export interface IV1_ToolCallDeniedAction extends IV1_ToolCallActionBase { + readonly type: 'session/toolCallConfirmed'; + readonly approved: false; + readonly reason: 'denied' | 'skipped'; + readonly userSuggestion?: IV1_UserMessage; + readonly reasonMessage?: IV1_StringOrMarkdown; +} + +export type IV1_ToolCallConfirmedAction = + | IV1_ToolCallApprovedAction + | IV1_ToolCallDeniedAction; + +export interface IV1_ToolCallCompleteAction extends IV1_ToolCallActionBase { + readonly type: 'session/toolCallComplete'; + readonly result: IV1_ToolCallResult; + readonly requiresResultConfirmation?: boolean; +} + +export interface IV1_ToolCallResultConfirmedAction extends IV1_ToolCallActionBase { + readonly type: 'session/toolCallResultConfirmed'; + readonly approved: boolean; } export interface IV1_PermissionRequestAction extends IV1_SessionActionBase { @@ -280,8 +349,12 @@ export type IV1_SessionAction = | IV1_TurnStartedAction | IV1_DeltaAction | IV1_ResponsePartAction - | IV1_ToolStartAction - | IV1_ToolCompleteAction + | IV1_ToolCallStartAction + | IV1_ToolCallDeltaAction + | IV1_ToolCallReadyAction + | IV1_ToolCallConfirmedAction + | IV1_ToolCallCompleteAction + | IV1_ToolCallResultConfirmedAction | IV1_PermissionRequestAction | IV1_PermissionResolvedAction | IV1_TurnCompleteAction diff --git a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts index c629f1c8788..5c5bf50854a 100644 --- a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts +++ b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts @@ -23,8 +23,12 @@ import type { ISessionReadyAction, IStateAction, ITitleChangedAction, - IToolCompleteAction, - IToolStartAction, + IToolCallCompleteAction, + IToolCallConfirmedAction, + IToolCallDeltaAction, + IToolCallReadyAction, + IToolCallResultConfirmedAction, + IToolCallStartAction, ITurnCancelledAction, ITurnCompleteAction, ITurnStartedAction, @@ -74,9 +78,13 @@ import type { IV1_SessionState, IV1_SessionSummary, IV1_TitleChangedAction, + IV1_ToolCallCompleteAction, + IV1_ToolCallConfirmedAction, + IV1_ToolCallDeltaAction, + IV1_ToolCallReadyAction, + IV1_ToolCallResultConfirmedAction, + IV1_ToolCallStartAction, IV1_ToolCallState, - IV1_ToolCompleteAction, - IV1_ToolStartAction, IV1_Turn, IV1_TurnCancelledAction, IV1_TurnCompleteAction, @@ -93,7 +101,9 @@ import type { * Increment when adding new action types or changing behavior. * * Version history: - * 1 — Initial: root state, session lifecycle, streaming, tools, permissions + * 1 — Initial: root state, session lifecycle, streaming, tools, permissions, + * tool call lifecycle (streaming → pending-confirmation → running → + * pending-result-confirmation → completed/cancelled) */ export const PROTOCOL_VERSION = 1; @@ -143,8 +153,12 @@ type _v1_CreationFailed = AssertCompatible; type _v1_Delta = AssertCompatible; type _v1_ResponsePart = AssertCompatible; -type _v1_ToolStart = AssertCompatible; -type _v1_ToolComplete = AssertCompatible; +type _v1_ToolCallStart = AssertCompatible; +type _v1_ToolCallDelta = AssertCompatible; +type _v1_ToolCallReady = AssertCompatible; +type _v1_ToolCallConfirmed = AssertCompatible; +type _v1_ToolCallComplete = AssertCompatible; +type _v1_ToolCallResultConfirmed = AssertCompatible; type _v1_PermissionRequestAction = AssertCompatible; type _v1_PermissionResolved = AssertCompatible; type _v1_TurnComplete = AssertCompatible; @@ -163,8 +177,10 @@ void (0 as unknown as _v1_ToolCallState & _v1_CompletedToolCall & _v1_PermissionRequest & _v1_UsageInfo & _v1_ErrorInfo & _v1_AgentsChanged & _v1_ActiveSessionsChanged & _v1_SessionReady & _v1_CreationFailed & - _v1_TurnStarted & _v1_Delta & _v1_ResponsePart & _v1_ToolStart & - _v1_ToolComplete & _v1_PermissionRequestAction & _v1_PermissionResolved & + _v1_TurnStarted & _v1_Delta & _v1_ResponsePart & + _v1_ToolCallStart & _v1_ToolCallDelta & _v1_ToolCallReady & + _v1_ToolCallConfirmed & _v1_ToolCallComplete & _v1_ToolCallResultConfirmed & + _v1_PermissionRequestAction & _v1_PermissionResolved & _v1_TurnComplete & _v1_TurnCancelled & _v1_SessionError & _v1_TitleChanged & _v1_Usage & _v1_Reasoning & _v1_ModelChanged ); @@ -191,8 +207,12 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe 'session/delta': 1, 'session/responsePart': 1, // Tool calls (v1) - 'session/toolStart': 1, - 'session/toolComplete': 1, + 'session/toolCallStart': 1, + 'session/toolCallDelta': 1, + 'session/toolCallReady': 1, + 'session/toolCallConfirmed': 1, + 'session/toolCallComplete': 1, + 'session/toolCallResultConfirmed': 1, // Permissions (v1) 'session/permissionRequest': 1, 'session/permissionResolved': 1, @@ -236,11 +256,12 @@ export function isNotificationKnownToVersion(notification: INotification, client // union for a version is built by combining all versions up to that point. // When you add a new protocol version, define its additions and extend the map. -/** Action types introduced in v1. */ +/** Action types introduced in v1 (current tip). */ type IRootAction_v1 = IV1_AgentsChangedAction | IV1_ActiveSessionsChangedAction; type ISessionAction_v1 = IV1_SessionReadyAction | IV1_SessionCreationFailedAction | IV1_TurnStartedAction | IV1_DeltaAction | IV1_ResponsePartAction - | IV1_ToolStartAction | IV1_ToolCompleteAction + | IV1_ToolCallStartAction | IV1_ToolCallDeltaAction | IV1_ToolCallReadyAction + | IV1_ToolCallConfirmedAction | IV1_ToolCallCompleteAction | IV1_ToolCallResultConfirmedAction | IV1_PermissionRequestAction | IV1_PermissionResolvedAction | IV1_TurnCompleteAction | IV1_TurnCancelledAction | IV1_SessionErrorAction | IV1_TitleChangedAction | IV1_UsageAction | IV1_ReasoningAction diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index c7639b180e6..fba92094999 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -17,8 +17,9 @@ import type { import type { ISessionAction, IDeltaAction, - IToolStartAction, - IToolCompleteAction, + IToolCallStartAction, + IToolCallReadyAction, + IToolCallCompleteAction, ITurnCompleteAction, ISessionErrorAction, IUsageAction, @@ -26,15 +27,17 @@ import type { IPermissionRequestAction, IReasoningAction, } from '../common/state/sessionActions.js'; -import { ToolCallStatus } from '../common/state/sessionState.js'; import { URI } from '../../../base/common/uri.js'; /** * Maps a flat {@link IAgentProgressEvent} from the agent host into - * a protocol {@link ISessionAction} suitable for dispatch to the reducer. + * protocol {@link ISessionAction}(s) suitable for dispatch to the reducer. + * * Returns `undefined` for events that have no corresponding action. + * May return an array when a single SDK event maps to multiple protocol actions + * (e.g. `tool_start` → `toolCallStart` + `toolCallReady`). */ -export function mapProgressEventToAction(event: IAgentProgressEvent, session: URI, turnId: string): ISessionAction | undefined { +export function mapProgressEventToActions(event: IAgentProgressEvent, session: URI, turnId: string): ISessionAction | ISessionAction[] | undefined { switch (event.type) { case 'delta': return { @@ -45,29 +48,36 @@ export function mapProgressEventToAction(event: IAgentProgressEvent, session: UR } satisfies IDeltaAction; case 'tool_start': { + // The Copilot SDK provides full parameters at tool_start time. + // We emit both toolCallStart (streaming → created) and toolCallReady + // (params complete → running with auto-confirm) as a pair. const e = event as IAgentToolStartEvent; - return { - type: 'session/toolStart', + const startAction: IToolCallStartAction = { + type: 'session/toolCallStart', session, turnId, - toolCall: { - toolCallId: e.toolCallId, - toolName: e.toolName, - displayName: e.displayName, - invocationMessage: e.invocationMessage, - toolInput: e.toolInput, - toolKind: e.toolKind, - language: e.language, - toolArguments: e.toolArguments, - status: ToolCallStatus.Running, - }, - } satisfies IToolStartAction; + toolCallId: e.toolCallId, + toolName: e.toolName, + displayName: e.displayName, + toolKind: e.toolKind, + language: e.language, + }; + const readyAction: IToolCallReadyAction = { + type: 'session/toolCallReady', + session, + turnId, + toolCallId: e.toolCallId, + invocationMessage: e.invocationMessage, + toolInput: e.toolInput, + confirmed: 'not-needed', + }; + return [startAction, readyAction]; } case 'tool_complete': { const e = event as IAgentToolCompleteEvent; return { - type: 'session/toolComplete', + type: 'session/toolCallComplete', session, turnId, toolCallId: e.toolCallId, @@ -77,7 +87,7 @@ export function mapProgressEventToAction(event: IAgentProgressEvent, session: UR toolOutput: e.toolOutput, error: e.error, }, - } satisfies IToolCompleteAction; + } satisfies IToolCallCompleteAction; } case 'idle': diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 24206fb56ee..b355d9f10d3 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -16,7 +16,7 @@ import { ISessionModelInfo, SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; -import { mapProgressEventToAction } from './agentEventMapper.js'; +import { mapProgressEventToActions } from './agentEventMapper.js'; import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; import { SessionStateManager } from './sessionStateManager.js'; @@ -99,9 +99,15 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH const turnId = this._stateManager.getActiveTurnId(e.session); if (turnId) { - const action = mapProgressEventToAction(e, e.session, turnId); - if (action) { - this._stateManager.dispatchServerAction(action); + const actions = mapProgressEventToActions(e, e.session, turnId); + if (actions) { + if (Array.isArray(actions)) { + for (const action of actions) { + this._stateManager.dispatchServerAction(action); + } + } else { + this._stateManager.dispatchServerAction(actions); + } } } })); diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index a152f4a454a..66f4a8ab79b 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -22,15 +22,24 @@ import type { IDeltaAction, IPermissionRequestAction, IReasoningAction, + ISessionAction, ISessionErrorAction, ITitleChangedAction, - IToolCompleteAction, - IToolStartAction, + IToolCallCompleteAction, + IToolCallReadyAction, + IToolCallStartAction, ITurnCompleteAction, IUsageAction, } from '../../common/state/sessionActions.js'; -import { ToolCallStatus } from '../../common/state/sessionState.js'; -import { mapProgressEventToAction } from '../../node/agentEventMapper.js'; +import { mapProgressEventToActions } from '../../node/agentEventMapper.js'; + +/** Helper: flatten the result of mapProgressEventToActions into an array. */ +function mapToArray(result: ISessionAction | ISessionAction[] | undefined): ISessionAction[] { + if (!result) { + return []; + } + return Array.isArray(result) ? result : [result]; +} suite('AgentEventMapper', () => { @@ -47,8 +56,9 @@ suite('AgentEventMapper', () => { content: 'hello world', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + const action = actions[0]; assert.strictEqual(action.type, 'session/delta'); const delta = action as IDeltaAction; assert.strictEqual(delta.content, 'hello world'); @@ -56,7 +66,7 @@ suite('AgentEventMapper', () => { assert.strictEqual(delta.turnId, turnId); }); - test('tool_start event maps to session/toolStart action', () => { + test('tool_start event maps to toolCallStart + toolCallReady actions', () => { const event: IAgentToolStartEvent = { session, type: 'tool_start', @@ -69,21 +79,26 @@ suite('AgentEventMapper', () => { language: 'shellscript', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/toolStart'); - const toolCall = (action as IToolStartAction).toolCall; - assert.strictEqual(toolCall.toolCallId, 'tc-1'); - assert.strictEqual(toolCall.toolName, 'readFile'); - assert.strictEqual(toolCall.displayName, 'Read File'); - assert.strictEqual(toolCall.invocationMessage, 'Reading file...'); - assert.strictEqual(toolCall.toolInput, '/src/foo.ts'); - assert.strictEqual(toolCall.toolKind, 'terminal'); - assert.strictEqual(toolCall.language, 'shellscript'); - assert.strictEqual(toolCall.status, ToolCallStatus.Running); + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 2); + + const startAction = actions[0] as IToolCallStartAction; + assert.strictEqual(startAction.type, 'session/toolCallStart'); + assert.strictEqual(startAction.toolCallId, 'tc-1'); + assert.strictEqual(startAction.toolName, 'readFile'); + assert.strictEqual(startAction.displayName, 'Read File'); + assert.strictEqual(startAction.toolKind, 'terminal'); + assert.strictEqual(startAction.language, 'shellscript'); + + const readyAction = actions[1] as IToolCallReadyAction; + assert.strictEqual(readyAction.type, 'session/toolCallReady'); + assert.strictEqual(readyAction.toolCallId, 'tc-1'); + assert.strictEqual(readyAction.invocationMessage, 'Reading file...'); + assert.strictEqual(readyAction.toolInput, '/src/foo.ts'); + assert.strictEqual(readyAction.confirmed, 'not-needed'); }); - test('tool_complete event maps to session/toolComplete action', () => { + test('tool_complete event maps to session/toolCallComplete action', () => { const event: IAgentToolCompleteEvent = { session, type: 'tool_complete', @@ -93,10 +108,10 @@ suite('AgentEventMapper', () => { toolOutput: 'file contents here', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/toolComplete'); - const complete = action as IToolCompleteAction; + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + const complete = actions[0] as IToolCallCompleteAction; + assert.strictEqual(complete.type, 'session/toolCallComplete'); assert.strictEqual(complete.toolCallId, 'tc-1'); assert.strictEqual(complete.result.success, true); assert.strictEqual(complete.result.pastTenseMessage, 'Read file successfully'); @@ -109,10 +124,10 @@ suite('AgentEventMapper', () => { type: 'idle', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/turnComplete'); - const turnComplete = action as ITurnCompleteAction; + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + const turnComplete = actions[0] as ITurnCompleteAction; + assert.strictEqual(turnComplete.type, 'session/turnComplete'); assert.strictEqual(turnComplete.session.toString(), session.toString()); assert.strictEqual(turnComplete.turnId, turnId); }); @@ -126,10 +141,10 @@ suite('AgentEventMapper', () => { stack: 'Error: Something went wrong\n at foo.ts:1', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/error'); - const errorAction = action as ISessionErrorAction; + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + const errorAction = actions[0] as ISessionErrorAction; + assert.strictEqual(errorAction.type, 'session/error'); assert.strictEqual(errorAction.error.errorType, 'runtime'); assert.strictEqual(errorAction.error.message, 'Something went wrong'); assert.strictEqual(errorAction.error.stack, 'Error: Something went wrong\n at foo.ts:1'); @@ -145,10 +160,10 @@ suite('AgentEventMapper', () => { cacheReadTokens: 25, }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/usage'); - const usageAction = action as IUsageAction; + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + const usageAction = actions[0] as IUsageAction; + assert.strictEqual(usageAction.type, 'session/usage'); assert.strictEqual(usageAction.usage.inputTokens, 100); assert.strictEqual(usageAction.usage.outputTokens, 50); assert.strictEqual(usageAction.usage.model, 'gpt-4'); @@ -162,10 +177,10 @@ suite('AgentEventMapper', () => { title: 'New Title', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/titleChanged'); - assert.strictEqual((action as ITitleChangedAction).title, 'New Title'); + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/titleChanged'); + assert.strictEqual((actions[0] as ITitleChangedAction).title, 'New Title'); }); test('permission_request event maps to session/permissionRequest action', () => { @@ -180,10 +195,10 @@ suite('AgentEventMapper', () => { rawRequest: '{}', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/permissionRequest'); - const req = (action as IPermissionRequestAction).request; + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/permissionRequest'); + const req = (actions[0] as IPermissionRequestAction).request; assert.strictEqual(req.requestId, 'perm-1'); assert.strictEqual(req.permissionKind, 'shell'); assert.strictEqual(req.toolCallId, 'tc-2'); @@ -198,10 +213,10 @@ suite('AgentEventMapper', () => { content: 'Let me think about this...', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.ok(action); - assert.strictEqual(action.type, 'session/reasoning'); - const reasoning = action as IReasoningAction; + const actions = mapToArray(mapProgressEventToActions(event, session, turnId)); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].type, 'session/reasoning'); + const reasoning = actions[0] as IReasoningAction; assert.strictEqual(reasoning.content, 'Let me think about this...'); assert.strictEqual(reasoning.turnId, turnId); }); @@ -215,7 +230,7 @@ suite('AgentEventMapper', () => { content: 'Some full message', }; - const action = mapProgressEventToAction(event, session, turnId); - assert.strictEqual(action, undefined); + const result = mapProgressEventToActions(event, session, turnId); + assert.strictEqual(result, undefined); }); }); diff --git a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts index 3441e560bbe..9813b182a53 100644 --- a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts @@ -347,16 +347,17 @@ suite('Protocol WebSocket E2E', function () { }); // 4. Tool invocation lifecycle - test('tool invocation: toolStart → toolComplete → delta → turnComplete', async function () { + test('tool invocation: toolCallStart → toolCallComplete → delta → turnComplete', async function () { this.timeout(10_000); const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation'); dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/toolStart')); - const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolComplete')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); const tcAction = getActionParams(toolComplete).envelope.action; - if (tcAction.type === 'session/toolComplete') { + if (tcAction.type === 'session/toolCallComplete') { assert.strictEqual(tcAction.result.success, true); } await client.waitForNotification(n => isActionNotification(n, 'session/delta')); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 09f9f680e89..d8e70394d8e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -18,7 +18,7 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { IAgentAttachment, AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; -import { ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatProgress, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; @@ -354,14 +354,27 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC for (const [toolCallId, tc] of Object.entries(activeTurn.toolCalls)) { const existing = activeToolInvocations.get(toolCallId); if (!existing) { - if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingPermission) { + if (tc.status === 'running' || tc.status === 'streaming' || tc.status === 'pending-confirmation') { const invocation = toolCallStateToInvocation(tc); activeToolInvocations.set(toolCallId, invocation); progress([invocation]); } - } else if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Failed) { + } else if (tc.status === 'completed' || tc.status === 'cancelled') { activeToolInvocations.delete(toolCallId); finalizeToolInvocation(existing, tc); + } else if (tc.status === 'running' || tc.status === 'pending-confirmation') { + // Tool transitioned from streaming to ready — update the invocation + // with the now-available invocationMessage and toolSpecificData. + existing.invocationMessage = typeof tc.invocationMessage === 'string' + ? tc.invocationMessage + : new MarkdownString(tc.invocationMessage.markdown); + if (tc.toolKind === 'terminal' && tc.toolInput) { + existing.toolSpecificData = { + kind: 'terminal', + commandLine: { original: tc.toolInput }, + language: tc.language ?? 'shellscript', + }; + } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index fcdb959660b..a1b68ba96e4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { ToolCallStatus, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -48,33 +48,56 @@ export function turnsToHistory(turns: readonly ITurn[], participantId: string): */ function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocationSerialized { const isTerminal = tc.toolKind === 'terminal'; + const isSuccess = tc.status === 'completed' && tc.success; + const invocationMsg = stringOrMarkdownToString(tc.invocationMessage) ?? ''; let toolSpecificData: IChatTerminalToolInvocationData | undefined; if (isTerminal && tc.toolInput) { + const toolOutput = tc.status === 'completed' ? tc.toolOutput : undefined; toolSpecificData = { kind: 'terminal', commandLine: { original: tc.toolInput }, language: tc.language ?? 'shellscript', - terminalCommandOutput: tc.toolOutput !== undefined ? { text: tc.toolOutput } : undefined, - terminalCommandState: { exitCode: tc.success ? 0 : 1 }, + terminalCommandOutput: toolOutput !== undefined ? { text: toolOutput } : undefined, + terminalCommandState: { exitCode: isSuccess ? 0 : 1 }, }; } + const pastTenseMsg = isSuccess + ? stringOrMarkdownToString(tc.pastTenseMessage) ?? invocationMsg + : invocationMsg; + return { kind: 'toolInvocationSerialized', toolCallId: tc.toolCallId, toolId: tc.toolName, source: ToolDataSource.Internal, - invocationMessage: new MarkdownString(tc.invocationMessage), + invocationMessage: invocationMsg, originMessage: undefined, - pastTenseMessage: isTerminal ? undefined : new MarkdownString(tc.pastTenseMessage), - isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + pastTenseMessage: isTerminal ? undefined : pastTenseMsg, + isConfirmed: isSuccess + ? { type: ToolConfirmKind.ConfirmationNotNeeded } + : { type: ToolConfirmKind.Denied }, isComplete: true, presentation: undefined, toolSpecificData, }; } +/** + * Creates a live {@link ChatToolInvocation} from the protocol's tool-call + * state. Used during active turns to represent running tool calls in the UI. + */ +/** + * Converts a protocol `StringOrMarkdown` value to a chat-layer `IMarkdownString`. + */ +function stringOrMarkdownToString(value: string | { readonly markdown: string } | undefined): string | IMarkdownString | undefined { + if (value === undefined) { + return undefined; + } + return typeof value === 'string' ? value : new MarkdownString(value.markdown); +} + /** * Creates a live {@link ChatToolInvocation} from the protocol's tool-call * state. Used during active turns to represent running tool calls in the UI. @@ -87,15 +110,10 @@ export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocatio modelDescription: tc.toolName, }; - let parameters: unknown; - if (tc.toolArguments) { - try { parameters = JSON.parse(tc.toolArguments); } catch { /* malformed JSON */ } - } + const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, undefined, undefined); + invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage) ?? ''; - const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, undefined, parameters); - invocation.invocationMessage = new MarkdownString(tc.invocationMessage); - - if (tc.toolKind === 'terminal' && tc.toolInput) { + if (tc.toolKind === 'terminal' && tc.status !== 'streaming' && tc.toolInput) { invocation.toolSpecificData = { kind: 'terminal', commandLine: { original: tc.toolInput }, @@ -183,17 +201,23 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo * protocol's tool-call state, transitioning it to the completed state. */ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState): void { - if (invocation.toolSpecificData?.kind === 'terminal') { + const isCompleted = tc.status === 'completed'; + const isCancelled = tc.status === 'cancelled'; + + if (invocation.toolSpecificData?.kind === 'terminal' && (isCompleted || isCancelled)) { const terminalData = invocation.toolSpecificData as IChatTerminalToolInvocationData; + const toolOutput = isCompleted ? tc.toolOutput : undefined; invocation.toolSpecificData = { ...terminalData, - terminalCommandOutput: tc.toolOutput !== undefined ? { text: tc.toolOutput } : undefined, - terminalCommandState: { exitCode: tc.status === ToolCallStatus.Completed ? 0 : 1 }, + terminalCommandOutput: toolOutput !== undefined ? { text: toolOutput } : undefined, + terminalCommandState: { exitCode: isCompleted && tc.success ? 0 : 1 }, }; - } else if (tc.pastTenseMessage) { - invocation.pastTenseMessage = new MarkdownString(tc.pastTenseMessage); + } else if (isCompleted && tc.pastTenseMessage) { + invocation.pastTenseMessage = stringOrMarkdownToString(tc.pastTenseMessage); } - const isFailure = tc.status === ToolCallStatus.Failed; - invocation.didExecuteTool(isFailure ? { content: [], toolResultError: tc.error?.message } : undefined); + const isFailure = (isCompleted && !tc.success) || isCancelled; + const errorMessage = isCompleted ? tc.error?.message : (isCancelled ? tc.reasonMessage : undefined); + const errorString = typeof errorMessage === 'string' ? errorMessage : errorMessage?.markdown; + invocation.didExecuteTool(isFailure ? { content: [], toolResultError: errorString } : undefined); } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 49cf49753e8..a667176b260 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -16,7 +16,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IActionEnvelope, INotification, IPermissionResolvedAction, ISessionAction, ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { SessionLifecycle, SessionStatus, ToolCallStatus, TurnState, createSessionState, type ISessionState, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionLifecycle, SessionStatus, TurnState, createSessionState, type ISessionState, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -479,10 +479,8 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); - fire({ - type: 'session/toolStart', session, turnId, - toolCall: { toolCallId: 'tc-1', toolName: 'read_file', displayName: 'Read File', invocationMessage: 'Reading file', status: ToolCallStatus.Running }, - } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-1', toolName: 'read_file', displayName: 'Read File' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-1', invocationMessage: 'Reading file', confirmed: 'not-needed' } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -496,12 +494,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-2', toolName: 'bash', displayName: 'Bash' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-2', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); fire({ - type: 'session/toolStart', session, turnId, - toolCall: { toolCallId: 'tc-2', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, - } as ISessionAction); - fire({ - type: 'session/toolComplete', session, turnId, toolCallId: 'tc-2', + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-2', result: { success: true, pastTenseMessage: 'Ran Bash command' }, } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -520,12 +516,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-3', toolName: 'bash', displayName: 'Bash' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-3', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); fire({ - type: 'session/toolStart', session, turnId, - toolCall: { toolCallId: 'tc-3', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, - } as ISessionAction); - fire({ - type: 'session/toolComplete', session, turnId, toolCallId: 'tc-3', + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-3', result: { success: false, pastTenseMessage: '"Bash" failed', toolOutput: 'command not found', error: { message: 'command not found' } }, } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -543,10 +537,8 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); - fire({ - type: 'session/toolStart', session, turnId, - toolCall: { toolCallId: 'tc-bad', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running, toolArguments: '{not valid json' }, - } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-bad', toolName: 'bash', displayName: 'Bash' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-bad', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -561,10 +553,8 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); // tool_start without tool_complete - fire({ - type: 'session/toolStart', session, turnId, - toolCall: { toolCallId: 'tc-orphan', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, - } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-orphan', toolName: 'bash', displayName: 'Bash' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-orphan', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -626,10 +616,8 @@ suite('AgentHostChatContribution', () => { cancellationToken: cts.token, }); - fire({ - type: 'session/toolStart', session, turnId, - toolCall: { toolCallId: 'tc-cancel', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running Bash command', status: ToolCallStatus.Running }, - } as ISessionAction); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-cancel', toolName: 'bash', displayName: 'Bash' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-cancel', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); cts.cancel(); await turnPromise; @@ -854,17 +842,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-shell', toolName: 'bash', displayName: 'Bash', toolKind: 'terminal' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-shell', invocationMessage: 'Running `echo hello`', toolInput: 'echo hello', confirmed: 'not-needed' } as ISessionAction); fire({ - type: 'session/toolStart', session, turnId, - toolCall: { - toolCallId: 'tc-shell', toolName: 'bash', displayName: 'Bash', - invocationMessage: 'Running `echo hello`', toolInput: 'echo hello', - toolKind: 'terminal', status: ToolCallStatus.Running, - toolArguments: JSON.stringify({ command: 'echo hello' }), - }, - } as ISessionAction); - fire({ - type: 'session/toolComplete', session, turnId, toolCallId: 'tc-shell', + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-shell', result: { success: true, pastTenseMessage: 'Ran `echo hello`', toolOutput: 'hello\n' }, } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -899,17 +880,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-fail', toolName: 'bash', displayName: 'Bash', toolKind: 'terminal' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-fail', invocationMessage: 'Running `bad_cmd`', toolInput: 'bad_cmd', confirmed: 'not-needed' } as ISessionAction); fire({ - type: 'session/toolStart', session, turnId, - toolCall: { - toolCallId: 'tc-fail', toolName: 'bash', displayName: 'Bash', - invocationMessage: 'Running `bad_cmd`', toolInput: 'bad_cmd', - toolKind: 'terminal', status: ToolCallStatus.Running, - toolArguments: JSON.stringify({ command: 'bad_cmd' }), - }, - } as ISessionAction); - fire({ - type: 'session/toolComplete', session, turnId, toolCallId: 'tc-fail', + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-fail', result: { success: false, pastTenseMessage: '"Bash" failed', toolOutput: 'command not found: bad_cmd', error: { message: 'command not found: bad_cmd' } }, } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -934,16 +908,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-gen', toolName: 'custom_tool', displayName: 'custom_tool' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-gen', invocationMessage: 'Using "custom_tool"', confirmed: 'not-needed' } as ISessionAction); fire({ - type: 'session/toolStart', session, turnId, - toolCall: { - toolCallId: 'tc-gen', toolName: 'custom_tool', displayName: 'custom_tool', - invocationMessage: 'Using "custom_tool"', status: ToolCallStatus.Running, - toolArguments: JSON.stringify({ input: 'data' }), - }, - } as ISessionAction); - fire({ - type: 'session/toolComplete', session, turnId, toolCallId: 'tc-gen', + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-gen', result: { success: true, pastTenseMessage: 'Used "custom_tool"' }, } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -967,16 +935,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-noargs', toolName: 'bash', displayName: 'Bash', toolKind: 'terminal' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-noargs', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); fire({ - type: 'session/toolStart', session, turnId, - toolCall: { - toolCallId: 'tc-noargs', toolName: 'bash', displayName: 'Bash', - invocationMessage: 'Running Bash command', toolKind: 'terminal', - status: ToolCallStatus.Running, - }, - } as ISessionAction); - fire({ - type: 'session/toolComplete', session, turnId, toolCallId: 'tc-noargs', + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-noargs', result: { success: true, pastTenseMessage: 'Ran Bash command' }, } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -1000,16 +962,10 @@ suite('AgentHostChatContribution', () => { const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-view', toolName: 'view', displayName: 'View File' } as ISessionAction); + fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-view', invocationMessage: 'Reading /tmp/test.txt', confirmed: 'not-needed' } as ISessionAction); fire({ - type: 'session/toolStart', session, turnId, - toolCall: { - toolCallId: 'tc-view', toolName: 'view', displayName: 'View File', - invocationMessage: 'Reading /tmp/test.txt', status: ToolCallStatus.Running, - toolArguments: JSON.stringify({ file_path: '/tmp/test.txt' }), - }, - } as ISessionAction); - fire({ - type: 'session/toolComplete', session, turnId, toolCallId: 'tc-view', + type: 'session/toolCallComplete', session, turnId, toolCallId: 'tc-view', result: { success: true, pastTenseMessage: 'Read /tmp/test.txt' }, } as ISessionAction); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -1045,9 +1001,9 @@ suite('AgentHostChatContribution', () => { responseParts: [], usage: undefined, toolCalls: [{ - toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', + status: 'completed' as const, toolCallId: 'tc-1', toolName: 'bash', displayName: 'Bash', invocationMessage: 'Running `ls`', toolInput: 'ls', toolKind: 'terminal' as const, - success: true, pastTenseMessage: 'Ran `ls`', toolOutput: 'file1\nfile2', + confirmed: 'not-needed' as const, success: true, pastTenseMessage: 'Ran `ls`', toolOutput: 'file1\nfile2', }], responseText: '', }], @@ -1090,7 +1046,7 @@ suite('AgentHostChatContribution', () => { responseParts: [], responseText: '', usage: undefined, - toolCalls: [{ toolCallId: 'tc-orphan', toolName: 'read_file', displayName: 'Read File', invocationMessage: 'Reading file', success: false, pastTenseMessage: 'Reading file' }], + toolCalls: [{ status: 'completed' as const, toolCallId: 'tc-orphan', toolName: 'read_file', displayName: 'Read File', invocationMessage: 'Reading file', confirmed: 'not-needed' as const, success: false, pastTenseMessage: 'Reading file' }], }], } as ISessionState); @@ -1121,7 +1077,7 @@ suite('AgentHostChatContribution', () => { responseParts: [], usage: undefined, responseText: '', - toolCalls: [{ toolCallId: 'tc-g', toolName: 'grep', displayName: 'Grep', invocationMessage: 'Searching...', success: true, pastTenseMessage: 'Searched for pattern' }], + toolCalls: [{ status: 'completed' as const, toolCallId: 'tc-g', toolName: 'grep', displayName: 'Grep', invocationMessage: 'Searching...', confirmed: 'not-needed' as const, success: true, pastTenseMessage: 'Searched for pattern' }], }], } as ISessionState); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index c7e54d2622a..ff46dd52fa4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -5,34 +5,37 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { ToolCallStatus, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- -function createToolCallState(overrides?: Partial): IToolCallState { +function createToolCallState(overrides?: Partial): IToolCallRunningState { return { toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', - status: ToolCallStatus.Running, + status: 'running', + confirmed: 'not-needed', ...overrides, }; } function createCompletedToolCall(overrides?: Partial): ICompletedToolCall { return { + status: 'completed', toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', success: true, + confirmed: 'not-needed', pastTenseMessage: 'Ran test tool', ...overrides, - }; + } as ICompletedToolCall; } function createTurn(overrides?: Partial): ITurn { @@ -181,7 +184,7 @@ suite('stateToProgressAdapter', () => { toolName: 'my_tool', displayName: 'My Tool', invocationMessage: 'Doing stuff', - status: ToolCallStatus.Running, + status: 'running', }); const invocation = toolCallStateToInvocation(tc); @@ -203,13 +206,11 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.commandLine.original, 'ls -la'); }); - test('parses toolArguments as parameters', () => { - const tc = createToolCallState({ - toolArguments: '{"path":"test.ts"}', - }); + test('creates invocation without toolArguments', () => { + const tc = createToolCallState({}); const invocation = toolCallStateToInvocation(tc); - assert.deepStrictEqual(invocation.parameters, { path: 'test.ts' }); + assert.strictEqual(invocation.toolCallId, 'tc-1'); }); }); @@ -260,19 +261,24 @@ suite('stateToProgressAdapter', () => { const tc = createToolCallState({ toolKind: 'terminal', toolInput: 'echo hi', - status: ToolCallStatus.Running, + status: 'running', }); const invocation = toolCallStateToInvocation(tc); - const completedTc = createToolCallState({ + finalizeToolInvocation(invocation, { + status: 'completed', + toolCallId: 'tc-1', + toolName: 'test_tool', + displayName: 'Test Tool', + invocationMessage: 'Running test tool...', toolKind: 'terminal', toolInput: 'echo hi', - status: ToolCallStatus.Completed, + confirmed: 'not-needed', + success: true, + pastTenseMessage: 'Ran echo hi', toolOutput: 'output text', }); - finalizeToolInvocation(invocation, completedTc); - assert.ok(invocation.toolSpecificData); assert.strictEqual(invocation.toolSpecificData.kind, 'terminal'); const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandOutput: { text: string }; terminalCommandState: { exitCode: number } }; @@ -282,17 +288,23 @@ suite('stateToProgressAdapter', () => { test('finalizes failed tool with error message', () => { const tc = createToolCallState({ - status: ToolCallStatus.Running, + status: 'running', }); const invocation = toolCallStateToInvocation(tc); - const failedTc = createToolCallState({ - status: ToolCallStatus.Failed, + finalizeToolInvocation(invocation, { + status: 'completed', + toolCallId: 'tc-1', + toolName: 'test_tool', + displayName: 'Test Tool', + invocationMessage: 'Running test tool...', + confirmed: 'not-needed', + success: false, + pastTenseMessage: 'Failed', error: { message: 'timeout' }, }); // Should not throw - finalizeToolInvocation(invocation, failedTc); }); }); });