blob: 3703aae80be0b145c22914d608214782dd3971f2 [file]
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { z } from 'zod';
import { MockLanguageModelV4, convertArrayToReadableStream } from 'ai/test';
import type { LanguageModelV4StreamPart, LanguageModelV4Usage } from '@ai-sdk/provider';
const ZERO_USAGE: LanguageModelV4Usage = {
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 0, text: 0, reasoning: 0 },
};
// Minimal valid V3 stream: start then immediately finish. Annotated so the
// 'stop' / 'stream-start' literals are checked against the part union.
const STREAM_PARTS: LanguageModelV4StreamPart[] = [
{ type: 'stream-start', warnings: [] },
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: ZERO_USAGE },
];
import { ModelAdapter } from '../model-adapter.js';
import type { ModelStreamEvent, ModelToolSet } from '../model-protocol.js';
import { canonicalizeToolSet } from '../request-shape.js';
import type { MakaTool } from '../tool-runtime.js';
// A tool with a real (non-trivial) zod schema so the AI SDK actually serializes it.
function tool(name: string): MakaTool {
return {
name,
description: `${name} tool`,
parameters: z.object({ q: z.string().describe('an argument') }),
impl: () => ({ ok: true }),
};
}
function newAdapter(): ModelAdapter {
return new ModelAdapter({
connection: { providerType: 'openai' } as never,
apiKey: 'test',
modelId: 'mock',
modelFactory: () => ({}),
providerOptions: {},
newId: () => 'id',
now: () => 0,
});
}
/**
* Drive the real ModelAdapter.startStream path with a mock model and report the
* tool names the provider actually received in doStream — i.e. what crosses the
* wire after the AI SDK applies `activeTools`.
*/
async function toolNamesSeenByProvider(activeNames: ReadonlySet<string>): Promise<string[]> {
const tools: MakaTool[] = [tool('Read'), tool('load_tools'), tool('Rive')];
const invalid = tool('invalid');
const canonical = canonicalizeToolSet(tools, invalid, activeNames);
const modelTools: ModelToolSet = {};
for (const t of canonical.providerTools) {
modelTools[t.name] = { description: t.description, inputSchema: t.parameters };
}
let seen: string[] = [];
const model = new MockLanguageModelV4({
doStream: async ({ tools }) => {
seen = (tools ?? []).map((t) => t.name);
return { stream: convertArrayToReadableStream(STREAM_PARTS) };
},
});
const result = await newAdapter().startStream({
model,
messages: [{ role: 'user', content: 'hi' }],
tools: modelTools,
activeTools: canonical.activeTools,
onStreamActivity: () => {},
system: 'sys',
abortSignal: new AbortController().signal,
repairToolCall: async () => null,
});
// Drain the stream so streamText materializes the provider call.
for await (const _chunk of result.events) {
void _chunk;
}
return seen;
}
describe('hidden tools are trimmed from the provider request (wire-level)', () => {
test('a tool outside the active set never reaches the model; invalid is never advertised', async () => {
const seen = await toolNamesSeenByProvider(new Set(['Read', 'load_tools']));
assert.ok(seen.includes('Read'), 'active Read should reach the provider');
assert.ok(seen.includes('load_tools'), 'load_tools should reach the provider');
assert.ok(!seen.includes('Rive'), 'unloaded Rive must NOT reach the provider');
assert.ok(!seen.includes('invalid'), 'invalid is providerTools-only, never advertised');
});
test('a tool added to the active set does reach the model (ratchet activates it)', async () => {
const seen = await toolNamesSeenByProvider(new Set(['Read', 'load_tools', 'Rive']));
assert.ok(seen.includes('Rive'), 'activated Rive should reach the provider');
assert.ok(seen.includes('Read'), 'active tools stay present after a load');
});
});
describe('ModelAdapter provider-step boundary', () => {
test('one startStream call ends after the provider returns tool calls', async () => {
let providerCalls = 0;
const model = new MockLanguageModelV4({
doStream: async () => {
providerCalls += 1;
return {
stream: convertArrayToReadableStream<LanguageModelV4StreamPart>(
providerCalls === 1
? [
{ type: 'stream-start', warnings: [] },
{
type: 'tool-call',
toolCallId: 'tool-1',
toolName: 'Read',
input: JSON.stringify({ q: 'README.md' }),
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
usage: ZERO_USAGE,
},
]
: [
{ type: 'stream-start', warnings: [] },
{
type: 'finish',
finishReason: { unified: 'stop', raw: 'stop' },
usage: ZERO_USAGE,
},
],
),
};
},
});
const result = await newAdapter().startStream({
model,
messages: [{ role: 'user', content: 'read it' }],
tools: {
Read: {
inputSchema: z.object({ q: z.string() }),
},
},
activeTools: ['Read'],
onStreamActivity: () => {},
abortSignal: new AbortController().signal,
repairToolCall: async () => null,
});
for await (const _event of result.events) {
void _event;
}
assert.equal(providerCalls, 1);
});
test('returns provider tool calls without executing tool behavior inside the SDK', async () => {
let executeCalls = 0;
const toolsWithExecutableBehavior = {
Read: {
inputSchema: z.object({ q: z.string() }),
execute: async () => {
executeCalls += 1;
return { leaked: true };
},
},
};
const model = new MockLanguageModelV4({
doStream: {
stream: convertArrayToReadableStream<LanguageModelV4StreamPart>([
{ type: 'stream-start', warnings: [] },
{
type: 'tool-call',
toolCallId: 'tool-1',
toolName: 'Read',
input: JSON.stringify({ q: 'README.md' }),
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
usage: ZERO_USAGE,
},
]),
},
});
const result = await newAdapter().startStream({
model,
messages: [{ role: 'user', content: 'read it' }],
tools: toolsWithExecutableBehavior,
activeTools: ['Read'],
onStreamActivity: () => {},
abortSignal: new AbortController().signal,
repairToolCall: async () => null,
});
const events: ModelStreamEvent[] = [];
for await (const event of result.events) events.push(event);
assert.deepEqual(
events.filter((event) => event.kind === 'tool-call').map((event) => event.toolCall),
[
{
type: 'tool-call',
toolCallId: 'tool-1',
toolName: 'Read',
input: { q: 'README.md' },
},
],
);
assert.equal(executeCalls, 0, 'ModelAdapter must strip executable behavior before streamText');
});
});