Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts
13406 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 { NonNullableUsage, SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookProgressMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKResultError, SDKResultSuccess, SDKStatusMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';6import type Anthropic from '@anthropic-ai/sdk';7import { beforeEach, describe, expect, it, vi } from 'vitest';8import type * as vscode from 'vscode';9import { ILogService } from '../../../../../platform/log/common/logService';10import { IOTelService, type ISpanHandle } from '../../../../../platform/otel/common/otelService';11import { IRequestLogger } from '../../../../../platform/requestLogger/common/requestLogger';12import { TestLogService } from '../../../../../platform/testing/common/testLogService';13import type { ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation';14import { IToolsService } from '../../../../tools/common/toolsService';15import {16ALL_KNOWN_MESSAGE_KEYS,17DENY_TOOL_MESSAGE,18dispatchMessage,19handleAssistantMessage,20handleCompactBoundary,21handleHookProgress,22handleHookResponse,23handleHookStarted,24handleResultMessage,25handleUserMessage,26KnownClaudeError,27MessageHandlerRequestContext,28MessageHandlerState,29messageKey,30parseHookJsonOutput,31SYNTHETIC_MODEL_ID,32} from '../claudeMessageDispatch';33import { ClaudeToolNames } from '../claudeTools';34import { IClaudeSessionStateService } from '../claudeSessionStateService';3536// #region Test helpers3738const TEST_UUID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as `${string}-${string}-${string}-${string}-${string}`;39const TEST_SESSION = 'test-session';4041const noopSpan: ISpanHandle = {42setAttribute() { },43setAttributes() { },44setStatus() { },45recordException() { },46addEvent() { },47getSpanContext() { return undefined; },48end() { },49};5051interface TestServices {52readonly logService: TestLogService;53readonly otelService: IOTelService;54readonly toolsService: IToolsService;55readonly requestLogger: { logToolCall: ReturnType<typeof vi.fn>; captureInvocation: ReturnType<typeof vi.fn> };56readonly sessionStateService: { setPermissionModeForSession: ReturnType<typeof vi.fn>; getCapturingTokenForSession: ReturnType<typeof vi.fn> };57}5859function createTestServices(): TestServices {60return {61logService: new TestLogService(),62otelService: { startSpan: () => noopSpan } as Pick<IOTelService, 'startSpan'> as IOTelService,63toolsService: { invokeTool: vi.fn() } as Pick<IToolsService, 'invokeTool'> as IToolsService,64requestLogger: { logToolCall: vi.fn(), captureInvocation: vi.fn() },65sessionStateService: { setPermissionModeForSession: vi.fn(), getCapturingTokenForSession: vi.fn().mockReturnValue(undefined) },66};67}6869const TEST_SESSION_ID = 'test-session-id';7071function createAccessor(services: TestServices): ServicesAccessor {72const serviceMap = new Map<unknown, unknown>([73[ILogService, services.logService],74[IOTelService, services.otelService],75[IToolsService, services.toolsService],76[IRequestLogger, services.requestLogger],77[IClaudeSessionStateService, services.sessionStateService],78]);79return { get: <T>(id: { toString(): string }): T => serviceMap.get(id) as T };80}8182function createRequestContext(): MessageHandlerRequestContext {83return {84stream: {85markdown: vi.fn(),86push: vi.fn(),87progress: vi.fn(),88hookProgress: vi.fn(),89} as Pick<vscode.ChatResponseStream, 'markdown' | 'push' | 'progress' | 'hookProgress'> as vscode.ChatResponseStream,90toolInvocationToken: {} as vscode.ChatParticipantToolToken,91token: { isCancellationRequested: false } as vscode.CancellationToken,92};93}9495function createState(): MessageHandlerState {96return {97unprocessedToolCalls: new Map(),98otelToolSpans: new Map(),99otelHookSpans: new Map(),100subagentTraceContexts: new Map(),101};102}103104function createMockSpan(): ISpanHandle {105return {106setAttribute: vi.fn(),107setAttributes: vi.fn(),108setStatus: vi.fn(),109recordException: vi.fn(),110addEvent: vi.fn(),111getSpanContext: vi.fn(),112end: vi.fn(),113};114}115116/**117* Creates a minimal BetaUsage satisfying the SDK type.118* New required fields from SDK upgrades get added here once.119*/120function makeBetaUsage(): Anthropic.Beta.Messages.BetaUsage {121return {122input_tokens: 10,123output_tokens: 20,124cache_creation_input_tokens: 0,125cache_read_input_tokens: 0,126cache_creation: null,127inference_geo: null,128iterations: null,129server_tool_use: null,130service_tier: null,131speed: null,132};133}134135/**136* Creates a NonNullableUsage (all fields non-null) for SDKResult factories.137*/138function makeNonNullableUsage(): NonNullableUsage {139return {140input_tokens: 10,141output_tokens: 20,142cache_creation_input_tokens: 0,143cache_read_input_tokens: 0,144cache_creation: { ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0 },145inference_geo: 'us',146iterations: [],147server_tool_use: { web_fetch_requests: 0, web_search_requests: 0 },148service_tier: 'standard',149speed: 'standard',150};151}152153function makeAssistantMessage(content: Anthropic.Beta.Messages.BetaContentBlock[], parentToolUseId: string | null = null, model = 'claude-3-sonnet'): SDKAssistantMessage {154return {155type: 'assistant',156message: {157id: 'msg-1',158type: 'message',159model,160role: 'assistant',161content,162container: null,163context_management: null,164stop_reason: 'end_turn',165stop_sequence: null,166usage: makeBetaUsage(),167},168parent_tool_use_id: parentToolUseId,169uuid: TEST_UUID,170session_id: TEST_SESSION,171};172}173174function makeUserMessage(content: Anthropic.Messages.ContentBlockParam[]): SDKUserMessage {175return {176type: 'user',177message: { role: 'user', content },178parent_tool_use_id: null,179session_id: TEST_SESSION,180};181}182183function makeSuccessResult(numTurns = 5): SDKResultSuccess {184return {185type: 'result',186subtype: 'success',187duration_ms: 1000,188duration_api_ms: 800,189is_error: false,190num_turns: numTurns,191result: '',192stop_reason: null,193total_cost_usd: 0.01,194usage: makeNonNullableUsage(),195modelUsage: {},196permission_denials: [],197uuid: TEST_UUID,198session_id: TEST_SESSION,199};200}201202function makeErrorResult(subtype: SDKResultError['subtype'], numTurns = 5): SDKResultError {203return {204type: 'result',205subtype,206duration_ms: 1000,207duration_api_ms: 800,208is_error: true,209num_turns: numTurns,210stop_reason: null,211total_cost_usd: 0.01,212usage: makeNonNullableUsage(),213modelUsage: {},214permission_denials: [],215errors: [],216uuid: TEST_UUID,217session_id: TEST_SESSION,218};219}220221function makeCompactBoundary(): SDKCompactBoundaryMessage {222return {223type: 'system',224subtype: 'compact_boundary',225compact_metadata: { trigger: 'auto', pre_tokens: 100 },226uuid: TEST_UUID,227session_id: TEST_SESSION,228};229}230231function makeStatusMessage(): SDKStatusMessage {232return {233type: 'system',234subtype: 'status',235status: null,236uuid: TEST_UUID,237session_id: TEST_SESSION,238};239}240241function makeHookStarted(hookId = 'hook-1', hookName = 'my-hook', hookEvent = 'PreToolUse'): SDKHookStartedMessage {242return {243type: 'system',244subtype: 'hook_started',245hook_id: hookId,246hook_name: hookName,247hook_event: hookEvent,248uuid: TEST_UUID,249session_id: TEST_SESSION,250};251}252253function makeHookResponse(254hookId = 'hook-1',255outcome: 'success' | 'error' | 'cancelled' = 'success',256overrides: Partial<Pick<SDKHookResponseMessage, 'output' | 'stderr' | 'stdout' | 'exit_code' | 'hook_name' | 'hook_event'>> = {},257): SDKHookResponseMessage {258return {259type: 'system',260subtype: 'hook_response',261hook_id: hookId,262hook_name: overrides.hook_name ?? 'my-hook',263hook_event: overrides.hook_event ?? 'PreToolUse',264output: overrides.output ?? '',265stdout: overrides.stdout ?? '',266stderr: overrides.stderr ?? '',267exit_code: overrides.exit_code,268outcome,269uuid: TEST_UUID,270session_id: TEST_SESSION,271};272}273274function makeHookProgress(275hookId = 'hook-1',276overrides: Partial<Pick<SDKHookProgressMessage, 'stdout' | 'stderr' | 'output' | 'hook_name' | 'hook_event'>> = {},277): SDKHookProgressMessage {278return {279type: 'system',280subtype: 'hook_progress',281hook_id: hookId,282hook_name: overrides.hook_name ?? 'my-hook',283hook_event: overrides.hook_event ?? 'PreToolUse',284stdout: overrides.stdout ?? '',285stderr: overrides.stderr ?? '',286output: overrides.output ?? '',287uuid: TEST_UUID,288session_id: TEST_SESSION,289};290}291292// #endregion293294// #region messageKey295296describe('messageKey', () => {297it('returns type for non-system messages', () => {298expect(messageKey(makeAssistantMessage([]))).toBe('assistant');299expect(messageKey(makeUserMessage([]))).toBe('user');300expect(messageKey(makeSuccessResult())).toBe('result');301});302303it('returns type:subtype for system messages', () => {304expect(messageKey(makeCompactBoundary())).toBe('system:compact_boundary');305expect(messageKey(makeStatusMessage())).toBe('system:status');306});307});308309// #endregion310311// #region dispatchMessage312313describe('dispatchMessage', () => {314let services: TestServices;315let accessor: ServicesAccessor;316let request: MessageHandlerRequestContext;317let state: MessageHandlerState;318319beforeEach(() => {320services = createTestServices();321accessor = createAccessor(services);322request = createRequestContext();323state = createState();324});325326it('dispatches assistant messages', () => {327const message = makeAssistantMessage([{ type: 'text', text: 'Hello', citations: null }]);328const result = dispatchMessage(accessor, message, TEST_SESSION_ID, request, state);329expect(result).toBeUndefined(); // assistant handler returns void330expect(request.stream.markdown).toHaveBeenCalledWith('Hello');331});332333it('dispatches result messages and returns requestComplete', () => {334const result = dispatchMessage(accessor, makeSuccessResult(), TEST_SESSION_ID, request, state);335expect(result).toEqual({ requestComplete: true });336});337338it('dispatches compact_boundary messages', () => {339dispatchMessage(accessor, makeCompactBoundary(), TEST_SESSION_ID, request, state);340expect(request.stream.markdown).toHaveBeenCalledWith('*Conversation compacted*');341});342343it('trace-logs known but unhandled message types', () => {344const traceSpy = vi.spyOn(services.logService, 'trace');345const result = dispatchMessage(accessor, makeStatusMessage(), TEST_SESSION_ID, request, state);346expect(result).toBeUndefined();347expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('Unhandled known message type: system:status'));348});349});350351// #endregion352353// #region handleAssistantMessage354355describe('handleAssistantMessage', () => {356let services: TestServices;357let accessor: ServicesAccessor;358let request: MessageHandlerRequestContext;359let state: MessageHandlerState;360361beforeEach(() => {362services = createTestServices();363accessor = createAccessor(services);364request = createRequestContext();365state = createState();366});367368it('skips synthetic messages', () => {369handleAssistantMessage(370makeAssistantMessage([{ type: 'text', text: 'should be ignored', citations: null }], null, SYNTHETIC_MODEL_ID),371accessor, TEST_SESSION_ID, request, state,372);373expect(request.stream.markdown).not.toHaveBeenCalled();374expect(request.stream.push).not.toHaveBeenCalled();375});376377it('streams text content as markdown', () => {378handleAssistantMessage(379makeAssistantMessage([{ type: 'text', text: 'Hello world', citations: null }]),380accessor, TEST_SESSION_ID, request, state,381);382expect(request.stream.markdown).toHaveBeenCalledWith('Hello world');383});384385it('pushes thinking content', () => {386handleAssistantMessage(387makeAssistantMessage([{ type: 'thinking', thinking: 'Let me think...', signature: 'sig' }]),388accessor, TEST_SESSION_ID, request, state,389);390expect(request.stream.push).toHaveBeenCalled();391});392393it('tracks tool_use blocks in unprocessedToolCalls', () => {394handleAssistantMessage(395makeAssistantMessage([{396type: 'tool_use', id: 'tool-123', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' },397}]),398accessor, TEST_SESSION_ID, request, state,399);400expect(state.unprocessedToolCalls.has('tool-123')).toBe(true);401});402403it('creates OTel spans for tool_use blocks', () => {404const startSpanSpy = vi.spyOn(services.otelService, 'startSpan');405handleAssistantMessage(406makeAssistantMessage([{407type: 'tool_use', id: 'tool-456', name: ClaudeToolNames.Bash, input: { command: 'ls' },408}]),409accessor, TEST_SESSION_ID, request, state,410);411expect(startSpanSpy).toHaveBeenCalledWith(412expect.stringContaining('execute_tool'),413expect.objectContaining({ attributes: expect.any(Object) }),414);415expect(state.otelToolSpans.has('tool-456')).toBe(true);416});417418it('sets subAgentInvocationId when parent_tool_use_id is present', () => {419handleAssistantMessage(420makeAssistantMessage([{421type: 'tool_use', id: 'tool-789', name: ClaudeToolNames.Bash, input: { command: 'echo test' },422}], 'parent-tool-id'),423accessor, TEST_SESSION_ID, request, state,424);425expect(request.stream.push).toHaveBeenCalled();426});427});428429// #endregion430431// #region handleUserMessage432433describe('handleUserMessage', () => {434let services: TestServices;435let accessor: ServicesAccessor;436let request: MessageHandlerRequestContext;437let state: MessageHandlerState;438439beforeEach(() => {440services = createTestServices();441accessor = createAccessor(services);442request = createRequestContext();443state = createState();444});445446it('processes tool_result blocks that match unprocessed tool calls', () => {447const toolUse: Anthropic.Beta.Messages.BetaToolUseBlock = {448type: 'tool_use', id: 'tool-100', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' },449};450state.unprocessedToolCalls.set('tool-100', toolUse);451452const mockSpan = createMockSpan();453state.otelToolSpans.set('tool-100', mockSpan);454455handleUserMessage(456makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-100', content: 'file contents here' }]),457accessor, TEST_SESSION_ID, request, state,458);459460expect(state.unprocessedToolCalls.has('tool-100')).toBe(false);461expect(mockSpan.end).toHaveBeenCalled();462});463464it('skips tool_result blocks with no matching tool call', () => {465handleUserMessage(466makeUserMessage([{ type: 'tool_result', tool_use_id: 'nonexistent-tool', content: 'result' }]),467accessor, TEST_SESSION_ID, request, state,468);469expect(request.stream.push).not.toHaveBeenCalled();470});471472it('handles non-array content gracefully', () => {473const message: SDKUserMessage = {474type: 'user',475message: { role: 'user', content: 'just a string' },476parent_tool_use_id: null,477session_id: TEST_SESSION,478};479// Should not throw480handleUserMessage(message, accessor, TEST_SESSION_ID, request, state);481});482483it('marks denied tool results with isConfirmed=false', () => {484const toolUse: Anthropic.Beta.Messages.BetaToolUseBlock = {485type: 'tool_use', id: 'tool-denied', name: ClaudeToolNames.Bash, input: { command: 'rm -rf /' },486};487state.unprocessedToolCalls.set('tool-denied', toolUse);488489handleUserMessage(490makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-denied', content: DENY_TOOL_MESSAGE }]),491accessor, TEST_SESSION_ID, request, state,492);493expect(request.stream.push).toHaveBeenCalled();494});495496it('invokes CoreManageTodoList for TodoWrite tool results', () => {497const toolUse: Anthropic.Beta.Messages.BetaToolUseBlock = {498type: 'tool_use',499id: 'tool-todo',500name: ClaudeToolNames.TodoWrite,501input: {502todos: [503{ content: 'Fix bug', status: 'in_progress', activeForm: 'Fixing bug' },504{ content: 'Write tests', status: 'pending', activeForm: 'Writing tests' },505]506},507};508state.unprocessedToolCalls.set('tool-todo', toolUse);509510handleUserMessage(511makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-todo', content: 'success' }]),512accessor, TEST_SESSION_ID, request, state,513);514515expect(services.toolsService.invokeTool).toHaveBeenCalledWith(516'manage_todo_list',517expect.objectContaining({518input: expect.objectContaining({519operation: 'write',520todoList: expect.arrayContaining([521expect.objectContaining({ title: 'Fix bug', status: 'in-progress' }),522expect.objectContaining({ title: 'Write tests', status: 'not-started' }),523]),524}),525}),526expect.anything(),527);528});529530it('sets permission mode to plan on EnterPlanMode tool completion', () => {531state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.EnterPlanMode, input: {} });532handleUserMessage(533makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'success' }]),534accessor, TEST_SESSION_ID, request, state,535);536537expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenCalledWith(TEST_SESSION_ID, 'plan');538});539540it('sets permission mode to acceptEdits on ExitPlanMode tool completion', () => {541state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.ExitPlanMode, input: {} });542handleUserMessage(543makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'success' }]),544accessor, TEST_SESSION_ID, request, state,545);546547expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenCalledWith(TEST_SESSION_ID, 'acceptEdits');548});549550it('handles EnterPlanMode followed by ExitPlanMode in same message', () => {551state.unprocessedToolCalls.set('tool-a', { type: 'tool_use', id: 'tool-a', name: ClaudeToolNames.EnterPlanMode, input: {} });552state.unprocessedToolCalls.set('tool-b', { type: 'tool_use', id: 'tool-b', name: ClaudeToolNames.ExitPlanMode, input: {} });553554handleUserMessage(555makeUserMessage([556{ type: 'tool_result', tool_use_id: 'tool-a', content: 'success' },557{ type: 'tool_result', tool_use_id: 'tool-b', content: 'success' },558]),559accessor, TEST_SESSION_ID, request, state,560);561562expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenCalledTimes(2);563expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenNthCalledWith(1, TEST_SESSION_ID, 'plan');564expect(services.sessionStateService.setPermissionModeForSession).toHaveBeenNthCalledWith(2, TEST_SESSION_ID, 'acceptEdits');565});566567it('does not set permission mode for non-plan-mode tools', () => {568state.unprocessedToolCalls.set('tool-x', { type: 'tool_use', id: 'tool-x', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' } });569handleUserMessage(570makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-x', content: 'success' }]),571accessor, TEST_SESSION_ID, request, state,572);573574expect(services.sessionStateService.setPermissionModeForSession).not.toHaveBeenCalled();575});576577it('calls logToolCall on IRequestLogger for completed tools', () => {578state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' } });579handleUserMessage(580makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'file contents here' }]),581accessor, TEST_SESSION_ID, request, state,582);583584expect(services.requestLogger.logToolCall).toHaveBeenCalledWith(585'tool-1',586ClaudeToolNames.Read,587{ file_path: '/test.ts' },588{ content: [expect.objectContaining({ value: 'file contents here' })] },589);590});591592it('uses captureInvocation when a capturing token is set', () => {593const mockToken = { label: 'test' };594services.sessionStateService.getCapturingTokenForSession.mockReturnValue(mockToken);595596state.unprocessedToolCalls.set('tool-1', { type: 'tool_use', id: 'tool-1', name: ClaudeToolNames.Read, input: { file_path: '/test.ts' } });597handleUserMessage(598makeUserMessage([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'file contents here' }]),599accessor, TEST_SESSION_ID, request, state,600);601602expect(services.requestLogger.captureInvocation).toHaveBeenCalledWith(mockToken, expect.any(Function));603});604});605606// #endregion607608// #region handleCompactBoundary609610describe('handleCompactBoundary', () => {611it('streams compact notification markdown', () => {612const request = createRequestContext();613handleCompactBoundary(makeCompactBoundary(), request);614expect(request.stream.markdown).toHaveBeenCalledWith('*Conversation compacted*');615});616});617618// #endregion619620// #region handleHookStarted / handleHookResponse621622describe('handleHookStarted', () => {623let services: TestServices;624let accessor: ServicesAccessor;625let state: MessageHandlerState;626627beforeEach(() => {628services = createTestServices();629accessor = createAccessor(services);630state = createState();631});632633it('creates an OTel span and stores it by hook_id', () => {634const startSpanSpy = vi.spyOn(services.otelService, 'startSpan');635handleHookStarted(makeHookStarted('hook-42', 'lint-check', 'PreToolUse'), accessor, TEST_SESSION_ID, state);636637expect(startSpanSpy).toHaveBeenCalledWith(638'execute_hook lint-check',639expect.objectContaining({ attributes: expect.any(Object) }),640);641expect(state.otelHookSpans.has('hook-42')).toBe(true);642});643});644645describe('handleHookResponse', () => {646let services: TestServices;647let accessor: ServicesAccessor;648let request: MessageHandlerRequestContext;649let state: MessageHandlerState;650651beforeEach(() => {652services = createTestServices();653accessor = createAccessor(services);654request = createRequestContext();655state = createState();656});657658it('ends the OTel span with OK on success', () => {659const mockSpan = createMockSpan();660state.otelHookSpans.set('hook-1', mockSpan);661662handleHookResponse(makeHookResponse('hook-1', 'success'), accessor, request, state);663664expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything()); // SpanStatusCode.OK665expect(mockSpan.end).toHaveBeenCalled();666expect(state.otelHookSpans.has('hook-1')).toBe(false);667});668669it('ends the OTel span with ERROR on failure and surfaces error via hookProgress', () => {670const mockSpan = createMockSpan();671state.otelHookSpans.set('hook-1', mockSpan);672673handleHookResponse(674makeHookResponse('hook-1', 'error', { stderr: 'lint failed', hook_name: 'lint-check', hook_event: 'PreToolUse' }),675accessor, request, state,676);677678expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything(), 'lint failed');679expect(mockSpan.end).toHaveBeenCalled();680expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', expect.stringContaining('lint failed'));681expect(request.stream.markdown).not.toHaveBeenCalled();682});683684it('does not surface anything to user on success with no stdout', () => {685const mockSpan = createMockSpan();686state.otelHookSpans.set('hook-1', mockSpan);687688handleHookResponse(makeHookResponse('hook-1', 'success'), accessor, request, state);689690expect(request.stream.hookProgress).not.toHaveBeenCalled();691expect(request.stream.markdown).not.toHaveBeenCalled();692});693694it('handles cancelled outcome — log only, no hookProgress', () => {695const mockSpan = createMockSpan();696state.otelHookSpans.set('hook-1', mockSpan);697698handleHookResponse(makeHookResponse('hook-1', 'cancelled'), accessor, request, state);699700expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything(), 'cancelled');701expect(mockSpan.end).toHaveBeenCalled();702expect(request.stream.hookProgress).not.toHaveBeenCalled();703});704705it('handles response without a matching started span gracefully', () => {706// No span in otelHookSpans — should not throw707handleHookResponse(708makeHookResponse('nonexistent', 'error', { stderr: 'some error', hook_name: 'my-hook', hook_event: 'PreToolUse' }),709accessor, request, state,710);711// Still surfaces the error via hookProgress712expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', expect.stringContaining('some error'));713});714715// #region Exit code handling716717it('exit code 2 — blocking error via hookProgress with stderr', () => {718handleHookResponse(719makeHookResponse('hook-1', 'error', { exit_code: 2, stderr: 'blocked!', hook_event: 'Stop' }),720accessor, request, state,721);722expect(request.stream.hookProgress).toHaveBeenCalledWith('Stop', expect.stringContaining('blocked!'));723});724725it('exit code 2 — ignores JSON in stdout', () => {726handleHookResponse(727makeHookResponse('hook-1', 'error', {728exit_code: 2,729stderr: 'real error',730stdout: '{"decision": "block", "reason": "should be ignored"}',731hook_event: 'PostToolUse',732}),733accessor, request, state,734);735// Should use stderr, not JSON736expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', expect.stringContaining('real error'));737});738739it('other non-zero exit codes — non-blocking warning', () => {740handleHookResponse(741makeHookResponse('hook-1', 'error', { exit_code: 1, stderr: 'warning text', hook_event: 'PreToolUse' }),742accessor, request, state,743);744expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'warning text');745});746747it('other non-zero exit codes without stderr — no hookProgress', () => {748handleHookResponse(749makeHookResponse('hook-1', 'error', { exit_code: 1, hook_event: 'PreToolUse' }),750accessor, request, state,751);752expect(request.stream.hookProgress).not.toHaveBeenCalled();753});754755// #endregion756757// #region JSON output parsing (exit code 0)758759it('exit code 0 — JSON with continue:false calls hookProgress with stopReason', () => {760handleHookResponse(761makeHookResponse('hook-1', 'success', {762exit_code: 0,763stdout: JSON.stringify({ continue: false, stopReason: 'Build failed' }),764hook_event: 'UserPromptSubmit',765}),766accessor, request, state,767);768expect(request.stream.hookProgress).toHaveBeenCalledWith('UserPromptSubmit', expect.stringContaining('Build failed'));769});770771it('exit code 0 — JSON with continue:false and no stopReason uses empty string', () => {772handleHookResponse(773makeHookResponse('hook-1', 'success', {774exit_code: 0,775stdout: JSON.stringify({ continue: false }),776hook_event: 'Stop',777}),778accessor, request, state,779);780expect(request.stream.hookProgress).toHaveBeenCalledWith('Stop', expect.any(String));781});782783it('exit code 0 — JSON with decision:block calls hookProgress with reason', () => {784handleHookResponse(785makeHookResponse('hook-1', 'success', {786exit_code: 0,787stdout: JSON.stringify({ decision: 'block', reason: 'Tests must pass' }),788hook_event: 'PostToolUse',789}),790accessor, request, state,791);792expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', expect.stringContaining('Tests must pass'));793});794795it('exit code 0 — JSON with systemMessage shows warning via hookProgress', () => {796handleHookResponse(797makeHookResponse('hook-1', 'success', {798exit_code: 0,799stdout: JSON.stringify({ systemMessage: 'Watch out for side effects' }),800hook_event: 'PreToolUse',801}),802accessor, request, state,803);804expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'Watch out for side effects');805});806807it('exit code 0 — non-JSON stdout logs warning, no hookProgress', () => {808const warnSpy = vi.spyOn(services.logService, 'warn');809handleHookResponse(810makeHookResponse('hook-1', 'success', {811exit_code: 0,812stdout: 'not valid json {',813hook_event: 'PreToolUse',814}),815accessor, request, state,816);817expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-JSON output'));818expect(request.stream.hookProgress).not.toHaveBeenCalled();819});820821it('exit code 0 — empty stdout means success, no hookProgress', () => {822handleHookResponse(823makeHookResponse('hook-1', 'success', { exit_code: 0, stdout: '' }),824accessor, request, state,825);826expect(request.stream.hookProgress).not.toHaveBeenCalled();827});828829it('exit code 0 — JSON with continue:true and no systemMessage is silent', () => {830handleHookResponse(831makeHookResponse('hook-1', 'success', {832exit_code: 0,833stdout: JSON.stringify({ continue: true }),834hook_event: 'PreToolUse',835}),836accessor, request, state,837);838expect(request.stream.hookProgress).not.toHaveBeenCalled();839});840841// #endregion842});843844// #endregion845846// #region handleHookProgress847848describe('handleHookProgress', () => {849let services: TestServices;850let accessor: ServicesAccessor;851let request: MessageHandlerRequestContext;852853beforeEach(() => {854services = createTestServices();855accessor = createAccessor(services);856request = createRequestContext();857});858859it('shows stdout via hookProgress as system message', () => {860handleHookProgress(861makeHookProgress('hook-1', { stdout: 'Running lint...', hook_event: 'PreToolUse' }),862accessor, request,863);864expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'Running lint...');865});866867it('falls back to stderr when stdout is empty', () => {868handleHookProgress(869makeHookProgress('hook-1', { stderr: 'warning output', hook_event: 'PostToolUse' }),870accessor, request,871);872expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', undefined, 'warning output');873});874875it('does not call hookProgress when both stdout and stderr are empty', () => {876handleHookProgress(877makeHookProgress('hook-1'),878accessor, request,879);880expect(request.stream.hookProgress).not.toHaveBeenCalled();881});882883it('trace-logs progress output', () => {884const traceSpy = vi.spyOn(services.logService, 'trace');885handleHookProgress(886makeHookProgress('hook-1', { stdout: 'progress text', hook_name: 'my-hook', hook_event: 'PreToolUse' }),887accessor, request,888);889expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('Hook progress'));890expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('progress text'));891});892});893894// #endregion895896// #region parseHookJsonOutput897898describe('parseHookJsonOutput', () => {899it('parses valid JSON with all fields', () => {900const result = parseHookJsonOutput(JSON.stringify({901continue: false,902stopReason: 'Build failed',903systemMessage: 'Warning',904decision: 'block',905reason: 'Not allowed',906}));907expect(result).toEqual({908continue: false,909stopReason: 'Build failed',910systemMessage: 'Warning',911decision: 'block',912reason: 'Not allowed',913});914});915916it('parses JSON with only some fields', () => {917const result = parseHookJsonOutput(JSON.stringify({ continue: false }));918expect(result).toEqual({ continue: false });919});920921it('returns undefined for non-JSON string', () => {922expect(parseHookJsonOutput('not json')).toBeUndefined();923});924925it('returns undefined for JSON null', () => {926expect(parseHookJsonOutput('null')).toBeUndefined();927});928929it('returns undefined for JSON array', () => {930expect(parseHookJsonOutput('[]')).toBeUndefined();931});932933it('returns undefined for JSON primitive', () => {934expect(parseHookJsonOutput('"hello"')).toBeUndefined();935});936937it('ignores fields with wrong types via fallback validation', () => {938const result = parseHookJsonOutput(JSON.stringify({939continue: 'not-a-boolean',940stopReason: 42,941systemMessage: 'valid string',942}));943expect(result).toEqual({ systemMessage: 'valid string' });944});945946it('returns undefined when all fields have wrong types', () => {947const result = parseHookJsonOutput(JSON.stringify({948continue: 'true',949decision: 'allow',950}));951expect(result).toBeUndefined();952});953954it('ignores unknown fields', () => {955const result = parseHookJsonOutput(JSON.stringify({956continue: true,957unknownField: 'whatever',958}));959expect(result).toEqual({ continue: true });960});961962it('rejects decision values other than block', () => {963const result = parseHookJsonOutput(JSON.stringify({964decision: 'allow',965systemMessage: 'hello',966}));967// decision: 'allow' fails vLiteral('block'), but systemMessage succeeds968expect(result).toEqual({ systemMessage: 'hello' });969});970});971972// #endregion973974// #region handleResultMessage975976describe('handleResultMessage', () => {977it('returns requestComplete for success', () => {978const result = handleResultMessage(makeSuccessResult(), createRequestContext());979expect(result).toEqual({ requestComplete: true });980});981982it('shows progress for error_max_turns', () => {983const request = createRequestContext();984const result = handleResultMessage(makeErrorResult('error_max_turns', 25), request);985expect(result).toEqual({ requestComplete: true });986expect(request.stream.progress).toHaveBeenCalled();987});988989it('throws KnownClaudeError for error_during_execution', () => {990expect(991() => handleResultMessage(makeErrorResult('error_during_execution'), createRequestContext()),992).toThrow(KnownClaudeError);993});994});995996// #endregion997998// #region ALL_KNOWN_MESSAGE_KEYS coverage9991000describe('ALL_KNOWN_MESSAGE_KEYS', () => {1001it('contains entries for all non-system SDKMessage type values', () => {1002const expectedNonSystemTypes = [1003'assistant', 'user', 'result', 'stream_event',1004'tool_progress', 'tool_use_summary', 'auth_status',1005'rate_limit_event', 'prompt_suggestion',1006];1007for (const key of expectedNonSystemTypes) {1008expect(ALL_KNOWN_MESSAGE_KEYS.has(key)).toBe(true);1009}1010});10111012it('contains entries for all system subtype values', () => {1013const expectedSystemSubtypes = [1014'init', 'compact_boundary', 'status', 'api_retry', 'local_command_output',1015'hook_started', 'hook_progress', 'hook_response',1016'task_notification', 'task_started', 'task_progress',1017'files_persisted', 'elicitation_complete',1018];1019for (const subtype of expectedSystemSubtypes) {1020expect(ALL_KNOWN_MESSAGE_KEYS.has(`system:${subtype}`)).toBe(true);1021}1022});1023});10241025// #endregion102610271028