import { describe, it, expect } from 'vitest'; import { createKimiReasoningPreservationMiddleware, createReasoningContentRewriter, restoreKimiReasoningInRequestBody, wrapJsonResponseWithReasoning, wrapResponseWithReasoning, } from '@/lib/ai/reasoning-sse'; // Build a streaming Response from SSE text, optionally split into arbitrary // byte-fragments to exercise the line buffer crossing chunk boundaries. function sseResponse(fragments: string[]): Response { const enc = new TextEncoder(); const stream = new ReadableStream({ start(controller) { for (const f of fragments) controller.enqueue(enc.encode(f)); controller.close(); }, }); return new Response(stream, { headers: { 'content-type': 'text/event-stream' } }); } async function readAll(res: Response): Promise { return await res.text(); } const dataLine = (delta: Record, finish_reason: string | null = null) => `data: ${JSON.stringify({ choices: [{ delta, finish_reason }] })}\n\n`; // A chat.completion.chunk shape (only the bits the rewriter touches). type Chunk = { choices?: { delta?: Record; finish_reason?: string | null }[]; }; const chunk = (delta: Record, finish_reason: string | null = null): Chunk => ({ choices: [{ delta, finish_reason }], }); const contentOf = (c: Chunk) => c.choices?.[0]?.delta?.content; const hasRC = (c: Chunk) => 'reasoning_content' in (c.choices?.[0]?.delta ?? {}); describe('createReasoningContentRewriter', () => { it('wraps the first reasoning_content delta with an opening and strips the field', () => { const rw = createReasoningContentRewriter(); const out = rw(chunk({ reasoning_content: 'We ' })); expect(contentOf(out)).toBe('We '); expect(hasRC(out)).toBe(false); }); it('leaves later reasoning_content deltas unwrapped (tag already open)', () => { const rw = createReasoningContentRewriter(); rw(chunk({ reasoning_content: 'We ' })); const out = rw(chunk({ reasoning_content: 'are asked' })); expect(contentOf(out)).toBe('are asked'); expect(hasRC(out)).toBe(false); }); it('closes the block by prepending to the first real content delta', () => { const rw = createReasoningContentRewriter(); rw(chunk({ reasoning_content: 'thinking' })); const out = rw(chunk({ content: '391' })); expect(contentOf(out)).toBe('391'); }); it('does not double-close: subsequent content deltas pass through untouched', () => { const rw = createReasoningContentRewriter(); rw(chunk({ reasoning_content: 'thinking' })); rw(chunk({ content: 'The answer ' })); const out = rw(chunk({ content: 'is 391' })); expect(contentOf(out)).toBe('is 391'); }); it('closes the block when reasoning is followed directly by a tool call (no content)', () => { const rw = createReasoningContentRewriter(); rw(chunk({ reasoning_content: 'I should call the tool' })); const out = rw( chunk({ tool_calls: [{ index: 0, function: { name: 'edit', arguments: '{' } }] }), ); expect(contentOf(out)).toBe(''); expect(out.choices?.[0]?.delta?.tool_calls).toBeDefined(); }); it('closes the block at finish_reason when only reasoning streamed', () => { const rw = createReasoningContentRewriter(); rw(chunk({ reasoning_content: 'done thinking' })); const out = rw(chunk({}, 'stop')); expect(contentOf(out)).toBe(''); }); it('closes the block within a chunk that carries BOTH reasoning_content and content', () => { const rw = createReasoningContentRewriter(); // Some providers send the transition in one delta: reasoning + answer. const out = rw(chunk({ reasoning_content: 'I think', content: 'answer' })); // The answer must NOT be absorbed into ; the block closes before it. expect(contentOf(out)).toBe('I thinkanswer'); expect(hasRC(out)).toBe(false); }); it('passes through a plain (no-reasoning) content stream unchanged', () => { const rw = createReasoningContentRewriter(); const out = rw(chunk({ content: 'hello' })); expect(contentOf(out)).toBe('hello'); expect(hasRC(out)).toBe(false); }); it('is a no-op for chunks without choices', () => { const rw = createReasoningContentRewriter(); const c: Chunk = {}; expect(() => rw(c)).not.toThrow(); }); }); describe('wrapResponseWithReasoning', () => { it('rewrites reasoning_content into a block across the stream', async () => { const res = wrapResponseWithReasoning( sseResponse([ dataLine({ role: 'assistant' }), dataLine({ reasoning_content: 'We ' }), dataLine({ reasoning_content: 'think' }), dataLine({ content: '391' }), 'data: [DONE]\n\n', ]), ); const text = await readAll(res); // Reconstruct the content stream the AI SDK will see. const contents = [...text.matchAll(/"content":"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`), ); expect(contents.join('')).toBe('We think391'); expect(text).toContain('data: [DONE]'); expect(text).not.toContain('reasoning_content'); }); it('preserves framing when a data: line is split across read chunks', async () => { const line = dataLine({ reasoning_content: 'hello' }); const mid = Math.floor(line.length / 2); const res = wrapResponseWithReasoning( sseResponse([ line.slice(0, mid), line.slice(mid), dataLine({ content: 'x' }), 'data: [DONE]\n\n', ]), ); const text = await readAll(res); const contents = [...text.matchAll(/"content":"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`), ); expect(contents.join('')).toBe('hellox'); }); it('returns the response unchanged when it has no body', () => { const res = new Response(null, { status: 204 }); expect(wrapResponseWithReasoning(res)).toBe(res); }); }); describe('Kimi reasoning preservation', () => { it('round-trips reasoning prompt parts through OpenAI-compatible serialization markers', async () => { const middleware = createKimiReasoningPreservationMiddleware(); const params = await middleware.transformParams!({ type: 'stream', model: {} as never, params: { prompt: [ { role: 'assistant', content: [ { type: 'reasoning', text: 'tool rationale' }, { type: 'tool-call', toolCallId: 'call-1', toolName: 'lookup', input: {}, }, ], }, ], }, }); const marked = (params.prompt[0] as { content: Array<{ text?: string }> }).content[0].text; const body = { messages: [ { role: 'assistant', content: marked, tool_calls: [{ id: 'call-1' }], }, ], }; restoreKimiReasoningInRequestBody(body); expect(body.messages[0]).toMatchObject({ content: null, reasoning_content: 'tool rationale', }); }); it('recovers reasoning_content from non-streaming responses', async () => { const response = await wrapJsonResponseWithReasoning( new Response( JSON.stringify({ choices: [ { message: { role: 'assistant', content: null, reasoning_content: 'tool rationale', tool_calls: [{ id: 'call-1' }], }, }, ], }), { headers: { 'content-type': 'application/json' } }, ), ); const body = await response.json(); expect(body.choices[0].message).toMatchObject({ content: 'tool rationale', tool_calls: [{ id: 'call-1' }], }); expect(body.choices[0].message).not.toHaveProperty('reasoning_content'); }); });