项目文件夹

文件
wehub-resource-sync 2771cff92b
CI / lint (push) Failing after 1s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 1s
CI / build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:22:56 +08:00

2631 行
109 KiB
TypeScript

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<typeof existsSync>;
const mockFsPromises = fsPromises as jest.Mocked<typeof fsPromises>;
const mockOs = os as jest.Mocked<typeof os>;
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<current_note>\nnotes/test.md\n</current_note>',
},
};
const chatMsg = parseSDKMessageToChat(sdkMsg);
expect(chatMsg).not.toBeNull();
expect(chatMsg!.content).toBe('Explain this file\n\n<current_note>\nnotes/test.md\n</current_note>');
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<editor_selection path="src/main.ts">\nfunction foo() {}\n</editor_selection>',
},
};
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<current_note>\ntest.md\n</current_note>\n\n<editor_selection path="test.md">\nselected\n</editor_selection>',
},
};
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":"<local-command-caveat>Caveat</local-command-caveat>"}}',
'{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"<command-name>/compact</command-name>\\n<command-message>compact</command-message>\\n<command-args></command-args>"}}',
'{"type":"user","uuid":"u4","timestamp":"2024-01-15T10:02:11Z","message":{"content":"<local-command-stdout>Compacted </local-command-stdout>"}}',
].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":"<command-name>/compact</command-name>\\n<command-message>compact</command-message>\\n<command-args></command-args>"}}',
'{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"<local-command-stderr>Error: Compaction canceled.</local-command-stderr>"}}',
].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\\n<local-command-stderr>Error: Compaction canceled.</local-command-stderr>"}}',
].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":"<command-message>md2docx</command-message>\\n<command-name>/md2docx</command-name>"}}',
'{"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\n<local-command-stderr>Error: Compaction canceled.</local-command-stderr>',
},
};
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: '<local-command-stderr>Error: Compaction canceled.</local-command-stderr>',
},
};
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\n<usage>total_tokens: 500</usage>' },
],
}] },
}),
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\n<usage>total_tokens: 500</usage>'
);
// 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\n<usage>total_tokens: 1000</usage>' },
];
expect(extractToolResultContent(content)).toBe(
'First block of output.\nagentId: abc123\n<usage>total_tokens: 1000</usage>'
);
});
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: `<task-notification>
<task-id>ae5eb9a</task-id>
<status>completed</status>
<summary>Agent "Review code" completed</summary>
<result>Found 3 issues in the codebase.
1. Missing error handling in auth module.
2. Unused import in utils.ts.
3. Race condition in fetchData.</result>
</task-notification>`,
},
];
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: '<task-notification><task-id>agent-1</task-id><status>completed</status><result>Result 1</result></task-notification>',
},
{
type: 'queue-operation',
operation: 'enqueue',
content: '<task-notification><task-id>agent-2</task-id><status>error</status><result>Task failed</result></task-notification>',
},
];
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: '<task-notification><status>completed</status><result>No task-id</result></task-notification>',
},
{
type: 'queue-operation',
operation: 'enqueue',
content: '<task-notification><task-id>has-id</task-id><status>completed</status></task-notification>',
},
];
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: '<task-notification><task-id>no-status</task-id><result>Done</result></task-notification>',
},
];
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":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><summary>Agent completed</summary><result>Found 3 issues:\\n1. Missing error handling\\n2. Unused import\\n3. Race condition</result></task-notification>"}`,
// 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":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><summary>Agent completed</summary><result>Background work finished cleanly</result></task-notification>"}`,
].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":"<task-notification><task-id>abc123</task-id><status>success</status><result>Background task succeeded</result></task-notification>"}',
].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":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><result>Done reviewing</result></task-notification>"}`,
].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' }),
]);
});
});
});