import { describe, it, expect } from 'vitest'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { mapSdkMessage, createClaudeSdkMapState } from '../claude-sdk-map.js'; import type { AgentEvent } from '../../agent-backend.js'; /** * Pure mapper for Claude-SDK messages → AgentEvents (claude-sdk-sessionstore #9 Part 2). * Verifies the partial-stream → live-delta mapping, tool assembly across blocks, and * the final-assistant dedup, with no live `claude` binary involved. * * Messages are cast through `unknown` to `SDKMessage`: the real SDK shapes carry many * fields (uuid, parent_tool_use_id, …) irrelevant to the mapper, which reads only the * `type`/`event`/`message.content` it discriminates on. The cast keeps the fixtures * minimal while the production code path sees the full real types (the backend's * typecheck against the real SDK is the type-safety proof). */ function msg(m: unknown): SDKMessage { return m as SDKMessage; } /** A partial-stream message wrapping one BetaRawMessageStreamEvent. */ function streamEvent(event: unknown): SDKMessage { return msg({ type: 'stream_event', event, parent_tool_use_id: null, uuid: 'u', session_id: 's' }); } describe('mapSdkMessage — partial stream deltas', () => { it('maps a text_delta to a text event', () => { const state = createClaudeSdkMapState(); const out = mapSdkMessage( streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } }), state, ); expect(out).toEqual([{ type: 'text', text: 'Hello' }]); }); it('maps a thinking_delta to a reasoning event', () => { const state = createClaudeSdkMapState(); const out = mapSdkMessage( streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'pondering', estimated_tokens: null }, }), state, ); expect(out).toEqual([{ type: 'reasoning', text: 'pondering' }]); }); it('drops empty text/thinking deltas', () => { const state = createClaudeSdkMapState(); expect( mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '' } }), state), ).toEqual([]); expect( mapSdkMessage( streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: '', estimated_tokens: null } }), state, ), ).toEqual([]); }); it('ignores message framing + signature/citation deltas', () => { const state = createClaudeSdkMapState(); expect(mapSdkMessage(streamEvent({ type: 'message_start', message: {} }), state)).toEqual([]); expect(mapSdkMessage(streamEvent({ type: 'message_stop' }), state)).toEqual([]); expect( mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'signature_delta', signature: 'x' } }), state), ).toEqual([]); }); }); describe('mapSdkMessage — tool assembly across blocks', () => { it('opens a tool_call on content_block_start, buffers input_json_delta, emits tool_update with parsed input on stop', () => { const state = createClaudeSdkMapState(); const started = mapSdkMessage( streamEvent({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'tool-1', name: 'view_file', input: {} }, }), state, ); expect(started).toEqual([ { type: 'tool_call', toolCall: { toolCallId: 'tool-1', title: 'view_file', kind: null, status: 'in_progress', rawInput: {}, rawOutput: undefined } }, ]); // args stream in fragments under the same block index expect( mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"path":' } }), state), ).toEqual([]); expect( mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '"a.ts"}' } }), state), ).toEqual([]); const stopped = mapSdkMessage(streamEvent({ type: 'content_block_stop', index: 1 }), state); expect(stopped).toHaveLength(1); const ev = stopped[0]!; expect(ev.type).toBe('tool_update'); if (ev.type === 'tool_update') { expect(ev.toolCall.toolCallId).toBe('tool-1'); expect(ev.toolCall.title).toBe('view_file'); expect(ev.toolCall.rawInput).toEqual({ path: 'a.ts' }); } }); it('content_block_stop for a non-tool block (no tracked index) emits nothing', () => { const state = createClaudeSdkMapState(); // text block was streamed at index 0 but never tracked as a tool mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } }), state); expect(mapSdkMessage(streamEvent({ type: 'content_block_stop', index: 0 }), state)).toEqual([]); }); it('falls back to the prior input when the buffered tool JSON is invalid', () => { const state = createClaudeSdkMapState(); mapSdkMessage( streamEvent({ type: 'content_block_start', index: 2, content_block: { type: 'tool_use', id: 't2', name: 'grep', input: { q: 'seed' } } }), state, ); mapSdkMessage(streamEvent({ type: 'content_block_delta', index: 2, delta: { type: 'input_json_delta', partial_json: '{not json' } }), state); const stopped = mapSdkMessage(streamEvent({ type: 'content_block_stop', index: 2 }), state); const ev = stopped[0]!; if (ev.type === 'tool_update') { expect(ev.toolCall.rawInput).toEqual({ q: 'seed' }); } else { throw new Error('expected tool_update'); } }); }); describe('mapSdkMessage — final assistant message', () => { function assistant(content: unknown[]): SDKMessage { return msg({ type: 'assistant', message: { content }, parent_tool_use_id: null, uuid: 'u', session_id: 's' }); } it('dedups text/thinking (already streamed) and emits a completed tool_update per tool_use block', () => { const state = createClaudeSdkMapState(); const out = mapSdkMessage( assistant([ { type: 'text', text: 'final answer', citations: null }, { type: 'thinking', thinking: 'reasoned', signature: 'sig' }, { type: 'tool_use', id: 'tool-9', name: 'find_files', input: { glob: '**/*.ts' } }, ]), state, ); expect(out).toEqual([ { type: 'tool_update', toolCall: { toolCallId: 'tool-9', title: 'find_files', kind: null, status: 'completed', rawInput: { glob: '**/*.ts' }, rawOutput: undefined }, }, ]); }); it('preserves a title from a prior partial tool_call snapshot', () => { const state = createClaudeSdkMapState(); mapSdkMessage( streamEvent({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'tool-x', name: 'view_file', input: {} } }), state, ); const out = mapSdkMessage(assistant([{ type: 'tool_use', id: 'tool-x', name: 'view_file', input: { path: 'z' } }]), state); const ev = out[0]!; if (ev.type === 'tool_update') { expect(ev.toolCall.status).toBe('completed'); expect(ev.toolCall.title).toBe('view_file'); expect(ev.toolCall.rawInput).toEqual({ path: 'z' }); } else { throw new Error('expected tool_update'); } }); }); describe('mapSdkMessage — non-content messages', () => { it('returns [] for system/init, status, result, and other variants', () => { const state = createClaudeSdkMapState(); expect(mapSdkMessage(msg({ type: 'system', subtype: 'init', session_id: 's', uuid: 'u' }), state)).toEqual([]); expect(mapSdkMessage(msg({ type: 'system', subtype: 'status', status: null, session_id: 's', uuid: 'u' }), state)).toEqual([]); expect( mapSdkMessage(msg({ type: 'result', subtype: 'success', result: 'done', session_id: 's', uuid: 'u' }), state), ).toEqual([]); }); });