evOo-local/packages/testkit/src/pi-offline.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

287 lines
9.4 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AgentRunRequest, AgentRuntimeEvent, ConnectorTool } from "@rakazo/adapter-kit";
import { PiAgentRuntime } from "@rakazo/adapters";
import { afterEach, describe, expect, it } from "vitest";
import { type ModelEmulatorRequest, startModelEmulator } from "./model-emulator.js";
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
});
const writeTool: ConnectorTool = {
name: "write_file",
description: "Save a UTF-8 file",
inputSchema: {
type: "object",
properties: { path: { type: "string" }, content: { type: "string" } },
required: ["path", "content"],
additionalProperties: false,
},
};
function runRequest(
model: AgentRunRequest["model"],
overrides: Partial<AgentRunRequest> = {},
): AgentRunRequest {
return {
botId: "fixture-bot",
threadId: "fixture-thread",
runId: randomUUID(),
prompt: "Save hello to notes.txt.",
instructions: "Complete the requested task.",
history: [],
tools: [writeTool],
model,
...overrides,
};
}
async function collect(stream: AsyncIterable<AgentRuntimeEvent>, events: AgentRuntimeEvent[] = []) {
for await (const event of stream) events.push(event);
return events;
}
function latestToolResult(request: ModelEmulatorRequest) {
return request.messages.findLast((message) => message.role === "tool");
}
describe("real Pi against an offline model HTTP endpoint", () => {
it("assembles fragmented tool arguments, executes the write, and sends its result back", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "rakazo-pi-offline-"));
cleanups.push(() => rm(dir, { recursive: true, force: true }));
const calls: Array<{ name: string; args: Record<string, unknown>; id?: string }> = [];
const server = await startModelEmulator({
apiKey: "fixture-only-key",
steps: [
{
expect(request) {
expect(request.messages.at(-1)).toMatchObject({
role: "user",
content: [{ type: "text", text: "Save hello to notes.txt." }],
});
expect(request.tools).toContainEqual(
expect.objectContaining({
function: expect.objectContaining({ name: "write_file" }),
}),
);
},
response: {
type: "tool",
id: "write-1",
name: "write_file",
arguments: { path: "notes.txt", content: "hello\n" },
argumentChunks: ['{"pa', 'th":"notes.', 'txt","content":"hel', "lo\\", 'n"}'],
},
},
{
expect(request) {
expect(
request.messages.find((message) => message.role === "assistant")?.tool_calls,
).toEqual([
{
id: "write-1",
type: "function",
function: {
name: "write_file",
arguments: JSON.stringify({ path: "notes.txt", content: "hello\n" }),
},
},
]);
expect(latestToolResult(request)).toMatchObject({ tool_call_id: "write-1" });
expect(JSON.parse(String(latestToolResult(request)?.content))).toEqual({
path: "notes.txt",
bytes: 6,
});
},
response: { type: "text", text: "Saved notes.txt." },
},
],
});
cleanups.push(() => server.close());
const events = await collect(
new PiAgentRuntime().run(
runRequest(server.model, {
async executeTool(name, args, id) {
calls.push({ name, args, id });
expect(name).toBe("write_file");
expect(args.path).toBe("notes.txt");
await writeFile(path.join(dir, "notes.txt"), String(args.content));
return { path: "notes.txt", bytes: Buffer.byteLength(String(args.content)) };
},
}),
),
).catch((error) => {
try {
server.assertComplete();
} catch (fixtureError) {
throw new AggregateError(
[error, fixtureError],
"Pi failed and model fixture validation also failed",
);
}
throw error;
});
server.assertComplete();
expect(calls).toEqual([
{ name: "write_file", args: { path: "notes.txt", content: "hello\n" }, id: "write-1" },
]);
expect(await readFile(path.join(dir, "notes.txt"), "utf8")).toBe("hello\n");
expect(events.at(-1)).toEqual({ type: "done", text: "Saved notes.txt." });
});
it("returns tool failure to the model and continues without claiming a successful write", async () => {
let calls = 0;
const server = await startModelEmulator({
steps: [
{
expect() {},
response: {
type: "tool",
id: "denied-write",
name: "write_file",
arguments: { path: "notes.txt", content: "hello" },
},
},
{
expect(request) {
expect(latestToolResult(request)).toMatchObject({ tool_call_id: "denied-write" });
expect(String(latestToolResult(request)?.content)).toContain("Fixture write denied");
},
response: { type: "text", text: "The file could not be saved." },
},
],
});
cleanups.push(() => server.close());
const events = await collect(
new PiAgentRuntime().run(
runRequest(server.model, {
executeTool: async () => {
calls++;
throw new Error("Fixture write denied");
},
}),
),
);
server.assertComplete();
expect(calls).toBe(1);
expect(events.at(-1)).toEqual({ type: "done", text: "The file could not be saved." });
});
it("propagates a provider rejection without dispatching tools or emitting done", async () => {
let calls = 0;
const server = await startModelEmulator({
steps: [
{
expect() {},
response: { type: "error", status: 400, message: "Fixture request rejected" },
},
],
});
cleanups.push(() => server.close());
const events: AgentRuntimeEvent[] = [];
await expect(
collect(
new PiAgentRuntime().run(
runRequest(server.model, {
executeTool: async () => {
calls++;
return {};
},
}),
),
events,
),
).rejects.toThrow(/Fixture request rejected/);
server.assertComplete();
expect(calls).toBe(0);
expect(events.some((event) => event.type === "done")).toBe(false);
});
it("rejects an interrupted SSE response without emitting successful completion", async () => {
const server = await startModelEmulator({
steps: [{ expect() {}, response: { type: "disconnect", text: "Working" } }],
});
cleanups.push(() => server.close());
const events: AgentRuntimeEvent[] = [];
await expect(
collect(new PiAgentRuntime().run(runRequest(server.model)), events),
).rejects.toThrow();
server.assertComplete();
expect(events.some((event) => event.type === "done")).toBe(false);
});
it("cancels a quiet HTTP stream and closes the provider connection", async () => {
const opened = deferred();
const closed = deferred();
const server = await startModelEmulator({
steps: [
{
expect() {},
response: {
type: "hold",
onOpen: opened.resolve,
onClose: closed.resolve,
},
},
],
});
cleanups.push(() => server.close());
const controller = new AbortController();
const events: AgentRuntimeEvent[] = [];
const work = collect(
new PiAgentRuntime().run(runRequest(server.model), { signal: controller.signal }),
events,
);
// Attach the rejection handler before aborting to avoid an unhandled rejection.
const outcome = work.then(
() => undefined,
(error: unknown) => error,
);
await opened.promise;
controller.abort();
expect(await outcome).toBeInstanceOf(Error);
await closed.promise;
server.assertComplete();
expect(events.some((event) => event.type === "done")).toBe(false);
});
it("keeps two model connections and their responses isolated", async () => {
const first = await startModelEmulator({
modelId: "fixture-first",
apiKey: "first-fixture-key",
steps: [{ expect() {}, response: { type: "text", text: "First connection" } }],
});
const second = await startModelEmulator({
modelId: "fixture-second",
apiKey: "second-fixture-key",
steps: [{ expect() {}, response: { type: "text", text: "Second connection" } }],
});
cleanups.push(
() => first.close(),
() => second.close(),
);
const runtime = new PiAgentRuntime();
const results = await Promise.all(
[first, second].map((server) => collect(runtime.run(runRequest(server.model)))),
);
expect(results.map((events) => events.at(-1))).toEqual([
{ type: "done", text: "First connection" },
{ type: "done", text: "Second connection" },
]);
first.assertComplete();
second.assertComplete();
});
});
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}