import { APIEmptyResponseError, isRetryableGenerateError } from '#/errors'; import { generate } from '#/generate'; import type { Message, StreamedMessagePart, ToolCall } from '#/message'; import type { ChatProvider, StreamedMessage, ThinkingEffort } from '#/provider'; import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; import { describe, expect, it, vi } from 'vitest'; function createMockStream( parts: StreamedMessagePart[], opts?: { id?: string; usage?: TokenUsage; finishReason?: StreamedMessage['finishReason']; rawFinishReason?: string | null; }, ): StreamedMessage { return { get id(): string | null { return opts?.id ?? null; }, get usage(): TokenUsage | null { return opts?.usage ?? null; }, finishReason: opts?.finishReason ?? null, rawFinishReason: opts?.rawFinishReason ?? null, async *[Symbol.asyncIterator](): AsyncIterator { for (const part of parts) { yield part; } }, }; } function createMockProvider(stream: StreamedMessage): ChatProvider { return { name: 'mock', modelName: 'mock-model', thinkingEffort: null, generate: async ( _systemPrompt: string, _tools: Tool[], _history: Message[], ): Promise => stream, withThinking(_effort: ThinkingEffort): ChatProvider { return this; }, }; } describe('generate()', () => { it('omits trace metadata when the provider does not expose it', async () => { const onTraceId = vi.fn(); const result = await generate( createMockProvider(createMockStream([{ type: 'text', text: 'ok' }])), '', [], [], undefined, { onTraceId }, ); expect(onTraceId).not.toHaveBeenCalled(); expect(Object.hasOwn(result, 'traceId')).toBe(false); }); it('merges consecutive TextParts and filters empty ones', async () => { const stream = createMockStream([ { type: 'text', text: 'Hello, ' }, { type: 'text', text: 'world' }, { type: 'text', text: '!' }, { type: 'image_url', imageUrl: { url: 'https://example.com/image.png' } }, { type: 'text', text: 'Another text.' }, { type: 'text', text: '' }, { type: 'function', id: 'get_weather#123', name: 'get_weather', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{' }, { type: 'tool_call_part', argumentsPart: '"city":' }, { type: 'tool_call_part', argumentsPart: '"Beijing"' }, { type: 'tool_call_part', argumentsPart: '}' }, { type: 'tool_call_part', argumentsPart: null }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.content).toEqual([ { type: 'text', text: 'Hello, world!' }, { type: 'image_url', imageUrl: { url: 'https://example.com/image.png' } }, { type: 'text', text: 'Another text.' }, ]); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'get_weather#123', name: 'get_weather', arguments: '{"city":"Beijing"}', }, ]); }); it('calls onMessagePart and onToolCall callbacks correctly', async () => { const inputParts: StreamedMessagePart[] = [ { type: 'text', text: 'Hello, ' }, { type: 'text', text: 'world' }, { type: 'text', text: '!' }, { type: 'function', id: 'get_weather#123', name: 'get_weather', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{' }, { type: 'tool_call_part', argumentsPart: '"city":' }, { type: 'tool_call_part', argumentsPart: '"Beijing"' }, { type: 'tool_call_part', argumentsPart: '}' }, { type: 'function', id: 'get_time#123', name: 'get_time', arguments: '', }, ]; const stream = createMockStream(inputParts); const provider = createMockProvider(stream); const outputParts: StreamedMessagePart[] = []; const outputToolCalls: ToolCall[] = []; const result = await generate(provider, '', [], [], { async onMessagePart(part: StreamedMessagePart): Promise { outputParts.push(part); }, async onToolCall(toolCall: ToolCall): Promise { outputToolCalls.push(toolCall); }, }); // Every raw part should be echoed to onMessagePart. expect(outputParts).toHaveLength(inputParts.length); // Callback parts should be copies — mutating pendingPart should not affect them. // The first three TextParts merge into one in the message, but the callback // should have received them individually. expect(outputParts[0]).toEqual({ type: 'text', text: 'Hello, ' }); expect(outputParts[1]).toEqual({ type: 'text', text: 'world' }); expect(outputParts[2]).toEqual({ type: 'text', text: '!' }); // onToolCall should fire for each complete ToolCall. expect(outputToolCalls).toEqual(result.message.toolCalls); }); it('isolates nested media payloads passed to onMessagePart', async () => { const stream = createMockStream([ { type: 'image_url', imageUrl: { url: 'https://example.com/original.png', id: 'img-1' } }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], [], { onMessagePart(part: StreamedMessagePart): void { if (part.type === 'image_url') { part.imageUrl.url = 'https://example.com/mutated.png'; part.imageUrl.id = 'img-mutated'; } }, }); expect(result.message.content).toEqual([ { type: 'image_url', imageUrl: { url: 'https://example.com/original.png', id: 'img-1' } }, ]); }); it('isolates nested ToolCall extras passed to callbacks', async () => { const stream = createMockStream([ { type: 'function', id: 'tool-1', name: 'search', arguments: '{}', extras: { metadata: { provider: 'kimi' }, tags: ['a', 'b'], }, }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], [], { onMessagePart(part: StreamedMessagePart): void { if (part.type === 'function') { (part.extras as { metadata: { provider: string }; tags: string[] }).metadata.provider = 'mutated'; (part.extras as { metadata: { provider: string }; tags: string[] }).tags.push('c'); } }, }); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'tool-1', name: 'search', arguments: '{}', extras: { metadata: { provider: 'kimi' }, tags: ['a', 'b'], }, }, ]); }); it('throws APIEmptyResponseError on empty response', async () => { const stream = createMockStream([]); const provider = createMockProvider(stream); await expect(generate(provider, '', [], [])).rejects.toThrow(APIEmptyResponseError); }); it('throws APIEmptyResponseError for think-only response', async () => { const stream = createMockStream([ { type: 'think', think: 'Deep thinking about the problem...' }, ]); const provider = createMockProvider(stream); await expect(generate(provider, '', [], [])).rejects.toThrow(/only thinking content/); }); it('includes finish reason details on think-only APIEmptyResponseError', async () => { const stream = createMockStream( [{ type: 'think', think: 'Deep thinking about the problem...' }], { finishReason: 'filtered', rawFinishReason: 'content_filter' }, ); const provider = createMockProvider(stream); let caught: unknown; try { await generate(provider, '', [], []); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(APIEmptyResponseError); const err = caught as APIEmptyResponseError; expect(err.finishReason).toBe('filtered'); expect(err.rawFinishReason).toBe('content_filter'); expect(err.message).toContain('finishReason=filtered'); expect(err.message).toContain('rawFinishReason=content_filter'); expect(err.message).toContain('provider filtered the response'); }); it('marks a provider-filtered think-only response as non-retryable', async () => { const stream = createMockStream([{ type: 'think', think: 'filtered mid-thought' }], { finishReason: 'filtered', rawFinishReason: 'content_filter', }); const provider = createMockProvider(stream); const caught = await generate(provider, '', [], []).catch((error: unknown) => error); expect(caught).toBeInstanceOf(APIEmptyResponseError); expect(isRetryableGenerateError(caught)).toBe(false); }); it('throws APIEmptyResponseError for think + empty/whitespace text', async () => { const stream = createMockStream([ { type: 'think', think: 'Thinking...' }, { type: 'text', text: ' \n ' }, ]); const provider = createMockProvider(stream); await expect(generate(provider, '', [], [])).rejects.toThrow(/only thinking content/); }); it('succeeds for think + real text', async () => { const stream = createMockStream([ { type: 'think', think: 'Let me think...' }, { type: 'text', text: 'Here is the answer.' }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.content.some((p) => p.type === 'think')).toBe(true); expect(result.message.content.some((p) => p.type === 'text')).toBe(true); }); it('succeeds for think + tool calls (no text)', async () => { const stream = createMockStream([ { type: 'think', think: 'I should call a tool...' }, { type: 'function', id: 'tool#1', name: 'read_file', arguments: '{"path": "/tmp"}', }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.content.some((p) => p.type === 'think')).toBe(true); expect(result.message.toolCalls.length).toBeGreaterThan(0); }); it('preserves an explicitly empty ThinkPart alongside a tool call', async () => { const stream = createMockStream([ { type: 'think', think: '' }, { type: 'function', id: 'tool#1', name: 'read_file', arguments: '{"path":"/tmp"}', }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.content).toEqual([{ type: 'think', think: '' }]); }); it('preserves stream id and usage', async () => { const usage: TokenUsage = { inputOther: 100, output: 50, inputCacheRead: 200, inputCacheCreation: 10, }; const stream = createMockStream([{ type: 'text', text: 'hi' }], { id: 'msg-123', usage, }); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.id).toBe('msg-123'); expect(result.usage).toEqual(usage); }); it('merges ThinkParts together', async () => { const stream = createMockStream([ { type: 'think', think: 'part1 ' }, { type: 'think', think: 'part2' }, { type: 'text', text: 'answer' }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); // The two ThinkParts should be merged. const thinkParts = result.message.content.filter((p) => p.type === 'think'); expect(thinkParts).toHaveLength(1); if (thinkParts[0]!.type === 'think') { expect(thinkParts[0]!.think).toBe('part1 part2'); } }); it('flushes pending ToolCall when a new ToolCall arrives (parallel tool calls)', async () => { const stream = createMockStream([ { type: 'function', id: 'tc-1', name: 'read_file', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"path":"/a"}' }, { type: 'function', id: 'tc-2', name: 'read_file', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"path":"/b"}' }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'tc-1', name: 'read_file', arguments: '{"path":"/a"}', }, { type: 'function', id: 'tc-2', name: 'read_file', arguments: '{"path":"/b"}', }, ]); }); it('handles ToolCallPart accumulation into ToolCall arguments', async () => { const stream = createMockStream([ { type: 'function', id: 'tc-1', name: 'search', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"q' }, { type: 'tool_call_part', argumentsPart: '":"hello"}' }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'tc-1', name: 'search', arguments: '{"q":"hello"}', }, ]); }); // When a provider streams multiple parallel tool calls, the argument // deltas can arrive interleaved. `ToolCallPart.index` must route each // delta to the correct ToolCall instead of falling through to the // "most recent pending ToolCall" which would cross-contaminate args. it('routes interleaved parallel ToolCallPart deltas by index', async () => { // Both tool calls are opened up-front, then their argument deltas // arrive interleaved (index 0 and 1 alternating). const stream = createMockStream([ { type: 'function', id: 'tc-a', name: 'read_file', arguments: null, _streamIndex: 0, }, { type: 'function', id: 'tc-b', name: 'read_file', arguments: null, _streamIndex: 1, }, // Interleaved argument deltas across the two tool calls. { type: 'tool_call_part', argumentsPart: '{"path":"', index: 0 }, { type: 'tool_call_part', argumentsPart: '{"path":"', index: 1 }, { type: 'tool_call_part', argumentsPart: '/a"', index: 0 }, { type: 'tool_call_part', argumentsPart: '/b"', index: 1 }, { type: 'tool_call_part', argumentsPart: '}', index: 0 }, { type: 'tool_call_part', argumentsPart: '}', index: 1 }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'tc-a', name: 'read_file', arguments: '{"path":"/a"}', }, { type: 'function', id: 'tc-b', name: 'read_file', arguments: '{"path":"/b"}', }, ]); // _streamIndex must NOT leak into the stored ToolCall. for (const tc of result.message.toolCalls) { expect(tc).not.toHaveProperty('_streamIndex'); } }); it('routes parallel ToolCallPart deltas by string index (Responses API item_id)', async () => { // Responses API uses string item_ids instead of numeric indices. const stream = createMockStream([ { type: 'function', id: 'call_a', name: 'read_file', arguments: null, _streamIndex: 'item_abc', }, { type: 'function', id: 'call_b', name: 'read_file', arguments: null, _streamIndex: 'item_xyz', }, { type: 'tool_call_part', argumentsPart: '{"p":"/x"}', index: 'item_xyz' }, { type: 'tool_call_part', argumentsPart: '{"p":"/a"}', index: 'item_abc' }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'call_a', name: 'read_file', arguments: '{"p":"/a"}', }, { type: 'function', id: 'call_b', name: 'read_file', arguments: '{"p":"/x"}', }, ]); }); it('falls back to sequential merge when ToolCallPart has no index', async () => { // Back-compat: providers that do not emit `index` continue to work // when there is a single tool call in flight. const stream = createMockStream([ { type: 'function', id: 'tc-1', name: 'search', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"q":' }, { type: 'tool_call_part', argumentsPart: '"hi"}' }, { type: 'function', id: 'tc-2', name: 'search', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"q":"bye"}' }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'tc-1', name: 'search', arguments: '{"q":"hi"}', }, { type: 'function', id: 'tc-2', name: 'search', arguments: '{"q":"bye"}', }, ]); }); it('handles indexed deltas arriving after the pending tool call has changed', async () => { // tc0 opens, receives its full arguments, then tc1 opens and becomes // pending, then a late delta for tc0 arrives by index. The late // delta must route back to tc0 rather than appending to tc1. const stream = createMockStream([ { type: 'function', id: 'tc-0', name: 'write', arguments: null, _streamIndex: 0, }, { type: 'tool_call_part', argumentsPart: '{"a":', index: 0 }, { type: 'tool_call_part', argumentsPart: '1', index: 0 }, { type: 'function', id: 'tc-1', name: 'write', arguments: null, _streamIndex: 1, }, { type: 'tool_call_part', argumentsPart: '{"b":2}', index: 1 }, // Late delta for tc-0 — must be routed via the index map. { type: 'tool_call_part', argumentsPart: '}', index: 0 }, ]); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'tc-0', name: 'write', arguments: '{"a":1}', }, { type: 'function', id: 'tc-1', name: 'write', arguments: '{"b":2}', }, ]); }); it('onToolCall callback receives stored ToolCall without _streamIndex', async () => { const stream = createMockStream([ { type: 'function', id: 'tc-1', name: 'f', arguments: null, _streamIndex: 0, }, { type: 'tool_call_part', argumentsPart: '{}', index: 0 }, ]); const provider = createMockProvider(stream); const received: ToolCall[] = []; await generate(provider, '', [], [], { async onToolCall(tc: ToolCall): Promise { received.push(tc); }, }); expect(received).toHaveLength(1); expect(received[0]).toEqual({ type: 'function', id: 'tc-1', name: 'f', arguments: '{}', }); expect(received[0]).not.toHaveProperty('_streamIndex'); }); // Parallel tool call streams can interleave headers and arguments: // // tc0-header → tc1-header → tc0-args → tc1-args → done // // Previously the generate loop fired onToolCall the moment a second // ToolCall header forced a flush of the first. At that point tc0's // arguments had not yet been received, so any consumer that parsed // `tc.arguments` inside the callback (e.g. step() dispatching // the tool) would hit a JSON parse error on an empty/partial string. // // `onToolCall` must stay deferred until the stream has drained. it('onToolCall fires only after the stream completes, with fully-assembled arguments', async () => { // Pathological stream: interleaved headers and argument deltas. const stream = createMockStream([ { type: 'function', id: 'call_a', name: 'tool_a', arguments: null, _streamIndex: 0, }, { type: 'function', id: 'call_b', name: 'tool_b', arguments: null, _streamIndex: 1, }, { type: 'tool_call_part', argumentsPart: '{"x":', index: 0 }, { type: 'tool_call_part', argumentsPart: '{"y":', index: 1 }, { type: 'tool_call_part', argumentsPart: '1}', index: 0 }, { type: 'tool_call_part', argumentsPart: '2}', index: 1 }, ]); const provider = createMockProvider(stream); // Capture what each callback invocation sees. Each callback must // observe fully-assembled arguments. const callbackSnapshots: Array<{ id: string; args: string | null }> = []; const result = await generate(provider, '', [], [], { async onToolCall(tc: ToolCall): Promise { callbackSnapshots.push({ id: tc.id, args: tc.arguments }); }, }); // Both callbacks fired, in the order the tool calls were // appended to `message.toolCalls`. expect(callbackSnapshots).toEqual([ { id: 'call_a', args: '{"x":1}' }, { id: 'call_b', args: '{"y":2}' }, ]); // The message itself reflects the same final state. expect(result.message.toolCalls).toEqual([ { type: 'function', id: 'call_a', name: 'tool_a', arguments: '{"x":1}', }, { type: 'function', id: 'call_b', name: 'tool_b', arguments: '{"y":2}', }, ]); }); it('onToolCall observes fully-populated message.toolCalls at callback time', async () => { // Stronger invariant: at the moment each onToolCall fires, every // tool call in the message is already present with complete // arguments. This is what step() relies on to dispatch tools // without hitting partial-JSON parse errors. const stream = createMockStream([ { type: 'function', id: 'call_a', name: 'tool_a', arguments: null, _streamIndex: 0, }, { type: 'function', id: 'call_b', name: 'tool_b', arguments: null, _streamIndex: 1, }, { type: 'function', id: 'call_c', name: 'tool_c', arguments: null, _streamIndex: 2, }, // Heavily interleaved argument deltas. { type: 'tool_call_part', argumentsPart: '{"k":', index: 2 }, { type: 'tool_call_part', argumentsPart: '{"k":', index: 0 }, { type: 'tool_call_part', argumentsPart: '{"k":', index: 1 }, { type: 'tool_call_part', argumentsPart: '3}', index: 2 }, { type: 'tool_call_part', argumentsPart: '1}', index: 0 }, { type: 'tool_call_part', argumentsPart: '2}', index: 1 }, ]); const provider = createMockProvider(stream); // Shared state so the callback can inspect the message. const state: { message: Message | null } = { message: null }; const observations: Array<{ id: string; totalToolCalls: number; allComplete: boolean }> = []; const result = await generate(provider, '', [], [], { async onToolCall(tc: ToolCall): Promise { const msg = state.message; if (msg === null) { // First callback: we don't have a message reference yet // because `generate` hasn't returned. But the invariant we // care about is that `tc.arguments` is complete // JSON — verify that directly. observations.push({ id: tc.id, totalToolCalls: -1, // unknown — message ref not yet bound allComplete: tc.arguments !== null && tc.arguments.endsWith('}'), }); return; } observations.push({ id: tc.id, totalToolCalls: msg.toolCalls.length, allComplete: msg.toolCalls.every( (c) => c.arguments !== null && c.arguments.endsWith('}'), ), }); }, }); state.message = result.message; // Every callback observed well-formed, complete arguments for its // own tool call. for (const obs of observations) { expect(obs.allComplete).toBe(true); } expect(observations.map((o) => o.id)).toEqual(['call_a', 'call_b', 'call_c']); // The final assembled arguments match the index-based routing. expect(result.message.toolCalls.map((tc) => tc.arguments)).toEqual([ '{"k":1}', '{"k":2}', '{"k":3}', ]); }); it('pre-aborted signal does not call provider.generate', async () => { let calledGenerate = false; const provider: ChatProvider = { name: 'mock', modelName: 'mock-model', thinkingEffort: null, generate: async ( _systemPrompt: string, _tools: Tool[], _history: Message[], ): Promise => { calledGenerate = true; return createMockStream([{ type: 'text', text: 'should not reach here' }]); }, withThinking(_effort: ThinkingEffort): ChatProvider { return this; }, }; const controller = new AbortController(); controller.abort(); await expect( generate(provider, '', [], [], undefined, { signal: controller.signal }), ).rejects.toMatchObject({ name: 'AbortError' }); expect(calledGenerate).toBe(false); }); it('signal aborted between provider.generate() and stream iteration is honored', async () => { const controller = new AbortController(); let observedParts = 0; const stream: StreamedMessage = { get id(): string | null { return null; }, get usage(): TokenUsage | null { return null; }, finishReason: null, rawFinishReason: null, async *[Symbol.asyncIterator](): AsyncIterator { observedParts += 1; yield { type: 'text', text: 'leaked' }; }, }; const provider: ChatProvider = { name: 'mock', modelName: 'mock-model', thinkingEffort: null, generate: async ( _systemPrompt: string, _tools: Tool[], _history: Message[], ): Promise => { // Abort between provider.generate() resolving and generate() seeing // its return value. controller.abort(); return stream; }, withThinking(_effort: ThinkingEffort): ChatProvider { return this; }, }; await expect( generate(provider, '', [], [], undefined, { signal: controller.signal }), ).rejects.toMatchObject({ name: 'AbortError' }); // Stream iteration must never have started. expect(observedParts).toBe(0); }); it('aborting in the last onMessagePart callback throws AbortError and skips onToolCall', async () => { const controller = new AbortController(); const onToolCall = vi.fn<(toolCall: ToolCall) => Promise>(); const stream = createMockStream([ { type: 'function', id: 'call-1', name: 'plus', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"a":1}' }, { type: 'text', text: 'done' }, ]); const provider = createMockProvider(stream); await expect( generate( provider, '', [], [], { async onMessagePart(part: StreamedMessagePart): Promise { if (part.type === 'text' && part.text === 'done') { controller.abort(); } }, onToolCall, }, { signal: controller.signal }, ), ).rejects.toMatchObject({ name: 'AbortError' }); expect(onToolCall).not.toHaveBeenCalled(); }); it('post-await abort cancels the acquired stream before throwing AbortError', async () => { const controller = new AbortController(); const cancel = vi.fn<() => Promise>().mockResolvedValue(undefined); const stream: StreamedMessage & { cancel(): Promise } = { cancel, get id(): string | null { return null; }, get usage(): TokenUsage | null { return null; }, finishReason: null, rawFinishReason: null, async *[Symbol.asyncIterator](): AsyncIterator { yield { type: 'text', text: 'leaked' }; }, }; const provider: ChatProvider = { name: 'mock', modelName: 'mock-model', thinkingEffort: null, generate: async (): Promise => { controller.abort(); return stream; }, withThinking(_effort: ThinkingEffort): ChatProvider { return this; }, }; await expect( generate(provider, '', [], [], undefined, { signal: controller.signal }), ).rejects.toMatchObject({ name: 'AbortError' }); expect(cancel).toHaveBeenCalledTimes(1); }); it('onToolCall receives tool calls in message order', async () => { // Stream yields tc0-header, then its full args, then tc1-header, // then its full args. The callback must see them in message order. const stream = createMockStream([ { type: 'function', id: 'first', name: 'f', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"i":1}' }, { type: 'function', id: 'second', name: 'g', arguments: null, }, { type: 'tool_call_part', argumentsPart: '{"i":2}' }, ]); const provider = createMockProvider(stream); const received: string[] = []; await generate(provider, '', [], [], { async onToolCall(tc: ToolCall): Promise { received.push(tc.id); }, }); expect(received).toEqual(['first', 'second']); }); describe('finishReason propagation', () => { function streamWithFinish( parts: StreamedMessagePart[], finishReason: | 'completed' | 'truncated' | 'tool_calls' | 'filtered' | 'paused' | 'other' | null, rawFinishReason: string | null, ): StreamedMessage { return { id: 'mock-id', usage: null, finishReason, rawFinishReason, async *[Symbol.asyncIterator](): AsyncIterator { for (const part of parts) { yield part; } }, }; } it('copies stream.finishReason and stream.rawFinishReason onto the generate result', async () => { const stream = streamWithFinish([{ type: 'text', text: 'hello' }], 'truncated', 'length'); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.finishReason).toBe('truncated'); expect(result.rawFinishReason).toBe('length'); }); it('copies null finishReason when the provider did not emit one', async () => { const stream = streamWithFinish([{ type: 'text', text: 'hi' }], null, null); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.finishReason).toBeNull(); expect(result.rawFinishReason).toBeNull(); }); it('copies filtered finishReason alongside its raw value', async () => { const stream = streamWithFinish( [{ type: 'text', text: 'nope' }], 'filtered', 'content_filter', ); const provider = createMockProvider(stream); const result = await generate(provider, '', [], []); expect(result.finishReason).toBe('filtered'); expect(result.rawFinishReason).toBe('content_filter'); }); }); describe('decode accounting', () => { const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); function createDelayedStream( parts: StreamedMessagePart[], perPartWaitMs: number, ): StreamedMessage { return { get id(): string | null { return null; }, get usage(): TokenUsage | null { return null; }, finishReason: 'completed', rawFinishReason: 'stop', async *[Symbol.asyncIterator](): AsyncIterator { let first = true; for (const part of parts) { // Simulate the provider taking time to produce each part after the // first (the first part's wait is time-to-first-token, not decode). if (!first && perPartWaitMs > 0) await sleep(perPartWaitMs); first = false; yield part; } }, }; } it('attributes per-part processing time to the client bucket', async () => { const stream = createDelayedStream( [ { type: 'text', text: 'a' }, { type: 'text', text: 'b' }, { type: 'text', text: 'c' }, ], 0, // provider yields instantly — all measurable time is client-side ); const provider = createMockProvider(stream); let stats: { serverDecodeMs: number; clientConsumeMs: number } | undefined; await generate(provider, '', [], [], { async onMessagePart(): Promise { await sleep(25); }, }, { onStreamEnd: (s) => { stats = s; }, }); expect(stats).toBeDefined(); expect(stats!.clientConsumeMs).toBeGreaterThan(stats!.serverDecodeMs); expect(stats!.clientConsumeMs).toBeGreaterThanOrEqual(50); }); it('attributes time spent awaiting parts to the server bucket', async () => { const stream = createDelayedStream( [ { type: 'text', text: 'a' }, { type: 'text', text: 'b' }, { type: 'text', text: 'c' }, ], 25, // provider stalls before each part after the first ); const provider = createMockProvider(stream); let stats: { serverDecodeMs: number; clientConsumeMs: number } | undefined; await generate(provider, '', [], [], { onMessagePart(): void { // instant client processing }, }, { onStreamEnd: (s) => { stats = s; }, }); expect(stats).toBeDefined(); expect(stats!.serverDecodeMs).toBeGreaterThan(stats!.clientConsumeMs); expect(stats!.serverDecodeMs).toBeGreaterThanOrEqual(40); }); }); });