Files
learnharness/tests/pdf-reader.test.ts
T
amosblomqvist 9a90dd51d9 feat(T2b): sources v0 — read tool + pdf-reader sub-agent
Learner source files, on the teacher's own terms (nothing auto-injects):

- read tool (src/agent/tools/read.ts): pi-read port — path/offset/limit,
  2000-line/50KB truncation with use-offset continuation notices, image
  files (jpg/png/gif/webp/bmp) returned as native image content parts
  (new LhToolImageResult escape hatch in lh-tool.ts). Always-on in both
  the prober and teacher registries (user-decided 2026-09).
- pdf-reader sub-agent (JobKind 'pdf', toggleable, ack-and-steer): triage
  via pdf_info (pdfjs text layer: per-page text/image/math-density),
  targeted pdf_extract, regex pdf_search (hits count as consultation),
  and pdf_render — pages painted by pdfjs onto a canvas shim in headless
  Chrome (the pdfium viewer doesn't paint headless), screenshot fed back
  image-in-the-loop (visual-maker idiom, providerSafePng applied). The
  unread-finish gate bounces a zero-consultation brief once. Teacher gets
  a text brief with page citations only; renders never leave the loop.
- MODELS.pdfReader = qwen/qwen3.8-flash (user override 2026-09-19: the
  look-at-renders workload wants the vision-proven model; catalog listed
  glm-5.3-flash as image-capable but qwen is the known-good daily driver).
- UI: PdfCompleteBadge + PdfBriefCard (steer-card, job-card), dispatch
  card + activity-dock kind entries (type-forced, flagged for UI review).
- Prompts: 'Learner source files' section in teacher/30-jobs.md; read
  line in prober/10-probe-doctrine.md. Doctrine rides tool descriptions
  per the self-contained-tools principle.
2026-09-19 15:24:26 +03:00

154 lines
6.0 KiB
TypeScript

/**
* 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";
import * as path from "node:path";
import { describe, it } from "node:test";
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,
]);
function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[] } {
const calls: string[] = [];
return {
calls,
available: () => true,
unavailableReason: () => null,
runDemo: async () => {
throw new Error("unused");
},
renderSvg: async () => {
throw new Error("unused");
},
renderPdfPages: async (_pdfPath, pages) => {
calls.push(`render:${pages.join(",")}`);
rendered.pages.push(...pages);
return pages.map((page) => ({ page, png: PNG_1PX }));
},
};
}
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/);
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/);
assert.deepEqual(result.pagesConsulted, [1]);
// 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 },
);
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/);
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"));
assert.deepEqual(result.pagesConsulted, []);
});
});