/** * Tests for ChunkBuilder service. * * Tests chunk building from parsed messages: * - UserChunk creation from user messages * - AIChunk creation from assistant messages (with tool grouping) * - SystemChunk creation from command output * - Subagent linking to AIChunks */ import { describe, expect, it } from 'vitest'; import { ChunkBuilder } from '../../../../src/main/services/analysis/ChunkBuilder'; import { isAIChunk, isCompactChunk, isSystemChunk, isUserChunk } from '../../../../src/main/types'; import type { ParsedMessage, Process } from '../../../../src/main/types'; // ============================================================================= // Test Helpers // ============================================================================= /** * Creates a minimal ParsedMessage for testing. */ function createMessage(overrides: Partial): 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, }; } /** * Creates a minimal Process (subagent) for testing. */ function createSubagent(overrides: Partial): Process { return { id: `agent-${Math.random().toString(36).slice(2, 11)}`, filePath: '/path/to/agent.jsonl', parentTaskId: 'task-1', description: 'Test subagent', startTime: new Date(), endTime: new Date(), durationMs: 1000, isOngoing: false, isParallel: false, messages: [], metrics: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 0, cacheCreationTokens: 0, totalTokens: 150, messageCount: 2, durationMs: 1000, }, ...overrides, }; } // ============================================================================= // Tests // ============================================================================= describe('ChunkBuilder', () => { const builder = new ChunkBuilder(); describe('buildChunks', () => { it('should return empty array for empty input', () => { const chunks = builder.buildChunks([]); expect(chunks).toEqual([]); }); it('should filter out sidechain messages', () => { const messages = [ createMessage({ type: 'user', content: 'Main thread message', isMeta: false, isSidechain: false, }), createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Sidechain response' }], isSidechain: true, }), ]; const chunks = builder.buildChunks(messages); // Only the main thread user message should create a chunk expect(chunks).toHaveLength(1); expect(isUserChunk(chunks[0])).toBe(true); }); it('should include sidechain messages when requested (subagent files)', () => { const messages = [ createMessage({ type: 'user', content: 'Subagent input', isSidechain: true, }), createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Subagent output' }], isSidechain: true, }), ]; const chunks = builder.buildChunks(messages, [], { includeSidechain: true }); expect(chunks.length).toBeGreaterThan(0); expect(chunks.some((c) => isUserChunk(c))).toBe(true); expect(chunks.some((c) => isAIChunk(c))).toBe(true); }); describe('UserChunk creation', () => { it('should create UserChunk from real user message', () => { const messages = [ createMessage({ type: 'user', content: 'Help me debug this', isMeta: false, }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isUserChunk(chunks[0])).toBe(true); if (isUserChunk(chunks[0])) { expect(chunks[0].userMessage.content).toBe('Help me debug this'); } }); it('should create UserChunk with array content', () => { const messages = [ createMessage({ type: 'user', content: [{ type: 'text', text: 'Hello world' }], isMeta: false, }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isUserChunk(chunks[0])).toBe(true); }); }); describe('AIChunk creation', () => { it('should create AIChunk from assistant message', () => { const messages = [ createMessage({ type: 'assistant', content: [{ type: 'text', text: "Here's how to fix it" }], }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isAIChunk(chunks[0])).toBe(true); if (isAIChunk(chunks[0])) { expect(chunks[0].responses).toHaveLength(1); } }); it('should extract semantic output from string assistant content', () => { const messages = [ createMessage({ type: 'assistant', content: 'Assistant: visible activity from member logs', }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isAIChunk(chunks[0])).toBe(true); if (isAIChunk(chunks[0])) { expect(chunks[0].semanticSteps).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'output', content: expect.objectContaining({ outputText: 'Assistant: visible activity from member logs', }), }), ]) ); } }); it('should group consecutive assistant messages into one AIChunk', () => { const messages = [ createMessage({ type: 'assistant', content: [{ type: 'text', text: 'First response' }], }), createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Second response' }], }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isAIChunk(chunks[0])).toBe(true); if (isAIChunk(chunks[0])) { expect(chunks[0].responses).toHaveLength(2); } }); it('should include tool results in AIChunk', () => { const messages = [ createMessage({ type: 'assistant', content: [ { type: 'text', text: 'Reading file' }, { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'test.ts' } }, ], toolCalls: [{ id: 't1', name: 'Read', input: { file_path: 'test.ts' }, isTask: false }], }), createMessage({ type: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'file contents' }], isMeta: true, }), createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Found the issue' }], }), ]; const chunks = builder.buildChunks(messages); // All should be in one AIChunk expect(chunks).toHaveLength(1); expect(isAIChunk(chunks[0])).toBe(true); if (isAIChunk(chunks[0])) { // 2 assistant messages + 1 tool result expect(chunks[0].responses.length).toBeGreaterThanOrEqual(2); } }); }); describe('SystemChunk creation', () => { it('should create SystemChunk from command output', () => { const messages = [ createMessage({ type: 'user', content: 'Model set to sonnet', isMeta: false, }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isSystemChunk(chunks[0])).toBe(true); if (isSystemChunk(chunks[0])) { expect(chunks[0].commandOutput).toContain('Model set to sonnet'); } }); }); describe('CompactChunk creation', () => { it('should create CompactChunk from compact summary', () => { const messages = [ createMessage({ type: 'user', content: 'Summary of conversation...', isCompactSummary: true, }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isCompactChunk(chunks[0])).toBe(true); }); }); describe('hardNoise filtering', () => { it('should filter out system messages', () => { const messages = [ createMessage({ type: 'system', content: 'System prompt', }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(0); }); it('should filter out empty synthetic assistant messages', () => { const messages = [ createMessage({ type: 'assistant', content: '', model: '', }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(0); }); it('should keep synthetic assistant messages with renderable content', () => { const messages = [ createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Codex-native assistant activity' }], model: '', }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(1); expect(isAIChunk(chunks[0])).toBe(true); if (isAIChunk(chunks[0])) { expect(chunks[0].semanticSteps).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'output', content: expect.objectContaining({ outputText: 'Codex-native assistant activity', }), }), ]) ); } }); it('should filter out caveat messages', () => { const messages = [ createMessage({ type: 'user', content: 'This is a caveat', }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(0); }); }); describe('AIChunk flushing', () => { it('should flush AIChunk buffer when user message arrives', () => { const messages = [ createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Response 1' }], }), createMessage({ type: 'user', content: 'New question', isMeta: false, }), createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Response 2' }], }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(3); expect(isAIChunk(chunks[0])).toBe(true); expect(isUserChunk(chunks[1])).toBe(true); expect(isAIChunk(chunks[2])).toBe(true); }); it('should flush AIChunk buffer when system message arrives', () => { const messages = [ createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Response' }], }), createMessage({ type: 'user', content: 'Output', isMeta: false, }), ]; const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(2); expect(isAIChunk(chunks[0])).toBe(true); expect(isSystemChunk(chunks[1])).toBe(true); }); }); describe('subagent linking', () => { it('should link subagent to AIChunk containing Task call', () => { const taskId = 'task-123'; const messages = [ createMessage({ type: 'assistant', content: [ { type: 'text', text: 'Spawning agent' }, { type: 'tool_use', id: taskId, name: 'Task', input: { prompt: 'Do something', subagent_type: 'explore' }, }, ], toolCalls: [ { id: taskId, name: 'Task', input: { prompt: 'Do something', subagent_type: 'explore' }, isTask: true, taskDescription: 'Do something', taskSubagentType: 'explore', }, ], }), ]; const subagent = createSubagent({ parentTaskId: taskId, }); const chunks = builder.buildChunks(messages, [subagent]); expect(chunks).toHaveLength(1); expect(isAIChunk(chunks[0])).toBe(true); if (isAIChunk(chunks[0])) { expect(chunks[0].processes).toHaveLength(1); expect(chunks[0].processes[0].id).toBe(subagent.id); } }); it('should NOT link subagent without parentTaskId (no timing fallback)', () => { const taskId = 'task-456'; const messages = [ createMessage({ type: 'assistant', timestamp: new Date('2026-01-01T00:00:00Z'), content: [ { type: 'text', text: 'Spawning' }, { type: 'tool_use', id: taskId, name: 'Task', input: { prompt: 'Do something' }, }, ], toolCalls: [ { id: taskId, name: 'Task', input: { prompt: 'Do something' }, isTask: true, taskDescription: 'Do something', taskSubagentType: 'explore', }, ], }), ]; // Subagent with NO parentTaskId — should NOT be linked even if time overlaps const orphan = createSubagent({ parentTaskId: undefined, startTime: new Date('2026-01-01T00:00:01Z'), endTime: new Date('2026-01-01T00:00:30Z'), }); const chunks = builder.buildChunks(messages, [orphan]); expect(chunks).toHaveLength(1); expect(isAIChunk(chunks[0])).toBe(true); if (isAIChunk(chunks[0])) { expect(chunks[0].processes).toHaveLength(0); } }); }); }); describe('getTotalChunkMetrics', () => { it('should return empty metrics for empty chunks', () => { const metrics = builder.getTotalChunkMetrics([]); expect(metrics.totalTokens).toBe(0); expect(metrics.durationMs).toBe(0); expect(metrics.messageCount).toBe(0); }); }); describe('buildSessionDetail', () => { it('should build complete session detail', () => { const session = { id: 'session-1', projectId: 'project-1', projectPath: '/path/to/project', filePath: '/path/to/session.jsonl', timestamp: new Date(), lastModified: new Date(), isOngoing: false, hasSubagents: false, messageCount: 0, createdAt: Date.now(), }; const messages = [ createMessage({ type: 'user', content: 'Hello', isMeta: false, }), createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Hi' }], }), ]; const detail = builder.buildSessionDetail(session, messages, []); expect(detail.session).toBe(session); expect(detail.messages).toBe(messages); expect(detail.chunks.length).toBeGreaterThan(0); expect(detail.processes).toEqual([]); expect(detail.metrics).toBeDefined(); }); }); describe('buildWaterfallData', () => { it('should build sorted waterfall items from chunks and subagents', () => { const start = new Date('2026-01-01T00:00:00.000Z'); const end = new Date('2026-01-01T00:00:10.000Z'); const messages = [ createMessage({ type: 'assistant', timestamp: start, content: [{ type: 'text', text: 'Running tools' }], toolCalls: [{ id: 'tool-1', name: 'Read', input: {}, isTask: false }], }), createMessage({ type: 'user', timestamp: end, isMeta: true, content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }], }), ]; const subagent = createSubagent({ id: 'agent-1', startTime: new Date('2026-01-01T00:00:03.000Z'), endTime: new Date('2026-01-01T00:00:08.000Z'), durationMs: 5000, }); const chunks = builder.buildChunks(messages, [subagent]); const waterfall = builder.buildWaterfallData(chunks, [subagent]); expect(waterfall.items.length).toBeGreaterThan(0); expect(waterfall.totalDurationMs).toBeGreaterThanOrEqual(0); expect(waterfall.minTime.getTime()).toBeLessThanOrEqual(waterfall.maxTime.getTime()); expect(waterfall.items.some((item) => item.type === 'subagent')).toBe(true); }); }); });