2026-09-19 15:24:26 +03:00
|
|
|
/**
|
|
|
|
|
* The pdf-reader sub-agent (sources v0) — the hand-rolled loop on a faux
|
|
|
|
|
* engine: triage→extract→brief flow, the unread-finish doctrine gate, the
|
|
|
|
|
* render path via the headless fake, and the unreadable-source failure.
|
|
|
|
|
* The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture.
|
|
|
|
|
*/
|
|
|
|
|
import assert from "node:assert/strict";
|
2026-10-26 01:18:53 +02:00
|
|
|
import * as fs from "node:fs";
|
|
|
|
|
import * as os from "node:os";
|
2026-09-19 15:24:26 +03:00
|
|
|
import * as path from "node:path";
|
2026-10-26 01:18:53 +02:00
|
|
|
import { after, before, describe, it } from "node:test";
|
2026-09-19 15:24:26 +03:00
|
|
|
import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
|
|
|
|
import type { PiEngine } from "../src/agent/models.ts";
|
|
|
|
|
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
|
|
|
|
|
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
|
|
|
|
|
|
|
|
|
|
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
|
|
|
|
|
|
|
|
|
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
|
|
|
|
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
|
|
|
|
|
faux.setResponses(steps);
|
|
|
|
|
return { model: faux.getModel(), streamFn: faux.streamSimple };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PNG_1PX = Buffer.from([
|
|
|
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
|
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
|
|
|
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
|
|
|
|
|
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
|
|
|
|
|
0x42, 0x60, 0x82,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-10-26 01:18:53 +02:00
|
|
|
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[]; paths: string[] } {
|
2026-09-19 15:24:26 +03:00
|
|
|
const calls: string[] = [];
|
2026-10-26 01:18:53 +02:00
|
|
|
const paths: string[] = [];
|
2026-09-19 15:24:26 +03:00
|
|
|
return {
|
|
|
|
|
calls,
|
2026-10-26 01:18:53 +02:00
|
|
|
paths,
|
2026-09-19 15:24:26 +03:00
|
|
|
available: () => true,
|
|
|
|
|
unavailableReason: () => null,
|
|
|
|
|
runDemo: async () => {
|
|
|
|
|
throw new Error("unused");
|
|
|
|
|
},
|
|
|
|
|
renderSvg: async () => {
|
|
|
|
|
throw new Error("unused");
|
|
|
|
|
},
|
2026-10-26 01:18:53 +02:00
|
|
|
renderPdfPages: async (pdfPath, pages) => {
|
|
|
|
|
paths.push(pdfPath);
|
2026-09-19 15:24:26 +03:00
|
|
|
calls.push(`render:${pages.join(",")}`);
|
|
|
|
|
rendered.pages.push(...pages);
|
|
|
|
|
return pages.map((page) => ({ page, png: PNG_1PX }));
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-26 01:18:53 +02:00
|
|
|
describe("pdf-reader source paths", () => {
|
|
|
|
|
let home: string;
|
|
|
|
|
const relativePath = path.join("Course notes", "sample document.pdf");
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
const originalUserProfile = process.env.USERPROFILE;
|
|
|
|
|
|
|
|
|
|
before(() => {
|
|
|
|
|
home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-home-"));
|
|
|
|
|
const target = path.join(home, relativePath);
|
|
|
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
|
|
|
fs.copyFileSync(FIXTURE, target);
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
process.env.USERPROFILE = home;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
after(() => {
|
|
|
|
|
if (originalHome === undefined) delete process.env.HOME;
|
|
|
|
|
else process.env.HOME = originalHome;
|
|
|
|
|
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
|
|
|
else process.env.USERPROFILE = originalUserProfile;
|
|
|
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for (const kind of ["absolute", "home-relative"] as const) {
|
|
|
|
|
it(`${kind} path reaches text tools and rendering as the same absolute path`, async () => {
|
|
|
|
|
const absolutePath = path.join(home, relativePath);
|
|
|
|
|
const inputPath = kind === "absolute" ? absolutePath : relativePath;
|
|
|
|
|
assert.equal(os.homedir(), home);
|
|
|
|
|
assert.notEqual(path.resolve(relativePath), absolutePath, "cwd is not the learner's home");
|
|
|
|
|
const headless = fakeHeadless();
|
|
|
|
|
const engine = fauxEngine([
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }),
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_search", { query: "integral", literal: true }, { id: "s1" })], { stopReason: "toolUse" }),
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "2" }, { id: "e1" })], { stopReason: "toolUse" }),
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "3" }, { id: "r1" })], { stopReason: "toolUse" }),
|
|
|
|
|
fauxAssistantMessage([fauxText("Read the requested document (p. 1, p. 2, p. 3).")]),
|
|
|
|
|
]);
|
|
|
|
|
let lastContext = "";
|
|
|
|
|
const stream = engine.streamFn;
|
|
|
|
|
engine.streamFn = (...args) => {
|
|
|
|
|
lastContext = JSON.stringify(args[1]);
|
|
|
|
|
return stream(...args);
|
|
|
|
|
};
|
|
|
|
|
const result = await runPdfReaderAgent(
|
|
|
|
|
{ path: inputPath, question: "Read the document" },
|
|
|
|
|
{ engine, headless },
|
|
|
|
|
);
|
|
|
|
|
assert.equal(result.path, absolutePath);
|
|
|
|
|
assert.match(result.brief, /^Read the requested document/);
|
|
|
|
|
assert.match(lastContext, /Pages: 3/, "metadata opened the home-anchored PDF");
|
|
|
|
|
assert.deepEqual(result.pagesRead, [2, 3]);
|
|
|
|
|
assert.deepEqual(result.pagesConsulted, [1, 2, 3], "search and extraction opened the home-anchored PDF");
|
|
|
|
|
assert.deepEqual(headless.paths, [absolutePath]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("a missing home-relative source fails before any model call", async () => {
|
|
|
|
|
let modelCalls = 0;
|
|
|
|
|
const engine = fauxEngine([]);
|
|
|
|
|
engine.streamFn = () => {
|
|
|
|
|
modelCalls++;
|
|
|
|
|
throw new Error("the model must not be called");
|
|
|
|
|
};
|
|
|
|
|
const missing = path.join("Course notes", "missing.pdf");
|
|
|
|
|
const result = await runPdfReaderAgent({ path: missing, question: "anything" }, { engine });
|
|
|
|
|
assert.equal(result.path, path.join(home, missing));
|
|
|
|
|
assert.match(result.brief, /^PDF failed:.*ENOENT/);
|
|
|
|
|
assert.equal(modelCalls, 0);
|
|
|
|
|
assert.deepEqual(result.pagesRead, []);
|
|
|
|
|
assert.deepEqual(result.pagesConsulted, []);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-19 15:24:26 +03:00
|
|
|
describe("pdf-reader loop (faux engine)", () => {
|
|
|
|
|
it("triage → extract → final brief: pagesConsulted tracks the extract", async () => {
|
|
|
|
|
const engine = fauxEngine([
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_info", {}, { id: "i1" })], { stopReason: "toolUse" }),
|
|
|
|
|
fauxAssistantMessage(
|
|
|
|
|
[fauxToolCall("pdf_extract", { pages: "1-2" }, { id: "e1" })],
|
|
|
|
|
{ stopReason: "toolUse" },
|
|
|
|
|
),
|
|
|
|
|
fauxAssistantMessage([fauxText("The integral rule appears on page 1 (p. 1). Pages consulted: 1, 2")]),
|
|
|
|
|
]);
|
|
|
|
|
const result = await runPdfReaderAgent(
|
|
|
|
|
{ path: FIXTURE, question: "what is the integral of x?" },
|
|
|
|
|
{ engine },
|
|
|
|
|
);
|
|
|
|
|
assert.equal(result.path, FIXTURE);
|
|
|
|
|
assert.match(result.brief, /integral rule/);
|
2026-09-19 16:52:09 +03:00
|
|
|
assert.deepEqual(result.pagesRead, [1, 2], "extracted pages are read pages");
|
2026-09-19 15:24:26 +03:00
|
|
|
assert.deepEqual(result.pagesConsulted, [1, 2]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("the unread-finish gate: a brief with zero consultation is bounced, not delivered", async () => {
|
|
|
|
|
let nudged = false;
|
|
|
|
|
const engine = fauxEngine([
|
|
|
|
|
// first finish attempt with NOTHING read — the loop must inject the
|
|
|
|
|
// nudge and keep going rather than deliver invention
|
|
|
|
|
fauxAssistantMessage([fauxText("The answer is 42, trust me.")]),
|
|
|
|
|
// after the nudge: consult, then brief honestly
|
|
|
|
|
fauxAssistantMessage(
|
|
|
|
|
[fauxToolCall("pdf_search", { query: "integral", literal: false }, { id: "s1" })],
|
|
|
|
|
{ stopReason: "toolUse" },
|
|
|
|
|
),
|
|
|
|
|
fauxAssistantMessage([fauxText("Found it on page 1 (p. 1): the integral of x dx.")]),
|
|
|
|
|
]);
|
|
|
|
|
const result = await runPdfReaderAgent(
|
|
|
|
|
{ path: FIXTURE, question: "integral of x?" },
|
|
|
|
|
{ engine, onActivity: (line) => void (line && (nudged ||= line.startsWith("pdf_search"))) },
|
|
|
|
|
);
|
|
|
|
|
assert.match(result.brief, /Found it on page 1/);
|
2026-09-19 16:52:09 +03:00
|
|
|
assert.deepEqual(result.pagesRead, [], "search hits consult but do not read (the split)");
|
2026-09-19 15:24:26 +03:00
|
|
|
assert.deepEqual(result.pagesConsulted, [1]);
|
2026-09-19 16:52:09 +03:00
|
|
|
// and the gate lets it through: search hits ARE genuine consultation
|
2026-09-19 15:24:26 +03:00
|
|
|
// the first (invention) answer must NOT be the delivered brief
|
|
|
|
|
assert.notEqual(result.brief, "The answer is 42, trust me.");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("the render path: images ride a user message after tool results; pages counted", async () => {
|
|
|
|
|
const headless = fakeHeadless();
|
|
|
|
|
const engine = fauxEngine([
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "2-3", dpi: 150 }, { id: "r1" })], {
|
|
|
|
|
stopReason: "toolUse",
|
|
|
|
|
}),
|
|
|
|
|
fauxAssistantMessage([fauxText("The physics formula is visible on page 2 (p. 2).")]),
|
|
|
|
|
]);
|
|
|
|
|
const result = await runPdfReaderAgent(
|
|
|
|
|
{ path: FIXTURE, question: "what is the force formula?" },
|
|
|
|
|
{ engine, headless },
|
|
|
|
|
);
|
2026-09-19 16:52:09 +03:00
|
|
|
assert.deepEqual(result.pagesRead, [2, 3], "rendered pages are read pages");
|
2026-09-19 15:24:26 +03:00
|
|
|
assert.deepEqual(result.pagesConsulted, [2, 3]);
|
|
|
|
|
assert.deepEqual(headless.calls, ["render:2,3"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("no Chrome: render calls degrade to an error note, text tools still work", async () => {
|
|
|
|
|
const engine = fauxEngine([
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "r1" })], {
|
|
|
|
|
stopReason: "toolUse",
|
|
|
|
|
}),
|
|
|
|
|
// the reader adapts after the error: extract instead, then brief
|
|
|
|
|
fauxAssistantMessage([fauxToolCall("pdf_extract", { pages: "1" }, { id: "e1" })], {
|
|
|
|
|
stopReason: "toolUse",
|
|
|
|
|
}),
|
|
|
|
|
fauxAssistantMessage([fauxText("Extracted instead: the calculus line (p. 1).")]),
|
|
|
|
|
]);
|
|
|
|
|
const result = await runPdfReaderAgent(
|
|
|
|
|
{ path: FIXTURE, question: "page 1 content?" },
|
|
|
|
|
{
|
|
|
|
|
engine,
|
|
|
|
|
headless: {
|
|
|
|
|
available: () => false,
|
|
|
|
|
unavailableReason: () => "No Chrome found",
|
|
|
|
|
runDemo: async () => {
|
|
|
|
|
throw new Error("unused");
|
|
|
|
|
},
|
|
|
|
|
renderSvg: async () => {
|
|
|
|
|
throw new Error("unused");
|
|
|
|
|
},
|
|
|
|
|
renderPdfPages: async () => {
|
|
|
|
|
throw new Error("should not render");
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
assert.match(result.brief, /Extracted instead/);
|
2026-09-19 16:52:09 +03:00
|
|
|
assert.deepEqual(result.pagesRead, [1]);
|
2026-09-19 15:24:26 +03:00
|
|
|
assert.deepEqual(result.pagesConsulted, [1]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("an unreadable source fails deterministically before any model call", async () => {
|
|
|
|
|
const engine = fauxEngine([fauxAssistantMessage([fauxText("never reached")])]);
|
|
|
|
|
const result = await runPdfReaderAgent(
|
|
|
|
|
{ path: path.join(import.meta.dirname, "fixtures", "not-a-pdf.txt"), question: "anything" },
|
|
|
|
|
{ engine },
|
|
|
|
|
);
|
|
|
|
|
// had the model been consulted, the brief would be its text instead
|
|
|
|
|
assert.match(result.brief, /^PDF failed:/);
|
|
|
|
|
assert.ok(!result.brief.includes("never reached"));
|
2026-09-19 16:52:09 +03:00
|
|
|
assert.deepEqual(result.pagesRead, []);
|
2026-09-19 15:24:26 +03:00
|
|
|
assert.deepEqual(result.pagesConsulted, []);
|
|
|
|
|
});
|
|
|
|
|
});
|