Download components/chat-panel/__tests__/event-processor.test.ts from Leon4gr45/builder: direct link, hf CLI and curl.
- Browser
- Download file 37.8 kB
-
https://huggingface.co/spaces/Leon4gr45/builder/resolve/main/components/chat-panel/__tests__/event-processor.test.ts
- Command line
-
hf download hf://spaces/Leon4gr45/builder/components/chat-panel/__tests__/event-processor.test.ts
-
curl -L -o event-processor.test.ts https://huggingface.co/spaces/Leon4gr45/builder/resolve/main/components/chat-panel/__tests__/event-processor.test.ts
37.8 kB
| 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); | |
| // 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: '<html>...</html>' }), | |
| ]; | |
| 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: "<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); | |
| // 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 <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); | |
| // 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 <<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'); | |
| // 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<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'); | |
| // 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); | |
| }); | |
| }); | |