Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts
13406 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 { NonNullableUsage, SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookProgressMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKResultError, SDKResultSuccess, SDKStatusMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
7
import type Anthropic from '@anthropic-ai/sdk';
8
import { beforeEach, describe, expect, it, vi } from 'vitest';
9
import type * as vscode from 'vscode';
10
import { ILogService } from '../../../../../platform/log/common/logService';
11
import { IOTelService, type ISpanHandle } from '../../../../../platform/otel/common/otelService';
12
import { IRequestLogger } from '../../../../../platform/requestLogger/common/requestLogger';
13
import { TestLogService } from '../../../../../platform/testing/common/testLogService';
14
import type { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';
15
import { IToolsService } from '../../../../tools/common/toolsService';
16
import {
17
ALL_KNOWN_MESSAGE_KEYS,
18
DENY_TOOL_MESSAGE,
19
dispatchMessage,
20
handleAssistantMessage,
21
handleCompactBoundary,
22
handleHookProgress,
23
handleHookResponse,
24
handleHookStarted,
25
handleResultMessage,
26
handleUserMessage,
27
KnownClaudeError,
28
MessageHandlerRequestContext,
29
MessageHandlerState,
30
messageKey,
31
parseHookJsonOutput,
32
SYNTHETIC_MODEL_ID,
33
} from '../claudeMessageDispatch';
34
import { ClaudeToolNames } from '../claudeTools';
35
import { IClaudeSessionStateService } from '../claudeSessionStateService';
36
37
// #region Test helpers
38
39
const TEST_UUID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as `${string}-${string}-${string}-${string}-${string}`;
40
const TEST_SESSION = 'test-session';
41
42
const noopSpan: ISpanHandle = {
43
setAttribute() { },
44
setAttributes() { },
45
setStatus() { },
46
recordException() { },
47
addEvent() { },
48
getSpanContext() { return undefined; },
49
end() { },
50
};
51
52
interface TestServices {
53
readonly logService: TestLogService;
54
readonly otelService: IOTelService;
55
readonly toolsService: IToolsService;
56
readonly requestLogger: { logToolCall: ReturnType<typeof vi.fn>; captureInvocation: ReturnType<typeof vi.fn> };
57
readonly sessionStateService: { setPermissionModeForSession: ReturnType<typeof vi.fn>; getCapturingTokenForSession: ReturnType<typeof vi.fn> };
58
}
59
60
function createTestServices(): TestServices {
61
return {
62
logService: new TestLogService(),
63
otelService: { startSpan: () => noopSpan } as Pick<IOTelService, 'startSpan'> as IOTelService,
64
toolsService: { invokeTool: vi.fn() } as Pick<IToolsService, 'invokeTool'> as IToolsService,
65
requestLogger: { logToolCall: vi.fn(), captureInvocation: vi.fn() },
66
sessionStateService: { setPermissionModeForSession: vi.fn(), getCapturingTokenForSession: vi.fn().mockReturnValue(undefined) },
67
};
68
}
69
70
const TEST_SESSION_ID = 'test-session-id';
71
72
function createAccessor(services: TestServices): ServicesAccessor {
73
const serviceMap = new Map<unknown, unknown>([
74
[ILogService, services.logService],
75
[IOTelService, services.otelService],
76
[IToolsService, services.toolsService],
77
[IRequestLogger, services.requestLogger],
78
[IClaudeSessionStateService, services.sessionStateService],
79
]);
80
return { get: <T>(id: { toString(): string }): T => serviceMap.get(id) as T };
81
}
82
83
function createRequestContext(): MessageHandlerRequestContext {
84
return {
85
stream: {
86
markdown: vi.fn(),
87
push: vi.fn(),
88
progress: vi.fn(),
89
hookProgress: vi.fn(),
90
} as Pick<vscode.ChatResponseStream, 'markdown' | 'push' | 'progress' | 'hookProgress'> as vscode.ChatResponseStream,
91
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
92
token: { isCancellationRequested: false } as vscode.CancellationToken,
93
};
94
}
95
96
function createState(): MessageHandlerState {
97
return {
98
unprocessedToolCalls: new Map(),
99
otelToolSpans: new Map(),
100
otelHookSpans: new Map(),
101
subagentTraceContexts: new Map(),
102
};
103
}
104
105
function createMockSpan(): ISpanHandle {
106
return {
107
setAttribute: vi.fn(),
108
setAttributes: vi.fn(),
109
setStatus: vi.fn(),
110
recordException: vi.fn(),
111
addEvent: vi.fn(),
112
getSpanContext: vi.fn(),
113
end: vi.fn(),
114
};
115
}
116
117
/**
118
* Creates a minimal BetaUsage satisfying the SDK type.
119
* New required fields from SDK upgrades get added here once.
120
*/
121
function makeBetaUsage(): Anthropic.Beta.Messages.BetaUsage {
122
return {
123
input_tokens: 10,
124
output_tokens: 20,
125
cache_creation_input_tokens: 0,
126
cache_read_input_tokens: 0,
127
cache_creation: null,
128
inference_geo: null,
129
iterations: null,
130
server_tool_use: null,
131
service_tier: null,
132
speed: null,
133
};
134
}
135
136
/**
137
* Creates a NonNullableUsage (all fields non-null) for SDKResult factories.
138
*/
139
function makeNonNullableUsage(): NonNullableUsage {
140
return {
141
input_tokens: 10,
142
output_tokens: 20,
143
cache_creation_input_tokens: 0,
144
cache_read_input_tokens: 0,
145
cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },
146
inference_geo: 'us',
147
iterations: [],
148
server_tool_use: { web_fetch_requests: 0, web_search_requests: 0 },
149
service_tier: 'standard',
150
speed: 'standard',
151
};
152
}
153
154
function makeAssistantMessage(content: Anthropic.Beta.Messages.BetaContentBlock[], parentToolUseId: string | null = null, model = 'claude-3-sonnet'): SDKAssistantMessage {
155
return {
156
type: 'assistant',
157
message: {
158
id: 'msg-1',
159
type: 'message',
160
model,
161
role: 'assistant',
162
content,
163
container: null,
164
context_management: null,
165
stop_reason: 'end_turn',
166
stop_sequence: null,
167
usage: makeBetaUsage(),
168
},
169
parent_tool_use_id: parentToolUseId,
170
uuid: TEST_UUID,
171
session_id: TEST_SESSION,
172
};
173
}
174
175
function makeUserMessage(content: Anthropic.Messages.ContentBlockParam[]): SDKUserMessage {
176
return {
177
type: 'user',
178
message: { role: 'user', content },
179
parent_tool_use_id: null,
180
session_id: TEST_SESSION,
181
};
182
}
183
184
function makeSuccessResult(numTurns = 5): SDKResultSuccess {
185
return {
186
type: 'result',
187
subtype: 'success',
188
duration_ms: 1000,
189
duration_api_ms: 800,
190
is_error: false,
191
num_turns: numTurns,
192
result: '',
193
stop_reason: null,
194
total_cost_usd: 0.01,
195
usage: makeNonNullableUsage(),
196
modelUsage: {},
197
permission_denials: [],
198
uuid: TEST_UUID,
199
session_id: TEST_SESSION,
200
};
201
}
202
203
function makeErrorResult(subtype: SDKResultError['subtype'], numTurns = 5): SDKResultError {
204
return {
205
type: 'result',
206
subtype,
207
duration_ms: 1000,
208
duration_api_ms: 800,
209
is_error: true,
210
num_turns: numTurns,
211
stop_reason: null,
212
total_cost_usd: 0.01,
213
usage: makeNonNullableUsage(),
214
modelUsage: {},
215
permission_denials: [],
216
errors: [],
217
uuid: TEST_UUID,
218
session_id: TEST_SESSION,
219
};
220
}
221
222
function makeCompactBoundary(): SDKCompactBoundaryMessage {
223
return {
224
type: 'system',
225
subtype: 'compact_boundary',
226
compact_metadata: { trigger: 'auto', pre_tokens: 100 },
227
uuid: TEST_UUID,
228
session_id: TEST_SESSION,
229
};
230
}
231
232
function makeStatusMessage(): SDKStatusMessage {
233
return {
234
type: 'system',
235
subtype: 'status',
236
status: null,
237
uuid: TEST_UUID,
238
session_id: TEST_SESSION,
239
};
240
}
241
242
function makeHookStarted(hookId = 'hook-1', hookName = 'my-hook', hookEvent = 'PreToolUse'): SDKHookStartedMessage {
243
return {
244
type: 'system',
245
subtype: 'hook_started',
246
hook_id: hookId,
247
hook_name: hookName,
248
hook_event: hookEvent,
249
uuid: TEST_UUID,
250
session_id: TEST_SESSION,
251
};
252
}
253
254
function makeHookResponse(
255
hookId = 'hook-1',
256
outcome: 'success' | 'error' | 'cancelled' = 'success',
257
overrides: Partial<Pick<SDKHookResponseMessage, 'output' | 'stderr' | 'stdout' | 'exit_code' | 'hook_name' | 'hook_event'>> = {},
258
): SDKHookResponseMessage {
259
return {
260
type: 'system',
261
subtype: 'hook_response',
262
hook_id: hookId,
263
hook_name: overrides.hook_name ?? 'my-hook',
264
hook_event: overrides.hook_event ?? 'PreToolUse',
265
output: overrides.output ?? '',
266
stdout: overrides.stdout ?? '',
267
stderr: overrides.stderr ?? '',
268
exit_code: overrides.exit_code,
269
outcome,
270
uuid: TEST_UUID,
271
session_id: TEST_SESSION,
272
};
273
}
274
275
function makeHookProgress(
276
hookId = 'hook-1',
277
overrides: Partial<Pick<SDKHookProgressMessage, 'stdout' | 'stderr' | 'output' | 'hook_name' | 'hook_event'>> = {},
278
): SDKHookProgressMessage {
279
return {
280
type: 'system',
281
subtype: 'hook_progress',
282
hook_id: hookId,
283
hook_name: overrides.hook_name ?? 'my-hook',
284
hook_event: overrides.hook_event ?? 'PreToolUse',
285
stdout: overrides.stdout ?? '',
286
stderr: overrides.stderr ?? '',
287
output: overrides.output ?? '',
288
uuid: TEST_UUID,
289
session_id: TEST_SESSION,
290
};
291
}
292
293
// #endregion
294
295
// #region messageKey
296
297
describe('messageKey', () => {
298
it('returns type for non-system messages', () => {
299
expect(messageKey(makeAssistantMessage([]))).toBe('assistant');
300
expect(messageKey(makeUserMessage([]))).toBe('user');
301
expect(messageKey(makeSuccessResult())).toBe('result');
302
});
303
304
it('returns type:subtype for system messages', () => {
305
expect(messageKey(makeCompactBoundary())).toBe('system:compact_boundary');
306
expect(messageKey(makeStatusMessage())).toBe('system:status');
307
});
308
});
309
310
// #endregion
311
312
// #region dispatchMessage
313
314
describe('dispatchMessage', () => {
315
let services: TestServices;
316
let accessor: ServicesAccessor;
317
let request: MessageHandlerRequestContext;
318
let state: MessageHandlerState;
319
320
beforeEach(() => {
321
services = createTestServices();
322
accessor = createAccessor(services);
323
request = createRequestContext();
324
state = createState();
325
});
326
327
it('dispatches assistant messages', () => {
328
const message = makeAssistantMessage([{ type: 'text', text: 'Hello', citations: null }]);
329
const result = dispatchMessage(accessor, message, TEST_SESSION_ID, request, state);
330
expect(result).toBeUndefined(); // assistant handler returns void
331
expect(request.stream.markdown).toHaveBeenCalledWith('Hello');
332
});
333
334
it('dispatches result messages and returns requestComplete', () => {
335
const result = dispatchMessage(accessor, makeSuccessResult(), TEST_SESSION_ID, request, state);
336
expect(result).toEqual({ requestComplete: true });
337
});
338
339
it('dispatches compact_boundary messages', () => {
340
dispatchMessage(accessor, makeCompactBoundary(), TEST_SESSION_ID, request, state);
341
expect(request.stream.markdown).toHaveBeenCalledWith('*Conversation compacted*');
342
});
343
344
it('trace-logs known but unhandled message types', () => {
345
const traceSpy = vi.spyOn(services.logService, 'trace');
346
const result = dispatchMessage(accessor, makeStatusMessage(), TEST_SESSION_ID, request, state);
347
expect(result).toBeUndefined();
348
expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('Unhandled known message type: system:status'));
349
});
350
});
351
352
// #endregion
353
354
// #region handleAssistantMessage
355
356
describe('handleAssistantMessage', () => {
357
let services: TestServices;
358
let accessor: ServicesAccessor;
359
let request: MessageHandlerRequestContext;
360
let state: MessageHandlerState;
361
362
beforeEach(() => {
363
services = createTestServices();
364
accessor = createAccessor(services);
365
request = createRequestContext();
366
state = createState();
367
});
368
369
it('skips synthetic messages', () => {
370
handleAssistantMessage(
371
makeAssistantMessage([{ type: 'text', text: 'should be ignored', citations: null }], null, SYNTHETIC_MODEL_ID),
372
accessor, TEST_SESSION_ID, request, state,
373
);
374
expect(request.stream.markdown).not.toHaveBeenCalled();
375
expect(request.stream.push).not.toHaveBeenCalled();
376
});
377
378
it('streams text content as markdown', () => {
379
handleAssistantMessage(
380
makeAssistantMessage([{ type: 'text', text: 'Hello world', citations: null }]),
381
accessor, TEST_SESSION_ID, request, state,
382
);
383
expect(request.stream.markdown).toHaveBeenCalledWith('Hello world');
384
});
385
386
it('pushes thinking content', () => {
387
handleAssistantMessage(
388
makeAssistantMessage([{ type: 'thinking', thinking: 'Let me think...', signature: 'sig' }]),
389
accessor, TEST_SESSION_ID, request, state,
390
);
391
expect(request.stream.push).toHaveBeenCalled();
392
});
393
394
it('tracks tool_use blocks in unprocessedToolCalls', () => {
395
handleAssistantMessage(
396
makeAssistantMessage([{
397
type: 'tool_use', id: 'tool-123', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' },
398
}]),
399
accessor, TEST_SESSION_ID, request, state,
400
);
401
expect(state.unprocessedToolCalls.has('tool-123')).toBe(true);
402
});
403
404
it('creates OTel spans for tool_use blocks', () => {
405
const startSpanSpy = vi.spyOn(services.otelService, 'startSpan');
406
handleAssistantMessage(
407
makeAssistantMessage([{
408
type: 'tool_use', id: 'tool-456', name: ClaudeToolNames.Bash, input: { command: 'ls' },
409
}]),
410
accessor, TEST_SESSION_ID, request, state,
411
);
412
expect(startSpanSpy).toHaveBeenCalledWith(
413
expect.stringContaining('execute_tool'),
414
expect.objectContaining({ attributes: expect.any(Object) }),
415
);
416
expect(state.otelToolSpans.has('tool-456')).toBe(true);
417
});
418
419
it('sets subAgentInvocationId when parent_tool_use_id is present', () => {
420
handleAssistantMessage(
421
makeAssistantMessage([{
422
type: 'tool_use', id: 'tool-789', name: ClaudeToolNames.Bash, input: { command: 'echo test' },
423
}], 'parent-tool-id'),
424
accessor, TEST_SESSION_ID, request, state,
425
);
426
expect(request.stream.push).toHaveBeenCalled();
427
});
428
});
429
430
// #endregion
431
432
// #region handleUserMessage
433
434
describe('handleUserMessage', () => {
435
let services: TestServices;
436
let accessor: ServicesAccessor;
437
let request: MessageHandlerRequestContext;
438
let state: MessageHandlerState;
439
440
beforeEach(() => {
441
services = createTestServices();
442
accessor = createAccessor(services);
443
request = createRequestContext();
444
state = createState();
445
});
446
447
it('processes tool_result blocks that match unprocessed tool calls', () => {
448
const toolUse: Anthropic.Beta.Messages.BetaToolUseBlock = {
449
type: 'tool_use', id: 'tool-100', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' },
450
};
451
state.unprocessedToolCalls.set('tool-100', toolUse);
452
453
const mockSpan = createMockSpan();
454
state.otelToolSpans.set('tool-100', mockSpan);
455
456
handleUserMessage(
457
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-100', content: 'file contents here' }]),
458
accessor, TEST_SESSION_ID, request, state,
459
);
460
461
expect(state.unprocessedToolCalls.has('tool-100')).toBe(false);
462
expect(mockSpan.end).toHaveBeenCalled();
463
});
464
465
it('skips tool_result blocks with no matching tool call', () => {
466
handleUserMessage(
467
makeUserMessage([{ type: 'tool_result', tool_use_id: 'nonexistent-tool', content: 'result' }]),
468
accessor, TEST_SESSION_ID, request, state,
469
);
470
expect(request.stream.push).not.toHaveBeenCalled();
471
});
472
473
it('handles non-array content gracefully', () => {
474
const message: SDKUserMessage = {
475
type: 'user',
476
message: { role: 'user', content: 'just a string' },
477
parent_tool_use_id: null,
478
session_id: TEST_SESSION,
479
};
480
// Should not throw
481
handleUserMessage(message, accessor, TEST_SESSION_ID, request, state);
482
});
483
484
it('marks denied tool results with isConfirmed=false', () => {
485
const toolUse: Anthropic.Beta.Messages.BetaToolUseBlock = {
486
type: 'tool_use', id: 'tool-denied', name: ClaudeToolNames.Bash, input: { command: 'rm -rf /' },
487
};
488
state.unprocessedToolCalls.set('tool-denied', toolUse);
489
490
handleUserMessage(
491
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-denied', content: DENY_TOOL_MESSAGE }]),
492
accessor, TEST_SESSION_ID, request, state,
493
);
494
expect(request.stream.push).toHaveBeenCalled();
495
});
496
497
it('invokes CoreManageTodoList for TodoWrite tool results', () => {
498
const toolUse: Anthropic.Beta.Messages.BetaToolUseBlock = {
499
type: 'tool_use',
500
id: 'tool-todo',
501
name: ClaudeToolNames.TodoWrite,
502
input: {
503
todos: [
504
{ content: 'Fix bug', status: 'in_progress', activeForm: 'Fixing bug' },
505
{ content: 'Write tests', status: 'pending', activeForm: 'Writing tests' },
506
]
507
},
508
};
509
state.unprocessedToolCalls.set('tool-todo', toolUse);
510
511
handleUserMessage(
512
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-todo', content: 'success' }]),
513
accessor, TEST_SESSION_ID, request, state,
514
);
515
516
expect(services.toolsService.invokeTool).toHaveBeenCalledWith(
517
'manage_todo_list',
518
expect.objectContaining({
519
input: expect.objectContaining({
520
operation: 'write',
521
todoList: expect.arrayContaining([
522
expect.objectContaining({ title: 'Fix bug', status: 'in-progress' }),
523
expect.objectContaining({ title: 'Write tests', status: 'not-started' }),
524
]),
525
}),
526
}),
527
expect.anything(),
528
);
529
});
530
531
it('sets permission mode to plan on EnterPlanMode tool completion', () => {
532
state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.EnterPlanMode, input: {} });
533
handleUserMessage(
534
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'success' }]),
535
accessor, TEST_SESSION_ID, request, state,
536
);
537
538
expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenCalledWith(TEST_SESSION_ID, 'plan');
539
});
540
541
it('sets permission mode to acceptEdits on ExitPlanMode tool completion', () => {
542
state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.ExitPlanMode, input: {} });
543
handleUserMessage(
544
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'success' }]),
545
accessor, TEST_SESSION_ID, request, state,
546
);
547
548
expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenCalledWith(TEST_SESSION_ID, 'acceptEdits');
549
});
550
551
it('handles EnterPlanMode followed by ExitPlanMode in same message', () => {
552
state.unprocessedToolCalls.set('tool-a', { type: 'tool_use', id: 'tool-a', name: ClaudeToolNames.EnterPlanMode, input: {} });
553
state.unprocessedToolCalls.set('tool-b', { type: 'tool_use', id: 'tool-b', name: ClaudeToolNames.ExitPlanMode, input: {} });
554
555
handleUserMessage(
556
makeUserMessage([
557
{ type: 'tool_result', tool_use_id: 'tool-a', content: 'success' },
558
{ type: 'tool_result', tool_use_id: 'tool-b', content: 'success' },
559
]),
560
accessor, TEST_SESSION_ID, request, state,
561
);
562
563
expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenCalledTimes(2);
564
expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenNthCalledWith(1, TEST_SESSION_ID, 'plan');
565
expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenNthCalledWith(2, TEST_SESSION_ID, 'acceptEdits');
566
});
567
568
it('does not set permission mode for non-plan-mode tools', () => {
569
state.unprocessedToolCalls.set('tool-x', { type: 'tool_use', id: 'tool-x', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' } });
570
handleUserMessage(
571
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-x', content: 'success' }]),
572
accessor, TEST_SESSION_ID, request, state,
573
);
574
575
expect(services.sessionStateService.setPermissionModeForSession).not.toHaveBeenCalled();
576
});
577
578
it('calls logToolCall on IRequestLogger for completed tools', () => {
579
state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' } });
580
handleUserMessage(
581
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'file contents here' }]),
582
accessor, TEST_SESSION_ID, request, state,
583
);
584
585
expect(services.requestLogger.logToolCall).toHaveBeenCalledWith(
586
'tool-1',
587
ClaudeToolNames.Read,
588
{ file_path: '/test.ts' },
589
{ content: [expect.objectContaining({ value: 'file contents here' })] },
590
);
591
});
592
593
it('uses captureInvocation when a capturing token is set', () => {
594
const mockToken = { label: 'test' };
595
services.sessionStateService.getCapturingTokenForSession.mockReturnValue(mockToken);
596
597
state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' } });
598
handleUserMessage(
599
makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'file contents here' }]),
600
accessor, TEST_SESSION_ID, request, state,
601
);
602
603
expect(services.requestLogger.captureInvocation).toHaveBeenCalledWith(mockToken, expect.any(Function));
604
});
605
});
606
607
// #endregion
608
609
// #region handleCompactBoundary
610
611
describe('handleCompactBoundary', () => {
612
it('streams compact notification markdown', () => {
613
const request = createRequestContext();
614
handleCompactBoundary(makeCompactBoundary(), request);
615
expect(request.stream.markdown).toHaveBeenCalledWith('*Conversation compacted*');
616
});
617
});
618
619
// #endregion
620
621
// #region handleHookStarted / handleHookResponse
622
623
describe('handleHookStarted', () => {
624
let services: TestServices;
625
let accessor: ServicesAccessor;
626
let state: MessageHandlerState;
627
628
beforeEach(() => {
629
services = createTestServices();
630
accessor = createAccessor(services);
631
state = createState();
632
});
633
634
it('creates an OTel span and stores it by hook_id', () => {
635
const startSpanSpy = vi.spyOn(services.otelService, 'startSpan');
636
handleHookStarted(makeHookStarted('hook-42', 'lint-check', 'PreToolUse'), accessor, TEST_SESSION_ID, state);
637
638
expect(startSpanSpy).toHaveBeenCalledWith(
639
'execute_hook lint-check',
640
expect.objectContaining({ attributes: expect.any(Object) }),
641
);
642
expect(state.otelHookSpans.has('hook-42')).toBe(true);
643
});
644
});
645
646
describe('handleHookResponse', () => {
647
let services: TestServices;
648
let accessor: ServicesAccessor;
649
let request: MessageHandlerRequestContext;
650
let state: MessageHandlerState;
651
652
beforeEach(() => {
653
services = createTestServices();
654
accessor = createAccessor(services);
655
request = createRequestContext();
656
state = createState();
657
});
658
659
it('ends the OTel span with OK on success', () => {
660
const mockSpan = createMockSpan();
661
state.otelHookSpans.set('hook-1', mockSpan);
662
663
handleHookResponse(makeHookResponse('hook-1', 'success'), accessor, request, state);
664
665
expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything()); // SpanStatusCode.OK
666
expect(mockSpan.end).toHaveBeenCalled();
667
expect(state.otelHookSpans.has('hook-1')).toBe(false);
668
});
669
670
it('ends the OTel span with ERROR on failure and surfaces error via hookProgress', () => {
671
const mockSpan = createMockSpan();
672
state.otelHookSpans.set('hook-1', mockSpan);
673
674
handleHookResponse(
675
makeHookResponse('hook-1', 'error', { stderr: 'lint failed', hook_name: 'lint-check', hook_event: 'PreToolUse' }),
676
accessor, request, state,
677
);
678
679
expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything(), 'lint failed');
680
expect(mockSpan.end).toHaveBeenCalled();
681
expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', expect.stringContaining('lint failed'));
682
expect(request.stream.markdown).not.toHaveBeenCalled();
683
});
684
685
it('does not surface anything to user on success with no stdout', () => {
686
const mockSpan = createMockSpan();
687
state.otelHookSpans.set('hook-1', mockSpan);
688
689
handleHookResponse(makeHookResponse('hook-1', 'success'), accessor, request, state);
690
691
expect(request.stream.hookProgress).not.toHaveBeenCalled();
692
expect(request.stream.markdown).not.toHaveBeenCalled();
693
});
694
695
it('handles cancelled outcome — log only, no hookProgress', () => {
696
const mockSpan = createMockSpan();
697
state.otelHookSpans.set('hook-1', mockSpan);
698
699
handleHookResponse(makeHookResponse('hook-1', 'cancelled'), accessor, request, state);
700
701
expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything(), 'cancelled');
702
expect(mockSpan.end).toHaveBeenCalled();
703
expect(request.stream.hookProgress).not.toHaveBeenCalled();
704
});
705
706
it('handles response without a matching started span gracefully', () => {
707
// No span in otelHookSpans — should not throw
708
handleHookResponse(
709
makeHookResponse('nonexistent', 'error', { stderr: 'some error', hook_name: 'my-hook', hook_event: 'PreToolUse' }),
710
accessor, request, state,
711
);
712
// Still surfaces the error via hookProgress
713
expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', expect.stringContaining('some error'));
714
});
715
716
// #region Exit code handling
717
718
it('exit code 2 — blocking error via hookProgress with stderr', () => {
719
handleHookResponse(
720
makeHookResponse('hook-1', 'error', { exit_code: 2, stderr: 'blocked!', hook_event: 'Stop' }),
721
accessor, request, state,
722
);
723
expect(request.stream.hookProgress).toHaveBeenCalledWith('Stop', expect.stringContaining('blocked!'));
724
});
725
726
it('exit code 2 — ignores JSON in stdout', () => {
727
handleHookResponse(
728
makeHookResponse('hook-1', 'error', {
729
exit_code: 2,
730
stderr: 'real error',
731
stdout: '{"decision": "block", "reason": "should be ignored"}',
732
hook_event: 'PostToolUse',
733
}),
734
accessor, request, state,
735
);
736
// Should use stderr, not JSON
737
expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', expect.stringContaining('real error'));
738
});
739
740
it('other non-zero exit codes — non-blocking warning', () => {
741
handleHookResponse(
742
makeHookResponse('hook-1', 'error', { exit_code: 1, stderr: 'warning text', hook_event: 'PreToolUse' }),
743
accessor, request, state,
744
);
745
expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'warning text');
746
});
747
748
it('other non-zero exit codes without stderr — no hookProgress', () => {
749
handleHookResponse(
750
makeHookResponse('hook-1', 'error', { exit_code: 1, hook_event: 'PreToolUse' }),
751
accessor, request, state,
752
);
753
expect(request.stream.hookProgress).not.toHaveBeenCalled();
754
});
755
756
// #endregion
757
758
// #region JSON output parsing (exit code 0)
759
760
it('exit code 0 — JSON with continue:false calls hookProgress with stopReason', () => {
761
handleHookResponse(
762
makeHookResponse('hook-1', 'success', {
763
exit_code: 0,
764
stdout: JSON.stringify({ continue: false, stopReason: 'Build failed' }),
765
hook_event: 'UserPromptSubmit',
766
}),
767
accessor, request, state,
768
);
769
expect(request.stream.hookProgress).toHaveBeenCalledWith('UserPromptSubmit', expect.stringContaining('Build failed'));
770
});
771
772
it('exit code 0 — JSON with continue:false and no stopReason uses empty string', () => {
773
handleHookResponse(
774
makeHookResponse('hook-1', 'success', {
775
exit_code: 0,
776
stdout: JSON.stringify({ continue: false }),
777
hook_event: 'Stop',
778
}),
779
accessor, request, state,
780
);
781
expect(request.stream.hookProgress).toHaveBeenCalledWith('Stop', expect.any(String));
782
});
783
784
it('exit code 0 — JSON with decision:block calls hookProgress with reason', () => {
785
handleHookResponse(
786
makeHookResponse('hook-1', 'success', {
787
exit_code: 0,
788
stdout: JSON.stringify({ decision: 'block', reason: 'Tests must pass' }),
789
hook_event: 'PostToolUse',
790
}),
791
accessor, request, state,
792
);
793
expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', expect.stringContaining('Tests must pass'));
794
});
795
796
it('exit code 0 — JSON with systemMessage shows warning via hookProgress', () => {
797
handleHookResponse(
798
makeHookResponse('hook-1', 'success', {
799
exit_code: 0,
800
stdout: JSON.stringify({ systemMessage: 'Watch out for side effects' }),
801
hook_event: 'PreToolUse',
802
}),
803
accessor, request, state,
804
);
805
expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'Watch out for side effects');
806
});
807
808
it('exit code 0 — non-JSON stdout logs warning, no hookProgress', () => {
809
const warnSpy = vi.spyOn(services.logService, 'warn');
810
handleHookResponse(
811
makeHookResponse('hook-1', 'success', {
812
exit_code: 0,
813
stdout: 'not valid json {',
814
hook_event: 'PreToolUse',
815
}),
816
accessor, request, state,
817
);
818
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-JSON output'));
819
expect(request.stream.hookProgress).not.toHaveBeenCalled();
820
});
821
822
it('exit code 0 — empty stdout means success, no hookProgress', () => {
823
handleHookResponse(
824
makeHookResponse('hook-1', 'success', { exit_code: 0, stdout: '' }),
825
accessor, request, state,
826
);
827
expect(request.stream.hookProgress).not.toHaveBeenCalled();
828
});
829
830
it('exit code 0 — JSON with continue:true and no systemMessage is silent', () => {
831
handleHookResponse(
832
makeHookResponse('hook-1', 'success', {
833
exit_code: 0,
834
stdout: JSON.stringify({ continue: true }),
835
hook_event: 'PreToolUse',
836
}),
837
accessor, request, state,
838
);
839
expect(request.stream.hookProgress).not.toHaveBeenCalled();
840
});
841
842
// #endregion
843
});
844
845
// #endregion
846
847
// #region handleHookProgress
848
849
describe('handleHookProgress', () => {
850
let services: TestServices;
851
let accessor: ServicesAccessor;
852
let request: MessageHandlerRequestContext;
853
854
beforeEach(() => {
855
services = createTestServices();
856
accessor = createAccessor(services);
857
request = createRequestContext();
858
});
859
860
it('shows stdout via hookProgress as system message', () => {
861
handleHookProgress(
862
makeHookProgress('hook-1', { stdout: 'Running lint...', hook_event: 'PreToolUse' }),
863
accessor, request,
864
);
865
expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'Running lint...');
866
});
867
868
it('falls back to stderr when stdout is empty', () => {
869
handleHookProgress(
870
makeHookProgress('hook-1', { stderr: 'warning output', hook_event: 'PostToolUse' }),
871
accessor, request,
872
);
873
expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', undefined, 'warning output');
874
});
875
876
it('does not call hookProgress when both stdout and stderr are empty', () => {
877
handleHookProgress(
878
makeHookProgress('hook-1'),
879
accessor, request,
880
);
881
expect(request.stream.hookProgress).not.toHaveBeenCalled();
882
});
883
884
it('trace-logs progress output', () => {
885
const traceSpy = vi.spyOn(services.logService, 'trace');
886
handleHookProgress(
887
makeHookProgress('hook-1', { stdout: 'progress text', hook_name: 'my-hook', hook_event: 'PreToolUse' }),
888
accessor, request,
889
);
890
expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('Hook progress'));
891
expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('progress text'));
892
});
893
});
894
895
// #endregion
896
897
// #region parseHookJsonOutput
898
899
describe('parseHookJsonOutput', () => {
900
it('parses valid JSON with all fields', () => {
901
const result = parseHookJsonOutput(JSON.stringify({
902
continue: false,
903
stopReason: 'Build failed',
904
systemMessage: 'Warning',
905
decision: 'block',
906
reason: 'Not allowed',
907
}));
908
expect(result).toEqual({
909
continue: false,
910
stopReason: 'Build failed',
911
systemMessage: 'Warning',
912
decision: 'block',
913
reason: 'Not allowed',
914
});
915
});
916
917
it('parses JSON with only some fields', () => {
918
const result = parseHookJsonOutput(JSON.stringify({ continue: false }));
919
expect(result).toEqual({ continue: false });
920
});
921
922
it('returns undefined for non-JSON string', () => {
923
expect(parseHookJsonOutput('not json')).toBeUndefined();
924
});
925
926
it('returns undefined for JSON null', () => {
927
expect(parseHookJsonOutput('null')).toBeUndefined();
928
});
929
930
it('returns undefined for JSON array', () => {
931
expect(parseHookJsonOutput('[]')).toBeUndefined();
932
});
933
934
it('returns undefined for JSON primitive', () => {
935
expect(parseHookJsonOutput('"hello"')).toBeUndefined();
936
});
937
938
it('ignores fields with wrong types via fallback validation', () => {
939
const result = parseHookJsonOutput(JSON.stringify({
940
continue: 'not-a-boolean',
941
stopReason: 42,
942
systemMessage: 'valid string',
943
}));
944
expect(result).toEqual({ systemMessage: 'valid string' });
945
});
946
947
it('returns undefined when all fields have wrong types', () => {
948
const result = parseHookJsonOutput(JSON.stringify({
949
continue: 'true',
950
decision: 'allow',
951
}));
952
expect(result).toBeUndefined();
953
});
954
955
it('ignores unknown fields', () => {
956
const result = parseHookJsonOutput(JSON.stringify({
957
continue: true,
958
unknownField: 'whatever',
959
}));
960
expect(result).toEqual({ continue: true });
961
});
962
963
it('rejects decision values other than block', () => {
964
const result = parseHookJsonOutput(JSON.stringify({
965
decision: 'allow',
966
systemMessage: 'hello',
967
}));
968
// decision: 'allow' fails vLiteral('block'), but systemMessage succeeds
969
expect(result).toEqual({ systemMessage: 'hello' });
970
});
971
});
972
973
// #endregion
974
975
// #region handleResultMessage
976
977
describe('handleResultMessage', () => {
978
it('returns requestComplete for success', () => {
979
const result = handleResultMessage(makeSuccessResult(), createRequestContext());
980
expect(result).toEqual({ requestComplete: true });
981
});
982
983
it('shows progress for error_max_turns', () => {
984
const request = createRequestContext();
985
const result = handleResultMessage(makeErrorResult('error_max_turns', 25), request);
986
expect(result).toEqual({ requestComplete: true });
987
expect(request.stream.progress).toHaveBeenCalled();
988
});
989
990
it('throws KnownClaudeError for error_during_execution', () => {
991
expect(
992
() => handleResultMessage(makeErrorResult('error_during_execution'), createRequestContext()),
993
).toThrow(KnownClaudeError);
994
});
995
});
996
997
// #endregion
998
999
// #region ALL_KNOWN_MESSAGE_KEYS coverage
1000
1001
describe('ALL_KNOWN_MESSAGE_KEYS', () => {
1002
it('contains entries for all non-system SDKMessage type values', () => {
1003
const expectedNonSystemTypes = [
1004
'assistant', 'user', 'result', 'stream_event',
1005
'tool_progress', 'tool_use_summary', 'auth_status',
1006
'rate_limit_event', 'prompt_suggestion',
1007
];
1008
for (const key of expectedNonSystemTypes) {
1009
expect(ALL_KNOWN_MESSAGE_KEYS.has(key)).toBe(true);
1010
}
1011
});
1012
1013
it('contains entries for all system subtype values', () => {
1014
const expectedSystemSubtypes = [
1015
'init', 'compact_boundary', 'status', 'api_retry', 'local_command_output',
1016
'hook_started', 'hook_progress', 'hook_response',
1017
'task_notification', 'task_started', 'task_progress',
1018
'files_persisted', 'elicitation_complete',
1019
];
1020
for (const subtype of expectedSystemSubtypes) {
1021
expect(ALL_KNOWN_MESSAGE_KEYS.has(`system:${subtype}`)).toBe(true);
1022
}
1023
});
1024
});
1025
1026
// #endregion
1027
1028