diff --git a/src/agent/subagents/pdf-reader.ts b/src/agent/subagents/pdf-reader.ts index 462a3e3..c3feae2 100644 --- a/src/agent/subagents/pdf-reader.ts +++ b/src/agent/subagents/pdf-reader.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai"; import { Type } from "typebox"; +import { resolveSourcePath } from "../tools/fs-shared.ts"; import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts"; import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts"; import type { PdfResult, PdfToolInput } from "../../core/contracts.ts"; @@ -166,7 +167,7 @@ export async function runPdfReaderAgent( ): Promise { const headless = opts.headless ?? getHeadless(); const maxIterations = opts.maxIterations ?? MAX_ITERATIONS; - const pdfPath = input.path; + const pdfPath = resolveSourcePath(input.path); // Deterministic upfront failure: unreadable / unparseable // for renders โ€” the brief never starts on a broken source. diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts index af9387a..73e1957 100644 --- a/tests/jobs.test.ts +++ b/tests/jobs.test.ts @@ -25,8 +25,9 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; -import { after, afterEach, describe, it } from "node:test"; +import { after, afterEach, before, describe, it } from "node:test"; import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai"; import { accumulateJobUsage, @@ -717,6 +718,27 @@ describe("research runner (async protocol ยง3)", () => { describe("pdf runner (sources v0)", () => { const PDF_FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf"); const PDF_INPUT = { path: PDF_FIXTURE, question: "What is the integral of x dx?" }; + const relativePath = path.join("Course notes", "sample document.pdf"); + let home: string; + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + + before(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-job-home-")); + const target = path.join(home, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(PDF_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 }); + }); /** A pdf-reader that triages, extracts pages 1-2, then briefs. */ function pdfReaderModel(): PiEngine { @@ -759,6 +781,37 @@ describe("pdf runner (sources v0)", () => { assert.ok(steer.content.includes("integral rule"), "steer text carries the brief"); }); + for (const mode of ["dispatch", "recovery"] as const) { + it(`resolves a home-relative PDF from ${mode} input without rewriting history`, async () => { + const chat = chatStore.createChat(`Test: relative PDF ${mode}`); + trackChat(chat.id); + const toolCallId = `call-pdf-relative-${mode}`; + const input = { ...PDF_INPUT, path: relativePath }; + const messages = nativeToolMessages("read this pdf", "pdf", toolCallId, input); + chatStore.saveMessages(chat.id, messages); + const job = jobStore.createJob({ kind: "pdf", chatId: chat.id, toolCallId }); + trackJob(job.id); + const delivery = fakeDeliver(); + const runner = createJobRunner({ chatStore, jobStore, deliver: delivery.deliver }); + pdfModelOverride.current = pdfReaderModel(); + try { + await runner.runJob(job.id, mode === "dispatch" ? input : undefined); + } finally { + pdfModelOverride.current = undefined; + } + + const done = jobStore.getJob(job.id)!; + assert.equal(done.status, "done"); + const result = done.result as PdfResult; + assert.equal(result.path, path.join(home, relativePath)); + assert.match(result.brief, /integral rule/); + assert.deepEqual(result.pagesRead, [1, 2]); + assert.deepEqual(result.pagesConsulted, [1, 2]); + assert.equal(delivery.steers.length, 1); + assert.deepEqual(chatStore.getChat(chat.id)!.messages, messages, "tool-call arguments remain as supplied"); + }); + } + it("an unreadable source: the job lands error with the PDF failed: brief and the steer flags the gap", async () => { const chat = chatStore.createChat("Test: pdf runner failure"); trackChat(chat.id); diff --git a/tests/pdf-reader.test.ts b/tests/pdf-reader.test.ts index 2f4c142..166c412 100644 --- a/tests/pdf-reader.test.ts +++ b/tests/pdf-reader.test.ts @@ -5,8 +5,10 @@ * The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture. */ import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; -import { describe, it } from "node:test"; +import { after, before, 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"; @@ -28,10 +30,12 @@ const PNG_1PX = Buffer.from([ 0x42, 0x60, 0x82, ]); -function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[] } { +function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessApi & { calls: string[]; paths: string[] } { const calls: string[] = []; + const paths: string[] = []; return { calls, + paths, available: () => true, unavailableReason: () => null, runDemo: async () => { @@ -40,7 +44,8 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp renderSvg: async () => { throw new Error("unused"); }, - renderPdfPages: async (_pdfPath, pages) => { + renderPdfPages: async (pdfPath, pages) => { + paths.push(pdfPath); calls.push(`render:${pages.join(",")}`); rendered.pages.push(...pages); return pages.map((page) => ({ page, png: PNG_1PX })); @@ -48,6 +53,79 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp }; } +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, []); + }); +}); + describe("pdf-reader loop (faux engine)", () => { it("triage โ†’ extract โ†’ final brief: pagesConsulted tracks the extract", async () => { const engine = fauxEngine([