import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts"; /** 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?: { id: string; name: string; input: unknown } }>) { let text = ""; const toolCalls: { id: string; name: string; input: unknown }[] = []; for await (const ev of it) { if (ev.text) text += ev.text; if (ev.toolCall) toolCalls.push(ev.toolCall); } return { text, toolCalls }; } /** 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?: { id: string; name: string; input: 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 }; }, }; return { provider, seen }; }