| 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>): DebugEvent { |
| return { |
| id: `evt-${++idCounter}`, |
| timestamp: Date.now(), |
| event, |
| data, |
| count: 1, |
| version: 1, |
| ...overrides, |
| }; |
| } |
|
|
| function userMsg(content: string, uiMeta?: Record<string, any>, overrides?: Partial<DebugEvent>): 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: '<h1>Hi</h1>' }; |
| 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); |
| |
| 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); |
| |
| 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', () => { |
| |
| 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); |
|
|
| |
| const reasonEvt = evt('reasoning_delta', { text: 'analyzing...' }); |
| const events2 = [...events1, reasonEvt]; |
| proc.process(events2); |
|
|
| |
| 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); |
|
|
| |
| const userItems = turns3[0].items.filter(i => i.type === 'user'); |
| expect(userItems).toHaveLength(1); |
| expect(userItems[0].data).toBe('change welcome to hi'); |
|
|
| |
| 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); |
|
|
| |
| const turns2 = proc.process(events); |
| expect(turns2).toHaveLength(turns1.length); |
| }); |
|
|
| it('reparse handles multiple events correctly', () => { |
| |
| 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: '<html>...</html>' }), |
| ]; |
| const turns1 = proc.process(events); |
|
|
| |
| expect(turns1[0].items.some(i => i.type === 'user')).toBe(true); |
| expect(turns1[0].items.some(i => i.type === 'tool')).toBe(true); |
|
|
| |
| 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); |
|
|
| |
| 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', () => { |
| |
| |
| |
| const userEvt = userMsg('hello', { displayContent: 'hello' }); |
| const waitEvt = evt('waiting'); |
| const reasonEvt = evt('reasoning_delta', { text: 'thinking' }); |
|
|
| |
| const events1 = [userEvt, waitEvt, reasonEvt]; |
| const turns1 = proc.process(events1); |
| expect(turns1[0].items.some(i => i.type === 'user')).toBe(true); |
|
|
| |
| const mergedUser: DebugEvent = { |
| ...userEvt, |
| version: 2, |
| data: { |
| message: { |
| role: 'user', |
| content: 'hello', |
| ui_metadata: { |
| displayContent: 'hello', |
| projectContext: 'file tree', |
| }, |
| }, |
| }, |
| }; |
| const events2 = [mergedUser, waitEvt, reasonEvt]; |
| const turns2 = proc.process(events2); |
|
|
| |
| 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); |
|
|
| |
| const merged: DebugEvent = { ...userEvt, version: 2 }; |
| const events2 = [merged, events[1]]; |
| const turns2 = proc.process(events2); |
|
|
| |
| 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: "<automated_reminder>\nNot done yet — these items aren't captured.\n</automated_reminder>" }, |
| }), |
| ]; |
| 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); |
| |
| 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', () => { |
| |
| const events1 = [userMsg('msg1'), evt('waiting')]; |
| proc.process(events1); |
|
|
| |
| 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); |
| |
| const allItems = turns.flatMap(t => t.items); |
| expect(allItems.filter(i => i.type === 'user')).toHaveLength(0); |
| }); |
|
|
| it('skips harness-injected <automated_reminder> nudges (not user input)', () => { |
| const events = [ |
| userMsg('<automated_reminder>\nYour previous response contained only reasoning — invoke a tool or reply with text.\n</automated_reminder>'), |
| ]; |
| 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); |
|
|
| |
| const turns = proc.process([]); |
| expect(turns).toEqual([]); |
|
|
| |
| 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 <<EOF')).toBe('write'); |
| expect(classifyBashCommand("tee /file.txt <<-'HEREDOC'")).toBe('write'); |
| }); |
|
|
| it('returns write for sed -i', () => { |
| 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'); |
| |
| expect(toolItem!.data.parameters.command).toBe('cat > /f'); |
| |
| 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<h1>Hello</h1>\\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'); |
| |
| 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"}' } }], |
| }), |
| |
| 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"}' } }, |
| ], |
| }), |
| |
| evt('tool_status', { toolIndex: 0, status: 'completed', result: 'files' }), |
| |
| 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: '' } }], |
| }), |
| |
| evt('tool_param_delta', { toolId: 'tc-raw', fragment: '{"command":"ls /home"}' }, { id }), |
| |
| 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(); |
| |
| 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"}' } }], |
| }), |
| |
| evt('tool_status', { toolIndex: 0, status: 'executing' }), |
| |
| ]; |
| let turns = proc.process(events); |
| let toolItem = turns[0].items.find(i => i.type === 'tool'); |
| expect(toolItem!.data.status).toBe('executing'); |
|
|
| |
| 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'); |
| |
| 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); |
| }); |
| }); |
|
|