import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { createEngine, buildPiArgs, PI_FIXED_ARGS, READONLY_TOOLS_EXTENSION, assistantText } from "../src/engine-pi.mjs"; import { existsSync } from "node:fs"; import { makeRoot } from "./helpers.mjs"; const fakePi = join(import.meta.dirname, "fake-pi.mjs"); function start(root, extra = {}) { const logPath = join(root, "commands.jsonl"); const logs = []; const engine = createEngine({ command: process.execPath, args: [fakePi], cwd: root, env: { ...process.env, FAKE_PI_LOG: logPath }, log: (m) => logs.push(m), ...extra, }); engine.start(); return { engine, logs, commands: () => readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) }; } test("engine: buildPiArgs carries the fixed flags, engine settings, session dir and prompt file", () => { const args = buildPiArgs({ provider: "zai", model: "glm-5.3", thinking: "high", sessionDir: "/s", appendSystemPromptFile: "/p.md", continueSession: true }); for (const f of PI_FIXED_ARGS) assert.ok(args.includes(f), f); assert.ok(args.includes("--no-tools") && args.includes("--offline")); assert.deepEqual(args.slice(-9), ["--provider", "zai", "--model", "glm-5.3", "--thinking", "high", "--session-dir", "/s", "--append-system-prompt", "/p.md", "--continue"].slice(-9)); assert.ok(!buildPiArgs({ provider: "p", model: "m", thinking: "off", sessionDir: "/s", appendSystemPromptFile: "/p", continueSession: false }).includes("--continue")); assert.equal(assistantText({ content: [{ type: "thinking", thinking: "x" }, { type: "text", text: " a " }, { type: "text", text: "b" }] }), "a b".replace(" ", " ")); assert.ok(!args.includes("--no-builtin-tools") && !args.includes("--extension"), "no extension without tools"); }); test("engine: with tools, buildPiArgs turns pi's own tools off, loads the extension explicitly and allowlists exactly our three", () => { const tools = { roots: [{ name: "docs", path: "/r" }], maxFileBytes: 4096, maxCallsPerTurn: 8 }; const args = buildPiArgs({ provider: "p", model: "m", thinking: "off", sessionDir: "/s", appendSystemPromptFile: "/p", continueSession: false, tools }); assert.ok(!args.includes("--no-tools"), "--no-tools would hide the extension's tools too"); assert.ok(args.includes("--no-extensions"), "discovery stays off; only the explicit path loads"); assert.ok(args.includes("--no-builtin-tools")); assert.equal(args[args.indexOf("--extension") + 1], READONLY_TOOLS_EXTENSION); assert.equal(args[args.indexOf("--tools") + 1], "list_dir,read_file,search"); assert.ok(existsSync(READONLY_TOOLS_EXTENSION), READONLY_TOOLS_EXTENSION); }); test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", async () => { const { engine } = start(makeRoot()); const r = await engine.prompt("tools 3"); assert.equal(r.text, "read 3 file(s)"); assert.equal(r.turns, 2); assert.equal(r.tools.length, 3); assert.deepEqual(r.tools[0], { name: "read_file", root: "docs", path: "f1.md", ok: true, reason: null, bytes: 9, ms: 2 }); assert.equal(r.tools[2].ok, false); assert.match(r.tools[2].reason, /budget/); const plain = await engine.prompt("hello"); assert.equal(plain.text, "echo: hello"); assert.deepEqual(plain.tools, []); assert.equal(plain.turns, 1); assert.equal(engine.busy, false); await engine.stop(); }); test("engine: a run that ends on a tool-only turn fails the prompt as empty; a retried run settles on the real end", async () => { const { engine } = start(makeRoot()); const r = await engine.prompt("toolonly"); assert.equal(r.text, "", "no text: the connector turns this into engine-empty"); assert.equal(r.tools.length, 1); const again = await engine.prompt("retry"); assert.equal(again.text, "after retry"); await engine.stop(); }); test("engine: one prompt, one turn, text and usage come back", async () => { const { engine } = start(makeRoot()); const r = await engine.prompt("hello"); assert.equal(r.text, "echo: hello"); assert.deepEqual(r.usage, { input: 3, output: 2 }); assert.equal(engine.busy, false); await engine.stop(); }); test("engine: a prompt while streaming is sent as a follow-up and answered in order", async () => { const { engine, commands } = start(makeRoot()); const first = engine.prompt("slow 150"); await new Promise((r) => setTimeout(r, 20)); assert.equal(engine.busy, true); const second = engine.prompt("second"); const [r1, r2] = await Promise.all([first, second]); assert.equal(r1.text, "slow reply"); assert.equal(r2.text, "echo: second"); const prompts = commands().filter((c) => c.type === "prompt"); assert.equal(prompts[0].streamingBehavior, undefined); assert.equal(prompts[1].streamingBehavior, "followUp"); await engine.stop(); }); test("engine: timeout sends abort and fails only that turn; the process stays", async () => { const { engine, commands, logs } = start(makeRoot()); await assert.rejects(engine.prompt("slow 5000", { timeoutMs: 100 }), (err) => err.details.code === "timeout"); assert.ok(commands().some((c) => c.type === "abort")); assert.ok(logs.some((l) => /timed out/.test(l))); const r = await engine.prompt("again"); assert.equal(r.text, "echo: again"); await engine.stop(); }); test("engine: tool events from a run that outlived its timeout never land in the next prompt's record", async () => { const { engine } = start(makeRoot()); try { await assert.rejects(engine.prompt("late 200", { timeoutMs: 40 }), (err) => err.details.code === "timeout"); const r = await engine.prompt("after late"); assert.equal(r.text, "echo: after late"); assert.deepEqual(r.tools, [], "the dead run's read is not this prompt's evidence"); assert.equal(r.turns, 1, "the dead run's turns are not counted here"); } finally { await engine.stop(); } }); test("engine: a malformed JSONL line fails the turn, not the process", async () => { const { engine, logs } = start(makeRoot()); await assert.rejects(engine.prompt("garbage"), (err) => err.details.code === "engine-protocol"); assert.ok(logs.some((l) => /malformed/.test(l))); const r = await engine.prompt("still here"); assert.equal(r.text, "echo: still here"); await engine.stop(); }); test("engine: a turn that ends in error rejects with the error code; process exit fails pending turns", async () => { const root = makeRoot(); let exited = null; const { engine } = start(root, { onExit: (e) => (exited = e) }); await assert.rejects(engine.prompt("error"), (err) => err.details.code === "engine-error" && /fake provider error/.test(err.message)); const pending = engine.prompt("slow 5000"); await new Promise((r) => setTimeout(r, 20)); await engine.stop(); await assert.rejects(pending, (err) => err.details.code === "engine-down"); assert.ok(exited); await assert.rejects(engine.prompt("x"), /not running/); });