import { describe, it, expect, beforeEach } from 'vitest'; import { EventProcessor, classifyBashCommand } from '../event-processor'; import type { DebugEvent } from '@/lib/stores/types'; let idCounter = 0; function evt(event: string, data: any = {}, overrides?: Partial): DebugEvent { return { id: `evt-${++idCounter}`, timestamp: Date.now(), event, data, count: 1, version: 1, ...overrides, }; } function userMsg(content: string, uiMeta?: Record, overrides?: Partial): DebugEvent { return evt('conversation_message', { message: { role: 'user', content, ui_metadata: uiMeta }, }, overrides); } function systemMsg(content: string): DebugEvent { return evt('conversation_message', { message: { role: 'system', content }, }); } describe('EventProcessor', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); describe('basic event processing', () => { it('returns empty for empty events', () => { expect(proc.process([])).toEqual([]); }); it('processes a single user message into a turn with one item', () => { const events = [userMsg('hello')]; const turns = proc.process(events); expect(turns).toHaveLength(1); expect(turns[0].items).toHaveLength(1); expect(turns[0].items[0].type).toBe('user'); expect(turns[0].items[0].data).toBe('hello'); }); it('uses displayContent from ui_metadata when available', () => { const events = [userMsg('ctx\n\nhello', { displayContent: 'hello' })]; const turns = proc.process(events); expect(turns[0].items[0].data).toBe('hello'); }); it('shows project_context item when projectContext is in ui_metadata', () => { const events = [userMsg('hello', { displayContent: 'hello', projectContext: 'files: index.html' })]; const turns = proc.process(events); expect(turns[0].items).toHaveLength(2); expect(turns[0].items[0].type).toBe('project_context'); expect(turns[0].items[0].data).toBe('files: index.html'); expect(turns[0].items[1].type).toBe('user'); }); it('skips system messages (no items rendered)', () => { const events = [systemMsg('You are an AI assistant'), userMsg('hello')]; const turns = proc.process(events); expect(turns).toHaveLength(1); expect(turns[0].items).toHaveLength(1); expect(turns[0].items[0].type).toBe('user'); }); it('processes waiting event as a spinner item', () => { const events = [userMsg('hello'), evt('waiting')]; const turns = proc.process(events); expect(turns[0].items.some(i => i.type === 'waiting')).toBe(true); }); it('removes waiting indicator when reasoning arrives', () => { const events = [ userMsg('hello'), evt('waiting'), evt('reasoning_delta', { text: 'thinking...' }), ]; const turns = proc.process(events); expect(turns[0].items.some(i => i.type === 'waiting')).toBe(false); expect(turns[0].items.some(i => i.type === 'reasoning')).toBe(true); }); it('processes error events', () => { const events = [userMsg('hello'), evt('error', { message: 'API failed' })]; const turns = proc.process(events); expect(turns[0].items.some(i => i.type === 'error')).toBe(true); }); it('passes focusContext and semanticBlocks to user item', () => { const fc = { domPath: 'body > h1', snippet: '

Hi

' }; const sb = [{ name: 'Header', domPath: 'body > h1', position: 'top', description: 'The header' }]; const events = [userMsg('hello', { focusContext: fc, semanticBlocks: sb })]; const turns = proc.process(events); expect(turns[0].items[0].focusContext).toEqual(fc); expect(turns[0].items[0].semanticBlocks).toEqual(sb); }); }); describe('incremental processing', () => { it('processes new events without reprocessing old ones', () => { const events1 = [userMsg('hello'), evt('waiting')]; const turns1 = proc.process(events1); expect(turns1[0].items).toHaveLength(2); const events2 = [...events1, evt('reasoning_delta', { text: 'think' })]; const turns2 = proc.process(events2); // waiting removed, reasoning added → user + reasoning expect(turns2[0].items.filter(i => i.type === 'waiting')).toHaveLength(0); expect(turns2[0].items.filter(i => i.type === 'reasoning')).toHaveLength(1); }); it('returns cached result when no new events', () => { const events = [userMsg('hello')]; const turns1 = proc.process(events); const turns2 = proc.process(events); // Same content (shallow structure check) expect(turns2).toHaveLength(turns1.length); expect(turns2[0].items).toHaveLength(turns1[0].items.length); }); }); describe('version-based reparse (projectContext merge)', () => { it('reparsing after version bump preserves user message', () => { // Step 1: initial events — user message without projectContext const userEvt = userMsg('change welcome to hi', { displayContent: 'change welcome to hi' }); const waitEvt = evt('waiting'); const events1 = [userEvt, waitEvt]; const turns1 = proc.process(events1); expect(turns1[0].items.some(i => i.type === 'user')).toBe(true); expect(turns1[0].items.some(i => i.type === 'project_context')).toBe(false); // Step 2: more events arrive from server (reasoning, tool calls) const reasonEvt = evt('reasoning_delta', { text: 'analyzing...' }); const events2 = [...events1, reasonEvt]; proc.process(events2); // Step 3: server's user message arrives — client merges projectContext + bumps version const mergedUser: DebugEvent = { ...userEvt, version: 2, data: { message: { role: 'user', content: 'change welcome to hi', ui_metadata: { displayContent: 'change welcome to hi', projectContext: 'files:\n index.html\n styles.css', }, }, }, }; const events3 = [mergedUser, waitEvt, reasonEvt]; const turns3 = proc.process(events3); // User message must still be present after reparse const userItems = turns3[0].items.filter(i => i.type === 'user'); expect(userItems).toHaveLength(1); expect(userItems[0].data).toBe('change welcome to hi'); // Project context should now appear const ctxItems = turns3[0].items.filter(i => i.type === 'project_context'); expect(ctxItems).toHaveLength(1); expect(ctxItems[0].data).toContain('index.html'); }); it('does not reparse when version has not changed', () => { const userEvt = userMsg('hello'); const events = [userEvt, evt('waiting')]; const turns1 = proc.process(events); // Process same events again (no version change) const turns2 = proc.process(events); expect(turns2).toHaveLength(turns1.length); }); it('reparse handles multiple events correctly', () => { // Full server-gen scenario: user msg, waiting, reasoning, tool, more reasoning const userEvt = userMsg('add a button', { displayContent: 'add a button' }); const events = [ userEvt, evt('waiting'), evt('reasoning_delta', { text: 'I will add a button' }), evt('toolCalls', { toolCalls: [{ id: 'tc-1', function: { name: 'bash', arguments: '{"command":"cat /index.html"}' } }], }), evt('tool_status', { toolIndex: 0, status: 'completed', result: '...' }), ]; const turns1 = proc.process(events); // Verify initial state is correct expect(turns1[0].items.some(i => i.type === 'user')).toBe(true); expect(turns1[0].items.some(i => i.type === 'tool')).toBe(true); // Now merge projectContext via version bump const mergedUser: DebugEvent = { ...userEvt, version: 2, data: { message: { role: 'user', content: 'add a button', ui_metadata: { displayContent: 'add a button', projectContext: 'project files here', }, }, }, }; const events2 = [mergedUser, ...events.slice(1)]; const turns2 = proc.process(events2); // Everything must survive the reparse expect(turns2[0].items.some(i => i.type === 'user')).toBe(true); expect(turns2[0].items.some(i => i.type === 'project_context')).toBe(true); expect(turns2[0].items.some(i => i.type === 'tool')).toBe(true); expect(turns2[0].items.some(i => i.type === 'reasoning')).toBe(true); }); it('reparse with no new events still processes all events', () => { // This is the exact scenario that caused the bug: version bump triggers // reparse but newEventsCount was 0 (computed before startIndex reset), // causing the early-return to fire with empty state. const userEvt = userMsg('hello', { displayContent: 'hello' }); const waitEvt = evt('waiting'); const reasonEvt = evt('reasoning_delta', { text: 'thinking' }); // Process initial events const events1 = [userEvt, waitEvt, reasonEvt]; const turns1 = proc.process(events1); expect(turns1[0].items.some(i => i.type === 'user')).toBe(true); // Version bump on user event — same event count, no new events const mergedUser: DebugEvent = { ...userEvt, version: 2, data: { message: { role: 'user', content: 'hello', ui_metadata: { displayContent: 'hello', projectContext: 'file tree', }, }, }, }; const events2 = [mergedUser, waitEvt, reasonEvt]; // same length! const turns2 = proc.process(events2); // CRITICAL: user message must NOT disappear expect(turns2.length).toBeGreaterThan(0); const allItems = turns2.flatMap(t => t.items); expect(allItems.some(i => i.type === 'user')).toBe(true); expect(allItems.find(i => i.type === 'user')!.data).toBe('hello'); expect(allItems.some(i => i.type === 'project_context')).toBe(true); }); it('stabilizes after reparse — no infinite reparse loop', () => { const userEvt = userMsg('test', { displayContent: 'test' }); const events = [userEvt, evt('waiting')]; proc.process(events); // Trigger reparse via version bump const merged: DebugEvent = { ...userEvt, version: 2 }; const events2 = [merged, events[1]]; const turns2 = proc.process(events2); // Process again with same events — should NOT reparse again const turns3 = proc.process(events2); expect(turns3).toHaveLength(turns2.length); }); }); describe('assistant text deduplication', () => { it('does not duplicate assistant text streamed via delta then finalized via conversation_message', () => { const events = [ userMsg('hello'), evt('assistant_delta', { all: [{ text: 'Hi there!' }] }, { id: 'a1' }), evt('conversation_message', { message: { role: 'assistant', content: 'Hi there!' } }), ]; const turns = proc.process(events); const textItems = turns.flatMap(t => t.items).filter(i => i.type === 'text'); expect(textItems).toHaveLength(1); expect(textItems[0].data).toBe('Hi there!'); }); it('still shows assistant text on replay when only conversation_message is present (no delta)', () => { const events = [ userMsg('hello'), evt('conversation_message', { message: { role: 'assistant', content: 'Replayed answer' } }), ]; const turns = proc.process(events); const textItems = turns.flatMap(t => t.items).filter(i => i.type === 'text'); expect(textItems).toHaveLength(1); expect(textItems[0].data).toBe('Replayed answer'); }); }); describe('interview gate surfacing', () => { it('renders an interview_gate event as an interview_gate item', () => { const events = [ userMsg('hello'), evt('interview_gate', { complete: true, items: [{ id: 'a', elicit: 'Name', passed: true }] }), ]; const item = proc.process(events).flatMap(t => t.items).find(i => i.type === 'interview_gate'); expect(item).toBeDefined(); expect(item!.data.complete).toBe(true); }); it('does not render the gate feedback harness message as a user item', () => { const events = [ userMsg('hello'), evt('conversation_message', { message: { role: 'user', content: "\nNot done yet — these items aren't captured.\n" }, }), ]; const userItems = proc.process(events).flatMap(t => t.items).filter(i => i.type === 'user'); expect(userItems).toHaveLength(1); expect(userItems[0].data).toBe('hello'); }); }); describe('multi-turn conversations', () => { it('second user message starts a new turn', () => { const events = [ userMsg('first'), evt('assistant_delta', { text: 'reply' }), evt('usage', { totalCost: 0.01 }), evt('iteration', { iteration: 1 }), userMsg('second'), ]; const turns = proc.process(events); // First turn has user + assistant text, second turn starts with second user msg expect(turns.length).toBeGreaterThanOrEqual(2); const lastTurn = turns[turns.length - 1]; expect(lastTurn.items.some(i => i.type === 'user' && i.data === 'second')).toBe(true); }); }); describe('front-pruning recovery', () => { it('recovers when lastProcessedEventId is pruned from events', () => { // Process initial events const events1 = [userMsg('msg1'), evt('waiting')]; proc.process(events1); // Simulate front-pruning: completely different event IDs const events2 = [ userMsg('msg2', undefined, { id: 'new-1' }), evt('waiting', {}, { id: 'new-2' }), ]; const turns = proc.process(events2); expect(turns).toHaveLength(1); expect(turns[0].items.some(i => i.type === 'user' && i.data === 'msg2')).toBe(true); }); }); it('processes toolCalls and tool_status', () => { const events = [ userMsg('list files'), evt('toolCalls', { toolCalls: [{ id: 'tc-1', function: { name: 'bash', arguments: '{"command":"ls /"}' } }], }), evt('tool_status', { toolIndex: 0, status: 'executing' }), evt('tool_status', { toolIndex: 0, status: 'completed', result: 'index.html\nstyles.css' }), ]; const turns = proc.process(events); const toolItems = turns[0].items.filter(i => i.type === 'tool'); expect(toolItems).toHaveLength(1); expect(toolItems[0].data.status).toBe('completed'); expect(toolItems[0].data.parameters.command).toBe('ls /'); }); it('matches tool_status by toolCallId', () => { const events = [ userMsg('edit file'), evt('toolCalls', { toolCalls: [{ id: 'tc-abc', function: { name: 'bash', arguments: '{"command":"ss /file"}' } }], }), evt('tool_status', { toolCallId: 'tc-abc', status: 'executing' }), evt('tool_status', { toolCallId: 'tc-abc', status: 'failed', error: 'Error: search text not found' }), ]; const turns = proc.process(events); const toolItems = turns[0].items.filter(i => i.type === 'tool'); expect(toolItems).toHaveLength(1); expect(toolItems[0].data.status).toBe('failed'); expect(toolItems[0].data.error).toBe('Error: search text not found'); }); it('conversation_message does not overwrite failed tool status', () => { const events = [ userMsg('edit file'), evt('toolCalls', { toolCalls: [{ id: 'tc-fail', function: { name: 'bash', arguments: '{"command":"ss /file"}' } }], }), evt('tool_status', { toolCallId: 'tc-fail', status: 'executing' }), evt('tool_status', { toolCallId: 'tc-fail', status: 'failed', error: 'Error: ss: search text not found' }), evt('tool_result', { toolCallId: 'tc-fail', result: 'Error: ss: search text not found' }), evt('conversation_message', { message: { role: 'assistant', content: '', tool_calls: [{ id: 'tc-fail', function: { name: 'bash', arguments: '{"command":"ss /file"}' } }] }, }), evt('conversation_message', { message: { role: 'tool', content: 'Error: ss: search text not found', tool_call_id: 'tc-fail' }, }), ]; const turns = proc.process(events); const toolItems = turns.flatMap(t => t.items).filter(i => i.type === 'tool'); expect(toolItems).toHaveLength(1); expect(toolItems[0].data.status).toBe('failed'); }); it('conversation_message infers failed from Error: prefix on replay', () => { const events = [ userMsg('edit file'), evt('conversation_message', { message: { role: 'assistant', content: '', tool_calls: [{ id: 'tc-replay', function: { name: 'bash', arguments: '{"command":"ss /file"}' } }] }, }), evt('conversation_message', { message: { role: 'tool', content: 'Error: ss: search text not found', tool_call_id: 'tc-replay' }, }), ]; const turns = proc.process(events); const toolItems = turns.flatMap(t => t.items).filter(i => i.type === 'tool'); expect(toolItems).toHaveLength(1); expect(toolItems[0].data.status).toBe('failed'); expect(toolItems[0].data.error).toBe('Error: ss: search text not found'); }); describe('edge cases', () => { it('skips internal status nudge prompts', () => { const events = [ userMsg('Before finishing, run the status command to verify all changes.'), ]; const turns = proc.process(events); // No user item should be added for nudge prompts const allItems = turns.flatMap(t => t.items); expect(allItems.filter(i => i.type === 'user')).toHaveLength(0); }); it('skips harness-injected nudges (not user input)', () => { const events = [ userMsg('\nYour previous response contained only reasoning — invoke a tool or reply with text.\n'), ]; const turns = proc.process(events); const allItems = turns.flatMap(t => t.items); expect(allItems.filter(i => i.type === 'user')).toHaveLength(0); }); it('handles empty events after non-empty (conversation cleared)', () => { const events1 = [userMsg('hello'), evt('waiting')]; proc.process(events1); // Clear const turns = proc.process([]); expect(turns).toEqual([]); // New conversation const events2 = [userMsg('new start', undefined, { id: 'fresh-1' })]; const turns2 = proc.process(events2); expect(turns2).toHaveLength(1); expect(turns2[0].items[0].data).toBe('new start'); }); }); }); describe('classifyBashCommand', () => { it('returns bash for undefined', () => { expect(classifyBashCommand(undefined)).toBe('bash'); }); it('returns agent for agent commands', () => { expect(classifyBashCommand('agent explore "find auth"')).toBe('agent'); }); it('returns agent for delegate commands (backward compat)', () => { expect(classifyBashCommand('delegate explore "find auth"')).toBe('agent'); }); it('returns status for status command', () => { expect(classifyBashCommand('status')).toBe('status'); }); it('returns status for build command', () => { expect(classifyBashCommand('build')).toBe('status'); }); it('returns write for cat with redirect', () => { expect(classifyBashCommand('cat > /file.txt')).toBe('write'); expect(classifyBashCommand('cat >/file.txt')).toBe('write'); expect(classifyBashCommand('cat file.txt > /out.txt')).toBe('write'); }); it('returns write for heredoc', () => { expect(classifyBashCommand('cat < { expect(classifyBashCommand('sed -i "s/old/new/g" file.txt')).toBe('write'); }); it('returns write for ss', () => { expect(classifyBashCommand("ss /file.txt << 'EOF'")).toBe('write'); }); it('returns write for file-mutating commands', () => { expect(classifyBashCommand('mkdir -p /src')).toBe('write'); expect(classifyBashCommand('touch /file.txt')).toBe('write'); expect(classifyBashCommand('rm /file.txt')).toBe('write'); expect(classifyBashCommand('mv /a.txt /b.txt')).toBe('write'); expect(classifyBashCommand('cp /a.txt /b.txt')).toBe('write'); }); it('returns write for echo with redirect', () => { expect(classifyBashCommand('echo "hello" >> /file.txt')).toBe('write'); expect(classifyBashCommand('echo "hello" > /file.txt')).toBe('write'); }); it('returns bash for read-only commands', () => { expect(classifyBashCommand('ls -la')).toBe('bash'); expect(classifyBashCommand('cat /file.txt')).toBe('bash'); expect(classifyBashCommand('grep -r "pattern" /src')).toBe('bash'); }); it('returns bash for cat with stderr redirect (not a write)', () => { expect(classifyBashCommand('cat /file.txt 2>/dev/null')).toBe('bash'); expect(classifyBashCommand('cat /index.html && echo "---" && cat /src/App.tsx 2>/dev/null')).toBe('bash'); }); it('returns bash for echo without file redirect', () => { expect(classifyBashCommand('echo "---"')).toBe('bash'); expect(classifyBashCommand('echo "hello"')).toBe('bash'); }); it('handles array input', () => { expect(classifyBashCommand(['agent', 'task', '"prompt"'])).toBe('agent'); expect(classifyBashCommand(['delegate', 'task', '"prompt"'])).toBe('agent'); expect(classifyBashCommand(['ls', '-la'])).toBe('bash'); }); }); describe('reasoning accumulation', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('accumulates multiple reasoning deltas into one item', () => { const id = 'r1'; const events = [ userMsg('hello'), evt('reasoning_delta', { text: 'First ' }, { id }), evt('reasoning_delta', { all: [{ text: 'First ' }, { text: 'second ' }] }, { id }), evt('reasoning_delta', { all: [{ text: 'First ' }, { text: 'second ' }, { text: 'third' }] }, { id }), ]; const turns = proc.process(events); const reasoning = turns[0].items.find(i => i.type === 'reasoning'); expect(reasoning).toBeDefined(); expect(reasoning!.data).toBe('First second third'); }); it('marks reasoning complete on reasoning_complete event', () => { const id = 'r2'; const events = [ userMsg('hello'), evt('reasoning_delta', { text: 'thinking' }, { id }), evt('reasoning_complete', { reasoning: 'thinking' }), ]; const turns = proc.process(events); const reasoning = turns[0].items.find(i => i.type === 'reasoning'); expect(reasoning).toBeDefined(); expect(reasoning!.complete).toBe(true); }); it('marks reasoning complete when toolCalls arrive', () => { const id = 'r3'; const events = [ userMsg('hello'), evt('reasoning_delta', { text: 'analyzing' }, { id }), evt('toolCalls', { toolCalls: [{ id: 'tc1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] }), ]; const turns = proc.process(events); const reasoning = turns[0].items.find(i => i.type === 'reasoning'); expect(reasoning).toBeDefined(); expect(reasoning!.complete).toBe(true); }); it('skips reasoning item when text is only whitespace', () => { const events = [ userMsg('hello'), evt('waiting'), evt('reasoning_delta', { text: ' \n ' }), ]; const turns = proc.process(events); expect(turns[0].items.some(i => i.type === 'reasoning')).toBe(false); expect(turns[0].items.some(i => i.type === 'waiting')).toBe(false); }); }); describe('tool_param_delta accumulation', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('accumulates fragments into tool parameters with raw text', () => { const id = 'pd1'; const events = [ userMsg('list files'), evt('toolCalls', { toolCalls: [{ id: 'tc-1', function: { name: 'bash', arguments: '' } }], }), evt('tool_param_delta', { toolId: 'tc-1', fragment: '{"command":"ls /' }, { id }), evt('tool_param_delta', { all: [ { toolId: 'tc-1', fragment: '{"command":"ls /' }, { toolId: 'tc-1', fragment: '"}' }, ] }, { id }), ]; const turns = proc.process(events); const toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem).toBeDefined(); expect(toolItem!.data.parameters.command).toBe('ls /'); expect(toolItem!.data.parameters._raw).toBe('{"command":"ls /"}'); }); it('handles multiple tools interleaved in one event stream', () => { const id = 'pd2'; const events = [ userMsg('do stuff'), evt('toolCalls', { toolCalls: [ { id: 'tc-a', function: { name: 'bash', arguments: '' } }, { id: 'tc-b', function: { name: 'bash', arguments: '' } }, ], }), evt('tool_param_delta', { toolId: 'tc-a', fragment: '{"command":"ls"}' }, { id }), evt('tool_param_delta', { all: [ { toolId: 'tc-a', fragment: '{"command":"ls"}' }, { toolId: 'tc-b', fragment: '{"command":"pwd"}' }, ] }, { id }), ]; const turns = proc.process(events); const tools = turns[0].items.filter(i => i.type === 'tool'); expect(tools).toHaveLength(2); expect(tools[0].data.parameters.command).toBe('ls'); expect(tools[1].data.parameters.command).toBe('pwd'); }); it('caches cmd extraction from first fragment', () => { const id = 'pd3'; const events = [ userMsg('write file'), evt('toolCalls', { toolCalls: [{ id: 'tc-1', function: { name: 'bash', arguments: '' } }], }), evt('tool_param_delta', { toolId: 'tc-1', fragment: '{"command":"cat > /f' }, { id }), evt('tool_param_delta', { all: [ { toolId: 'tc-1', fragment: '{"command":"cat > /f' }, { toolId: 'tc-1', fragment: 'ile.txt"}' }, ] }, { id }), ]; const turns = proc.process(events); const toolItem = turns[0].items.find(i => i.type === 'tool'); // command is cached from first fragment (partial match "cat > /f"), not re-parsed expect(toolItem!.data.parameters.command).toBe('cat > /f'); // _raw has full accumulated text expect(toolItem!.data.parameters._raw).toBe('{"command":"cat > /file.txt"}'); }); }); describe('assistant_delta accumulation', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('accumulates multiple assistant deltas into one text item', () => { const id = 'ad1'; const events = [ userMsg('hi'), evt('assistant_delta', { text: 'Hello' }, { id }), evt('assistant_delta', { all: [{ text: 'Hello' }, { text: ' world' }] }, { id }), evt('assistant_delta', { all: [{ text: 'Hello' }, { text: ' world' }, { text: '!' }] }, { id }), ]; const turns = proc.process(events); const textItems = turns[0].items.filter(i => i.type === 'text'); expect(textItems).toHaveLength(1); expect(textItems[0].data).toBe('Hello world!'); }); it('marks reasoning complete when assistant_delta arrives', () => { const rid = 'r-ad'; const aid = 'a-ad'; const events = [ userMsg('hi'), evt('reasoning_delta', { text: 'thinking...' }, { id: rid }), evt('assistant_delta', { text: 'Here is my answer' }, { id: aid }), ]; const turns = proc.process(events); const reasoning = turns[0].items.find(i => i.type === 'reasoning'); expect(reasoning!.complete).toBe(true); }); }); describe('conversation_message role=assistant reconstruction', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('creates reasoning items (marked complete) and tool items with parsed parameters', () => { const events = [ userMsg('make a page'), evt('conversation_message', { message: { role: 'assistant', reasoning_details: [{ text: 'I need to create an HTML file' }], tool_calls: [ { id: 'tc-r1', function: { name: 'bash', arguments: '{"command":"cat > /index.html << \'EOF\'\\n

Hello

\\nEOF"}', }, }, ], }, }), ]; const turns = proc.process(events); const reasoning = turns[0].items.find(i => i.type === 'reasoning'); expect(reasoning).toBeDefined(); expect(reasoning!.data).toBe('I need to create an HTML file'); expect(reasoning!.complete).toBe(true); const toolItems = turns[0].items.filter(i => i.type === 'tool'); expect(toolItems).toHaveLength(1); expect(toolItems[0].data.id).toBe('tc-r1'); expect(toolItems[0].data.name).toBe('bash'); expect(toolItems[0].data.parameters.command).toContain('cat > /index.html'); }); it('deduplicates: updates existing tool parameters when toolCalls event already created a tool with empty params', () => { const events = [ userMsg('list files'), evt('toolCalls', { toolCalls: [{ id: 'tc-dup', function: { name: 'bash', arguments: '' } }], }), evt('conversation_message', { message: { role: 'assistant', tool_calls: [ { id: 'tc-dup', function: { name: 'bash', arguments: '{"command":"ls -la /"}' }, }, ], }, }), ]; const turns = proc.process(events); const toolItems = turns[0].items.filter(i => i.type === 'tool'); // Should NOT create a duplicate tool — only one tool with id tc-dup expect(toolItems).toHaveLength(1); expect(toolItems[0].data.id).toBe('tc-dup'); expect(toolItems[0].data.parameters.command).toBe('ls -la /'); }); it('adds assistant text content as a text item', () => { const events = [ userMsg('hi'), evt('conversation_message', { message: { role: 'assistant', content: 'Here is my reply.', }, }), ]; const turns = proc.process(events); const textItems = turns[0].items.filter(i => i.type === 'text'); expect(textItems).toHaveLength(1); expect(textItems[0].data).toBe('Here is my reply.'); }); }); describe('tool_status fallback matching', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('finds and updates the last pending tool when toolIndex is omitted', () => { const events = [ userMsg('do something'), evt('toolCalls', { toolCalls: [{ id: 'tc-fb', function: { name: 'bash', arguments: '{"command":"echo hi"}' } }], }), // tool_status WITHOUT toolIndex — should fallback to last pending/executing tool evt('tool_status', { status: 'completed', result: 'hi' }), ]; const turns = proc.process(events); const toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem).toBeDefined(); expect(toolItem!.data.status).toBe('completed'); expect(toolItem!.data.result).toBe('hi'); }); it('selects the last pending tool when multiple tools exist', () => { const events = [ userMsg('do two things'), evt('toolCalls', { toolCalls: [ { id: 'tc-m1', function: { name: 'bash', arguments: '{"command":"ls"}' } }, { id: 'tc-m2', function: { name: 'bash', arguments: '{"command":"pwd"}' } }, ], }), // Mark the first tool completed explicitly by index evt('tool_status', { toolIndex: 0, status: 'completed', result: 'files' }), // Now send a status without toolIndex — should match tc-m2 (last pending) evt('tool_status', { status: 'completed', result: '/home' }), ]; const turns = proc.process(events); const tools = turns[0].items.filter(i => i.type === 'tool'); expect(tools).toHaveLength(2); expect(tools[0].data.status).toBe('completed'); expect(tools[0].data.result).toBe('files'); expect(tools[1].data.status).toBe('completed'); expect(tools[1].data.result).toBe('/home'); }); }); describe('tool_status args population', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('populates tool parameters from args when status is executing and no _raw exists', () => { const events = [ userMsg('run something'), evt('toolCalls', { toolCalls: [{ id: 'tc-args', function: { name: 'bash', arguments: '' } }], }), evt('tool_status', { toolIndex: 0, status: 'executing', args: '{"command":"grep -r pattern /src"}', }), ]; const turns = proc.process(events); const toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem).toBeDefined(); expect(toolItem!.data.status).toBe('executing'); expect(toolItem!.data.parameters.command).toBe('grep -r pattern /src'); }); it('prefers _raw over args when _raw exists on parameters', () => { const id = 'pd-raw'; const events = [ userMsg('run something'), evt('toolCalls', { toolCalls: [{ id: 'tc-raw', function: { name: 'bash', arguments: '' } }], }), // Accumulate _raw via tool_param_delta evt('tool_param_delta', { toolId: 'tc-raw', fragment: '{"command":"ls /home"}' }, { id }), // Now executing status arrives with args — should parse _raw, not args evt('tool_status', { toolIndex: 0, status: 'executing', args: '{"command":"different command"}', }), ]; const turns = proc.process(events); const toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem).toBeDefined(); // _raw was '{"command":"ls /home"}' and should be parsed on executing expect(toolItem!.data.parameters.command).toBe('ls /home'); }); }); describe('conversation_message role=tool status transition', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('matches tool by tool_call_id, sets result, and transitions to completed', () => { const events = [ userMsg('read file'), evt('toolCalls', { toolCalls: [{ id: 'tc-tr', function: { name: 'bash', arguments: '{"command":"cat /file.txt"}' } }], }), evt('tool_status', { toolIndex: 0, status: 'executing' }), evt('conversation_message', { message: { role: 'tool', tool_call_id: 'tc-tr', content: 'file contents here', }, }), ]; const turns = proc.process(events); const toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem).toBeDefined(); expect(toolItem!.data.status).toBe('completed'); expect(toolItem!.data.result).toBe('file contents here'); }); it('transitions from executing to completed (not only from pending)', () => { const events = [ userMsg('run command'), evt('toolCalls', { toolCalls: [{ id: 'tc-exec', function: { name: 'bash', arguments: '{"command":"echo test"}' } }], }), // Explicitly set to executing first evt('tool_status', { toolIndex: 0, status: 'executing' }), // Verify it is executing at this point ]; let turns = proc.process(events); let toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem!.data.status).toBe('executing'); // Now conversation_message with role=tool arrives const moreEvents = [ ...events, evt('conversation_message', { message: { role: 'tool', tool_call_id: 'tc-exec', content: 'test', }, }), ]; turns = proc.process(moreEvents); toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem!.data.status).toBe('completed'); expect(toolItem!.data.result).toBe('test'); }); it('does not regress a completed tool back to completed (no-op on already completed)', () => { const events = [ userMsg('list'), evt('toolCalls', { toolCalls: [{ id: 'tc-done', function: { name: 'bash', arguments: '{"command":"ls"}' } }], }), evt('tool_status', { toolIndex: 0, status: 'completed', result: 'original result' }), evt('conversation_message', { message: { role: 'tool', tool_call_id: 'tc-done', content: 'new result from conversation', }, }), ]; const turns = proc.process(events); const toolItem = turns[0].items.find(i => i.type === 'tool'); expect(toolItem!.data.status).toBe('completed'); // Result gets updated since conversation_message sets it unconditionally expect(toolItem!.data.result).toBe('new result from conversation'); }); }); describe('task_complete clears waiting', () => { let proc: EventProcessor; beforeEach(() => { proc = new EventProcessor(); idCounter = 0; }); it('removes waiting indicator when task_complete arrives', () => { const events = [ userMsg('hello'), evt('waiting'), evt('task_complete', {}), ]; const turns = proc.process(events); expect(turns[0].items.some(i => i.type === 'waiting')).toBe(false); }); });