// Test fixtures: a harness over the faux provider, with small extensions to exercise it. import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context"; import { type AssistantMessage, type Message, Type } from "@earendil-works/pi-ai"; import { createModels } from "@earendil-works/pi-ai/models"; import { getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; import { fauxAssistantMessage, fauxProvider, fauxToolCall, } from "@earendil-works/pi-ai/providers/faux"; import { MemoryStorage } from "@earendil-works/pi-durable"; import { encodeBase64 } from "@std/encoding/base64"; import { fromFileUrl } from "@std/path"; import * as aftercare from "@char/aftercare"; import * as jsxRuntime from "@char/aftercare/jsx-runtime"; import { defineExtension } from "@berry/api"; import { HOST_GLOBAL } from "@berry/api/host.ts"; import { until } from "@berry/util"; import * as api from "@berry/api/web.ts"; import { type Berry, openBerry } from "./berry.ts"; import type { Environments } from "./environment.ts"; export type Dir = { cwd: string }; export type Cd = { type: "cd"; cwd: string }; export const dir = (apply: (state: Dir, event: Cd) => Dir = (_, e) => ({ cwd: e.cwd })) => defineExtension({ name: "dir", description: "a working directory", initial: () => ({ cwd: "/" }), apply, setup(ext) { ext.configuration({ parameters: Type.Object({ cwd: Type.Optional(Type.String()) }), values: ({ cwd }) => ({ cwd }), events: ({ cwd }) => { if (cwd !== undefined && !cwd.startsWith("/")) throw new Error(`not absolute: ${cwd}`); return cwd === undefined ? [] : [{ type: "cd", cwd }]; }, }); ext.tool({ name: "cd", description: "change directory", parameters: Type.Object({ path: Type.String() }), expose: "both", async execute({ path }, ctx) { await ctx.emit({ type: "cd", cwd: path }); return `now in ${ctx.state.cwd}`; }, }); ext.tool({ name: "say", description: "stream text as running output", parameters: Type.Object({ text: Type.String() }), execute: ({ text }, ctx) => { for (const word of text.split(" ")) ctx.output(`${word}\n`); return text.length; }, }); ext.tool({ name: "shout", description: "model-only", parameters: Type.Object({}), expose: "model", execute: () => ({ word: "HEY" }), text: ({ word }) => `${word}!`, }); return (conv) => { conv.prompt(() => `cwd: ${conv.state.cwd}`); conv.tool({ name: "pwd", description: "print the working directory", parameters: Type.Object({}), execute: (_, ctx) => ctx.state.cwd, }); conv.command({ name: "go", description: "change directory, as the user", execute: async (path, ctx) => { if (!path.startsWith("/")) throw new Error(`not absolute: ${path}`); await ctx.emit({ type: "cd", cwd: path }); return path; }, }); }; }, }); /** A direct tool that holds its turn open until released. */ export function gated() { const gate = { entered: Promise.withResolvers(), release: Promise.withResolvers() }; const extension = defineExtension({ name: "gate", initial: () => null, setup(ext) { ext.tool({ name: "hold", description: "wait", parameters: Type.Object({}), expose: "model", execute: () => { gate.entered.resolve(); return gate.release.promise; }, }); }, }); return { gate, extension }; } /** * A model tool, `block`, holding each call until `release` settles or the call is aborted, and a * codemode tool, `ready`, that waits until one is held. */ export function blocking() { const blocker = { release: Promise.withResolvers(), running: 0, /** Each started call's name, to the conversation it ran in. */ started: new Map(), cancelled: [] as string[], }; const extension = defineExtension({ name: "blocking", initial: () => null, setup(ext) { ext.tool({ name: "block", description: "wait for release or cancellation", parameters: Type.Object({ name: Type.Optional(Type.String()) }), expose: "model", execute({ name = "" }, ctx) { blocker.started.set(name, ctx.tid); blocker.running++; return new Promise((resolve, reject) => { const abort = () => { blocker.cancelled.push(name); reject(ctx.signal.reason); }; ctx.signal.addEventListener("abort", abort, { once: true }); void blocker.release.promise.then((answer) => { ctx.signal.removeEventListener("abort", abort); resolve(answer); }); }).finally(() => blocker.running--); }, }); ext.tool({ name: "ready", description: "wait until a block is held", parameters: Type.Object({}), execute: () => until(() => blocker.running > 0).then(() => "ready"), }); }, }); return Object.assign(blocker, { extension }); } /** A harness with `dir` and `gate` installed and the faux model enabled; `await using` closes it. */ export async function setup( environments?: Environments, provider?: Parameters[0], ) { const faux = fauxProvider(provider); const models = createModels(); models.setProvider(faux.provider); const berry = await openBerry(new MemoryStorage(), models, undefined, environments); await berry.install(dir()); const { gate, extension } = gated(); await berry.install(extension); const model = { provider: "faux", modelId: faux.getModel().id }; await berry.enableModels([model]); return { faux, berry, model, models, gate, [Symbol.asyncDispose]: () => berry.close() }; } export const script = (code: string) => fauxAssistantMessage(fauxToolCall("codemode", { code }), { stopReason: "toolUse" }); /** * Answer one input with scripted steps, then "done"; resolves with the messages each request saw, once the * conversation is idle. */ export async function turn( { faux, berry }: { faux: ReturnType; berry: Berry }, tid: string, steps: readonly AssistantMessage[], ): Promise<(readonly Message[])[]> { const seen: (readonly Message[])[] = []; faux.setResponses( [...steps, fauxAssistantMessage("done")].map( (step) => (ctx) => (seen.push(ctx.messages), step), ), ); const conversation = (await berry.conversation(tid))!; await (await conversation.submit({ type: "input", content: "go" }, context)).wait(context); await conversation.waitForIdle(context); return seen; } export function lastToolResult(messages: readonly Message[]) { const result = messages.findLast((m) => m.role === "toolResult"); if (result?.role !== "toolResult") throw new Error("no tool result"); return { text: result.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"), isError: result.isError, details: result.details, }; } export const sectionsSeen = (messages: readonly Message[]) => messages.flatMap((m) => (m.role === "system" ? [m.sections ?? {}] : [])); export const toolsOffered = (messages: readonly Message[]) => getCurrentTools(messages).map((tool) => tool.name); /** An extension's frontend as the app loads it: built on its own, over the app's instances of the shared modules. */ export async function builtRenderers(name: string): Promise { // vite is slow to load, and only these tests need it const web = (await import("./web.ts")).extensionWeb(); const entry = fromFileUrl(new URL(`../extensions/${name}/web.tsx`, import.meta.url)); const js = await web.serve((await web.build(name, entry)).module)!.text(); const host = globalThis as Record; host[HOST_GLOBAL] = { "@char/aftercare": aftercare, "@char/aftercare/jsx-runtime": jsxRuntime, "@berry/api/web.ts": api, }; try { const encoded = encodeBase64(new TextEncoder().encode(js)); return (await import(`data:text/javascript;base64,${encoded}`)).default; } finally { delete host[HOST_GLOBAL]; } }