fix(pdf): resolve learner paths relative to home
This commit is contained in:
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
|
||||||
import { normalizeContext } from "@earendil-works/pi-ai";
|
import { normalizeContext } from "@earendil-works/pi-ai";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
import { resolveSourcePath } from "../tools/fs-shared.ts";
|
||||||
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
|
||||||
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
|
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
|
||||||
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
|
||||||
@@ -166,7 +167,7 @@ export async function runPdfReaderAgent(
|
|||||||
): Promise<PdfResult> {
|
): Promise<PdfResult> {
|
||||||
const headless = opts.headless ?? getHeadless();
|
const headless = opts.headless ?? getHeadless();
|
||||||
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
|
||||||
const pdfPath = input.path;
|
const pdfPath = resolveSourcePath(input.path);
|
||||||
|
|
||||||
// Deterministic upfront failure: unreadable / unparseable
|
// Deterministic upfront failure: unreadable / unparseable
|
||||||
// for renders — the brief never starts on a broken source.
|
// for renders — the brief never starts on a broken source.
|
||||||
|
|||||||
+54
-1
@@ -25,8 +25,9 @@
|
|||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
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 { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type AssistantMessage, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
accumulateJobUsage,
|
accumulateJobUsage,
|
||||||
@@ -717,6 +718,27 @@ describe("research runner (async protocol §3)", () => {
|
|||||||
describe("pdf runner (sources v0)", () => {
|
describe("pdf runner (sources v0)", () => {
|
||||||
const PDF_FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
|
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 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. */
|
/** A pdf-reader that triages, extracts pages 1-2, then briefs. */
|
||||||
function pdfReaderModel(): PiEngine {
|
function pdfReaderModel(): PiEngine {
|
||||||
@@ -759,6 +781,37 @@ describe("pdf runner (sources v0)", () => {
|
|||||||
assert.ok(steer.content.includes("integral rule"), "steer text carries the brief");
|
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 () => {
|
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");
|
const chat = chatStore.createChat("Test: pdf runner failure");
|
||||||
trackChat(chat.id);
|
trackChat(chat.id);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
* The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture.
|
* The pdfjs layer itself is covered by pdf-ops.test.ts against the fixture.
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
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 * 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 { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
|
import { runPdfReaderAgent } from "../src/agent/subagents/pdf-reader.ts";
|
||||||
@@ -28,10 +30,12 @@ const PNG_1PX = Buffer.from([
|
|||||||
0x42, 0x60, 0x82,
|
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 calls: string[] = [];
|
||||||
|
const paths: string[] = [];
|
||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
|
paths,
|
||||||
available: () => true,
|
available: () => true,
|
||||||
unavailableReason: () => null,
|
unavailableReason: () => null,
|
||||||
runDemo: async () => {
|
runDemo: async () => {
|
||||||
@@ -40,7 +44,8 @@ function fakeHeadless(rendered: { pages: number[] } = { pages: [] }): HeadlessAp
|
|||||||
renderSvg: async () => {
|
renderSvg: async () => {
|
||||||
throw new Error("unused");
|
throw new Error("unused");
|
||||||
},
|
},
|
||||||
renderPdfPages: async (_pdfPath, pages) => {
|
renderPdfPages: async (pdfPath, pages) => {
|
||||||
|
paths.push(pdfPath);
|
||||||
calls.push(`render:${pages.join(",")}`);
|
calls.push(`render:${pages.join(",")}`);
|
||||||
rendered.pages.push(...pages);
|
rendered.pages.push(...pages);
|
||||||
return pages.map((page) => ({ page, png: PNG_1PX }));
|
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)", () => {
|
describe("pdf-reader loop (faux engine)", () => {
|
||||||
it("triage → extract → final brief: pagesConsulted tracks the extract", async () => {
|
it("triage → extract → final brief: pagesConsulted tracks the extract", async () => {
|
||||||
const engine = fauxEngine([
|
const engine = fauxEngine([
|
||||||
|
|||||||
Reference in New Issue
Block a user