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 { 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 { forEachDb(name, async (db) => { await migrate(db, { "feature-ai": migrationsDirs }); await fn(db); }); }