import { describe, it, expect } from 'vitest'; import { makeCallModel } from '../../lib/ai/anthropic.js'; // --------------------------------------------------------------------------- // Fake client whose messages.stream() returns an async-iterable of real-SDK- // shaped RawMessageStreamEvent objects, plus a finalMessage() method that // resolves to the assembled Message. Shape matches @anthropic-ai/sdk@0.40.1. // --------------------------------------------------------------------------- function makeFakeStream({ events, finalMsg }) { // The stream object is both async-iterable and has a finalMessage() method, // exactly as MessageStream from the real SDK. return { [Symbol.asyncIterator]() { let idx = 0; return { async next() { if (idx < events.length) return { value: events[idx++], done: false }; return { value: undefined, done: true }; }, }; }, async finalMessage() { return finalMsg; }, }; } const FAKE_FINAL_MESSAGE = { content: [ { type: 'text', text: 'Hello', citations: null }, { type: 'tool_use', id: 'tu_1', name: 'search', input: { q: 'x' } }, ], stop_reason: 'tool_use', usage: { input_tokens: 10, output_tokens: 5 }, }; // Events emitted during streaming: two text deltas that together form 'Hello' const FAKE_EVENTS = [ // message_start — no text { type: 'message_start', message: {} }, // content_block_start for text block { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, // text deltas { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hel' } }, { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'lo' } }, // content_block_stop for text block { type: 'content_block_stop', index: 0 }, // content_block_start for tool_use block { type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'tu_1', name: 'search', input: {} } }, // input_json delta (not text — should NOT trigger onTextDelta) { type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"q":"x"}' } }, { type: 'content_block_stop', index: 1 }, // message_stop { type: 'message_stop' }, ]; const fakeClient = { messages: { stream(_params) { return makeFakeStream({ events: FAKE_EVENTS, finalMsg: FAKE_FINAL_MESSAGE }); }, }, }; describe('makeCallModel', () => { it('streams text deltas and returns stable shape', async () => { const callModel = makeCallModel({ client: fakeClient, model: 'm', maxTokens: 512 }); const deltas = []; const out = await callModel({ system: 'You are helpful.', messages: [{ role: 'user', content: 'Hi' }], tools: [], onTextDelta: (chunk) => deltas.push(chunk), }); // Streamed deltas must concatenate to 'Hello' expect(deltas.join('')).toBe('Hello'); // Stable return shape expect(out.text).toBe('Hello'); expect(out.toolUses).toHaveLength(1); expect(out.toolUses[0]).toMatchObject({ id: 'tu_1', name: 'search' }); expect(out.toolUses[0].input).toEqual({ q: 'x' }); expect(out.stopReason).toBe('tool_use'); expect(out.usage).toMatchObject({ input_tokens: 10, output_tokens: 5 }); }); it('works without onTextDelta callback', async () => { const callModel = makeCallModel({ client: fakeClient, model: 'm' }); const out = await callModel({ messages: [{ role: 'user', content: 'Hi' }], }); expect(out.text).toBe('Hello'); expect(out.stopReason).toBe('tool_use'); }); it('returns empty toolUses when no tool_use blocks', async () => { const textOnlyMsg = { content: [{ type: 'text', text: 'Just text', citations: null }], stop_reason: 'end_turn', usage: { input_tokens: 5, output_tokens: 3 }, }; const textOnlyClient = { messages: { stream: () => makeFakeStream({ events: [], finalMsg: textOnlyMsg }), }, }; const callModel = makeCallModel({ client: textOnlyClient, model: 'm' }); const out = await callModel({ messages: [{ role: 'user', content: 'Hi' }] }); expect(out.text).toBe('Just text'); expect(out.toolUses).toEqual([]); expect(out.stopReason).toBe('end_turn'); }); });