UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

424 lines (387 loc) 11.9 kB
import { describe, expect, it } from 'vitest' import type { InterruptSubmissionError } from '../interrupts' import { EventType } from '../types' import type { StreamChunk } from '../types' import type { AdapterYieldChunk } from './adapter-yield-chunk' import { tanstackMetadata } from './merge-metadata' import { normalizeStreamChunk } from './normalize-stream-chunk' import { isSpecTopLevelKey } from './spec-event-keys' function assertSpec(chunk: { type: string }) { for (const key of Object.keys(chunk)) { expect(isSpecTopLevelKey(chunk.type, key), key).toBe(true) } } function normalizeAll(chunk: AdapterYieldChunk) { const out = normalizeStreamChunk(chunk) for (const specChunk of out) { assertSpec(specChunk) } return out } function chunkAt(out: Array<StreamChunk>, index: number) { const chunk = out[index] if (chunk === undefined) { throw new Error(`expected stream chunk at ${String(index)}`) } return chunk } function normalizeOne(chunk: AdapterYieldChunk) { const out = normalizeAll(chunk) expect(out).toHaveLength(1) return chunkAt(out, 0) } describe('normalizeStreamChunk', () => { it('maps RUN_FINISHED TokenUsage onto spec usage[] and leftover metadata', () => { const chunk = { type: EventType.RUN_FINISHED, threadId: 't1', runId: 'r1', model: 'gpt-5.5', finishReason: 'stop', usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15, cost: 0.02, promptTokensDetails: { cachedTokens: 3, audioTokens: 1 }, completionTokensDetails: { reasoningTokens: 2 }, }, } as AdapterYieldChunk const out = normalizeOne(chunk) expect(out).toEqual({ type: EventType.RUN_FINISHED, threadId: 't1', runId: 'r1', usage: [ { model: 'gpt-5.5', inputTokens: 10, outputTokens: 5, totalTokens: 15, cachedInputTokens: 3, reasoningTokens: 2, }, ], metadata: { tanstack: { model: 'gpt-5.5', finishReason: 'stop', usage: { cost: 0.02, promptTokensDetails: { audioTokens: 1 }, }, }, }, }) expect(out).not.toHaveProperty('model') expect(out).not.toHaveProperty('finishReason') }) it('moves RUN_STARTED model into metadata.tanstack.model only', () => { const out = normalizeOne({ type: EventType.RUN_STARTED, threadId: 't1', runId: 'r1', model: 'gpt-5.5', } as AdapterYieldChunk) expect(out).toEqual({ type: EventType.RUN_STARTED, threadId: 't1', runId: 'r1', metadata: { tanstack: { model: 'gpt-5.5' } }, }) expect(out).not.toHaveProperty('model') expect(tanstackMetadata(out)).toEqual({ model: 'gpt-5.5' }) }) it('puts TEXT_MESSAGE_START model in metadata and drops TEXT_MESSAGE_CONTENT extras', () => { const start = normalizeOne({ type: EventType.TEXT_MESSAGE_START, messageId: 'm1', role: 'assistant', model: 'gpt-5.5', } as AdapterYieldChunk) expect(start).toEqual({ type: EventType.TEXT_MESSAGE_START, messageId: 'm1', role: 'assistant', metadata: { tanstack: { model: 'gpt-5.5' } }, }) const content = normalizeOne({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'm1', delta: 'Hi', content: 'Hello Hi', model: 'gpt-5.5', } as AdapterYieldChunk) expect(content).toEqual({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: 'm1', delta: 'Hi', }) expect(content).not.toHaveProperty('content') expect(content).not.toHaveProperty('model') expect(content).not.toHaveProperty('metadata') }) it('strips RUN_ERROR nested error and moves interruptErrors + ids into metadata.tanstack', () => { const interruptError: InterruptSubmissionError = { scope: 'batch', code: 'server', message: 'Interrupt persistence failed.', threadId: 't1', interruptedRunId: 'r1', generation: 0, source: 'server', retryable: false, interruptIds: [], } const interruptErrors = [interruptError] const out = normalizeOne({ type: EventType.RUN_ERROR, message: 'Interrupt persistence failed.', code: 'server', error: { message: 'Interrupt persistence failed.', code: 'server', }, 'tanstack:interruptErrors': interruptErrors, threadId: 't1', runId: 'r1', } as AdapterYieldChunk) expect(out).not.toHaveProperty('error') expect(out).not.toHaveProperty('tanstack:interruptErrors') expect(out).not.toHaveProperty('threadId') expect(out).not.toHaveProperty('runId') expect(out).toEqual({ type: EventType.RUN_ERROR, message: 'Interrupt persistence failed.', code: 'server', metadata: { tanstack: { interruptErrors, threadId: 't1', runId: 'r1', }, }, }) expect(tanstackMetadata(out)?.interruptErrors).toBe(interruptErrors) expect(tanstackMetadata(out)?.interruptErrors?.[0]).toBe(interruptError) }) it('keeps STATE_SNAPSHOT snapshot and drops the state alias and model', () => { const snapshot = { count: 1 } const out = normalizeOne({ type: EventType.STATE_SNAPSHOT, snapshot, state: { count: 2 }, model: 'gpt-5.5', } as AdapterYieldChunk) expect(out).toMatchObject({ type: EventType.STATE_SNAPSHOT, snapshot, }) expect(out).not.toHaveProperty('state') expect(out).not.toHaveProperty('model') }) it('moves CUSTOM generation:result threadId/runId/model into metadata.tanstack', () => { const value = { url: 'https://example.com/img.png' } const out = normalizeOne({ type: EventType.CUSTOM, name: 'generation:result', value, threadId: 't1', runId: 'r1', model: 'gpt-5.5', } as AdapterYieldChunk) expect(out).toEqual({ type: EventType.CUSTOM, name: 'generation:result', value, metadata: { tanstack: { model: 'gpt-5.5', threadId: 't1', runId: 'r1', }, }, }) expect(out).not.toHaveProperty('threadId') expect(out).not.toHaveProperty('runId') expect(out).not.toHaveProperty('model') }) it('returns an already-spec TEXT_MESSAGE_END chunk unchanged', () => { const chunk = { type: EventType.TEXT_MESSAGE_END, messageId: 'm1', timestamp: 1, } as AdapterYieldChunk const out = normalizeStreamChunk(chunk) expect(out).toHaveLength(1) assertSpec(chunkAt(out, 0)) expect(out[0]).toEqual(chunk) expect(out[0]).not.toHaveProperty('metadata') }) it('keeps TOOL_CALL_START toolCallName and emits REASONING_ENCRYPTED_VALUE for thoughtSignature', () => { const out = normalizeAll({ type: EventType.TOOL_CALL_START, toolCallId: 'tc1', toolCallName: 'get_weather', toolName: 'get_weather', index: 0, parentMessageId: 'm1', metadata: { thoughtSignature: 'sig-1' }, } as AdapterYieldChunk) expect(out).toHaveLength(2) expect(out[0]).toEqual({ type: EventType.TOOL_CALL_START, toolCallId: 'tc1', toolCallName: 'get_weather', parentMessageId: 'm1', metadata: { thoughtSignature: 'sig-1', tanstack: { index: 0 }, }, }) expect(out[0]).not.toHaveProperty('toolName') expect(out[0]).not.toHaveProperty('index') expect(out[1]).toEqual({ type: EventType.REASONING_ENCRYPTED_VALUE, subtype: 'tool-call', entityId: 'tc1', encryptedValue: 'sig-1', }) }) it('drops TOOL_CALL_ARGS accumulated args', () => { const out = normalizeOne({ type: EventType.TOOL_CALL_ARGS, toolCallId: 'tc1', delta: '{"q":', args: '{"q":', model: 'gpt-5.5', } as AdapterYieldChunk) expect(out).toEqual({ type: EventType.TOOL_CALL_ARGS, toolCallId: 'tc1', delta: '{"q":', }) expect(out).not.toHaveProperty('args') expect(out).not.toHaveProperty('model') expect(out).not.toHaveProperty('metadata') }) it('splits TOOL_CALL_END with result into spec END then RESULT', () => { const out = normalizeAll({ type: EventType.TOOL_CALL_END, toolCallId: 'tc1', toolCallName: 'get_weather', toolName: 'get_weather', parentMessageId: 'm1', result: '{"temp":72}', input: { q: 'sf' }, output: { temp: 72 }, } as AdapterYieldChunk) expect(out).toHaveLength(2) expect(out[0]).toEqual({ type: EventType.TOOL_CALL_END, toolCallId: 'tc1', metadata: { tanstack: { toolCallName: 'get_weather', toolName: 'get_weather', parentMessageId: 'm1', output: { temp: 72 }, input: { q: 'sf' }, }, }, }) expect(out[0]).not.toHaveProperty('input') expect(out[1]).toEqual({ type: EventType.TOOL_CALL_RESULT, toolCallId: 'tc1', content: '{"temp":72}', messageId: 'm1', }) }) it('returns one spec TOOL_CALL_END when result is missing', () => { const out = normalizeOne({ type: EventType.TOOL_CALL_END, toolCallId: 'tc1', toolCallName: 'get_weather', } as AdapterYieldChunk) expect(out).toEqual({ type: EventType.TOOL_CALL_END, toolCallId: 'tc1', metadata: { tanstack: { toolCallName: 'get_weather' } }, }) expect(out).not.toHaveProperty('result') expect(out).not.toHaveProperty('toolCallName') }) it('stringifies an array TOOL_CALL_END result onto RESULT content', () => { const parts = [{ type: 'text', content: 'hello' }] const out = normalizeAll({ type: EventType.TOOL_CALL_END, toolCallId: 'tc1', result: parts, } as AdapterYieldChunk) expect(out).toHaveLength(2) expect(out[1]).toEqual({ type: EventType.TOOL_CALL_RESULT, toolCallId: 'tc1', content: JSON.stringify(parts), messageId: 'tc1', }) }) it('copies TOOL_CALL_END output-error state onto RESULT metadata.tanstack.state', () => { const out = normalizeAll({ type: EventType.TOOL_CALL_END, toolCallId: 'tc1', result: 'Error executing tool: boom', state: 'output-error', } as AdapterYieldChunk) expect(out).toHaveLength(2) expect(out[0]).toEqual({ type: EventType.TOOL_CALL_END, toolCallId: 'tc1', metadata: { tanstack: { state: 'output-error' } }, }) expect(out[1]).toEqual({ type: EventType.TOOL_CALL_RESULT, toolCallId: 'tc1', content: 'Error executing tool: boom', messageId: 'tc1', metadata: { tanstack: { state: 'output-error' } }, }) expect(tanstackMetadata(chunkAt(out, 1))).toEqual({ state: 'output-error', }) }) it('strips STEP_FINISHED thinking extras and emits REASONING_ENCRYPTED_VALUE for the signature', () => { const out = normalizeAll({ type: EventType.STEP_FINISHED, stepName: 'thinking', stepId: 's1', delta: 'hmm', content: 'hmm full', signature: 'sig', } as AdapterYieldChunk) expect(out).toHaveLength(2) expect(out[0]).toEqual({ type: EventType.STEP_FINISHED, stepName: 'thinking', metadata: { tanstack: { content: 'hmm full', delta: 'hmm', signature: 'sig', stepId: 's1', }, }, }) expect(out[0]).not.toHaveProperty('delta') expect(out[0]).not.toHaveProperty('content') expect(out[0]).not.toHaveProperty('signature') expect(out[0]).not.toHaveProperty('stepId') expect(out[1]).toEqual({ type: EventType.REASONING_ENCRYPTED_VALUE, subtype: 'message', entityId: 's1', encryptedValue: 'sig', }) }) })