// 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];
}
}