import { describe, it, expect, vi } from 'vitest'; import { parseStreamingResponse } from '../streaming-parser'; function makeSSEStream(chunks: string[]): Response { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk)); } controller.close(); }, }); return new Response(stream); } function sseChunk(data: object): string { return `data: ${JSON.stringify(data)}\n\n`; } describe('streaming-parser reasoning merge', () => { it('merges id-less reasoning_details into a single entry', async () => { const chunks = [ sseChunk({ choices: [{ delta: { reasoning_details: [{ type: 'thinking', text: 'First ' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { reasoning_details: [{ type: 'thinking', text: 'second ' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { reasoning_details: [{ type: 'thinking', text: 'third.' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { content: 'Answer.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; const response = makeSSEStream(chunks); const result = await parseStreamingResponse(response, { provider: 'openrouter', model: 'test' }); expect(result.reasoningDetails).toHaveLength(1); expect(result.reasoningDetails![0].text).toBe('First second third.'); expect(result.content).toBe('Answer.'); }); it('keeps entries with different ids separate', async () => { const chunks = [ sseChunk({ choices: [{ delta: { reasoning_details: [{ id: 'r1', type: 'thinking', text: 'Block 1' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { reasoning_details: [{ id: 'r2', type: 'thinking', text: 'Block 2' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { content: 'Done.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; const response = makeSSEStream(chunks); const result = await parseStreamingResponse(response, { provider: 'openrouter', model: 'test' }); expect(result.reasoningDetails).toHaveLength(2); expect(result.reasoningDetails![0].text).toBe('Block 1'); expect(result.reasoningDetails![1].text).toBe('Block 2'); }); it('updates existing entry by id (cumulative snapshot pattern)', async () => { const chunks = [ sseChunk({ choices: [{ delta: { reasoning_details: [{ id: 'r1', type: 'thinking', text: 'Hello' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { reasoning_details: [{ id: 'r1', type: 'thinking', text: 'Hello world' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { content: 'Done.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; const response = makeSSEStream(chunks); const result = await parseStreamingResponse(response, { provider: 'gemini', model: 'test' }); expect(result.reasoningDetails).toHaveLength(1); expect(result.reasoningDetails![0].text).toBe('Hello world'); }); it('creates synthetic reasoningDetails entry from reasoning buffer', async () => { const chunks = [ sseChunk({ choices: [{ delta: { reasoning: 'Thinking about this...' }, index: 0 }], }), sseChunk({ choices: [{ delta: { content: 'Answer.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; const response = makeSSEStream(chunks); const result = await parseStreamingResponse(response, { provider: 'openrouter', model: 'deepseek-reasoner' }); expect(result.reasoning).toBe('Thinking about this...'); expect(result.reasoningDetails).toHaveLength(1); expect(result.reasoningDetails![0].type).toBe('thinking'); expect(result.reasoningDetails![0].text).toBe('Thinking about this...'); }); it('does not create synthetic entry when reasoningDetails already has text', async () => { const chunks = [ sseChunk({ choices: [{ delta: { reasoning: 'incremental', reasoning_details: [{ type: 'thinking', text: 'incremental' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { content: 'Done.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; const response = makeSSEStream(chunks); const result = await parseStreamingResponse(response, { provider: 'openrouter', model: 'test' }); expect(result.reasoningDetails).toHaveLength(1); expect(result.reasoningDetails![0].text).toBe('incremental'); }); it('emits reasoning_delta progress events for merged chunks', async () => { const progressEvents: { event: string; data: any }[] = []; const onProgress = (event: string, data?: any) => progressEvents.push({ event, data }); const chunks = [ sseChunk({ choices: [{ delta: { reasoning_details: [{ type: 'thinking', text: 'A' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { reasoning_details: [{ type: 'thinking', text: 'B' }] }, index: 0 }], }), sseChunk({ choices: [{ delta: { content: 'X' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; const response = makeSSEStream(chunks); await parseStreamingResponse(response, { provider: 'openrouter', model: 'test', onProgress }); const reasoningDeltas = progressEvents.filter(e => e.event === 'reasoning_delta'); expect(reasoningDeltas).toHaveLength(2); expect(reasoningDeltas[0].data.text).toBe('A'); expect(reasoningDeltas[1].data.text).toBe('B'); }); }); describe('streaming-parser reasoning_complete closure', () => { function collectProgress() { const progressEvents: { event: string; data?: any }[] = []; const onProgress = (event: string, data?: any) => progressEvents.push({ event, data }); return { progressEvents, onProgress }; } it('emits reasoning_complete at stream end for a reasoning-only response (delta.reasoning)', async () => { const { progressEvents, onProgress } = collectProgress(); const chunks = [ sseChunk({ choices: [{ delta: { reasoning: 'Thinking about it. ' }, index: 0 }] }), sseChunk({ choices: [{ delta: { reasoning: 'Still thinking.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; await parseStreamingResponse(makeSSEStream(chunks), { provider: 'openrouter', model: 'test', onProgress }); const completes = progressEvents.filter(e => e.event === 'reasoning_complete'); expect(completes).toHaveLength(1); expect(completes[0].data.reasoning).toBe('Thinking about it. Still thinking.'); }); it('emits reasoning_complete exactly once when delta.reasoning is followed by content', async () => { const { progressEvents, onProgress } = collectProgress(); const chunks = [ sseChunk({ choices: [{ delta: { reasoning: 'Plan first.' }, index: 0 }] }), sseChunk({ choices: [{ delta: { content: 'The answer.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; await parseStreamingResponse(makeSSEStream(chunks), { provider: 'openrouter', model: 'test', onProgress }); expect(progressEvents.filter(e => e.event === 'reasoning_complete')).toHaveLength(1); }); it('does not duplicate reasoning_complete when the think-block was already closed by tool calls', async () => { const { progressEvents, onProgress } = collectProgress(); const chunks = [ sseChunk({ choices: [{ delta: { content: 'Let me act.' }, index: 0 }] }), sseChunk({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'tc1', function: { name: 'bash', arguments: '{"command":"ls"}' } }] }, index: 0, finish_reason: 'tool_calls', }], }), 'data: [DONE]\n\n', ]; await parseStreamingResponse(makeSSEStream(chunks), { provider: 'openrouter', model: 'test', onProgress }); expect(progressEvents.filter(e => e.event === 'reasoning_complete')).toHaveLength(1); }); it('does not emit reasoning_complete when there was no reasoning', async () => { const { progressEvents, onProgress } = collectProgress(); const chunks = [ sseChunk({ choices: [{ delta: { content: 'Plain answer.' }, index: 0, finish_reason: 'stop' }] }), 'data: [DONE]\n\n', ]; await parseStreamingResponse(makeSSEStream(chunks), { provider: 'openrouter', model: 'test', onProgress }); expect(progressEvents.filter(e => e.event === 'reasoning_complete')).toHaveLength(0); }); });