570 lines
17 KiB
TypeScript
570 lines
17 KiB
TypeScript
/**
|
|
* 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>): 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>): 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: '<local-command-stdout>Model set to sonnet</local-command-stdout>',
|
|
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: '<synthetic>',
|
|
}),
|
|
];
|
|
|
|
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: '<synthetic>',
|
|
}),
|
|
];
|
|
|
|
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: '<local-command-caveat>This is a caveat</local-command-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: '<local-command-stdout>Output</local-command-stdout>',
|
|
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);
|
|
});
|
|
});
|
|
});
|