- Add getFileSystemProvider() getter to ProjectScanner - Update SessionParser.parseSessionFile() to pass provider to parseJsonlFile() - Update SessionParser.parseSubagentFile() to pass provider to parseJsonlFile() - Update SubagentResolver.parseSubagentFile() to pass provider to parseJsonlFile() - Update SessionParser test mock to include getFileSystemProvider method Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
/**
|
|
* Tests for SessionParser service.
|
|
*
|
|
* Tests parsing functionality:
|
|
* - Message type grouping
|
|
* - Sidechain vs main thread separation
|
|
* - Task call extraction
|
|
* - Tool result linking
|
|
* - Time range calculation
|
|
*/
|
|
|
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
|
|
import {
|
|
SessionParser,
|
|
type ParsedSession,
|
|
} from '../../../../src/main/services/parsing/SessionParser';
|
|
import type { ParsedMessage } from '../../../../src/main/types';
|
|
import { LocalFileSystemProvider } from '../../../../src/main/services/infrastructure/LocalFileSystemProvider';
|
|
|
|
// =============================================================================
|
|
// Mock ProjectScanner
|
|
// =============================================================================
|
|
|
|
const mockProjectScanner = {
|
|
scan: vi.fn(),
|
|
getSessionPath: vi.fn(),
|
|
listSessionsPaginated: vi.fn(),
|
|
listSessions: vi.fn(),
|
|
listSubagentFiles: vi.fn(),
|
|
getSession: vi.fn(),
|
|
listWorktreeSessions: vi.fn(),
|
|
scanWithWorktreeGrouping: vi.fn(),
|
|
getFileSystemProvider: vi.fn().mockReturnValue(new LocalFileSystemProvider()),
|
|
};
|
|
|
|
// =============================================================================
|
|
// Test Helpers
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Creates a minimal ParsedMessage for testing.
|
|
*/
|
|
function createMessage(overrides: Partial<ParsedMessage>): ParsedMessage {
|
|
return {
|
|
uuid: `msg-${Math.random().toString(36).slice(2, 11)}`,
|
|
parentUuid: null,
|
|
type: 'user',
|
|
timestamp: new Date(),
|
|
content: '',
|
|
isSidechain: false,
|
|
isMeta: false,
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
describe('SessionParser', () => {
|
|
let parser: SessionParser;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// @ts-expect-error - Using partial mock
|
|
parser = new SessionParser(mockProjectScanner);
|
|
});
|
|
|
|
describe('processMessages (via public methods)', () => {
|
|
// Since processMessages is private, we test its behavior through the query methods
|
|
|
|
describe('message type grouping', () => {
|
|
it('should group user messages correctly', () => {
|
|
const messages = [
|
|
createMessage({ type: 'user', content: 'User message 1' }),
|
|
createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Response' }] }),
|
|
createMessage({ type: 'user', content: 'User message 2' }),
|
|
];
|
|
|
|
// Access processMessages result through getUserMessages
|
|
const processedResult = {
|
|
messages,
|
|
metrics: {
|
|
durationMs: 0,
|
|
totalTokens: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheCreationTokens: 0,
|
|
messageCount: messages.length,
|
|
},
|
|
taskCalls: [],
|
|
byType: {
|
|
user: messages.filter((m) => m.type === 'user'),
|
|
realUser: messages.filter((m) => m.type === 'user' && !m.isMeta),
|
|
internalUser: messages.filter((m) => m.type === 'user' && m.isMeta),
|
|
assistant: messages.filter((m) => m.type === 'assistant'),
|
|
system: [],
|
|
other: [],
|
|
},
|
|
sidechainMessages: [],
|
|
mainMessages: messages,
|
|
};
|
|
|
|
const userMessages = parser.getUserMessages(processedResult);
|
|
expect(userMessages).toHaveLength(2);
|
|
});
|
|
|
|
it('should separate real user vs internal user messages', () => {
|
|
const messages = [
|
|
createMessage({ type: 'user', content: 'Real user input', isMeta: false }),
|
|
createMessage({
|
|
type: 'user',
|
|
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'result' }],
|
|
isMeta: true,
|
|
}),
|
|
];
|
|
|
|
const processedResult: ParsedSession = {
|
|
messages,
|
|
metrics: {
|
|
durationMs: 0,
|
|
totalTokens: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadTokens: 0,
|
|
cacheCreationTokens: 0,
|
|
messageCount: messages.length,
|
|
},
|
|
taskCalls: [],
|
|
byType: {
|
|
user: messages.filter((m) => m.type === 'user'),
|
|
realUser: messages.filter((m) => m.type === 'user' && !m.isMeta),
|
|
internalUser: messages.filter((m) => m.type === 'user' && m.isMeta),
|
|
assistant: [],
|
|
system: [],
|
|
other: [],
|
|
},
|
|
sidechainMessages: [],
|
|
mainMessages: messages,
|
|
};
|
|
|
|
expect(processedResult.byType.realUser).toHaveLength(1);
|
|
expect(processedResult.byType.internalUser).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('sidechain separation', () => {
|
|
it('should separate sidechain from main thread messages', () => {
|
|
const messages = [
|
|
createMessage({ type: 'user', content: 'Main', isSidechain: false }),
|
|
createMessage({
|
|
type: 'assistant',
|
|
content: [{ type: 'text', text: 'Sidechain' }],
|
|
isSidechain: true,
|
|
}),
|
|
createMessage({
|
|
type: 'assistant',
|
|
content: [{ type: 'text', text: 'Main' }],
|
|
isSidechain: false,
|
|
}),
|
|
];
|
|
|
|
const sidechainMessages = messages.filter((m) => m.isSidechain);
|
|
const mainMessages = messages.filter((m) => !m.isSidechain);
|
|
|
|
expect(sidechainMessages).toHaveLength(1);
|
|
expect(mainMessages).toHaveLength(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getResponses', () => {
|
|
it('should get assistant responses after user message', () => {
|
|
const userMsgUuid = 'user-1';
|
|
const messages = [
|
|
createMessage({ uuid: userMsgUuid, type: 'user', content: 'Question' }),
|
|
createMessage({
|
|
uuid: 'asst-1',
|
|
type: 'assistant',
|
|
content: [{ type: 'text', text: 'Answer 1' }],
|
|
}),
|
|
createMessage({
|
|
uuid: 'asst-2',
|
|
type: 'assistant',
|
|
content: [{ type: 'text', text: 'Answer 2' }],
|
|
}),
|
|
createMessage({ uuid: 'user-2', type: 'user', content: 'Next question' }),
|
|
];
|
|
|
|
const responses = parser.getResponses(messages, userMsgUuid);
|
|
expect(responses).toHaveLength(2);
|
|
expect(responses[0].uuid).toBe('asst-1');
|
|
expect(responses[1].uuid).toBe('asst-2');
|
|
});
|
|
|
|
it('should stop at next user message', () => {
|
|
const userMsgUuid = 'user-1';
|
|
const messages = [
|
|
createMessage({ uuid: userMsgUuid, type: 'user', content: 'Q1' }),
|
|
createMessage({
|
|
uuid: 'asst-1',
|
|
type: 'assistant',
|
|
content: [{ type: 'text', text: 'A1' }],
|
|
}),
|
|
createMessage({ uuid: 'user-2', type: 'user', content: 'Q2' }),
|
|
createMessage({
|
|
uuid: 'asst-2',
|
|
type: 'assistant',
|
|
content: [{ type: 'text', text: 'A2' }],
|
|
}),
|
|
];
|
|
|
|
const responses = parser.getResponses(messages, userMsgUuid);
|
|
expect(responses).toHaveLength(1);
|
|
expect(responses[0].uuid).toBe('asst-1');
|
|
});
|
|
|
|
it('should return empty for non-existent message', () => {
|
|
const messages = [createMessage({ uuid: 'user-1', type: 'user', content: 'Q' })];
|
|
|
|
const responses = parser.getResponses(messages, 'non-existent');
|
|
expect(responses).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('getTaskCalls', () => {
|
|
it('should extract Task tool calls from messages', () => {
|
|
const messages = [
|
|
createMessage({
|
|
type: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: 'Spawning agent' },
|
|
{
|
|
type: 'tool_use',
|
|
id: 'task-1',
|
|
name: 'Task',
|
|
input: { prompt: 'Do something', subagent_type: 'explore' },
|
|
},
|
|
],
|
|
toolCalls: [
|
|
{
|
|
id: 'task-1',
|
|
name: 'Task',
|
|
input: { prompt: 'Do something', subagent_type: 'explore' },
|
|
isTask: true,
|
|
taskDescription: 'Do something',
|
|
taskSubagentType: 'explore',
|
|
},
|
|
],
|
|
}),
|
|
createMessage({
|
|
type: 'assistant',
|
|
content: [
|
|
{ type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.ts' } },
|
|
],
|
|
toolCalls: [
|
|
{ id: 'read-1', name: 'Read', input: { file_path: 'test.ts' }, isTask: false },
|
|
],
|
|
}),
|
|
];
|
|
|
|
const taskCalls = parser.getTaskCalls(messages);
|
|
expect(taskCalls).toHaveLength(1);
|
|
expect(taskCalls[0].name).toBe('Task');
|
|
expect(taskCalls[0].isTask).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getToolCallsByName', () => {
|
|
it('should get tool calls by name', () => {
|
|
const messages = [
|
|
createMessage({
|
|
type: 'assistant',
|
|
toolCalls: [
|
|
{ id: 'read-1', name: 'Read', input: { file_path: 'a.ts' }, isTask: false },
|
|
{
|
|
id: 'write-1',
|
|
name: 'Write',
|
|
input: { file_path: 'b.ts', content: '' },
|
|
isTask: false,
|
|
},
|
|
{ id: 'read-2', name: 'Read', input: { file_path: 'c.ts' }, isTask: false },
|
|
],
|
|
}),
|
|
];
|
|
|
|
const readCalls = parser.getToolCallsByName(messages, 'Read');
|
|
expect(readCalls).toHaveLength(2);
|
|
expect(readCalls[0].id).toBe('read-1');
|
|
expect(readCalls[1].id).toBe('read-2');
|
|
});
|
|
});
|
|
|
|
describe('findToolResult', () => {
|
|
it('should find tool result by tool call ID', () => {
|
|
const toolCallId = 'tool-1';
|
|
const messages = [
|
|
createMessage({
|
|
type: 'user',
|
|
isMeta: true,
|
|
toolResults: [{ toolUseId: toolCallId, content: 'result content', isError: false }],
|
|
}),
|
|
];
|
|
|
|
const found = parser.findToolResult(messages, toolCallId);
|
|
expect(found).not.toBeNull();
|
|
expect(found?.result.toolUseId).toBe(toolCallId);
|
|
expect(found?.result.content).toBe('result content');
|
|
});
|
|
|
|
it('should return null for non-existent tool call', () => {
|
|
const messages = [
|
|
createMessage({
|
|
type: 'user',
|
|
isMeta: true,
|
|
toolResults: [{ toolUseId: 'other-id', content: '', isError: false }],
|
|
}),
|
|
];
|
|
|
|
const found = parser.findToolResult(messages, 'non-existent');
|
|
expect(found).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getTimeRange', () => {
|
|
it('should calculate time range correctly', () => {
|
|
const start = new Date('2024-01-01T10:00:00Z');
|
|
const end = new Date('2024-01-01T10:05:00Z');
|
|
const messages = [
|
|
createMessage({ timestamp: start }),
|
|
createMessage({ timestamp: new Date('2024-01-01T10:02:00Z') }),
|
|
createMessage({ timestamp: end }),
|
|
];
|
|
|
|
const range = parser.getTimeRange(messages);
|
|
expect(range.start.getTime()).toBe(start.getTime());
|
|
expect(range.end.getTime()).toBe(end.getTime());
|
|
expect(range.durationMs).toBe(5 * 60 * 1000); // 5 minutes
|
|
});
|
|
|
|
it('should handle empty messages', () => {
|
|
const range = parser.getTimeRange([]);
|
|
expect(range.durationMs).toBe(0);
|
|
});
|
|
|
|
it('should handle single message', () => {
|
|
const timestamp = new Date('2024-01-01T10:00:00Z');
|
|
const messages = [createMessage({ timestamp })];
|
|
|
|
const range = parser.getTimeRange(messages);
|
|
expect(range.start.getTime()).toBe(timestamp.getTime());
|
|
expect(range.end.getTime()).toBe(timestamp.getTime());
|
|
expect(range.durationMs).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('buildMessageTree', () => {
|
|
it('should build parent-child tree', () => {
|
|
const messages = [
|
|
createMessage({ uuid: 'root', parentUuid: null }),
|
|
createMessage({ uuid: 'child1', parentUuid: 'root' }),
|
|
createMessage({ uuid: 'child2', parentUuid: 'root' }),
|
|
createMessage({ uuid: 'grandchild', parentUuid: 'child1' }),
|
|
];
|
|
|
|
const tree = parser.buildMessageTree(messages);
|
|
|
|
expect(tree.get('root')?.map((m) => m.uuid)).toContain('child1');
|
|
expect(tree.get('root')?.map((m) => m.uuid)).toContain('child2');
|
|
expect(tree.get('child1')?.map((m) => m.uuid)).toContain('grandchild');
|
|
});
|
|
});
|
|
|
|
describe('getChildMessages', () => {
|
|
it('should get direct children', () => {
|
|
const messages = [
|
|
createMessage({ uuid: 'parent', parentUuid: null }),
|
|
createMessage({ uuid: 'child1', parentUuid: 'parent' }),
|
|
createMessage({ uuid: 'child2', parentUuid: 'parent' }),
|
|
createMessage({ uuid: 'other', parentUuid: 'other-parent' }),
|
|
];
|
|
|
|
const children = parser.getChildMessages(messages, 'parent');
|
|
expect(children).toHaveLength(2);
|
|
expect(children.map((m) => m.uuid)).toContain('child1');
|
|
expect(children.map((m) => m.uuid)).toContain('child2');
|
|
});
|
|
});
|
|
|
|
describe('extractText', () => {
|
|
it('should extract text from string content', () => {
|
|
const message = createMessage({ content: 'Hello world' });
|
|
expect(parser.extractText(message)).toBe('Hello world');
|
|
});
|
|
});
|
|
|
|
describe('getMessagePreview', () => {
|
|
it('should truncate long messages', () => {
|
|
const longText = 'A'.repeat(200);
|
|
const message = createMessage({ content: longText });
|
|
|
|
const preview = parser.getMessagePreview(message, 50);
|
|
expect(preview.length).toBe(53); // 50 chars + '...'
|
|
expect(preview.endsWith('...')).toBe(true);
|
|
});
|
|
|
|
it('should not truncate short messages', () => {
|
|
const message = createMessage({ content: 'Short' });
|
|
const preview = parser.getMessagePreview(message, 50);
|
|
expect(preview).toBe('Short');
|
|
});
|
|
});
|
|
});
|