import { existsSync } from 'fs'; import * as fsPromises from 'fs/promises'; import * as os from 'os'; import { collectAsyncSubagentResults, deleteSDKSession, encodeVaultPathForSDK, filterActiveBranch, getSDKProjectsPath, getSDKSessionAvailability, getSDKSessionPath, isValidSessionId, loadSDKSessionMessages, loadSubagentFinalResult, loadSubagentToolCalls, parseSDKMessageToChat, readSDKSession, resolveToolUseResultStatus, type SDKNativeMessage, sdkSessionExists, } from '@/providers/claude/history/ClaudeHistoryStore'; import { extractToolResultContent } from '@/providers/claude/sdk/toolResultContent'; // Mock fs, fs/promises, and os modules jest.mock('fs', () => ({ existsSync: jest.fn(), })); jest.mock('fs/promises'); jest.mock('os'); const mockExistsSync = existsSync as jest.MockedFunction; const mockFsPromises = fsPromises as jest.Mocked; const mockOs = os as jest.Mocked; describe('sdkSession', () => { beforeEach(() => { jest.clearAllMocks(); mockOs.homedir.mockReturnValue('/Users/test'); }); describe('encodeVaultPathForSDK', () => { it('encodes vault path by replacing all non-alphanumeric chars with dash', () => { const encoded = encodeVaultPathForSDK('/Users/test/vault'); // SDK replaces ALL non-alphanumeric characters with `-` expect(encoded).toBe('-Users-test-vault'); }); it('handles paths with spaces and special characters', () => { const encoded = encodeVaultPathForSDK("/Users/test/My Vault's~Data"); expect(encoded).toBe('-Users-test-My-Vault-s-Data'); }); it('handles Unicode characters (Chinese, Japanese, etc.)', () => { // Unicode characters should be replaced with `-` to match SDK behavior const encoded = encodeVaultPathForSDK('/Volumes/[Work]弘毅之鹰/学习/东京大学/2025年 秋'); // All non-alphanumeric (including Chinese, brackets) become `-` expect(encoded).toBe('-Volumes--Work--------------2025---'); // Verify only ASCII alphanumeric and dash remain expect(encoded).toMatch(/^[a-zA-Z0-9-]+$/); }); it('handles brackets and other special characters', () => { const encoded = encodeVaultPathForSDK('/Users/test/[my-vault](notes)'); expect(encoded).toBe('-Users-test--my-vault--notes-'); expect(encoded).not.toContain('['); expect(encoded).not.toContain(']'); expect(encoded).not.toContain('('); expect(encoded).not.toContain(')'); }); it('produces consistent encoding', () => { const path1 = '/Users/test/my-vault'; const encoded1 = encodeVaultPathForSDK(path1); const encoded2 = encodeVaultPathForSDK(path1); expect(encoded1).toBe(encoded2); }); it('produces different encodings for different paths', () => { const encoded1 = encodeVaultPathForSDK('/Users/test/vault1'); const encoded2 = encodeVaultPathForSDK('/Users/test/vault2'); expect(encoded1).not.toBe(encoded2); }); it('handles backslashes for Windows compatibility', () => { // Test that backslashes are replaced (Windows path separators) // Note: path.resolve may modify the input, so we check the output contains no backslashes const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault'); expect(encoded).not.toContain('\\'); expect(encoded).toContain('-Users-test-vault'); }); it('replaces colons for Windows drive letters', () => { // Windows paths have colons after drive letter const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault'); expect(encoded).not.toContain(':'); }); }); describe('getSDKProjectsPath', () => { it('returns path under home directory', () => { const projectsPath = getSDKProjectsPath(); expect(projectsPath).toBe('/Users/test/.claude/projects'); }); it('uses CLAUDE_CONFIG_DIR from the effective SDK environment', () => { const projectsPath = getSDKProjectsPath({ environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, vaultPath: '/Users/test/vault', }); expect(projectsPath).toBe('/custom/claude/projects'); }); it('resolves a relative CLAUDE_CONFIG_DIR from the SDK working directory', () => { const projectsPath = getSDKProjectsPath({ environment: { CLAUDE_CONFIG_DIR: '.claude-custom' }, vaultPath: '/Users/test/vault', }); expect(projectsPath).toBe('/Users/test/vault/.claude-custom/projects'); }); it('falls back to the default directory when CLAUDE_CONFIG_DIR is unset', () => { const projectsPath = getSDKProjectsPath({ environment: {}, vaultPath: '/Users/test/vault', }); expect(projectsPath).toBe('/Users/test/.claude/projects'); }); it('uses the effective SDK HOME when CLAUDE_CONFIG_DIR is unset', () => { const projectsPath = getSDKProjectsPath({ environment: { HOME: '/custom/home' }, hostPlatform: 'linux', vaultPath: '/Users/test/vault', }); expect(projectsPath).toBe('/custom/home/.claude/projects'); }); it('uses the effective SDK USERPROFILE on Windows', () => { const projectsPath = getSDKProjectsPath({ environment: { USERPROFILE: '/custom/windows-home' }, hostPlatform: 'win32', vaultPath: '/Users/test/vault', }); expect(projectsPath).toBe('/custom/windows-home/.claude/projects'); }); it('resolves an empty SDK HOME from the SDK working directory', () => { const projectsPath = getSDKProjectsPath({ environment: { HOME: '' }, hostPlatform: 'linux', vaultPath: '/Users/test/vault', }); expect(projectsPath).toBe('/Users/test/vault/.claude/projects'); }); it('preserves an explicitly empty CLAUDE_CONFIG_DIR like the SDK', () => { const projectsPath = getSDKProjectsPath({ environment: { CLAUDE_CONFIG_DIR: '' }, vaultPath: '/Users/test/vault', }); expect(projectsPath).toBe('/Users/test/vault/projects'); }); }); describe('isValidSessionId', () => { it('accepts valid UUID-style session IDs', () => { expect(isValidSessionId('abc123')).toBe(true); expect(isValidSessionId('session-123')).toBe(true); expect(isValidSessionId('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe(true); expect(isValidSessionId('test_session_id')).toBe(true); }); it('rejects empty or too long session IDs', () => { expect(isValidSessionId('')).toBe(false); expect(isValidSessionId('a'.repeat(129))).toBe(false); }); it('rejects path traversal attempts', () => { expect(isValidSessionId('../etc/passwd')).toBe(false); expect(isValidSessionId('..\\windows\\system32')).toBe(false); expect(isValidSessionId('foo/../bar')).toBe(false); expect(isValidSessionId('session/subdir')).toBe(false); expect(isValidSessionId('session\\subdir')).toBe(false); }); it('rejects special characters', () => { expect(isValidSessionId('session.jsonl')).toBe(false); expect(isValidSessionId('session:123')).toBe(false); expect(isValidSessionId('session@host')).toBe(false); }); }); describe('getSDKSessionPath', () => { it('constructs correct session file path', () => { const sessionPath = getSDKSessionPath('/Users/test/vault', 'session-123'); expect(sessionPath).toContain('.claude/projects'); expect(sessionPath).toContain('session-123.jsonl'); }); it('throws error for path traversal attempts', () => { expect(() => getSDKSessionPath('/Users/test/vault', '../etc/passwd')).toThrow('Invalid session ID'); expect(() => getSDKSessionPath('/Users/test/vault', 'foo/../bar')).toThrow('Invalid session ID'); expect(() => getSDKSessionPath('/Users/test/vault', 'session/subdir')).toThrow('Invalid session ID'); }); it('throws error for empty session ID', () => { expect(() => getSDKSessionPath('/Users/test/vault', '')).toThrow('Invalid session ID'); }); it('builds session paths under the effective Claude config directory', () => { const sessionPath = getSDKSessionPath('/Users/test/vault', 'session-123', { environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, vaultPath: '/Users/test/vault', }); expect(sessionPath).toBe('/custom/claude/projects/-Users-test-vault/session-123.jsonl'); }); }); describe('sdkSessionExists', () => { it('returns true when session file exists', () => { mockExistsSync.mockReturnValue(true); const exists = sdkSessionExists('/Users/test/vault', 'session-abc'); expect(exists).toBe(true); }); it('returns false when session file does not exist', () => { mockExistsSync.mockReturnValue(false); const exists = sdkSessionExists('/Users/test/vault', 'session-xyz'); expect(exists).toBe(false); }); it('returns false on error', () => { mockExistsSync.mockImplementation(() => { throw new Error('Permission denied'); }); const exists = sdkSessionExists('/Users/test/vault', 'session-err'); expect(exists).toBe(false); }); }); describe('getSDKSessionAvailability', () => { it('reports an available session', async () => { mockFsPromises.access.mockResolvedValue(undefined); await expect(getSDKSessionAvailability( '/Users/test/vault', 'session-abc', )).resolves.toBe('available'); expect(mockFsPromises.access).toHaveBeenCalledWith( '/Users/test/.claude/projects/-Users-test-vault/session-abc.jsonl', ); }); it('reports a missing session when a complete scan finds no transcript', async () => { mockFsPromises.access.mockRejectedValue( Object.assign(new Error('Missing'), { code: 'ENOENT' }), ); mockFsPromises.readdir.mockResolvedValue([]); await expect(getSDKSessionAvailability( '/Users/test/vault', 'session-missing', )).resolves.toBe('missing'); }); it('reports a session found under a previous vault project as relocated', async () => { mockFsPromises.access.mockRejectedValue( Object.assign(new Error('Missing'), { code: 'ENOENT' }), ); mockFsPromises.readdir .mockResolvedValueOnce([{ isDirectory: () => true, isFile: () => false, name: '-Users-test-previous-vault', }] as any) .mockResolvedValueOnce([{ isDirectory: () => false, isFile: () => true, name: 'session-relocated.jsonl', }] as any); await expect(getSDKSessionAvailability( '/Users/test/vault', 'session-relocated', )).resolves.toBe('relocated'); }); it('finds relocated sessions in nested project directories', async () => { mockFsPromises.access.mockRejectedValue( Object.assign(new Error('Missing'), { code: 'ENOENT' }), ); mockFsPromises.readdir .mockResolvedValueOnce([{ isDirectory: () => true, isFile: () => false, name: '-Users-test-current-project', }] as any) .mockResolvedValueOnce([{ isDirectory: () => true, isFile: () => false, name: '-Users-test-previous-vault', }] as any) .mockResolvedValueOnce([{ isDirectory: () => false, isFile: () => true, name: 'session-nested.jsonl', }] as any); await expect(getSDKSessionAvailability( '/Users/test/vault', 'session-nested', )).resolves.toBe('relocated'); }); it('reports unknown when the local Claude projects root is absent', async () => { mockFsPromises.access.mockRejectedValue( Object.assign(new Error('Missing'), { code: 'ENOENT' }), ); mockFsPromises.readdir.mockRejectedValue( Object.assign(new Error('Missing root'), { code: 'ENOENT' }), ); await expect(getSDKSessionAvailability( '/Users/test/vault', 'session-on-another-machine', )).resolves.toBe('unknown'); }); it('reports unknown for other filesystem failures', async () => { mockFsPromises.access.mockRejectedValue( Object.assign(new Error('Permission denied'), { code: 'EACCES' }), ); await expect(getSDKSessionAvailability( '/Users/test/vault', 'session-inaccessible', )).resolves.toBe('unknown'); }); it('reports unknown when an unscanned symlink could contain the transcript', async () => { mockFsPromises.access.mockRejectedValue( Object.assign(new Error('Missing'), { code: 'ENOENT' }), ); mockFsPromises.readdir.mockResolvedValue([{ isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true, name: 'linked-project', }] as any); await expect(getSDKSessionAvailability( '/Users/test/vault', 'session-in-linked-project', )).resolves.toBe('unknown'); }); it('reports unknown for an invalid session ID', async () => { await expect(getSDKSessionAvailability( '/Users/test/vault', '../invalid', )).resolves.toBe('unknown'); expect(mockFsPromises.access).not.toHaveBeenCalled(); }); }); describe('deleteSDKSession', () => { it('deletes session file when it exists', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.unlink.mockResolvedValue(undefined); await deleteSDKSession('/Users/test/vault', 'session-abc'); expect(mockFsPromises.unlink).toHaveBeenCalledWith( '/Users/test/.claude/projects/-Users-test-vault/session-abc.jsonl' ); }); it('does nothing when session file does not exist', async () => { mockExistsSync.mockReturnValue(false); await deleteSDKSession('/Users/test/vault', 'nonexistent'); expect(mockFsPromises.unlink).not.toHaveBeenCalled(); }); it('fails silently when unlink throws', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.unlink.mockRejectedValue(new Error('Permission denied')); // Should not throw await expect(deleteSDKSession('/Users/test/vault', 'session-err')).resolves.toBeUndefined(); }); it('does nothing for invalid session ID', async () => { await deleteSDKSession('/Users/test/vault', '../invalid'); expect(mockFsPromises.unlink).not.toHaveBeenCalled(); }); it('deletes from the effective Claude config directory', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.unlink.mockResolvedValue(undefined); await deleteSDKSession('/Users/test/vault', 'session-custom', { environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, vaultPath: '/Users/test/vault', }); expect(mockFsPromises.unlink).toHaveBeenCalledWith( '/custom/claude/projects/-Users-test-vault/session-custom.jsonl', ); }); }); describe('readSDKSession', () => { it('returns empty result when file does not exist', async () => { mockExistsSync.mockReturnValue(false); const result = await readSDKSession('/Users/test/vault', 'nonexistent'); expect(result.messages).toEqual([]); expect(result.skippedLines).toBe(0); expect(result.error).toBeUndefined(); }); it('parses valid JSONL file', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","message":{"content":"Hi there"}}', ].join('\n')); const result = await readSDKSession('/Users/test/vault', 'session-1'); expect(result.messages).toHaveLength(2); expect(result.messages[0].type).toBe('user'); expect(result.messages[1].type).toBe('assistant'); expect(result.skippedLines).toBe(0); }); it('reads from the effective Claude config directory', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue( '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', ); const result = await readSDKSession('/Users/test/vault', 'session-custom', { environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, vaultPath: '/Users/test/vault', }); expect(result.messages).toHaveLength(1); expect(mockFsPromises.readFile).toHaveBeenCalledWith( '/custom/claude/projects/-Users-test-vault/session-custom.jsonl', 'utf-8', ); }); it('skips invalid JSON lines and reports count', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', 'invalid json line', '{"type":"assistant","uuid":"a1","message":{"content":"Hi"}}', ].join('\n')); const result = await readSDKSession('/Users/test/vault', 'session-2'); expect(result.messages).toHaveLength(2); expect(result.skippedLines).toBe(1); }); it('handles empty lines', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","message":{"content":"Test"}}', '', ' ', '{"type":"assistant","uuid":"a1","message":{"content":"Response"}}', ].join('\n')); const result = await readSDKSession('/Users/test/vault', 'session-3'); expect(result.messages).toHaveLength(2); }); it('returns error on read failure', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockRejectedValue(new Error('Read error')); const result = await readSDKSession('/Users/test/vault', 'session-err'); expect(result.messages).toEqual([]); expect(result.error).toBe('Read error'); }); }); describe('loadSubagentToolCalls', () => { it('loads tool calls from subagent sidechain JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Bash","input":{"command":"ls"}}]}}', '{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"ok","is_error":false}]}}', ].join('\n')); const toolCalls = await loadSubagentToolCalls( '/Users/test/vault', 'session-abc', 'a123' ); expect(mockFsPromises.readFile).toHaveBeenCalledWith( '/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', 'utf-8' ); expect(toolCalls).toHaveLength(1); expect(toolCalls[0]).toEqual( expect.objectContaining({ id: 'sub-tool-1', name: 'Bash', input: { command: 'ls' }, status: 'completed', result: 'ok', }) ); }); it('filters out entries that only have tool_result but no tool_use', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue( '{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"missing","content":"done"}]}}' ); const toolCalls = await loadSubagentToolCalls( '/Users/test/vault', 'session-abc', 'a123' ); expect(toolCalls).toEqual([]); }); it('returns empty when agent id is invalid', async () => { const toolCalls = await loadSubagentToolCalls( '/Users/test/vault', 'session-abc', '../bad-agent' ); expect(toolCalls).toEqual([]); expect(mockFsPromises.readFile).not.toHaveBeenCalled(); }); }); describe('loadSubagentFinalResult', () => { it('returns the latest assistant text from sidecar JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"First"}]}}', '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Final answer"}]}}', ].join('\n')); const result = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', 'a123' ); expect(result).toBe('Final answer'); expect(mockFsPromises.readFile).toHaveBeenCalledWith( '/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', 'utf-8' ); }); it('falls back to top-level result when assistant text is absent', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"progress","result":"Intermediate result"}', '{"type":"result","result":"Final result text"}', ].join('\n')); const result = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', 'a123' ); expect(result).toBe('Final result text'); }); it('returns null when sidecar file is missing or agent id is invalid', async () => { mockExistsSync.mockReturnValue(false); const missing = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', 'a123' ); expect(missing).toBeNull(); const invalid = await loadSubagentFinalResult( '/Users/test/vault', 'session-abc', '../bad-agent' ); expect(invalid).toBeNull(); expect(mockFsPromises.readFile).not.toHaveBeenCalled(); }); }); describe('parseSDKMessageToChat', () => { it('converts user message with string content', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-123', timestamp: '2024-01-15T10:30:00Z', message: { content: 'What is the weather?', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toBe('user-123'); expect(chatMsg!.role).toBe('user'); expect(chatMsg!.content).toBe('What is the weather?'); expect(chatMsg!.timestamp).toBe(new Date('2024-01-15T10:30:00Z').getTime()); }); it('sets userMessageId on user messages with uuid', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-rewind-123', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Hello' }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.userMessageId).toBe('user-rewind-123'); expect(chatMsg!.assistantMessageId).toBeUndefined(); }); it('sets assistantMessageId on assistant messages with uuid', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-rewind-456', timestamp: '2024-01-15T10:31:00Z', message: { content: [{ type: 'text', text: 'Hello back' }], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.assistantMessageId).toBe('asst-rewind-456'); expect(chatMsg!.userMessageId).toBeUndefined(); }); it('does not set SDK UUIDs when uuid is absent', () => { const sdkMsg: SDKNativeMessage = { type: 'user', timestamp: '2024-01-15T10:30:00Z', message: { content: 'No uuid' }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.userMessageId).toBeUndefined(); expect(chatMsg!.assistantMessageId).toBeUndefined(); }); it('converts assistant message with text content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-456', timestamp: '2024-01-15T10:31:00Z', message: { content: [ { type: 'text', text: 'The weather is sunny.' }, { type: 'text', text: 'Temperature is 72°F.' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toBe('asst-456'); expect(chatMsg!.role).toBe('assistant'); expect(chatMsg!.content).toBe('The weather is sunny.\nTemperature is 72°F.'); }); it('extracts tool calls from content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-tool', timestamp: '2024-01-15T10:32:00Z', message: { content: [ { type: 'text', text: 'Let me search for that.' }, { type: 'tool_use', id: 'tool-1', name: 'WebSearch', input: { query: 'weather today' }, }, { type: 'tool_result', tool_use_id: 'tool-1', content: 'Sunny, 72°F', }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.toolCalls).toHaveLength(1); expect(chatMsg!.toolCalls![0].id).toBe('tool-1'); expect(chatMsg!.toolCalls![0].name).toBe('WebSearch'); expect(chatMsg!.toolCalls![0].input).toEqual({ query: 'weather today' }); expect(chatMsg!.toolCalls![0].status).toBe('completed'); expect(chatMsg!.toolCalls![0].result).toBe('Sunny, 72°F'); }); it('marks tool call as error when is_error is true', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-err', timestamp: '2024-01-15T10:33:00Z', message: { content: [ { type: 'tool_use', id: 'tool-err', name: 'Bash', input: { command: 'invalid' }, }, { type: 'tool_result', tool_use_id: 'tool-err', content: 'Command not found', is_error: true, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.toolCalls![0].status).toBe('error'); }); it('keeps tool calls running when no matching tool_result exists yet', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-running', timestamp: '2024-01-15T10:33:30Z', message: { content: [ { type: 'tool_use', id: 'tool-running', name: 'Read', input: { file_path: 'notes/todo.md' }, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.toolCalls![0].status).toBe('running'); expect(chatMsg!.toolCalls![0].result).toBeUndefined(); }); it('extracts thinking content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-think', timestamp: '2024-01-15T10:34:00Z', message: { content: [ { type: 'thinking', thinking: 'Let me consider this...' }, { type: 'text', text: 'Here is my answer.' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.contentBlocks).toHaveLength(2); const thinkingBlock = chatMsg!.contentBlocks![0]; expect(thinkingBlock.type).toBe('thinking'); // Type narrowing for thinking block content check expect(thinkingBlock.type === 'thinking' && thinkingBlock.content).toBe('Let me consider this...'); expect(chatMsg!.contentBlocks![1].type).toBe('text'); }); it('preserves text block whitespace in contentBlocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-whitespace', timestamp: '2024-01-15T10:34:30Z', message: { content: [ { type: 'text', text: ' Preserve leading and trailing space ' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg!.content).toBe(' Preserve leading and trailing space '); expect(chatMsg!.contentBlocks).toEqual([ { type: 'text', content: ' Preserve leading and trailing space ' }, ]); }); it('returns null for system messages', () => { const sdkMsg: SDKNativeMessage = { type: 'system', uuid: 'sys-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns synthetic assistant message for compact_boundary system messages', () => { const sdkMsg: SDKNativeMessage = { type: 'system', subtype: 'compact_boundary', uuid: 'compact-1', timestamp: '2024-06-15T12:00:00Z', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toBe('compact-1'); expect(chatMsg!.role).toBe('assistant'); expect(chatMsg!.content).toBe(''); expect(chatMsg!.timestamp).toBe(new Date('2024-06-15T12:00:00Z').getTime()); expect(chatMsg!.contentBlocks).toEqual([{ type: 'context_compacted' }]); }); it('generates ID for compact_boundary without uuid', () => { const sdkMsg: SDKNativeMessage = { type: 'system', subtype: 'compact_boundary', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toMatch(/^compact-/); }); it('returns null for result messages', () => { const sdkMsg: SDKNativeMessage = { type: 'result', uuid: 'res-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns null for file-history-snapshot messages', () => { const sdkMsg: SDKNativeMessage = { type: 'file-history-snapshot', uuid: 'fhs-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('generates ID when uuid is missing', () => { const sdkMsg: SDKNativeMessage = { type: 'user', timestamp: '2024-01-15T10:35:00Z', message: { content: 'No UUID message', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.id).toMatch(/^sdk-/); }); it('uses current time when timestamp is missing', () => { const before = Date.now(); const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'no-time', message: { content: 'No timestamp', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); const after = Date.now(); expect(chatMsg!.timestamp).toBeGreaterThanOrEqual(before); expect(chatMsg!.timestamp).toBeLessThanOrEqual(after); }); it('marks interrupt messages with isInterrupt flag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-1', timestamp: '2024-01-15T10:30:00Z', message: { content: [{ type: 'text', text: '[Request interrupted by user]' }], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBe(true); expect(chatMsg!.content).toBe('[Request interrupted by user]'); }); it('does not mark non-canonical interrupt text variants', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-non-canonical', timestamp: '2024-01-15T10:30:00Z', message: { content: 'prefix [Request interrupted by user]', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBeUndefined(); }); it('does not mark regular user messages as interrupt', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-regular', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Hello, how are you?', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBeUndefined(); }); it('marks rebuilt context messages with isRebuiltContext flag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'rebuilt-1', timestamp: '2024-01-15T10:30:00Z', message: { content: 'User: hi\n\nAssistant: Hello!\n\nUser: how are you?', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBe(true); }); it('marks rebuilt context messages starting with Assistant', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'rebuilt-2', timestamp: '2024-01-15T10:31:00Z', message: { content: 'Assistant: Hello\n\nUser: Hi again', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBe(true); }); it('does not mark regular messages starting with User as rebuilt context', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-normal', timestamp: '2024-01-15T10:30:00Z', message: { content: 'User settings should be configurable', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBeUndefined(); }); it('extracts displayContent from user message with current_note tag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-note', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Explain this file\n\n\nnotes/test.md\n', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.content).toBe('Explain this file\n\n\nnotes/test.md\n'); expect(chatMsg!.displayContent).toBe('Explain this file'); }); it('extracts displayContent from user message with editor_selection tag', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-selection', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Refactor this code\n\n\nfunction foo() {}\n', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.displayContent).toBe('Refactor this code'); }); it('extracts displayContent from user message with multiple context tags', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-multi', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Update this\n\n\ntest.md\n\n\n\nselected\n', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.displayContent).toBe('Update this'); }); it('does not set displayContent for plain user messages without XML context', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-plain', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Just a regular question', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.displayContent).toBeUndefined(); }); }); describe('loadSDKSessionMessages', () => { it('loads and converts all messages from session file', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"system","uuid":"s1"}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Thanks"}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-full'); // Should have 3 messages (system skipped) expect(result.messages).toHaveLength(3); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); expect(result.messages[1].content).toBe('Hi!'); expect(result.messages[2].role).toBe('user'); expect(result.messages[2].content).toBe('Thanks'); }); it('sorts messages by timestamp ascending', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Second"}]}}', '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"First"}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Third"}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-unordered'); expect(result.messages[0].content).toBe('First'); expect(result.messages[1].content).toBe('Second'); expect(result.messages[2].content).toBe('Third'); }); it('returns empty result when session does not exist', async () => { mockExistsSync.mockReturnValue(false); const result = await loadSDKSessionMessages('/Users/test/vault', 'nonexistent'); expect(result.messages).toEqual([]); }); it('matches tool_result from user message to tool_use in assistant message', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Search for cats"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Let me search"},{"type":"tool_use","id":"tool-1","name":"WebSearch","input":{"query":"cats"}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"Found 10 results"}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"I found 10 results about cats."}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-cross-tool'); // Should have 2 messages (tool_result-only user skipped, assistant messages merged) expect(result.messages).toHaveLength(2); expect(result.messages[0].content).toBe('Search for cats'); // Merged assistant message has tool calls and combined content expect(result.messages[1].toolCalls).toHaveLength(1); expect(result.messages[1].toolCalls![0].id).toBe('tool-1'); expect(result.messages[1].toolCalls![0].result).toBe('Found 10 results'); expect(result.messages[1].toolCalls![0].status).toBe('completed'); expect(result.messages[1].content).toContain('Let me search'); expect(result.messages[1].content).toContain('I found 10 results about cats.'); }); it('hydrates AskUserQuestion answers from result text when toolUseResult has no answers', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"ask-1","name":"AskUserQuestion","input":{"questions":[{"question":"Color?","options":["Blue","Red"]}]}}]}}', '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"ask-1","content":"\\"Color?\\"=\\"Blue\\""}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-ask-result-fallback'); expect(result.messages).toHaveLength(1); expect(result.messages[0].toolCalls).toHaveLength(1); expect(result.messages[0].toolCalls?.[0].resolvedAnswers).toEqual({ 'Color?': 'Blue' }); }); it('skips user messages that are tool results', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"done"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-tool-result'); // Should have 2 messages (tool_result user skipped) expect(result.messages).toHaveLength(2); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); }); it('skips skill prompt injection messages (sourceToolUseID)', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"/commit"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"commit"}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Launching skill: commit"}]}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","sourceToolUseID":"t1","isMeta":true,"message":{"content":[{"type":"text","text":"## Your task\\n\\nCommit the changes..."}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Committing the changes now."}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-skill'); // Should have 2 messages: user query, merged assistant (tool_use + text merged together) // Skill prompt injection (u3) and tool result (u2) should be skipped // Consecutive assistant messages are merged expect(result.messages).toHaveLength(2); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('/commit'); expect(result.messages[1].role).toBe('assistant'); expect(result.messages[1].toolCalls?.[0].name).toBe('Skill'); expect(result.messages[1].content).toContain('Committing'); }); it('skips meta messages without sourceToolUseID', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:00:01Z","isMeta":true,"message":{"content":"System context injection"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi there!"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-meta'); // Should have 2 messages (meta message u2 skipped) expect(result.messages).toHaveLength(2); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); }); it('preserves /compact command as user message with clean displayContent', async () => { // File ordering mirrors real SDK JSONL: compact_boundary written BEFORE /compact command. // The timestamp sort must reorder so /compact (earlier) precedes boundary (later). mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"system","subtype":"compact_boundary","uuid":"c1","timestamp":"2024-01-15T10:02:10Z"}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"Caveat"}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"/compact\\ncompact\\n"}}', '{"type":"user","uuid":"u4","timestamp":"2024-01-15T10:02:11Z","message":{"content":"Compacted "}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact'); // Should have: user "Hello", assistant "Hi!", user "/compact", assistant compact_boundary // Meta (u2), stdout (u4) should be skipped // /compact (10:02:01) sorted before compact_boundary (10:02:10) by timestamp expect(result.messages).toHaveLength(4); expect(result.messages[0].role).toBe('user'); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].role).toBe('assistant'); expect(result.messages[2].role).toBe('user'); expect(result.messages[2].displayContent).toBe('/compact'); expect(result.messages[3].role).toBe('assistant'); expect(result.messages[3].contentBlocks).toEqual([{ type: 'context_compacted' }]); }); it('renders compact cancellation stderr as interrupt (not filtered)', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"/compact\\ncompact\\n"}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"Error: Compaction canceled."}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-cancel'); // Compact cancellation stderr should appear as interrupt, not be filtered const interruptMsg = result.messages.find(m => m.isInterrupt); expect(interruptMsg).toBeDefined(); expect(interruptMsg!.isInterrupt).toBe(true); }); it('does not treat embedded compaction stderr mentions as interrupt markers', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:01Z","message":{"content":"## Context\\nError: Compaction canceled."}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-quoted-cancel'); expect(result.messages).toHaveLength(2); expect(result.messages.some(m => m.isInterrupt)).toBe(false); }); it('preserves slash command invocations with clean displayContent', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"md2docx\\n/md2docx"}}', '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"Use bash command md2word..."}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"(no content)"}]}}', '{"type":"assistant","uuid":"a3","timestamp":"2024-01-15T10:03:01Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"md2docx"}}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-slash-cmd'); // user "Hello", assistant "Hi!", user "/md2docx", assistant with Skill tool // META (u3) should be skipped; "(no content)" text should be filtered expect(result.messages).toHaveLength(4); expect(result.messages[2].role).toBe('user'); expect(result.messages[2].displayContent).toBe('/md2docx'); expect(result.messages[3].role).toBe('assistant'); expect(result.messages[3].content).toBe(''); expect(result.messages[3].toolCalls).toHaveLength(1); expect(result.messages[3].toolCalls![0].name).toBe('Skill'); }); it('handles tool_result with error flag', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"invalid"}}]}}', '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:01:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Command not found","is_error":true}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-error-result'); expect(result.messages).toHaveLength(1); expect(result.messages[0].toolCalls![0].status).toBe('error'); expect(result.messages[0].toolCalls![0].result).toBe('Command not found'); }); it('returns error pass-through from readSDKSession', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockRejectedValue(new Error('Disk failure')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-disk-err'); expect(result.messages).toEqual([]); expect(result.error).toBe('Disk failure'); }); it('merges tool calls from consecutive assistant messages', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"path":"b.ts"}}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-tools'); // Consecutive assistant messages should merge into one expect(result.messages).toHaveLength(1); expect(result.messages[0].toolCalls).toHaveLength(2); expect(result.messages[0].toolCalls![0].name).toBe('Read'); expect(result.messages[0].toolCalls![1].name).toBe('Write'); }); it('updates assistantMessageId to last entry when merging assistant messages', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"hello"}}', '{"type":"assistant","uuid":"a1-first","parentUuid":"u1","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"thinking..."}]}}', '{"type":"assistant","uuid":"a1-mid","parentUuid":"a1-first","timestamp":"2024-01-15T10:00:02Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}', '{"type":"assistant","uuid":"a1-last","parentUuid":"a1-mid","timestamp":"2024-01-15T10:00:03Z","message":{"content":[{"type":"text","text":"Done!"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-uuid'); expect(result.messages).toHaveLength(2); const assistant = result.messages[1]; expect(assistant.role).toBe('assistant'); // Must be the last UUID so rewind targets the end of the turn expect(assistant.assistantMessageId).toBe('a1-last'); }); }); describe('parseSDKMessageToChat - image extraction', () => { it('extracts image attachments from user message with image blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-img', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'text', text: 'Check this image' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk', }, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.images).toHaveLength(1); expect(chatMsg!.images![0].mediaType).toBe('image/png'); expect(chatMsg!.images![0].data).toContain('iVBORw0KGgo'); expect(chatMsg!.images![0].source).toBe('paste'); expect(chatMsg!.images![0].name).toBe('image-1'); }); it('does not extract images from assistant messages', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-img', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'text', text: 'Here is a response' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.images).toBeUndefined(); }); it('returns null for user message with only tool_result content blocks', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-tool-only', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_result', tool_use_id: 't1', content: 'result data' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); // Array content bypasses the null-return guard even without text/tool_use/images expect(chatMsg).not.toBeNull(); }); it('returns null for user message with empty string content', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-empty', timestamp: '2024-01-15T10:30:00Z', message: { content: '', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns null for user message with no content', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'user-nocontent', timestamp: '2024-01-15T10:30:00Z', message: {}, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); it('returns null for queue-operation messages', () => { const sdkMsg: SDKNativeMessage = { type: 'queue-operation', uuid: 'queue-1', }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).toBeNull(); }); }); describe('parseSDKMessageToChat - content block edge cases', () => { it('skips text blocks that are whitespace-only in contentBlocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-whitespace', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'text', text: ' ' }, { type: 'text', text: 'Actual content' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); // The whitespace-only text block should be skipped in contentBlocks expect(chatMsg!.contentBlocks).toHaveLength(1); expect(chatMsg!.contentBlocks![0].type).toBe('text'); }); it('skips thinking blocks with empty thinking field', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-empty-think', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'thinking', thinking: '' }, { type: 'text', text: 'Some answer' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); // Empty thinking block should be skipped expect(chatMsg!.contentBlocks).toHaveLength(1); expect(chatMsg!.contentBlocks![0].type).toBe('text'); }); it('skips tool_use blocks without id in contentBlocks', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-no-id-tool', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_use', name: 'Bash', input: {} }, { type: 'text', text: 'After tool' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); // tool_use without id should be skipped in contentBlocks expect(chatMsg!.contentBlocks).toHaveLength(1); expect(chatMsg!.contentBlocks![0].type).toBe('text'); }); it('returns undefined contentBlocks when all blocks are filtered out', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-all-filtered', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_result', tool_use_id: 't1', content: 'result' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); // Content is array (so not null), but all blocks filtered → undefined contentBlocks expect(chatMsg).not.toBeNull(); expect(chatMsg!.contentBlocks).toBeUndefined(); }); it('handles tool_use without input field', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-no-input', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_use', id: 'tool-noinput', name: 'SomeTool' }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.toolCalls).toHaveLength(1); expect(chatMsg!.toolCalls![0].input).toEqual({}); }); it('handles tool_result with non-string content (JSON object)', () => { const sdkMsg: SDKNativeMessage = { type: 'assistant', uuid: 'asst-json-result', timestamp: '2024-01-15T10:30:00Z', message: { content: [ { type: 'tool_use', id: 'tool-json', name: 'Read', input: {} }, { type: 'tool_result', tool_use_id: 'tool-json', content: { file: 'test.ts', lines: 42 }, }, ], }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.toolCalls).toHaveLength(1); // Non-string content should be JSON.stringified expect(chatMsg!.toolCalls![0].result).toBe('{"file":"test.ts","lines":42}'); }); }); describe('parseSDKMessageToChat - rebuilt context with A: shorthand', () => { it('detects rebuilt context using A: shorthand marker', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'rebuilt-short', timestamp: '2024-01-15T10:30:00Z', message: { content: 'User: hello\n\nA: hi there', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isRebuiltContext).toBe(true); }); }); describe('parseSDKMessageToChat - interrupt tool use variant', () => { it('marks tool use interrupt messages', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-tool', timestamp: '2024-01-15T10:30:00Z', message: { content: '[Request interrupted by user for tool use]', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBe(true); }); it('does not mark quoted compact cancellation mention as interrupt', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-compact-quoted', timestamp: '2024-01-15T10:30:00Z', message: { content: '## Context\nError: Compaction canceled.', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBeUndefined(); }); it('marks compact cancellation stderr as interrupt', () => { const sdkMsg: SDKNativeMessage = { type: 'user', uuid: 'interrupt-compact', timestamp: '2024-01-15T10:30:00Z', message: { content: 'Error: Compaction canceled.', }, }; const chatMsg = parseSDKMessageToChat(sdkMsg); expect(chatMsg).not.toBeNull(); expect(chatMsg!.isInterrupt).toBe(true); }); }); describe('loadSDKSessionMessages - merge edge cases', () => { it('merges assistant content blocks when first has no content blocks', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"thinking","thinking":"hmm"},{"type":"text","text":"Result here"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-blocks'); expect(result.messages).toHaveLength(1); // Merged: tool call from first + content blocks from both expect(result.messages[0].toolCalls).toHaveLength(1); expect(result.messages[0].contentBlocks!.length).toBeGreaterThanOrEqual(2); }); it('merges assistant with empty target content', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ // First assistant: only tool_use, no text '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', // Second assistant: has text content '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"Here is the result"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-empty-target'); expect(result.messages).toHaveLength(1); expect(result.messages[0].content).toBe('Here is the result'); }); it('handles multiple user images in a message', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ JSON.stringify({ type: 'user', uuid: 'u-imgs', timestamp: '2024-01-15T10:00:00Z', message: { content: [ { type: 'text', text: 'Check these images' }, { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'def456' } }, ], }, }), ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-multi-images'); expect(result.messages).toHaveLength(1); expect(result.messages[0].images).toHaveLength(2); expect(result.messages[0].images![0].mediaType).toBe('image/png'); expect(result.messages[0].images![1].mediaType).toBe('image/jpeg'); expect(result.messages[0].images![1].name).toBe('image-2'); }); it('extracts text from Agent tool results with array content', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2024-01-15T10:00:00Z', message: { content: 'Use an agent' }, }), JSON.stringify({ type: 'assistant', uuid: 'a1', parentUuid: 'u1', timestamp: '2024-01-15T10:00:01Z', message: { content: [{ type: 'tool_use', id: 'agent-1', name: 'Agent', input: { description: 'test', prompt: 'do stuff' } }] }, }), // Agent tool result has array content (not string) JSON.stringify({ type: 'user', uuid: 'tr1', parentUuid: 'a1', timestamp: '2024-01-15T10:00:30Z', toolUseResult: { status: 'completed', agentId: 'abc123' }, message: { content: [{ type: 'tool_result', tool_use_id: 'agent-1', is_error: false, content: [ { type: 'text', text: 'Agent completed the task successfully.' }, { type: 'text', text: 'agentId: abc123\ntotal_tokens: 500' }, ], }] }, }), JSON.stringify({ type: 'assistant', uuid: 'a2', parentUuid: 'tr1', timestamp: '2024-01-15T10:00:31Z', message: { content: [{ type: 'text', text: 'Done.' }] }, }), ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-agent-result'); // The Agent tool call should have extracted text, not JSON.stringify'd array const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.length); expect(assistantMsg).toBeDefined(); const agentToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Agent'); expect(agentToolCall).toBeDefined(); expect(agentToolCall!.result).toBe( 'Agent completed the task successfully.\nagentId: abc123\ntotal_tokens: 500' ); // Must NOT contain JSON artifacts expect(agentToolCall!.result).not.toContain('"type":"text"'); }); }); describe('filterActiveBranch', () => { it('returns all entries for linear chain without resumeAtMessageId', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries); expect(result).toHaveLength(4); expect(result).toEqual(entries); }); it('truncates linear chain at resumeAtMessageId UUID', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries, 'a1'); expect(result).toHaveLength(2); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); }); it('returns only new branch after rewind + follow-up', () => { // Original: u1 → a1 → u2 → a2 // Rewind to a1, follow-up: u3 → a3 (u3.parentUuid = a1) const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch point: a1 has 2 children { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; const result = filterActiveBranch(entries); // Should include: u1, a1, u3, a3 (new branch), not u2, a2 expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); }); it('returns latest branch after multiple rewinds', () => { // Original: u1 → a1 → u2 → a2 // Rewind 1: u3 → a3 (parent a1) // Rewind 2: u4 → a4 (parent a1) — third child of a1 const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, { type: 'user', uuid: 'u4', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a4', parentUuid: 'u4' }, ]; const result = filterActiveBranch(entries); // Last entry with uuid is a4, walk back: a4 → u4 → a1 → u1 expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u4', 'a4']); }); it('returns all entries when resumeAtMessageId UUID not found (safety)', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, ]; const result = filterActiveBranch(entries, 'nonexistent-uuid'); expect(result).toHaveLength(2); expect(result).toEqual(entries); }); it('returns empty for empty entries', () => { const result = filterActiveBranch([]); expect(result).toEqual([]); }); it('does not misdetect branching when duplicate uuid entries exist', () => { // SDK may write the same message twice (e.g., around compaction). // Without dedup, duplicate entries inflate childCount causing false branch detection. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // Duplicate of u2 — SDK writes this again { type: 'user', uuid: 'u2', parentUuid: 'a1' }, ]; // Without dedup fix, a1 would have childCount=2 (u2 counted twice), // triggering branch detection and excluding u2/a2. const result = filterActiveBranch(entries); // Should be a no-op (linear chain, no branching) expect(result).toHaveLength(4); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u2', 'a2']); }); it('correctly truncates at resumeAtMessageId when duplicates exist', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // Duplicate of u2 { type: 'user', uuid: 'u2', parentUuid: 'a1' }, ]; const result = filterActiveBranch(entries, 'a1'); // Should truncate at a1, including only u1 and a1 expect(result).toHaveLength(2); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); }); it('preserves no-uuid entries within active branch region', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'queue-operation' }, // No uuid — between u1 (active) and a1 (active) { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']); // queue-operation is between u1 (active) and a1 (active), so preserved expect(result.some(e => e.type === 'queue-operation')).toBe(true); }); it('truncates at resumeAtMessageId on latest branch when branching exists', () => { // Rewind 1 + follow-up created a branch: u3/a3 branch off a1 // Rewind 2 on the new branch (no follow-up): resumeAtMessageId = a1 // On reload, should truncate at a1, not show u3/a3 const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, // old branch { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // old branch { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // new branch (from rewind 1) { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, // new branch ]; // Rewind 2 on new branch: truncate at a1 const result = filterActiveBranch(entries, 'a1'); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); }); it('truncates at resumeAtMessageId mid-branch when branching exists', () => { // Branch from a1: old (u2→a2) and new (u3→a3→u4→a4) // Rewind on new branch to u4: resumeAtMessageId = a3 const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, { type: 'user', uuid: 'u4', parentUuid: 'a3' }, { type: 'assistant', uuid: 'a4', parentUuid: 'u4' }, ]; const result = filterActiveBranch(entries, 'a3'); expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); }); it('ignores resumeAtMessageId not on latest branch', () => { // Branch from a1: old (u2→a2) and new (u3→a3) // resumeAtMessageId points to a2 (on the OLD branch) — should be ignored const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'user', uuid: 'u3', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; // a2 is on old branch, not an ancestor of leaf a3 const result = filterActiveBranch(entries, 'a2'); // Should return full latest branch (ignoring stale resumeAtMessageId) expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); }); it('drops no-uuid entries in old branch region', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'user', uuid: 'u2', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, { type: 'queue-operation' }, // No uuid — between a2 (old) and u3 (active) { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']); // queue-operation between a2 (not active) and u3 (active) — should be dropped expect(result.some(e => e.type === 'queue-operation')).toBe(false); }); it('excludes progress entries and does not treat them as branches', () => { // Simulates Agent tool call: assistant issues tool_use, SDK writes progress chain, // then next user message is parented to end of progress chain. // Without fix: progress creates false branching, losing the conversation branch. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, // a1 is a tool_use (Agent). SDK writes tool_result + progress chain as siblings: { type: 'user', uuid: 'tr1', parentUuid: 'a1', toolUseResult: {} }, // tool result { type: 'assistant', uuid: 'a2', parentUuid: 'tr1' }, // response after tool // Progress chain branching off a1 (subagent execution logs): { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p3', parentUuid: 'p2' }, // Next conversation message parented to end of progress chain: { type: 'user', uuid: 'u2', parentUuid: 'p3' }, { type: 'assistant', uuid: 'a3', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // All conversation entries should be present, progress entries excluded expect(uuids).toEqual(['u1', 'a1', 'tr1', 'a2', 'u2', 'a3']); expect(result.every(e => (e.type as string) !== 'progress')).toBe(true); }); it('reparents through long progress chains to preserve full conversation', () => { // Two turns, each with Agent tool calls generating progress entries. // The second turn's user message is parented to the end of the first progress chain. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1-think', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1-think' }, // Agent tool_use // Conversation branch: tool result → assistant response { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} }, { type: 'assistant', uuid: 'a1-think2', parentUuid: 'tr1' }, { type: 'assistant', uuid: 'a1-text', parentUuid: 'a1-think2' }, // Progress chain off a1-tool: { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1-tool' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, // System entry chained to progress: { type: 'system', uuid: 'sys1', parentUuid: 'p2' }, // Second turn parented to system (which is parented to progress chain): { type: 'user', uuid: 'u2', parentUuid: 'sys1' }, { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // Must include BOTH turns' content — nothing lost expect(uuids).toContain('a1-text'); // First turn's response expect(uuids).toContain('u2'); // Second turn's input expect(uuids).toContain('a2'); // Second turn's response // Progress entries must be excluded expect(uuids).not.toContain('p1'); expect(uuids).not.toContain('p2'); }); it('does not treat parallel tool calls as branches', () => { // Assistant sends two tool_use blocks in parallel. SDK writes them as // separate entries. Their tool results are parented to respective entries. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' }, // first tool_use { type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' }, // second tool_use (parallel) // Tool results: { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} }, { type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} }, // Assistant continues after both results: { type: 'assistant', uuid: 'a2', parentUuid: 'tr2' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // Both tool calls and their results should be present expect(uuids).toContain('a1-tool1'); expect(uuids).toContain('a1-tool2'); expect(uuids).toContain('tr1'); expect(uuids).toContain('tr2'); expect(uuids).toContain('a2'); }); it('handles real rewind alongside progress entries', () => { // Turn 1 with Agent tool (progress entries), then a real rewind at a1. const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, // Original continuation: { type: 'user', uuid: 'u2-old', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2-old', parentUuid: 'u2-old' }, // Progress entries off a1: { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' }, { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, // Rewind: new user message also branching off a1 { type: 'user', uuid: 'u2-new', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a2-new', parentUuid: 'u2-new' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); // Should follow the latest branch (u2-new), not the old one or progress expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a2-new']); }); it('detects rewind when abandoned path continues through assistant/tool nodes', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1' }, { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} }, { type: 'assistant', uuid: 'a2', parentUuid: 'tr1' }, { type: 'user', uuid: 'u2-new', parentUuid: 'a1' }, { type: 'assistant', uuid: 'a3-new', parentUuid: 'u2-new' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a3-new']); }); it('preserves earlier parallel tool-result descendants when a later rewind exists', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', parentUuid: null }, { type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' }, { type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' }, { type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' }, { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} }, { type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} }, { type: 'assistant', uuid: 'a2', parentUuid: 'tr2' }, { type: 'user', uuid: 'u3-old', parentUuid: 'a2' }, { type: 'assistant', uuid: 'a3-old', parentUuid: 'u3-old' }, { type: 'user', uuid: 'u3-new', parentUuid: 'a2' }, { type: 'assistant', uuid: 'a3-new', parentUuid: 'u3-new' }, ]; const result = filterActiveBranch(entries); const uuids = result.filter(e => e.uuid).map(e => e.uuid); expect(uuids).toEqual([ 'u1', 'a1-text', 'a1-tool1', 'a1-tool2', 'tr1', 'tr2', 'a2', 'u3-new', 'a3-new', ]); }); }); describe('loadSDKSessionMessages with resumeAtMessageId', () => { it('returns identical behavior without resumeAtMessageId', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-resume'); expect(result.messages).toHaveLength(2); }); it('truncates messages at resumeAtMessageId on linear JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"More"}}', '{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"More response"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-truncate', 'a1'); // Should only have u1 and a1 (truncated at a1) expect(result.messages).toHaveLength(2); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].content).toBe('Hi!'); }); it('returns correct active branch on branched JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockResolvedValue([ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', '{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Old branch"}}', '{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Old response"}]}}', '{"type":"user","uuid":"u3","parentUuid":"a1","timestamp":"2024-01-15T10:04:00Z","message":{"content":"New branch"}}', '{"type":"assistant","uuid":"a3","parentUuid":"u3","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"New response"}]}}', ].join('\n')); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-branched'); // Should have: u1 "Hello", a1 "Hi!", u3 "New branch", a3 "New response" // Old branch (u2, a2) should be excluded expect(result.messages).toHaveLength(4); expect(result.messages[0].content).toBe('Hello'); expect(result.messages[1].content).toBe('Hi!'); expect(result.messages[2].content).toBe('New branch'); expect(result.messages[3].content).toBe('New response'); }); }); describe('extractToolResultContent', () => { it('passes through string content unchanged', () => { expect(extractToolResultContent('hello world')).toBe('hello world'); }); it('extracts text from array of content blocks (Agent results)', () => { const content = [ { type: 'text', text: 'First block of output.' }, { type: 'text', text: 'agentId: abc123\ntotal_tokens: 1000' }, ]; expect(extractToolResultContent(content)).toBe( 'First block of output.\nagentId: abc123\ntotal_tokens: 1000' ); }); it('skips non-text blocks in array content', () => { const content = [ { type: 'image', source: { type: 'base64', data: 'abc' } }, { type: 'text', text: 'The only text.' }, ]; expect(extractToolResultContent(content)).toBe('The only text.'); }); it('returns empty string for null/undefined content', () => { expect(extractToolResultContent(null)).toBe(''); expect(extractToolResultContent(undefined)).toBe(''); }); it('JSON-stringifies unknown content types as fallback', () => { expect(extractToolResultContent({ custom: 'value' })).toBe('{"custom":"value"}'); }); it('handles empty array content', () => { expect(extractToolResultContent([])).toBe(''); }); it('JSON-stringifies non-empty array with no text blocks (e.g. tool_reference)', () => { const content = [ { type: 'tool_reference', tool_name: 'WebSearch' }, { type: 'tool_reference', tool_name: 'Grep' }, ]; expect(extractToolResultContent(content)).toBe(JSON.stringify(content)); }); it('JSON-stringifies non-empty array with no text blocks using fallbackIndent', () => { const content = [ { type: 'tool_reference', tool_name: 'Read' }, ]; expect(extractToolResultContent(content, { fallbackIndent: 2 })).toBe( JSON.stringify(content, null, 2) ); }); }); describe('collectAsyncSubagentResults', () => { it('extracts task-notification data from queue-operation enqueue entries', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: ` ae5eb9a completed Agent "Review code" completed Found 3 issues in the codebase. 1. Missing error handling in auth module. 2. Unused import in utils.ts. 3. Race condition in fetchData. `, }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(1); const entry = results.get('ae5eb9a')!; expect(entry.status).toBe('completed'); expect(entry.result).toContain('Found 3 issues'); expect(entry.result).toContain('Race condition in fetchData.'); }); it('collects multiple queue-operation entries', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: 'agent-1completedResult 1', }, { type: 'queue-operation', operation: 'enqueue', content: 'agent-2errorTask failed', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(2); expect(results.get('agent-1')!.status).toBe('completed'); expect(results.get('agent-2')!.status).toBe('error'); expect(results.get('agent-2')!.result).toBe('Task failed'); }); it('skips dequeue operations', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'dequeue', sessionId: 'session-1', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); it('skips entries without task-notification content', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: 'some other content', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); it('skips entries without task-id or result', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: 'completedNo task-id', }, { type: 'queue-operation', operation: 'enqueue', content: 'has-idcompleted', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); it('defaults status to completed when status tag is missing', () => { const entries: SDKNativeMessage[] = [ { type: 'queue-operation', operation: 'enqueue', content: 'no-statusDone', }, ]; const results = collectAsyncSubagentResults(entries); expect(results.get('no-status')!.status).toBe('completed'); }); it('ignores non-queue-operation messages', () => { const entries: SDKNativeMessage[] = [ { type: 'user', uuid: 'u1', message: { content: 'hello' } }, { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'hi' }] } }, ]; const results = collectAsyncSubagentResults(entries); expect(results.size).toBe(0); }); }); describe('resolveToolUseResultStatus', () => { it('preserves orphaned fallback when the tool result has no stronger signal', () => { expect(resolveToolUseResultStatus(undefined, 'orphaned')).toBe('orphaned'); expect(resolveToolUseResultStatus({ status: 'unknown' }, 'orphaned')).toBe('orphaned'); }); it('maps task notification failure statuses to error', () => { expect(resolveToolUseResultStatus({ status: 'failed' }, 'completed')).toBe('error'); expect(resolveToolUseResultStatus({ status: 'stopped' }, 'completed')).toBe('error'); expect(resolveToolUseResultStatus({ status: 'killed' }, 'completed')).toBe('error'); }); }); describe('loadSDKSessionMessages - async subagent hydration', () => { it('populates toolCall.subagent for async Task tools from queue-operation results', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}', // Assistant spawns async Task '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}', // Task tool_result with agentId (SDK launch shape) `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched","description":"Review code","prompt":"Check for bugs","outputFile":"/tmp/agent.output"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}`, // Queue-operation with full result `{"type":"queue-operation","operation":"enqueue","content":"ae5eb9acompletedAgent completedFound 3 issues:\\n1. Missing error handling\\n2. Unused import\\n3. Race condition"}`, // Assistant continues after '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"The review found 3 issues."}]}}', ].join('\n'); } // Subagent sidecar file return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-hydrate'); // Should have: user message, merged assistant with Task tool, assistant follow-up expect(result.messages.length).toBeGreaterThanOrEqual(2); const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task')); expect(assistantMsg).toBeDefined(); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.mode).toBe('async'); expect(taskToolCall.subagent!.agentId).toBe('ae5eb9a'); expect(taskToolCall.subagent!.status).toBe('completed'); expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); expect(taskToolCall.subagent!.result).toContain('Found 3 issues'); expect(taskToolCall.subagent!.result).toContain('Race condition'); // toolCall.result should also be updated expect(taskToolCall.result).toContain('Found 3 issues'); expect(taskToolCall.status).toBe('completed'); }); it('prefers queue-operation completion over misleading async tool_result error flags', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}', `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}`, `{"type":"queue-operation","operation":"enqueue","content":"ae5eb9acompletedAgent completedBackground work finished cleanly"}`, ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-error-flag'); const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.status).toBe('completed'); expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); expect(taskToolCall.subagent!.result).toBe('Background work finished cleanly'); expect(taskToolCall.status).toBe('completed'); expect(taskToolCall.result).toBe('Background work finished cleanly'); }); it('uses truncated API result when no queue-operation exists', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched."}]}}`, // No queue-operation entry ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-queue-op'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.agentId).toBe('abc123'); expect(taskToolCall.subagent!.status).toBe('running'); expect(taskToolCall.subagent!.asyncStatus).toBe('running'); expect(taskToolCall.status).toBe('running'); // Falls back to the API content (truncated) expect(taskToolCall.subagent!.result).toBe('Task launched.'); }); it('treats async launch tool results as running even when the content block is flagged as error', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}', ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-launch-error-flag'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.status).toBe('running'); expect(taskToolCall.subagent!.asyncStatus).toBe('running'); expect(taskToolCall.status).toBe('running'); expect(taskToolCall.result).toBe('Task launched in background.'); }); it('treats queue-operation success status as completed', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}', '{"type":"queue-operation","operation":"enqueue","content":"abc123successBackground task succeeded"}', ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-queue-success'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.status).toBe('completed'); expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); expect(taskToolCall.status).toBe('completed'); expect(taskToolCall.result).toBe('Background task succeeded'); }); it('does not build SubagentInfo for sync Task tools', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.endsWith('.jsonl') && !p.includes('subagents')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run sync task"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Sync task","prompt":"test","run_in_background":false}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Sync result"}]}}', ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sync-task'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; // Sync tasks should NOT get SubagentInfo from this pass expect(taskToolCall.subagent).toBeUndefined(); }); it('loads subagent tool calls from sidecar JSONL', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p.includes('subagents/agent-ae5eb9a.jsonl')) { return [ '{"type":"assistant","timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Grep","input":{"pattern":"TODO"}}]}}', '{"type":"user","timestamp":"2024-01-15T10:02:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"3 matches found"}]}}', ].join('\n'); } if (p.endsWith('.jsonl')) { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Review"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review","prompt":"check","run_in_background":true}}]}}', `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Launched"}]}}`, `{"type":"queue-operation","operation":"enqueue","content":"ae5eb9acompletedDone reviewing"}`, ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sidecar'); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(taskToolCall.subagent).toBeDefined(); expect(taskToolCall.subagent!.toolCalls).toHaveLength(1); expect(taskToolCall.subagent!.toolCalls[0].name).toBe('Grep'); expect(taskToolCall.subagent!.toolCalls[0].result).toBe('3 matches found'); }); it('loads subagent tool calls beside a relocated session transcript', async () => { mockExistsSync.mockReturnValue(true); mockFsPromises.readFile.mockImplementation(async (filePath: any) => { const p = String(filePath); if (p === '/old-project/session-sidecar.jsonl') { return [ '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Review"}}', '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review","prompt":"check","run_in_background":true}}]}}', '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Launched"}]}}', ].join('\n'); } if (p === '/old-project/session-sidecar/subagents/agent-ae5eb9a.jsonl') { return [ '{"type":"assistant","timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Grep","input":{"pattern":"TODO"}}]}}', '{"type":"user","timestamp":"2024-01-15T10:02:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"3 matches found"}]}}', ].join('\n'); } return ''; }); const result = await loadSDKSessionMessages( '/Users/test/vault', 'session-sidecar', undefined, '/old-project/session-sidecar.jsonl', ); const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; expect(mockFsPromises.readFile).toHaveBeenCalledWith( '/old-project/session-sidecar/subagents/agent-ae5eb9a.jsonl', 'utf-8', ); expect(taskToolCall.subagent!.toolCalls).toEqual([ expect.objectContaining({ name: 'Grep', result: '3 matches found' }), ]); }); }); });