Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookProgressMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKMessage, SDKResultMessage, SDKUserMessage, SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk';
7
import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools';
8
import type Anthropic from '@anthropic-ai/sdk';
9
import * as l10n from '@vscode/l10n';
10
import type * as vscode from 'vscode';
11
import { vBoolean, vLiteral, vObj, vString, type ValidatorType } from '../../../../platform/configuration/common/validator';
12
import { ILogService } from '../../../../platform/log/common/logService';
13
import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle, type TraceContext } from '../../../../platform/otel/common/index';
14
import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken';
15
import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger';
16
import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';
17
import { ChatResponseThinkingProgressPart, LanguageModelTextPart, type ChatHookType } from '../../../../vscodeTypes';
18
import { ExternalEditTracker } from '../../../chatSessions/common/externalEditTracker';
19
import { ToolName } from '../../../tools/common/toolNames';
20
import { IToolsService } from '../../../tools/common/toolsService';
21
import { ClaudeToolNames, claudeEditTools, getAffectedUrisForEditTool } from './claudeTools';
22
import { IClaudeSessionStateService } from './claudeSessionStateService';
23
import { completeToolInvocation, createFormattedToolInvocation } from './toolInvocationFormatter';
24
25
// #region Types
26
27
/** Per-request state passed to each handler */
28
export interface MessageHandlerRequestContext {
29
readonly stream: vscode.ChatResponseStream;
30
readonly toolInvocationToken: vscode.ChatParticipantToolToken;
31
readonly token: vscode.CancellationToken;
32
readonly editTracker?: ExternalEditTracker;
33
}
34
35
/** Mutable state shared across handlers within a single _processMessages loop */
36
export interface MessageHandlerState {
37
readonly unprocessedToolCalls: Map<string, Anthropic.Beta.Messages.BetaToolUseBlock>;
38
readonly otelToolSpans: Map<string, ISpanHandle>;
39
readonly otelHookSpans: Map<string, ISpanHandle>;
40
readonly parentTraceContext?: TraceContext;
41
/** Trace contexts for subagent tool spans, keyed by tool_use_id. Used to parent
42
* child spans (chat, tool) from subagent messages under the Agent tool span. */
43
readonly subagentTraceContexts: Map<string, TraceContext>;
44
}
45
46
export interface MessageHandlerResult {
47
/** When true, the current request is complete and should be dequeued */
48
readonly requestComplete: boolean;
49
}
50
51
// #endregion
52
53
// #region Message key
54
55
/**
56
* Computes a stable lookup key for an SDK message.
57
* Non-system messages use `type`; system messages use `type:subtype`.
58
*/
59
export function messageKey(message: SDKMessage): string {
60
if (message.type === 'system') {
61
return `system:${message.subtype}`;
62
}
63
return message.type;
64
}
65
66
// #endregion
67
68
// #region Known message keys
69
70
/**
71
* Every message key the Claude Agent SDK can produce.
72
* When the SDK adds new types, an unknown key will surface as a warning in logs.
73
*
74
* Keep this in sync with the SDKMessage union in @anthropic-ai/claude-agent-sdk.
75
*/
76
export const ALL_KNOWN_MESSAGE_KEYS = new Set([
77
'assistant',
78
'user',
79
'result',
80
'stream_event',
81
// TODO: Show `tool_progress` — has `tool_name` and `elapsed_time_seconds` for live tool status
82
// low pri, where would we show this?
83
'tool_progress',
84
// TODO: Show `tool_use_summary` — has `summary` text describing tool execution results
85
// low pri, where would we show this?
86
'tool_use_summary',
87
// TODO: Show `auth_status` — has `output` lines and `error` for auth failures
88
'auth_status',
89
// TODO: Show `rate_limit_event` — has `rate_limit_info.status` (allowed_warning | rejected) and reset time
90
'rate_limit_event',
91
// TODO: Show `prompt_suggestion` — has `suggestion` text for follow-up prompts
92
// low pri, follow ups are dead
93
'prompt_suggestion',
94
'system:init',
95
'system:compact_boundary',
96
'system:status',
97
// TODO: Show `system:api_retry` — has `error`, `attempt`, `max_retries` for retry visibility
98
'system:api_retry',
99
// TODO: Show `system:local_command_output` — has `content` text from local slash commands
100
'system:local_command_output',
101
'system:hook_started',
102
'system:hook_progress',
103
'system:hook_response',
104
// TODO: Show `system:task_notification` — has `summary` and `status` for subagent completion
105
'system:task_notification',
106
// TODO: Show `system:task_started` — has `description` and `prompt` for subagent launch
107
'system:task_started',
108
// TODO: Show `system:task_progress` — has `description` and `summary` for subagent progress
109
'system:task_progress',
110
'system:files_persisted',
111
'system:elicitation_complete',
112
]);
113
114
// #endregion
115
116
// #region Individual handlers
117
118
export const DENY_TOOL_MESSAGE = 'The user declined to run the tool';
119
120
export class KnownClaudeError extends Error { }
121
122
interface IManageTodoListToolInputParams {
123
readonly operation?: 'write' | 'read';
124
readonly todoList: readonly {
125
readonly id: number;
126
readonly title: string;
127
readonly description: string;
128
readonly status: 'not-started' | 'in-progress' | 'completed';
129
}[];
130
}
131
132
/**
133
* Model ID used by the SDK for synthetic messages (e.g., "No response requested." from abort).
134
* These should be filtered out from display and processing.
135
*/
136
export const SYNTHETIC_MODEL_ID = '<synthetic>';
137
138
export function handleAssistantMessage(
139
message: SDKAssistantMessage,
140
accessor: ServicesAccessor,
141
sessionId: string,
142
request: MessageHandlerRequestContext,
143
state: MessageHandlerState,
144
): void {
145
if (message.message.model === SYNTHETIC_MODEL_ID) {
146
accessor.get(ILogService).trace('[ClaudeMessageDispatch] Skipping synthetic message');
147
return;
148
}
149
150
const logService = accessor.get(ILogService);
151
const otelService = accessor.get(IOTelService);
152
const { stream } = request;
153
const { otelToolSpans, unprocessedToolCalls } = state;
154
155
// Resolve the OTel parent context for spans in this message.
156
// If the message is from a subagent (parent_tool_use_id is set), parent spans
157
// under the Agent tool's execute_tool span. Otherwise, use the root invoke_agent context.
158
const spanParentContext = (message.parent_tool_use_id
159
? state.subagentTraceContexts.get(message.parent_tool_use_id)
160
: undefined) ?? state.parentTraceContext;
161
162
for (const item of message.message.content) {
163
if (item.type === 'text') {
164
stream.markdown(item.text);
165
} else if (item.type === 'thinking') {
166
stream.push(new ChatResponseThinkingProgressPart(item.thinking));
167
} else if (item.type === 'tool_use') {
168
unprocessedToolCalls.set(item.id, item);
169
170
const toolSpan = otelService.startSpan(`execute_tool ${item.name}`, {
171
kind: SpanKind.INTERNAL,
172
attributes: {
173
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_TOOL,
174
[GenAiAttr.TOOL_NAME]: item.name,
175
[GenAiAttr.TOOL_CALL_ID]: item.id,
176
[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,
177
},
178
parentTraceContext: spanParentContext,
179
});
180
if (item.input !== undefined) {
181
try {
182
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_ARGUMENTS, truncateForOTel(
183
typeof item.input === 'string' ? item.input : JSON.stringify(item.input)
184
));
185
} catch (e) {
186
logService.warn(`[ClaudeMessageDispatch] Failed to serialize tool arguments for ${item.name}: ${e}`);
187
}
188
}
189
otelToolSpans.set(item.id, toolSpan);
190
191
// For Agent/Task (subagent) tool calls, store the span's trace context so that
192
// child messages (with parent_tool_use_id = this tool's id) are parented here.
193
if (item.name === ClaudeToolNames.Task || item.name === 'Agent') {
194
const toolSpanCtx = toolSpan.getSpanContext();
195
if (toolSpanCtx) {
196
state.subagentTraceContexts.set(item.id, toolSpanCtx);
197
}
198
}
199
200
if (request.editTracker && claudeEditTools.includes(item.name)) {
201
try {
202
const uris = getAffectedUrisForEditTool(item.name, item.input);
203
void request.editTracker.trackEdit(item.id, uris, stream, request.token);
204
} catch (e) {
205
logService.warn(`[ClaudeMessageDispatch] Failed to track edit for ${item.name}: ${e}`);
206
}
207
}
208
209
const invocation = createFormattedToolInvocation(item, false);
210
if (invocation) {
211
if (message.parent_tool_use_id) {
212
invocation.subAgentInvocationId = message.parent_tool_use_id;
213
}
214
invocation.enablePartialUpdate = true;
215
stream.push(invocation);
216
}
217
}
218
}
219
}
220
221
export function handleUserMessage(
222
message: SDKUserMessage | SDKUserMessageReplay,
223
accessor: ServicesAccessor,
224
sessionId: string,
225
request: MessageHandlerRequestContext,
226
state: MessageHandlerState,
227
): void {
228
if (!Array.isArray(message.message.content)) {
229
return;
230
}
231
for (const toolResult of message.message.content) {
232
if (toolResult.type === 'tool_result') {
233
processToolResult(toolResult, accessor, sessionId, request, state);
234
}
235
}
236
}
237
238
function logToolResult(
239
toolUseId: string,
240
toolUse: Anthropic.Beta.Messages.BetaToolUseBlock,
241
toolResult: Anthropic.Messages.ToolResultBlockParam,
242
logService: ILogService,
243
requestLogger: IRequestLogger,
244
otelToolSpans: Map<string, ISpanHandle>,
245
capturingToken: CapturingToken | undefined,
246
): void {
247
// OTel span
248
const toolSpan = otelToolSpans.get(toolUseId);
249
if (toolSpan) {
250
if (toolResult.is_error) {
251
const errContent = typeof toolResult.content === 'string' ? toolResult.content : 'tool error';
252
toolSpan.setStatus(SpanStatusCode.ERROR, errContent);
253
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(`ERROR: ${errContent}`));
254
} else {
255
toolSpan.setStatus(SpanStatusCode.OK);
256
if (toolResult.content !== undefined) {
257
try {
258
const result = typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content);
259
toolSpan.setAttribute(GenAiAttr.TOOL_CALL_RESULT, truncateForOTel(result));
260
} catch (e) {
261
logService.warn(`[ClaudeMessageDispatch] Failed to serialize tool result: ${e}`);
262
}
263
}
264
}
265
toolSpan.end();
266
otelToolSpans.delete(toolUseId);
267
}
268
269
// Request logger
270
try {
271
const resultContent = typeof toolResult.content === 'string'
272
? toolResult.content
273
: JSON.stringify(toolResult.content, undefined, 2) ?? '';
274
const response = { content: [new LanguageModelTextPart(resultContent)] };
275
if (capturingToken) {
276
void requestLogger.captureInvocation(capturingToken, async () =>
277
requestLogger.logToolCall(toolUseId, toolUse.name, toolUse.input, response));
278
} else {
279
requestLogger.logToolCall(toolUseId, toolUse.name, toolUse.input, response);
280
}
281
} catch (e) {
282
logService.warn(`[ClaudeMessageDispatch] Failed to log tool result: ${e}`);
283
}
284
}
285
286
function processToolResult(
287
toolResult: Anthropic.Messages.ToolResultBlockParam,
288
accessor: ServicesAccessor,
289
sessionId: string,
290
request: MessageHandlerRequestContext,
291
state: MessageHandlerState,
292
): void {
293
const logService = accessor.get(ILogService);
294
const requestLogger = accessor.get(IRequestLogger);
295
const claudeSessionStateService = accessor.get(IClaudeSessionStateService);
296
297
const { stream } = request;
298
const { unprocessedToolCalls, otelToolSpans } = state;
299
300
const toolUseId = toolResult.tool_use_id;
301
const toolUse = unprocessedToolCalls.get(toolUseId);
302
if (!toolUse) {
303
logService.warn(`[ClaudeMessageDispatch] Received tool result for unknown tool use ID: ${toolUseId}`);
304
return;
305
}
306
307
unprocessedToolCalls.delete(toolUseId);
308
309
logToolResult(
310
toolUseId,
311
toolUse,
312
toolResult,
313
logService,
314
requestLogger,
315
otelToolSpans,
316
claudeSessionStateService.getCapturingTokenForSession(sessionId)
317
);
318
319
// Tool-specific handling
320
if (toolUse.name === ClaudeToolNames.TodoWrite) {
321
processTodoWriteTool(toolUse, accessor, request);
322
} else if (toolUse.name === ClaudeToolNames.EnterPlanMode) {
323
claudeSessionStateService.setPermissionModeForSession(sessionId, 'plan');
324
} else if (toolUse.name === ClaudeToolNames.ExitPlanMode) {
325
claudeSessionStateService.setPermissionModeForSession(sessionId, 'acceptEdits');
326
} else if (claudeEditTools.includes(toolUse.name)) {
327
request.editTracker?.completeEdit(toolUseId);
328
}
329
330
// Create and push a formatted tool invocation to the stream
331
const invocation = createFormattedToolInvocation(toolUse, true);
332
if (invocation) {
333
invocation.enablePartialUpdate = true;
334
invocation.isComplete = true;
335
invocation.isError = toolResult.is_error;
336
if (toolResult.content === DENY_TOOL_MESSAGE) {
337
invocation.isConfirmed = false;
338
}
339
completeToolInvocation(toolUse, toolResult, invocation);
340
stream.push(invocation);
341
}
342
}
343
344
function processTodoWriteTool(
345
toolUse: Anthropic.Beta.Messages.BetaToolUseBlock,
346
accessor: ServicesAccessor,
347
request: MessageHandlerRequestContext,
348
): void {
349
const toolsService = accessor.get(IToolsService);
350
const input = toolUse.input as TodoWriteInput;
351
toolsService.invokeTool(ToolName.CoreManageTodoList, {
352
input: {
353
operation: 'write',
354
todoList: input.todos.map((todo, i) => ({
355
id: i,
356
title: todo.content,
357
description: '',
358
status: todo.status === 'pending' ?
359
'not-started' :
360
(todo.status === 'in_progress' ?
361
'in-progress' :
362
'completed'),
363
} satisfies IManageTodoListToolInputParams['todoList'][number])),
364
} satisfies IManageTodoListToolInputParams,
365
toolInvocationToken: request.toolInvocationToken,
366
}, request.token);
367
}
368
369
export function handleCompactBoundary(
370
_message: SDKCompactBoundaryMessage,
371
request: MessageHandlerRequestContext,
372
): void {
373
request.stream.markdown(`*${l10n.t('Conversation compacted')}*`);
374
}
375
376
export function handleHookStarted(
377
message: SDKHookStartedMessage,
378
accessor: ServicesAccessor,
379
sessionId: string,
380
state: MessageHandlerState,
381
): void {
382
const otelService = accessor.get(IOTelService);
383
const span = otelService.startSpan(`${GenAiOperationName.EXECUTE_HOOK} ${message.hook_name}`, {
384
kind: SpanKind.INTERNAL,
385
attributes: {
386
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK,
387
[CopilotChatAttr.HOOK_TYPE]: message.hook_event,
388
'copilot_chat.hook_command': message.hook_name,
389
'copilot_chat.hook_id': message.hook_id,
390
[CopilotChatAttr.CHAT_SESSION_ID]: sessionId,
391
},
392
parentTraceContext: state.parentTraceContext,
393
});
394
state.otelHookSpans.set(message.hook_id, span);
395
}
396
397
// #region Hook JSON output validator
398
399
/**
400
* Validator for structured JSON output from hooks (exit code 0 only).
401
*
402
* Hooks can return JSON with these fields:
403
* - `continue`: if false, stops processing entirely
404
* - `stopReason`: message shown to user when `continue` is false
405
* - `systemMessage`: warning shown to user
406
* - `decision`: "block" to prevent the operation
407
* - `reason`: explanation when `decision` is "block"
408
*
409
* @see https://code.claude.com/docs/en/hooks.md
410
*/
411
const vHookJsonOutput = vObj({
412
continue: vBoolean(),
413
stopReason: vString(),
414
systemMessage: vString(),
415
decision: vLiteral('block'),
416
reason: vString(),
417
});
418
419
export type HookJsonOutput = ValidatorType<typeof vHookJsonOutput>;
420
421
/**
422
* Parses JSON output from a hook's stdout.
423
* Returns the validated fields, or undefined if parsing/validation fails.
424
* Fields that are missing from the JSON are simply absent from the result.
425
*/
426
export function parseHookJsonOutput(stdout: string): Partial<HookJsonOutput> | undefined {
427
let raw: unknown;
428
try {
429
raw = JSON.parse(stdout);
430
} catch {
431
return undefined;
432
}
433
434
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
435
return undefined;
436
}
437
438
// Use the validator to extract known fields with type safety.
439
// vObj skips missing optional fields, so partial results are expected.
440
const result = vHookJsonOutput.validate(raw);
441
if (result.error) {
442
// Validation error means some present field had the wrong type —
443
// extract what we can by validating each field individually.
444
const obj = raw as Record<string, unknown>;
445
const partial: Partial<HookJsonOutput> = {};
446
447
const continueResult = vBoolean().validate(obj['continue']);
448
if (!continueResult.error) {
449
partial.continue = continueResult.content;
450
}
451
const stopReasonResult = vString().validate(obj['stopReason']);
452
if (!stopReasonResult.error) {
453
partial.stopReason = stopReasonResult.content;
454
}
455
const systemMessageResult = vString().validate(obj['systemMessage']);
456
if (!systemMessageResult.error) {
457
partial.systemMessage = systemMessageResult.content;
458
}
459
const decisionResult = vLiteral('block').validate(obj['decision']);
460
if (!decisionResult.error) {
461
partial.decision = decisionResult.content;
462
}
463
const reasonResult = vString().validate(obj['reason']);
464
if (!reasonResult.error) {
465
partial.reason = reasonResult.content;
466
}
467
468
return Object.keys(partial).length > 0 ? partial : undefined;
469
}
470
471
return result.content;
472
}
473
474
// #endregion
475
476
/**
477
* Formats a localized error message for a failed hook.
478
* @param errorMessage The error message from the hook
479
* @returns A localized error message string
480
* @todo use a common function with: https://github.com/microsoft/vscode-copilot-chat/blob/9a9461734da42f28e4e2d0b975ebeae6162e9b4c/src/extension/intents/node/hookResultProcessor.ts#L142
481
*/
482
function formatHookErrorMessage(errorMessage: string): string {
483
if (errorMessage) {
484
return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details. \nError message: {0}', errorMessage);
485
}
486
return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details.');
487
}
488
489
490
export function handleHookProgress(
491
message: SDKHookProgressMessage,
492
accessor: ServicesAccessor,
493
request: MessageHandlerRequestContext,
494
): void {
495
const logService = accessor.get(ILogService);
496
// TODO: can we map these types better
497
const hookType = message.hook_event as ChatHookType;
498
const progressText = message.stdout || message.stderr;
499
500
logService.trace(`[ClaudeMessageDispatch] Hook progress "${message.hook_name}" (${message.hook_event}): ${progressText}`);
501
502
if (progressText) {
503
request.stream.hookProgress(hookType, undefined, progressText);
504
}
505
}
506
507
export function handleHookResponse(
508
message: SDKHookResponseMessage,
509
accessor: ServicesAccessor,
510
request: MessageHandlerRequestContext,
511
state: MessageHandlerState,
512
): void {
513
const logService = accessor.get(ILogService);
514
// TODO: can we map these types better
515
const hookType = message.hook_event as ChatHookType;
516
517
// #region OTel span
518
const span = state.otelHookSpans.get(message.hook_id);
519
if (span) {
520
if (message.outcome === 'error') {
521
span.setStatus(SpanStatusCode.ERROR, message.stderr || message.output);
522
} else if (message.outcome === 'cancelled') {
523
span.setStatus(SpanStatusCode.ERROR, 'cancelled');
524
} else {
525
span.setStatus(SpanStatusCode.OK);
526
}
527
if (message.exit_code !== undefined) {
528
span.setAttribute('copilot_chat.hook_exit_code', message.exit_code);
529
}
530
if (message.output) {
531
span.setAttribute('copilot_chat.hook_output', truncateForOTel(message.output));
532
}
533
span.end();
534
state.otelHookSpans.delete(message.hook_id);
535
}
536
// #endregion
537
538
// Cancelled — log only, no user-facing output
539
if (message.outcome === 'cancelled') {
540
logService.trace(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) was cancelled`);
541
return;
542
}
543
544
// Exit code 2 — blocking error (stderr is the message, JSON ignored)
545
if (message.exit_code === 2) {
546
const errorMessage = message.stderr || message.output;
547
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) blocking error: ${errorMessage}`);
548
request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage));
549
return;
550
}
551
552
// Other non-zero exit codes — non-blocking warning
553
if (message.exit_code !== undefined && message.exit_code !== 0) {
554
const warningMessage = message.stderr || message.output;
555
const loggedMessage = warningMessage || l10n.t('Exit Code: {0}', message.exit_code);
556
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) non-blocking error (exit ${message.exit_code}): ${loggedMessage}`);
557
if (warningMessage) {
558
request.stream.hookProgress(hookType, undefined, warningMessage);
559
}
560
return;
561
}
562
563
// Outcome 'error' without a specific exit code — treat as blocking error
564
if (message.outcome === 'error') {
565
const errorMessage = message.stderr || message.output;
566
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) failed: ${errorMessage}`);
567
request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage));
568
return;
569
}
570
571
// Exit code 0 (or undefined with success outcome) — parse JSON from stdout
572
if (!message.stdout) {
573
return;
574
}
575
576
const parsed = parseHookJsonOutput(message.stdout);
577
if (!parsed) {
578
logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" returned non-JSON output`);
579
return;
580
}
581
582
// Handle `decision: "block"` with `reason`
583
if (parsed.decision === 'block') {
584
request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.reason ?? ''));
585
return;
586
}
587
588
// Handle `continue: false` with optional `stopReason`
589
if (parsed.continue === false) {
590
request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.stopReason ?? ''));
591
return;
592
}
593
594
// Handle `systemMessage` — shown as a warning
595
if (parsed.systemMessage) {
596
request.stream.hookProgress(hookType, undefined, parsed.systemMessage);
597
}
598
}
599
600
export function handleResultMessage(
601
message: SDKResultMessage,
602
request: MessageHandlerRequestContext,
603
): MessageHandlerResult {
604
if (message.subtype === 'error_max_turns') {
605
request.stream.progress(l10n.t('Maximum turns reached ({0})', message.num_turns));
606
} else if (message.subtype === 'error_during_execution') {
607
throw new KnownClaudeError(l10n.t('Error during execution'));
608
}
609
return { requestComplete: true };
610
}
611
612
// #endregion
613
614
// #region Dispatch
615
616
/**
617
* Routes an SDK message to the appropriate handler.
618
*
619
* Designed as an `invokeFunction` target — services are resolved from the DI
620
* accessor, extra arguments are passed through.
621
*
622
* Uses TypeScript discriminated union narrowing — no type assertions needed.
623
* Handlers that don't exist for a given key are logged:
624
* - Known keys without a handler → trace-logged.
625
* - Unknown keys → warn-logged.
626
*/
627
export function dispatchMessage(
628
accessor: ServicesAccessor,
629
message: SDKMessage,
630
sessionId: string,
631
request: MessageHandlerRequestContext,
632
state: MessageHandlerState,
633
): MessageHandlerResult | undefined {
634
const logService = accessor.get(ILogService);
635
636
switch (message.type) {
637
case 'assistant':
638
handleAssistantMessage(message, accessor, sessionId, request, state);
639
return;
640
case 'user':
641
handleUserMessage(message, accessor, sessionId, request, state);
642
return;
643
case 'result':
644
return handleResultMessage(message, request);
645
case 'system':
646
if (message.subtype === 'compact_boundary') {
647
handleCompactBoundary(message, request);
648
return;
649
}
650
if (message.subtype === 'hook_started') {
651
handleHookStarted(message, accessor, sessionId, state);
652
return;
653
}
654
if (message.subtype === 'hook_progress') {
655
handleHookProgress(message, accessor, request);
656
return;
657
}
658
if (message.subtype === 'hook_response') {
659
handleHookResponse(message, accessor, request, state);
660
return;
661
}
662
break;
663
}
664
665
// Not handled — log based on whether the key is expected
666
const key = messageKey(message);
667
if (ALL_KNOWN_MESSAGE_KEYS.has(key)) {
668
logService.trace(`[ClaudeMessageDispatch] Unhandled known message type: ${key}`);
669
} else {
670
logService.warn(`[ClaudeMessageDispatch] Unknown message type: ${key}`);
671
}
672
return undefined;
673
}
674
675
// #endregion
676
677