417 lines · 13.5 KB · TypeScript
RawDownload
/** * 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');    });  });});