Files
STCourses/packages/feature-ai/test/helpers.ts
T
Lucas WintherandClaude Opus 5.5 5c63f17cff feat(ai): structured tool calls on ChatMessage (contract request 3)
ChatMessage gains toolCalls and an opaque providerData; providers may
yield providerData once per turn. The assistant loop appends one
assistant message per model turn. Anthropic replays its own raw blocks
from providerData (no in-memory map) or maps text + tool_use blocks;
OpenAI maps toolCalls to tool_calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:36:38 +01:00

68 lines
3.0 KiB
TypeScript

import type { AiProvider, ChatMessage, ToolCall, ToolSpec } from "@stc/contracts";
import { migrate, type Db } from "@stc/db";
import { forEachDb } from "../../db/test/helpers";
import { migrationsDirs } from "../src/migrations";
/** SSE body from a list of [event, data] pairs, split into awkward chunks to exercise buffering. */
export function sseBody(events: [string | null, unknown][], chunk = 7): ReadableStream<Uint8Array> {
const text = events.map(([e, d]) => `${e ? `event: ${e}\n` : ""}data: ${typeof d === "string" ? d : JSON.stringify(d)}\n\n`).join("");
const bytes = new TextEncoder().encode(text);
let i = 0;
return new ReadableStream({
pull(c) {
if (i >= bytes.length) return c.close();
c.enqueue(bytes.slice(i, i + chunk));
i += chunk;
},
});
}
export type Captured = { url: string; init: RequestInit; body: any };
/** A fake fetch that records requests and answers from a queue of responses. */
export function fakeFetch(responses: (() => Response)[]) {
const calls: Captured[] = [];
const f = (async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init: init ?? {}, body: init?.body ? JSON.parse(String(init.body)) : null });
const next = responses.shift();
if (!next) throw new Error("fakeFetch: no response queued");
return next();
}) as unknown as typeof fetch;
return { fetch: f, calls };
}
export async function collect(it: AsyncIterable<{ text?: string; toolCall?: ToolCall; providerData?: unknown }>) {
let text = "";
const toolCalls: ToolCall[] = [];
let providerData: unknown;
for await (const ev of it) {
if (ev.text) text += ev.text;
if (ev.toolCall) toolCalls.push(ev.toolCall);
if (ev.providerData !== undefined) providerData = ev.providerData;
}
return { text, toolCalls, providerData };
}
/** Scripted fake provider: each call to chat() plays the next turn and records the messages it got. */
export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[]) => { text?: string; toolCalls?: ToolCall[]; providerData?: unknown })[]) {
const seen: { messages: ChatMessage[]; tools?: ToolSpec[] }[] = [];
const provider: AiProvider = {
id: "fake",
async *chat(messages, opts) {
seen.push({ messages: structuredClone(messages), tools: opts.tools });
const turn = turns[Math.min(seen.length - 1, turns.length - 1)]!(messages, opts.tools);
if (turn.text) for (const part of turn.text.match(/.{1,8}/gs) ?? []) yield { text: part };
for (const tc of turn.toolCalls ?? []) yield { toolCall: tc };
if (turn.providerData !== undefined) yield { providerData: turn.providerData };
},
};
return { provider, seen };
}
/** forEachDb with the AI package's migrations applied on top of core (the schema the server runs). */
export function forEachAiDb(name: string, fn: (db: Db) => Promise<void>): void {
forEachDb(name, async (db) => {
await migrate(db, { "feature-ai": migrationsDirs });
await fn(db);
});
}