Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookProgressMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKMessage, SDKResultMessage, SDKUserMessage, SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk';6import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools';7import type Anthropic from '@anthropic-ai/sdk';8import * as l10n from '@vscode/l10n';9import type * as vscode from 'vscode';10import { vBoolean, vLiteral, vObj, vString, type ValidatorType } from '../../../../platform/configuration/common/validator';11import { ILogService } from '../../../../platform/log/common/logService';12import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle, type TraceContext } from '../../../../platform/otel/common/index';13import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken';14import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger';15import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';16import { ChatResponseThinkingProgressPart, LanguageModelTextPart, type ChatHookType } from '../../../../vscodeTypes';17import { ExternalEditTracker } from '../../../chatSessions/common/externalEditTracker';18import { ToolName } from '../../../tools/common/toolNames';19import { IToolsService } from '../../../tools/common/toolsService';20import { ClaudeToolNames, claudeEditTools, getAffectedUrisForEditTool } from './claudeTools';21import { IClaudeSessionStateService } from './claudeSessionStateService';22import { completeToolInvocation, createFormattedToolInvocation } from './toolInvocationFormatter';2324// #region Types2526/** Per-request state passed to each handler */27export interface MessageHandlerRequestContext {28readonly stream: vscode.ChatResponseStream;29readonly toolInvocationToken: vscode.ChatParticipantToolToken;30readonly token: vscode.CancellationToken;31readonly editTracker?: ExternalEditTracker;32}3334/** Mutable state shared across handlers within a single _processMessages loop */35export interface MessageHandlerState {36readonly unprocessedToolCalls: Map<string, Anthropic.Beta.Messages.BetaToolUseBlock>;37readonly otelToolSpans: Map<string, ISpanHandle>;38readonly otelHookSpans: Map<string, ISpanHandle>;39readonly parentTraceContext?: TraceContext;40/** Trace contexts for subagent tool spans, keyed by tool_use_id. Used to parent41* child spans (chat, tool) from subagent messages under the Agent tool span. */42readonly subagentTraceContexts: Map<string, TraceContext>;43}4445export interface MessageHandlerResult {46/** When true, the current request is complete and should be dequeued */47readonly requestComplete: boolean;48}4950// #endregion5152// #region Message key5354/**55* Computes a stable lookup key for an SDK message.56* Non-system messages use `type`; system messages use `type:subtype`.57*/58export function messageKey(message: SDKMessage): string {59if (message.type === 'system') {60return `system:${message.subtype}`;61}62return message.type;63}6465// #endregion6667// #region Known message keys6869/**70* Every message key the Claude Agent SDK can produce.71* When the SDK adds new types, an unknown key will surface as a warning in logs.72*73* Keep this in sync with the SDKMessage union in @anthropic-ai/claude-agent-sdk.74*/75export const ALL_KNOWN_MESSAGE_KEYS = new Set([76'assistant',77'user',78'result',79'stream_event',80// TODO: Show `tool_progress` — has `tool_name` and `elapsed_time_seconds` for live tool status81// low pri, where would we show this?82'tool_progress',83// TODO: Show `tool_use_summary` — has `summary` text describing tool execution results84// low pri, where would we show this?85'tool_use_summary',86// TODO: Show `auth_status` — has `output` lines and `error` for auth failures87'auth_status',88// TODO: Show `rate_limit_event` — has `rate_limit_info.status` (allowed_warning | rejected) and reset time89'rate_limit_event',90// TODO: Show `prompt_suggestion` — has `suggestion` text for follow-up prompts91// low pri, follow ups are dead92'prompt_suggestion',93'system:init',94'system:compact_boundary',95'system:status',96// TODO: Show `system:api_retry` — has `error`, `attempt`, `max_retries` for retry visibility97'system:api_retry',98// TODO: Show `system:local_command_output` — has `content` text from local slash commands99'system:local_command_output',100'system:hook_started',101'system:hook_progress',102'system:hook_response',103// TODO: Show `system:task_notification` — has `summary` and `status` for subagent completion104'system:task_notification',105// TODO: Show `system:task_started` — has `description` and `prompt` for subagent launch106'system:task_started',107// TODO: Show `system:task_progress` — has `description` and `summary` for subagent progress108'system:task_progress',109'system:files_persisted',110'system:elicitation_complete',111]);112113// #endregion114115// #region Individual handlers116117export const DENY_TOOL_MESSAGE = 'The user declined to run the tool';118119export class KnownClaudeError extends Error { }120121interface IManageTodoListToolInputParams {122readonly operation?: 'write' | 'read';123readonly todoList: readonly {124readonly id: number;125readonly title: string;126readonly description: string;127readonly status: 'not-started' | 'in-progress' | 'completed';128}[];129}130131/**132* Model ID used by the SDK for synthetic messages (e.g., "No response requested." from abort).133* These should be filtered out from display and processing.134*/135export const SYNTHETIC_MODEL_ID = '<synthetic>';136137export function handleAssistantMessage(138message: SDKAssistantMessage,139accessor: ServicesAccessor,140sessionId: string,141request: MessageHandlerRequestContext,142state: MessageHandlerState,143): void {144if (message.message.model === SYNTHETIC_MODEL_ID) {145accessor.get(ILogService).trace('[ClaudeMessageDispatch] Skipping synthetic message');146return;147}148149const logService = accessor.get(ILogService);150const otelService = accessor.get(IOTelService);151const { stream } = request;152const { otelToolSpans, unprocessedToolCalls } = state;153154// Resolve the OTel parent context for spans in this message.155// If the message is from a subagent (parent_tool_use_id is set), parent spans156// under the Agent tool's execute_tool span. Otherwise, use the root invoke_agent context.157const spanParentContext = (message.parent_tool_use_id158? state.subagentTraceContexts.get(message.parent_tool_use_id)159: undefined) ?? state.parentTraceContext;160161for (const item of message.message.content) {162if (item.type === 'text') {163stream.markdown(item.text);164} else if (item.type === 'thinking') {165stream.push(new ChatResponseThinkingProgressPart(item.thinking));166} else if (item.type === 'tool_use') {167unprocessedToolCalls.set(item.id, item);168169const toolSpan = otelService.startSpan(`execute_tool ${item.name}`, {170kind: SpanKind.INTERNAL,171attributes: {172[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,173[GenAiAttr.TOOL_NAME]: item.name,174[GenAiAttr.TOOL_CALL_ID]: item.id,175[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,176},177parentTraceContext: spanParentContext,178});179if (item.input !== undefined) {180try {181toolSpan.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(182typeof item.input === 'string' ? item.input : JSON.stringify(item.input)183));184} catch (e) {185logService.warn(`[ClaudeMessageDispatch] Failed to serialize tool arguments for ${item.name}: ${e}`);186}187}188otelToolSpans.set(item.id, toolSpan);189190// For Agent/Task (subagent) tool calls, store the span's trace context so that191// child messages (with parent_tool_use_id = this tool's id) are parented here.192if (item.name === ClaudeToolNames.Task || item.name === 'Agent') {193const toolSpanCtx = toolSpan.getSpanContext();194if (toolSpanCtx) {195state.subagentTraceContexts.set(item.id, toolSpanCtx);196}197}198199if (request.editTracker && claudeEditTools.includes(item.name)) {200try {201const uris = getAffectedUrisForEditTool(item.name, item.input);202void request.editTracker.trackEdit(item.id, uris, stream, request.token);203} catch (e) {204logService.warn(`[ClaudeMessageDispatch] Failed to track edit for ${item.name}: ${e}`);205}206}207208const invocation = createFormattedToolInvocation(item, false);209if (invocation) {210if (message.parent_tool_use_id) {211invocation.subAgentInvocationId = message.parent_tool_use_id;212}213invocation.enablePartialUpdate = true;214stream.push(invocation);215}216}217}218}219220export function handleUserMessage(221message: SDKUserMessage | SDKUserMessageReplay,222accessor: ServicesAccessor,223sessionId: string,224request: MessageHandlerRequestContext,225state: MessageHandlerState,226): void {227if (!Array.isArray(message.message.content)) {228return;229}230for (const toolResult of message.message.content) {231if (toolResult.type === 'tool_result') {232processToolResult(toolResult, accessor, sessionId, request, state);233}234}235}236237function logToolResult(238toolUseId: string,239toolUse: Anthropic.Beta.Messages.BetaToolUseBlock,240toolResult: Anthropic.Messages.ToolResultBlockParam,241logService: ILogService,242requestLogger: IRequestLogger,243otelToolSpans: Map<string, ISpanHandle>,244capturingToken: CapturingToken | undefined,245): void {246// OTel span247const toolSpan = otelToolSpans.get(toolUseId);248if (toolSpan) {249if (toolResult.is_error) {250const errContent = typeof toolResult.content === 'string' ? toolResult.content : 'tool error';251toolSpan.setStatus(SpanStatusCode.ERROR, errContent);252toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(`ERROR: ${errContent}`));253} else {254toolSpan.setStatus(SpanStatusCode.OK);255if (toolResult.content !== undefined) {256try {257const result = typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content);258toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(result));259} catch (e) {260logService.warn(`[ClaudeMessageDispatch] Failed to serialize tool result: ${e}`);261}262}263}264toolSpan.end();265otelToolSpans.delete(toolUseId);266}267268// Request logger269try {270const resultContent = typeof toolResult.content === 'string'271? toolResult.content272: JSON.stringify(toolResult.content, undefined, 2) ?? '';273const response = { content: [new LanguageModelTextPart(resultContent)] };274if (capturingToken) {275void requestLogger.captureInvocation(capturingToken, async () =>276requestLogger.logToolCall(toolUseId, toolUse.name, toolUse.input, response));277} else {278requestLogger.logToolCall(toolUseId, toolUse.name, toolUse.input, response);279}280} catch (e) {281logService.warn(`[ClaudeMessageDispatch] Failed to log tool result: ${e}`);282}283}284285function processToolResult(286toolResult: Anthropic.Messages.ToolResultBlockParam,287accessor: ServicesAccessor,288sessionId: string,289request: MessageHandlerRequestContext,290state: MessageHandlerState,291): void {292const logService = accessor.get(ILogService);293const requestLogger = accessor.get(IRequestLogger);294const claudeSessionStateService = accessor.get(IClaudeSessionStateService);295296const { stream } = request;297const { unprocessedToolCalls, otelToolSpans } = state;298299const toolUseId = toolResult.tool_use_id;300const toolUse = unprocessedToolCalls.get(toolUseId);301if (!toolUse) {302logService.warn(`[ClaudeMessageDispatch] Received tool result for unknown tool use ID: ${toolUseId}`);303return;304}305306unprocessedToolCalls.delete(toolUseId);307308logToolResult(309toolUseId,310toolUse,311toolResult,312logService,313requestLogger,314otelToolSpans,315claudeSessionStateService.getCapturingTokenForSession(sessionId)316);317318// Tool-specific handling319if (toolUse.name === ClaudeToolNames.TodoWrite) {320processTodoWriteTool(toolUse, accessor, request);321} else if (toolUse.name === ClaudeToolNames.EnterPlanMode) {322claudeSessionStateService.setPermissionModeForSession(sessionId, 'plan');323} else if (toolUse.name === ClaudeToolNames.ExitPlanMode) {324claudeSessionStateService.setPermissionModeForSession(sessionId, 'acceptEdits');325} else if (claudeEditTools.includes(toolUse.name)) {326request.editTracker?.completeEdit(toolUseId);327}328329// Create and push a formatted tool invocation to the stream330const invocation = createFormattedToolInvocation(toolUse, true);331if (invocation) {332invocation.enablePartialUpdate = true;333invocation.isComplete = true;334invocation.isError = toolResult.is_error;335if (toolResult.content === DENY_TOOL_MESSAGE) {336invocation.isConfirmed = false;337}338completeToolInvocation(toolUse, toolResult, invocation);339stream.push(invocation);340}341}342343function processTodoWriteTool(344toolUse: Anthropic.Beta.Messages.BetaToolUseBlock,345accessor: ServicesAccessor,346request: MessageHandlerRequestContext,347): void {348const toolsService = accessor.get(IToolsService);349const input = toolUse.input as TodoWriteInput;350toolsService.invokeTool(ToolName.CoreManageTodoList, {351input: {352operation: 'write',353todoList: input.todos.map((todo, i) => ({354id: i,355title: todo.content,356description: '',357status: todo.status === 'pending' ?358'not-started' :359(todo.status === 'in_progress' ?360'in-progress' :361'completed'),362} satisfies IManageTodoListToolInputParams['todoList'][number])),363} satisfies IManageTodoListToolInputParams,364toolInvocationToken: request.toolInvocationToken,365}, request.token);366}367368export function handleCompactBoundary(369_message: SDKCompactBoundaryMessage,370request: MessageHandlerRequestContext,371): void {372request.stream.markdown(`*${l10n.t('Conversation compacted')}*`);373}374375export function handleHookStarted(376message: SDKHookStartedMessage,377accessor: ServicesAccessor,378sessionId: string,379state: MessageHandlerState,380): void {381const otelService = accessor.get(IOTelService);382const span = otelService.startSpan(`${GenAiOperationName.EXECUTE_HOOK} ${message.hook_name}`, {383kind: SpanKind.INTERNAL,384attributes: {385[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK,386[CopilotChatAttr.HOOK_TYPE]: message.hook_event,387'copilot_chat.hook_command': message.hook_name,388'copilot_chat.hook_id': message.hook_id,389[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,390},391parentTraceContext: state.parentTraceContext,392});393state.otelHookSpans.set(message.hook_id, span);394}395396// #region Hook JSON output validator397398/**399* Validator for structured JSON output from hooks (exit code 0 only).400*401* Hooks can return JSON with these fields:402* - `continue`: if false, stops processing entirely403* - `stopReason`: message shown to user when `continue` is false404* - `systemMessage`: warning shown to user405* - `decision`: "block" to prevent the operation406* - `reason`: explanation when `decision` is "block"407*408* @see https://code.claude.com/docs/en/hooks.md409*/410const vHookJsonOutput = vObj({411continue: vBoolean(),412stopReason: vString(),413systemMessage: vString(),414decision: vLiteral('block'),415reason: vString(),416});417418export type HookJsonOutput = ValidatorType<typeof vHookJsonOutput>;419420/**421* Parses JSON output from a hook's stdout.422* Returns the validated fields, or undefined if parsing/validation fails.423* Fields that are missing from the JSON are simply absent from the result.424*/425export function parseHookJsonOutput(stdout: string): Partial<HookJsonOutput> | undefined {426let raw: unknown;427try {428raw = JSON.parse(stdout);429} catch {430return undefined;431}432433if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {434return undefined;435}436437// Use the validator to extract known fields with type safety.438// vObj skips missing optional fields, so partial results are expected.439const result = vHookJsonOutput.validate(raw);440if (result.error) {441// Validation error means some present field had the wrong type —442// extract what we can by validating each field individually.443const obj = raw as Record<string, unknown>;444const partial: Partial<HookJsonOutput> = {};445446const continueResult = vBoolean().validate(obj['continue']);447if (!continueResult.error) {448partial.continue = continueResult.content;449}450const stopReasonResult = vString().validate(obj['stopReason']);451if (!stopReasonResult.error) {452partial.stopReason = stopReasonResult.content;453}454const systemMessageResult = vString().validate(obj['systemMessage']);455if (!systemMessageResult.error) {456partial.systemMessage = systemMessageResult.content;457}458const decisionResult = vLiteral('block').validate(obj['decision']);459if (!decisionResult.error) {460partial.decision = decisionResult.content;461}462const reasonResult = vString().validate(obj['reason']);463if (!reasonResult.error) {464partial.reason = reasonResult.content;465}466467return Object.keys(partial).length > 0 ? partial : undefined;468}469470return result.content;471}472473// #endregion474475/**476* Formats a localized error message for a failed hook.477* @param errorMessage The error message from the hook478* @returns A localized error message string479* @todo use a common function with: https://github.com/microsoft/vscode-copilot-chat/blob/9a9461734da42f28e4e2d0b975ebeae6162e9b4c/src/extension/intents/node/hookResultProcessor.ts#L142480*/481function formatHookErrorMessage(errorMessage: string): string {482if (errorMessage) {483return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details. \nError message: {0}', errorMessage);484}485return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details.');486}487488489export function handleHookProgress(490message: SDKHookProgressMessage,491accessor: ServicesAccessor,492request: MessageHandlerRequestContext,493): void {494const logService = accessor.get(ILogService);495// TODO: can we map these types better496const hookType = message.hook_event as ChatHookType;497const progressText = message.stdout || message.stderr;498499logService.trace(`[ClaudeMessageDispatch] Hook progress "${message.hook_name}" (${message.hook_event}): ${progressText}`);500501if (progressText) {502request.stream.hookProgress(hookType, undefined, progressText);503}504}505506export function handleHookResponse(507message: SDKHookResponseMessage,508accessor: ServicesAccessor,509request: MessageHandlerRequestContext,510state: MessageHandlerState,511): void {512const logService = accessor.get(ILogService);513// TODO: can we map these types better514const hookType = message.hook_event as ChatHookType;515516// #region OTel span517const span = state.otelHookSpans.get(message.hook_id);518if (span) {519if (message.outcome === 'error') {520span.setStatus(SpanStatusCode.ERROR, message.stderr || message.output);521} else if (message.outcome === 'cancelled') {522span.setStatus(SpanStatusCode.ERROR, 'cancelled');523} else {524span.setStatus(SpanStatusCode.OK);525}526if (message.exit_code !== undefined) {527span.setAttribute('copilot_chat.hook_exit_code', message.exit_code);528}529if (message.output) {530span.setAttribute('copilot_chat.hook_output', truncateForOTel(message.output));531}532span.end();533state.otelHookSpans.delete(message.hook_id);534}535// #endregion536537// Cancelled — log only, no user-facing output538if (message.outcome === 'cancelled') {539logService.trace(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) was cancelled`);540return;541}542543// Exit code 2 — blocking error (stderr is the message, JSON ignored)544if (message.exit_code === 2) {545const errorMessage = message.stderr || message.output;546logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) blocking error: ${errorMessage}`);547request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage));548return;549}550551// Other non-zero exit codes — non-blocking warning552if (message.exit_code !== undefined && message.exit_code !== 0) {553const warningMessage = message.stderr || message.output;554const loggedMessage = warningMessage || l10n.t('Exit Code: {0}', message.exit_code);555logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) non-blocking error (exit ${message.exit_code}): ${loggedMessage}`);556if (warningMessage) {557request.stream.hookProgress(hookType, undefined, warningMessage);558}559return;560}561562// Outcome 'error' without a specific exit code — treat as blocking error563if (message.outcome === 'error') {564const errorMessage = message.stderr || message.output;565logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) failed: ${errorMessage}`);566request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage));567return;568}569570// Exit code 0 (or undefined with success outcome) — parse JSON from stdout571if (!message.stdout) {572return;573}574575const parsed = parseHookJsonOutput(message.stdout);576if (!parsed) {577logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" returned non-JSON output`);578return;579}580581// Handle `decision: "block"` with `reason`582if (parsed.decision === 'block') {583request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.reason ?? ''));584return;585}586587// Handle `continue: false` with optional `stopReason`588if (parsed.continue === false) {589request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.stopReason ?? ''));590return;591}592593// Handle `systemMessage` — shown as a warning594if (parsed.systemMessage) {595request.stream.hookProgress(hookType, undefined, parsed.systemMessage);596}597}598599export function handleResultMessage(600message: SDKResultMessage,601request: MessageHandlerRequestContext,602): MessageHandlerResult {603if (message.subtype === 'error_max_turns') {604request.stream.progress(l10n.t('Maximum turns reached ({0})', message.num_turns));605} else if (message.subtype === 'error_during_execution') {606throw new KnownClaudeError(l10n.t('Error during execution'));607}608return { requestComplete: true };609}610611// #endregion612613// #region Dispatch614615/**616* Routes an SDK message to the appropriate handler.617*618* Designed as an `invokeFunction` target — services are resolved from the DI619* accessor, extra arguments are passed through.620*621* Uses TypeScript discriminated union narrowing — no type assertions needed.622* Handlers that don't exist for a given key are logged:623* - Known keys without a handler → trace-logged.624* - Unknown keys → warn-logged.625*/626export function dispatchMessage(627accessor: ServicesAccessor,628message: SDKMessage,629sessionId: string,630request: MessageHandlerRequestContext,631state: MessageHandlerState,632): MessageHandlerResult | undefined {633const logService = accessor.get(ILogService);634635switch (message.type) {636case 'assistant':637handleAssistantMessage(message, accessor, sessionId, request, state);638return;639case 'user':640handleUserMessage(message, accessor, sessionId, request, state);641return;642case 'result':643return handleResultMessage(message, request);644case 'system':645if (message.subtype === 'compact_boundary') {646handleCompactBoundary(message, request);647return;648}649if (message.subtype === 'hook_started') {650handleHookStarted(message, accessor, sessionId, state);651return;652}653if (message.subtype === 'hook_progress') {654handleHookProgress(message, accessor, request);655return;656}657if (message.subtype === 'hook_response') {658handleHookResponse(message, accessor, request, state);659return;660}661break;662}663664// Not handled — log based on whether the key is expected665const key = messageKey(message);666if (ALL_KNOWN_MESSAGE_KEYS.has(key)) {667logService.trace(`[ClaudeMessageDispatch] Unhandled known message type: ${key}`);668} else {669logService.warn(`[ClaudeMessageDispatch] Unknown message type: ${key}`);670}671return undefined;672}673674// #endregion675676677