feat(discord): writes on write-marked roots, web fetch and search, held prompts (#1509)
Row 23. write_file and edit_file for roots marked write: true under the same fence as reads; web_fetch (https only, public addresses, pinned connection, capped body) and web_search through SearXNG; extension renamed to tools.mjs. Engine holds a prompt while pi is busy and sends it as its own run, so a second message mid-turn no longer folds into the first (live defect). fake-pi models the real follow-up folding. Suite 52/52, node tests 129. rev-code-02 APPROVED round 3, comment 26362, tree dbd2ce9a. Records: QUEUE rows 23-24, CURRENT, BUILD-LOG phase, SESSIONS, row 24 brief (git verbs, D5-D7 ruled). Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -2,12 +2,24 @@ 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 { createEngine, buildPiArgs, PI_FIXED_ARGS, TOOLS_EXTENSION, 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");
|
||||
|
||||
// pi writes agent_settled after agent_end, at times in the next stdout
|
||||
// chunk, and the fake mirrors a command to its log only once it has read
|
||||
// it. Both are a few milliseconds; wait for them instead of racing them.
|
||||
async function until(check, ms = 1000) {
|
||||
for (let i = 0; i < ms / 10; i += 1) {
|
||||
if (check()) return true;
|
||||
await new Promise((res) => setTimeout(res, 10));
|
||||
}
|
||||
return check();
|
||||
}
|
||||
const idle = (engine) => until(() => !engine.busy);
|
||||
|
||||
function start(root, extra = {}) {
|
||||
const logPath = join(root, "commands.jsonl");
|
||||
const logs = [];
|
||||
@@ -35,9 +47,13 @@ test("engine: with tools, buildPiArgs turns pi's own tools off, loads the extens
|
||||
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("--extension") + 1], TOOLS_EXTENSION);
|
||||
assert.equal(READONLY_TOOLS_EXTENSION, TOOLS_EXTENSION);
|
||||
assert.equal(args[args.indexOf("--tools") + 1], "list_dir,read_file,search");
|
||||
assert.ok(existsSync(READONLY_TOOLS_EXTENSION), READONLY_TOOLS_EXTENSION);
|
||||
assert.ok(existsSync(TOOLS_EXTENSION), TOOLS_EXTENSION);
|
||||
assert.ok(TOOLS_EXTENSION.endsWith("/packages/discord/extension/tools.mjs"));
|
||||
const rw = buildPiArgs({ provider: "p", model: "m", thinking: "off", sessionDir: "/s", appendSystemPromptFile: "/p", continueSession: false, tools: { ...tools, roots: [{ name: "docs", path: "/r" }, { name: "vault", path: "/v", write: true }] } });
|
||||
assert.equal(rw[rw.indexOf("--tools") + 1], "list_dir,read_file,search,write_file,edit_file", "a writable root adds exactly the two write tools");
|
||||
});
|
||||
|
||||
test("engine: a run with tool turns settles once, on the answer, with every tool call in the result", async () => {
|
||||
@@ -53,6 +69,7 @@ test("engine: a run with tool turns settles once, on the answer, with every tool
|
||||
assert.equal(plain.text, "echo: hello");
|
||||
assert.deepEqual(plain.tools, []);
|
||||
assert.equal(plain.turns, 1);
|
||||
await idle(engine);
|
||||
assert.equal(engine.busy, false);
|
||||
await engine.stop();
|
||||
});
|
||||
@@ -69,32 +86,57 @@ test("engine: a run that ends on a tool-only turn fails the prompt as empty; a r
|
||||
|
||||
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();
|
||||
try {
|
||||
const r = await engine.prompt("hello");
|
||||
assert.equal(r.text, "echo: hello");
|
||||
assert.deepEqual(r.usage, { input: 3, output: 2 });
|
||||
await idle(engine);
|
||||
assert.equal(engine.busy, false);
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("engine: a prompt while streaming is sent as a follow-up and answered in order", async () => {
|
||||
test("engine: a prompt while streaming is held until pi settles, then sent as its own run, 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");
|
||||
assert.equal(engine.pendingCount, 2);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(commands().filter((c) => c.type === "prompt").length, 1, "the second prompt is not sent while pi is busy");
|
||||
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.length, 2);
|
||||
// Never a pi follow-up: pi would fold it into the first run and close both
|
||||
// answers with one agent_end (the live loss of 2026-09-17).
|
||||
assert.equal(prompts[0].streamingBehavior, undefined);
|
||||
assert.equal(prompts[1].streamingBehavior, "followUp");
|
||||
assert.equal(prompts[1].streamingBehavior, undefined);
|
||||
await idle(engine);
|
||||
assert.equal(engine.busy, false);
|
||||
await engine.stop();
|
||||
});
|
||||
|
||||
test("engine: a held prompt that times out before pi settles fails on its own and is never sent", async () => {
|
||||
const { engine, commands } = start(makeRoot());
|
||||
const first = engine.prompt("slow 200");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await assert.rejects(engine.prompt("late one", { timeoutMs: 50 }), (e) => e.details.code === "timeout" && /waiting for the engine/.test(e.message));
|
||||
const r1 = await first;
|
||||
assert.equal(r1.text, "slow reply");
|
||||
await idle(engine);
|
||||
assert.deepEqual(commands().filter((c) => c.type === "prompt").map((c) => c.message), ["slow 200"]);
|
||||
assert.deepEqual(commands().filter((c) => c.type === "abort"), [], "a held turn is not aborted; pi never had it");
|
||||
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(await until(() => commands().some((c) => c.type === "abort")), "abort reached pi");
|
||||
assert.ok(logs.some((l) => /timed out/.test(l)));
|
||||
const r = await engine.prompt("again");
|
||||
assert.equal(r.text, "echo: again");
|
||||
|
||||
Reference in New Issue
Block a user