fix: normalize model-visible images with Pi processing

This commit is contained in:
amosblomqvist
2026-10-28 18:16:04 +02:00
parent 8f6fd7cd29
commit 84f93999c5
28 changed files with 1870 additions and 60 deletions
+351
View File
@@ -0,0 +1,351 @@
/** Offline integration contracts at the provider boundary: real shared image
* processing, faux model streams, synthetic rasters, and fake headless I/O.
* No real browser, provider, learner data, or ambient credentials are used. */
import assert from "node:assert/strict";
import * as fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { after, test } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import * as photon from "@silvia-odwyer/photon-node";
import {
createFauxCore,
fauxAssistantMessage,
fauxText,
fauxToolCall,
type FauxResponseStep,
type ImageContent,
type TranscriptContext,
} from "@earendil-works/pi-ai";
import type { PiEngine } from "../src/agent/models.ts";
import type { ModelImageResizeOptions } from "../src/agent/image-processing/index.ts";
import type { HeadlessApi } from "../src/agent/subagents/headless.ts";
// Install storage/auth seams BEFORE importing the agent composition root.
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-agent-image-paths-"));
const originalEnv = process.env;
const originalFetch = globalThis.fetch;
fs.mkdirSync(path.join(scratch, "home"));
fs.writeFileSync(path.join(scratch, "auth.json"), "{}");
process.env = {
...(originalEnv.NODE_TEST_CONTEXT ? { NODE_TEST_CONTEXT: originalEnv.NODE_TEST_CONTEXT } : {}),
...(originalEnv.NODE_OPTIONS ? { NODE_OPTIONS: originalEnv.NODE_OPTIONS } : {}),
NODE_ENV: "test",
HOME: path.join(scratch, "home"),
LH_DATA_DIR: path.join(scratch, "data"),
LH_AUTH_FILE: path.join(scratch, "auth.json"),
};
globalThis.fetch = async () => { throw new Error("CLOSED: unexpected network request"); };
after(() => {
process.env = originalEnv;
globalThis.fetch = originalFetch;
fs.rmSync(scratch, { recursive: true, force: true });
});
const { readTool } = await import("../src/agent/tools/read.ts");
const { createTeacherAgent, createProberAgent, createPracticeAgent } = await import("../src/agent/teacher-agent.ts");
const { runPdfReaderAgent } = await import("../src/agent/subagents/pdf-reader.ts");
const { runVisualMaker } = await import("../src/agent/subagents/visual-maker.ts");
const PDF = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
const SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="4800" height="120" viewBox="0 0 4800 120"><rect width="4800" height="120" fill="white"/></svg>';
const CUSTOM_RESIZE = { maxWidth: 320, maxHeight: 200 };
function png(width: number, height: number): Buffer {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
context.fillStyle = "#336699";
context.fillRect(0, 0, width, height);
return canvas.toBuffer("image/png");
}
function dimensions(image: ImageContent): { width: number; height: number } {
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(image.data, "base64"));
try {
return { width: decoded.get_width(), height: decoded.get_height() };
} finally { decoded.free(); }
}
function images(context: TranscriptContext): ImageContent[] {
return context.messages.flatMap((message) => Array.isArray(message.content)
? message.content.filter((part): part is ImageContent => part.type === "image")
: []);
}
function text(context: TranscriptContext): string {
return context.messages.map((message) => typeof message.content === "string"
? message.content
: message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n")).join("\n");
}
function scripted(steps: FauxResponseStep[], resize?: ModelImageResizeOptions, vision = true) {
const faux = createFauxCore({ tokenSize: { min: 64, max: 64 } });
faux.setResponses(steps);
const base = faux.getModel();
const model: PiEngine["model"] = {
...base,
input: vision ? ["text", "image"] : ["text"],
...(resize ? { inputLimits: { ...base.inputLimits, images: { ...base.inputLimits?.images, resize } } } : {}),
};
const requests: TranscriptContext[] = [];
const engine: PiEngine = {
model,
streamFn: (model, context, options) => {
// Snapshot the actual provider-facing context, not mutable agent state.
requests.push(structuredClone(context));
return faux.streamSimple(model, context, options);
},
};
return { engine, requests };
}
function readSteps(file: string): FauxResponseStep[] {
return [
fauxAssistantMessage([fauxToolCall("read", { path: file }, { id: "read-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Inspected the supplied source.")]),
];
}
function fakeHeadless(rendered: Buffer | ((page: number) => Buffer)) {
const svgCalls: string[] = [];
const pdfCalls: number[][] = [];
const headless: HeadlessApi = {
available: () => true,
unavailableReason: () => null,
runDemo: async () => { throw new Error("unexpected browser execution"); },
renderSvg: async (svg) => {
svgCalls.push(svg);
return typeof rendered === "function" ? rendered(1) : rendered;
},
renderPdfPages: async (_file, pages) => {
pdfCalls.push([...pages]);
return pages.map((page) => ({ page, png: typeof rendered === "function" ? rendered(page) : rendered }));
},
};
return { headless, svgCalls, pdfCalls };
}
function assertProcessedImage(context: TranscriptContext, expected: { width: number; height: number }, raw: Buffer): void {
const parts = images(context);
assert.equal(parts.length, 1, "exactly one successful image reaches the faux provider");
assert.deepEqual(dimensions(parts[0]!), expected);
assert.ok(Buffer.byteLength(parts[0]!.data, "utf8") < 4.5 * 1024 * 1024);
assert.notEqual(parts[0]!.data, raw.toString("base64"), "the oversized raw raster is never sent");
assert.match(text(context), /\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by/);
}
test("shared teacher read detects valid image content under a non-image extension", async () => {
const bytes = png(24, 16);
const file = path.join(scratch, "lecture-notes.txt");
fs.writeFileSync(file, bytes);
const { engine, requests } = scripted(readSteps(file));
// Pass the actual shared read tool, exercising the teacher's native clone
// of read.execute rather than a pre-bound test-only implementation.
const agent = createTeacherAgent(undefined, { engine, tools: { read: readTool } });
await agent.prompt("Inspect the supplied local file.");
assert.equal(requests.length, 2);
const visible = requests[1]!;
assert.equal(images(visible).length, 1);
assert.equal(images(visible)[0]!.mimeType, "image/png");
assert.equal(images(visible)[0]!.data, bytes.toString("base64"));
assert.deepEqual(dimensions(images(visible)[0]!), { width: 24, height: 16 });
assert.match(text(visible), /Read image file \[image\/png\]/);
});
test("shared read omits corrupt recognized images without exposing a raw image fallback", async () => {
// Valid signature/IHDR, missing pixel data: sniff succeeds, decode fails.
const bytes = png(24, 16).subarray(0, 33);
const file = path.join(scratch, "corrupt.png");
fs.writeFileSync(file, bytes);
const { engine, requests } = scripted(readSteps(file));
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
assert.equal(requests.length, 2);
assert.deepEqual(images(requests[1]!), []);
assert.match(text(requests[1]!), /Image omitted: could not be resized below the inline image size limit/);
assert.ok(!JSON.stringify(requests[1]).includes(bytes.toString("base64")));
});
test("shared read supplies the native nonvision-model note", async () => {
const file = path.join(scratch, "nonvision.png");
fs.writeFileSync(file, png(24, 16));
const { engine, requests } = scripted(readSteps(file), undefined, false);
await createTeacherAgent(undefined, { engine, tools: { read: readTool } }).prompt("Inspect the file.");
assert.equal(requests.length, 2);
assert.match(text(requests[1]!), /\[Current model does not support images\. The image will be omitted from this request\.\]/);
});
test("teacher read binds independent active-model resize limits without mutating the shared tool", async () => {
const bytes = png(640, 320);
const file = path.join(scratch, "model-bound.png");
fs.writeFileSync(file, bytes);
const originalExecute = readTool.execute;
const a = scripted(readSteps(file), { maxWidth: 96, maxHeight: 64 });
const b = scripted(readSteps(file), { maxWidth: 80, maxHeight: 30 });
const agentA = createTeacherAgent(undefined, { engine: a.engine, tools: { read: readTool } });
const agentB = createTeacherAgent(undefined, { engine: b.engine, tools: { read: readTool } });
await agentB.prompt("Inspect with model B.");
await agentA.prompt("Inspect with model A.");
assertProcessedImage(a.requests[1]!, { width: 96, height: 48 }, bytes);
assertProcessedImage(b.requests[1]!, { width: 60, height: 30 }, bytes);
assert.equal(readTool.execute, originalExecute);
});
for (const [role, resize, expected] of [
["prober", { maxWidth: 96, maxHeight: 64 }, { width: 96, height: 48 }],
["practice", { maxWidth: 80, maxHeight: 30 }, { width: 60, height: 30 }],
] as const) {
test(`${role}'s actual registry retains argument healing and text reads while binding image limits`, async () => {
const bytes = png(640, 320);
const imageFile = path.join(scratch, `${role}-actual-registry.png`);
const textFile = path.join(scratch, `${role}-actual-registry.txt`);
fs.writeFileSync(imageFile, bytes);
fs.writeFileSync(textFile, "first\nsecond\nthird\nfourth\nfifth");
const { engine, requests } = scripted([
fauxAssistantMessage([fauxToolCall("read", { path: imageFile }, { id: "registry-image" })], { stopReason: "toolUse" }),
// Exact existing heal contract: schema-integer fields encoded as
// JSON number strings heal before validation. The path stays intact.
fauxAssistantMessage([fauxToolCall("read", { path: textFile, offset: "2", limit: "2" }, { id: "registry-text" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Inspected the image and selected text lines.")]),
], resize);
const originalExecute = readTool.execute;
const forbidden = () => { throw new Error("unexpected job dispatch or handoff"); };
const chatStore = { getChat: () => null };
// Only context ports are mocked; no app/store imports or explicit
// tool overrides. Job/handoff operations must never execute here.
const agent = role === "prober"
? createProberAgent({
chatId: "offline-read-prober", chatStore,
jobStore: new Proxy({}, { get: forbidden }),
spawn: forbidden, deliver: forbidden,
} as never, { engine })
: createPracticeAgent({ chatId: "offline-read-practice", chatStore }, { engine });
const names = agent.state.tools.map((tool) => tool.name);
assert.ok(names.includes(role === "prober" ? "probe_done" : "problem"), "real role registry was assembled");
const boundRead = agent.state.tools.find((tool) => tool.name === "read");
assert.ok(boundRead?.prepareArguments, "model binding preserves the registry's prepareArguments wrapper");
assert.notEqual(boundRead.execute, originalExecute, "shared read execute is rebound to the active model");
assert.deepEqual(boundRead.prepareArguments({ path: textFile, offset: "2", limit: "2" }), { path: textFile, offset: 2, limit: 2 });
await agent.prompt("Inspect the local image, then read the selected text lines.");
assert.equal(requests.length, 3);
assertProcessedImage(requests[1]!, expected, bytes);
const selected = requests[2]!.messages.find((message) => message.role === "toolResult" && message.toolCallId === "registry-text");
assert.ok(selected && selected.role === "toolResult");
assert.equal(selected.isError, false);
assert.deepEqual(selected.content, [{ type: "text", text: "second\nthird\n\n[2 more lines in file. Use offset=4 to continue.]" }]);
assert.equal(readTool.execute, originalExecute, "role construction did not mutate the shared tool");
});
}
test("PDF reader preserves both processed image outputs from multiple render calls in one batch", async () => {
const wide = png(4800, 120);
const tall = png(120, 4800);
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? wide : tall);
const { engine, requests } = scripted([
fauxAssistantMessage([
fauxToolCall("pdf_render", { pages: "1", dpi: 150 }, { id: "batch-render-1" }),
fauxToolCall("pdf_render", { pages: "2", dpi: 150 }, { id: "batch-render-2" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Both rendered pages are visible (p. 1, p. 2). Pages consulted: 1, 2")]),
]);
const result = await runPdfReaderAgent({ path: PDF, question: "Compare the two figures." }, { engine, headless });
assert.equal(requests.length, 2);
assert.deepEqual(pdfCalls, [[1], [2]]);
const visible = requests[1]!;
const parts = images(visible);
assert.equal(parts.length, 2, "the second render call must not overwrite the first call's images");
assert.deepEqual(parts.map(dimensions), [{ width: 2000, height: 50 }, { width: 50, height: 2000 }]);
for (let i = 0; i < parts.length; i++) {
assert.ok(parts[i]!.data.length < 4.5 * 1024 * 1024);
assert.notEqual(parts[i]!.data, [wide, tall][i]!.toString("base64"));
}
assert.deepEqual(visible.messages.slice(-3).map((message) => message.role), ["toolResult", "toolResult", "user"]);
const pageMessage = visible.messages.at(-1)!;
assert.ok(Array.isArray(pageMessage.content));
assert.deepEqual(pageMessage.content.filter((part) => part.type === "text").map((part) => part.text.split("\n")[0]), [
"The rendered page(s), in order (p1, p2):", "Page 1", "Page 2",
]);
assert.deepEqual(result.pagesRead, [1, 2]);
assert.deepEqual(result.pagesConsulted, [1, 2]);
});
for (const [label, resize, expected] of [
["native defaults", undefined, { width: 2000, height: 50 }],
["active-model overrides", CUSTOM_RESIZE, { width: 320, height: 8 }],
] as const) {
test(`PDF reader sends processed renders using ${label}; failed page is not counted`, async () => {
const bytes = png(4800, 120);
const { headless, pdfCalls } = fakeHeadless((page) => page === 1 ? bytes : Buffer.from("invalid rendered page"));
const { engine, requests } = scripted([
fauxAssistantMessage([fauxToolCall("pdf_render", { pages: "1-2", dpi: 150 }, { id: "render-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("The visible figure is on page 1 (p. 1). Pages consulted: 1")]),
], resize);
const result = await runPdfReaderAgent({ path: PDF, question: "Inspect the page figure." }, { engine, headless });
assert.equal(requests.length, 2);
assert.deepEqual(pdfCalls, [[1, 2]]);
assertProcessedImage(requests[1]!, expected, bytes);
assert.match(text(requests[1]!), /Page 2: \[Image omitted:/);
assert.deepEqual(result.pagesRead, [1]);
assert.deepEqual(result.pagesConsulted, [1]);
assert.match(result.brief, /visible figure/);
const visible = requests[1]!.messages;
assert.equal(visible.at(-2)!.role, "toolResult");
assert.equal(visible.at(-1)!.role, "user", "processed page images ride the user message after tool results");
});
for (const forced of [false, true]) {
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} path processes images using ${label}`, async () => {
const bytes = png(4800, 120);
const { headless, svgCalls } = fakeHeadless(bytes);
const steps: FauxResponseStep[] = forced ? [
fauxAssistantMessage([fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Premature notes without inspection.")]),
fauxAssistantMessage([fauxText("Confirmed after inspecting the final render.")]),
] : [
fauxAssistantMessage([
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
fauxToolCall("render_svg", {}, { id: "render-1" }),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Confirmed after inspecting the render.")]),
];
const { engine, requests } = scripted(steps, resize);
const result = await runVisualMaker({ brief: "One precise synthetic figure." }, { engine, headless });
assert.equal(requests.length, forced ? 3 : 2);
assert.deepEqual(svgCalls, [SVG]);
assertProcessedImage(requests.at(-1)!, expected, bytes);
assert.equal(result.svg, SVG);
assert.match(result.notes!, /^Confirmed after inspecting/);
assert.equal(requests.at(-1)!.messages.at(-1)!.role, "user");
if (forced) {
assert.deepEqual(images(requests[1]!), [], "premature finish did not see an image yet");
assert.match(text(requests[2]!), /You finished without looking at a render of your final SVG/);
} else {
assert.match(text(requests[1]!), /Here is the current render of your SVG/);
assert.equal(requests[1]!.messages.at(-2)!.role, "toolResult");
}
});
}
}
for (const forced of [false, true]) {
test(`visual maker ${forced ? "forced-final" : "explicit render_svg"} processing failure never verifies or delivers the SVG`, async () => {
const { headless, svgCalls } = fakeHeadless(Buffer.from("corrupt render"));
const steps: FauxResponseStep[] = [
fauxAssistantMessage([
fauxToolCall("write_svg", { svg: SVG }, { id: "write-1" }),
...(!forced ? [fauxToolCall("render_svg", {}, { id: "render-1" })] : []),
], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Unverified claim that the picture is correct.")]),
];
const { engine, requests } = scripted(steps);
const activity: string[] = [];
const result = await runVisualMaker({ brief: "One synthetic figure." }, { engine, headless, onActivity: (line) => activity.push(line) });
assert.equal(requests.length, 2);
assert.ok(requests.every((request) => images(request).length === 0));
assert.equal(result.svg, "");
assert.match(result.notes!, /^FAILED: inspecting the final SVG failed/);
assert.match(result.notes!, /Image omitted:/);
assert.ok(!activity.some((line) => line.includes("inspecting")), "a failed image is never marked inspected");
assert.equal(svgCalls.length, forced ? 1 : 2, "an explicit processing failure leaves the final-SVG gate unsatisfied");
if (!forced) assert.match(text(requests[1]!), /RENDER ERROR.*Image omitted:/);
});
}
+6 -1
View File
@@ -28,6 +28,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { Value } from "typebox/value";
import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseStep, type ToolCall } from "@earendil-works/pi-ai";
import { renderToStaticMarkup } from "react-dom/server";
@@ -171,7 +172,11 @@ function fakeHeadless(opts: {
if (opts.renderFailures && api.svgRenders.length <= opts.renderFailures) {
throw new Error("XML parse error");
}
return Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); // PNG magic
// The shared native image processor decodes renders, so a signature
// alone is no longer a successful fake raster.
const canvas = createCanvas(1, 1);
canvas.getContext("2d").fillRect(0, 0, 1, 1);
return canvas.toBuffer("image/png");
},
};
return api as HeadlessApi & { demoRuns: string[]; svgRenders: string[] };
+357
View File
@@ -0,0 +1,357 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { Worker } from "node:worker_threads";
import { createCanvas } from "@napi-rs/canvas";
import * as photon from "@silvia-odwyer/photon-node";
import {
detectSupportedImageMimeType,
detectSupportedImageMimeTypeFromFile,
processImage,
} from "../src/agent/image-processing/index.ts";
import { applyExifOrientation } from "../src/agent/image-processing/exif-orientation.mjs";
import { resizeImageInProcess } from "../src/agent/image-processing/resize-core.mjs";
import { resizeImage } from "../src/agent/image-processing/resize.ts";
// Synthetic, offline fixtures only. Canvas generates inputs; Photon is the
// actual processing implementation and supplies independent encoding oracles.
function png(width = 96, height = 96, noise = false): Buffer {
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
if (noise) {
const pixels = context.createImageData(width, height);
let state = 0x12345678;
for (let i = 0; i < pixels.data.length; i += 4) {
for (let channel = 0; channel < 3; channel++) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
pixels.data[i + channel] = state >>> 24;
}
pixels.data[i + 3] = 255;
}
context.putImageData(pixels, 0, 0);
} else {
context.fillStyle = "#33aadd";
context.fillRect(0, 0, width, height);
}
return canvas.toBuffer("image/png");
}
function encodings(bytes: Uint8Array, width: number, height: number, qualities = [80, 85, 70, 55, 40]) {
const image = photon.PhotonImage.new_from_byteslice(bytes);
const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
try {
return [
{ mimeType: "image/png", data: Buffer.from(resized.get_bytes()).toString("base64") },
...qualities.map((quality) => ({ mimeType: "image/jpeg", data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64") })),
];
} finally {
resized.free();
image.free();
}
}
function bmp(): Buffer {
// A 2x1, uncompressed, 24-bit Windows BMP (padded scanline).
const bytes = Buffer.alloc(62);
bytes.write("BM");
bytes.writeUInt32LE(bytes.length, 2);
bytes.writeUInt32LE(54, 10);
bytes.writeUInt32LE(40, 14);
bytes.writeInt32LE(2, 18);
bytes.writeInt32LE(1, 22);
bytes.writeUInt16LE(1, 26);
bytes.writeUInt16LE(24, 28);
bytes.writeUInt32LE(8, 34);
bytes.set([0, 0, 255, 0, 255, 0], 54);
return bytes;
}
function exifJpeg(orientation: number): Buffer {
const image = photon.PhotonImage.new_from_byteslice(png(12, 8));
let jpeg: Buffer;
try {
jpeg = Buffer.from(image.get_bytes_jpeg(80));
} finally {
image.free();
}
const exif = Buffer.alloc(36);
exif.set([0xff, 0xe1, 0, 34]);
exif.write("Exif\0\0", 4, "binary");
exif.write("II", 10);
exif.writeUInt16LE(42, 12);
exif.writeUInt32LE(8, 14);
exif.writeUInt16LE(1, 18);
exif.writeUInt16LE(0x0112, 20);
exif.writeUInt16LE(3, 22);
exif.writeUInt32LE(1, 24);
exif.writeUInt16LE(orientation, 28);
return Buffer.concat([jpeg.subarray(0, 2), exif, jpeg.subarray(2)]);
}
test("already-fit image bytes are unchanged, MIME aliases normalize, and transfer leaves input intact", async () => {
const bytes = png();
const original = Buffer.from(bytes);
const result = await processImage(bytes, " Image/PNG; charset=binary ");
assert.deepEqual(result, { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
assert.deepEqual(bytes, original);
const jpeg = exifJpeg(1);
assert.deepEqual(await processImage(jpeg, "image/jpg"), { ok: true, data: jpeg.toString("base64"), mimeType: "image/jpeg", hints: [] });
});
test("native default dimensions are 2000x2000 and dimension hints use the native mapping", async () => {
const bytes = png(12000, 24);
const result = await processImage(bytes, "image/png");
assert.ok(result.ok);
assert.equal(result.mimeType, "image/png");
assert.deepEqual(result.hints, ["[Image: original 12000x24, displayed at 2000x4. Multiply coordinates by 6.00 to map to original image.]"]);
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
try {
assert.equal(decoded.get_width(), 2000);
assert.equal(decoded.get_height(), 4);
} finally { decoded.free(); }
const tall = await resizeImage(png(24, 12000), "image/png");
assert.ok(tall);
assert.equal(tall.width, 4);
assert.equal(tall.height, 2000);
});
test("model dimensions use sequential rounding, not a one-step scale", async () => {
const result = await resizeImage(png(301, 503), "image/png", { maxWidth: 101, maxHeight: 100 });
assert.ok(result);
// 301x503 -> 101x169 -> 60x100.
assert.equal(result.width, 60);
assert.equal(result.height, 100);
assert.equal(result.data, encodings(png(301, 503), 60, 100)[0]!.data);
const rounding = await resizeImage(png(50, 77), "image/png", { maxWidth: 10, maxHeight: 10 });
assert.ok(rounding);
assert.equal(rounding.width, 7); // round(100 / round(77 * 10 / 50)); one-step gives 6.
assert.equal(rounding.height, 10);
});
test("native cap is strict and measures base64 bytes, not binary bytes", async () => {
const source = png(24, 24);
// A valid PNG with ignored trailing bytes: under 4.5 MiB binary, exactly
// 4.5 MiB base64. At equality the unchanged fast path must NOT run.
const padded = Buffer.alloc((4.5 * 1024 * 1024 / 4) * 3);
source.copy(padded);
assert.ok(padded.length < 4.5 * 1024 * 1024);
assert.equal(padded.toString("base64").length, 4.5 * 1024 * 1024);
const exact = await processImage(padded, "image/png");
assert.ok(exact.ok);
assert.notEqual(exact.data, padded.toString("base64"));
assert.ok(exact.data.length < 4.5 * 1024 * 1024);
assert.equal(exact.hints.length, 1, "native re-encoding is marked resized even at identical dimensions");
const justBelow = padded.subarray(0, padded.length - 3);
const below = await processImage(justBelow, "image/png");
assert.ok(below.ok);
assert.equal(below.data, justBelow.toString("base64"));
assert.deepEqual(below.hints, []);
});
test("PNG wins first under cap even when JPEG is smaller", async () => {
const bytes = png(96, 96, true);
const candidates = encodings(bytes, 95, 95);
assert.ok(candidates[0]!.data.length > candidates[1]!.data.length);
const result = await resizeImage(bytes, "image/png", { maxWidth: 95, maxBytes: candidates[0]!.data.length + 1 });
assert.ok(result);
assert.equal(result.mimeType, "image/png");
assert.equal(result.data, candidates[0]!.data);
});
test("JPEG quality selection is native first-fit [80,85,70,55,40], not smallest output", async () => {
const bytes = png(96, 96, true);
const candidates = encodings(bytes, 96, 96);
assert.ok(candidates[0]!.data.length > candidates[2]!.data.length);
assert.ok(candidates[2]!.data.length > candidates[1]!.data.length);
assert.ok(candidates[1]!.data.length > candidates[3]!.data.length);
const at80 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length + 1 });
assert.ok(at80);
assert.equal(at80.data, candidates[1]!.data);
// Strict equality excludes 80; 85 is larger, so 70 is the next fit.
const at70 = await resizeImage(bytes, "image/png", { maxBytes: candidates[1]!.data.length });
assert.ok(at70);
assert.equal(at70.data, candidates[3]!.data);
assert.equal(at70.width, 96);
assert.equal(at70.height, 96);
for (const [index, quality] of [[4, 55], [5, 40]] as const) {
const candidate = candidates[index]!;
const cap = candidate.data.length + 1;
assert.ok(candidates.slice(0, index).every((earlier) => earlier.data.length >= cap));
const lowerQuality = await resizeImage(bytes, "image/png", { maxBytes: cap });
assert.ok(lowerQuality);
assert.equal(lowerQuality.data, candidate.data, `native quality ${quality} wins when preceding candidates are over cap`);
assert.equal(lowerQuality.width, 96);
assert.equal(lowerQuality.height, 96);
}
const override = await resizeImage(bytes, "image/png", { jpegQuality: 85, maxBytes: candidates[2]!.data.length + 1 });
assert.ok(override);
assert.equal(override.data, candidates[2]!.data, "caller quality precedes the native remaining quality steps");
});
test("all encodings over cap reduce each dimension by floor(0.75), eventually omitting at 1x1", async () => {
const bytes = png(97, 95, true);
const full = encodings(bytes, 97, 95);
const next = encodings(bytes, 72, 71);
const maxBytes = Math.min(...full.map((candidate) => candidate.data.length));
const expected = next.find((candidate) => candidate.data.length < maxBytes);
assert.ok(expected, "synthetic fixture fits on the first reduced dimensions");
const result = await resizeImage(bytes, "image/png", { maxBytes });
assert.ok(result);
assert.equal(result.width, 72);
assert.equal(result.height, 71);
assert.equal(result.mimeType, expected.mimeType);
assert.equal(result.data, expected.data);
assert.equal(await resizeImage(bytes, "image/png", { maxBytes: 1 }), null);
const omitted = await processImage(bytes, "image/png", { resizeOptions: { maxBytes: 1 } });
assert.deepEqual(omitted, { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
});
test("BMP converts to PNG and reports the native conversion hint", async () => {
const bytes = bmp();
assert.equal(detectSupportedImageMimeType(bytes), "image/bmp");
const result = await processImage(bytes, "IMAGE/BMP");
assert.ok(result.ok);
assert.equal(result.mimeType, "image/png");
assert.equal(detectSupportedImageMimeType(Buffer.from(result.data, "base64")), "image/png");
assert.deepEqual(result.hints, ["[Image converted from image/bmp to image/png.]"]);
const decoded = photon.PhotonImage.new_from_byteslice(Buffer.from(result.data, "base64"));
try {
assert.equal(decoded.get_width(), 2);
assert.equal(decoded.get_height(), 1);
} finally { decoded.free(); }
});
test("EXIF-oriented dimensions govern resizing, with native unchanged-byte behavior when already fit", async () => {
for (const orientation of [2, 3, 4, 5, 6, 7, 8]) {
const bytes = exifJpeg(orientation);
const untouched = await resizeImage(bytes, "image/jpeg");
assert.ok(untouched);
assert.equal(untouched.data, bytes.toString("base64"));
assert.equal(untouched.originalWidth, orientation >= 5 ? 8 : 12);
assert.equal(untouched.originalHeight, orientation >= 5 ? 12 : 8);
const resized = await resizeImage(bytes, "image/jpeg", { maxWidth: 6, maxHeight: 6 });
assert.ok(resized);
assert.equal(resized.width, orientation >= 5 ? 4 : 6);
assert.equal(resized.height, orientation >= 5 ? 6 : 4);
}
});
test("EXIF mirrors and rotations preserve exact pixel placement for JPEG and both WebP EXIF layouts", () => {
const expected = [
[1, 2, 3, 4, 5, 6], // unchanged
[3, 2, 1, 6, 5, 4], // horizontal mirror
[6, 5, 4, 3, 2, 1], // 180 degrees
[4, 5, 6, 1, 2, 3], // vertical mirror
[1, 4, 2, 5, 3, 6], // transpose
[4, 1, 5, 2, 6, 3], // 90 degrees clockwise
[6, 3, 5, 2, 4, 1], // transverse
[3, 6, 2, 5, 1, 4], // 90 degrees counterclockwise
];
for (let orientation = 1; orientation <= 8; orientation++) {
const jpeg = exifJpeg(orientation);
// Only metadata is needed here: decoding is covered by the resize test.
const exif = jpeg.subarray(6, 38);
for (const metadata of [jpeg, ...[exif, exif.subarray(6)].map((data) => {
// Odd-size RIFF chunk before EXIF verifies native even-byte padding.
const webp = Buffer.alloc(12 + 10 + 8 + data.length);
webp.write("RIFF", 0);
webp.writeUInt32LE(webp.length - 8, 4);
webp.write("WEBP", 8);
webp.write("JUNK", 12);
webp.writeUInt32LE(1, 16);
webp.write("EXIF", 22);
webp.writeUInt32LE(data.length, 26);
data.copy(webp, 30);
return webp;
})]) {
const pixels = Uint8Array.from([1, 2, 3, 4, 5, 6].flatMap((value) => [value, 0, 0, 255]));
const raw = new photon.PhotonImage(pixels, 3, 2);
const oriented = applyExifOrientation(photon, raw, metadata);
try {
assert.equal(oriented.get_width(), orientation >= 5 ? 2 : 3);
assert.equal(oriented.get_height(), orientation >= 5 ? 3 : 2);
assert.deepEqual(Array.from(oriented.get_raw_pixels()).filter((_, index) => index % 4 === 0), expected[orientation - 1]);
} finally {
if (oriented !== raw) oriented.free();
raw.free();
}
}
}
});
test("corrupt supported input is omitted, unsupported conversion fails, and explicit autoResizeImages=false retains native bypass", async () => {
const bytes = Buffer.from("not an image");
assert.deepEqual(await processImage(bytes, "image/png"), { ok: false, message: "[Image omitted: could not be resized below the inline image size limit.]" });
assert.deepEqual(await processImage(bytes, "image/bmp"), { ok: false, message: "[Image omitted: could not be converted to a supported inline image format.]" });
assert.deepEqual(await processImage(bytes, "image/png", { autoResizeImages: false }), { ok: true, data: bytes.toString("base64"), mimeType: "image/png", hints: [] });
const wide = png(12000, 24);
const bypass = await processImage(wide, "image/png", { autoResizeImages: false });
assert.ok(bypass.ok);
assert.equal(bypass.data, wide.toString("base64"));
const converted = await processImage(bmp(), "image/bmp", { autoResizeImages: false });
assert.ok(converted.ok);
assert.equal(converted.mimeType, "image/png");
});
test("MIME sniffing matches native supported signatures and rejects APNG/JPEG-LS/malformed BMP", async () => {
assert.equal(detectSupportedImageMimeType(png()), "image/png");
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg");
assert.equal(detectSupportedImageMimeType(Buffer.from([0xff, 0xd8, 0xff, 0xf7])), null);
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF89a")), "image/gif");
assert.equal(detectSupportedImageMimeType(Buffer.from("GIF87a")), "image/gif");
assert.equal(detectSupportedImageMimeType(Buffer.from("RIFFxxxxWEBP")), "image/webp");
assert.equal(detectSupportedImageMimeType(Buffer.from("BM")), null);
assert.equal(detectSupportedImageMimeType(Buffer.from("not an image")), null);
const truncatedPng = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
assert.equal(detectSupportedImageMimeType(truncatedPng), null);
// MIME sniffing, like Pi, walks chunk headers without checking CRCs.
const source = png(2, 2);
const acTL = Buffer.alloc(20);
acTL.writeUInt32BE(8, 0);
acTL.write("acTL", 4);
const apng = Buffer.concat([source.subarray(0, 33), acTL, source.subarray(33)]);
assert.equal(detectSupportedImageMimeType(apng), null);
const badBmp = bmp();
badBmp.writeUInt16LE(2, 26);
assert.equal(detectSupportedImageMimeType(badBmp), null);
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-processing-"));
try {
await writeFile(path.join(dir, "renamed.txt"), source);
await writeFile(path.join(dir, "animated.png"), apng);
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "renamed.txt")), "image/png");
assert.equal(await detectSupportedImageMimeTypeFromFile(path.join(dir, "animated.png")), null);
await assert.rejects(detectSupportedImageMimeTypeFromFile(path.join(dir, "missing")), { code: "ENOENT" });
} finally { await rm(dir, { recursive: true, force: true }); }
});
test("unbundled Node worker responds with tagged output and matches the in-process fallback", async () => {
const bytes = png(96, 96, true);
const options = { maxWidth: 64, maxHeight: 64, maxBytes: 4000 };
const worker = new Worker(path.join(process.cwd(), "src/agent/image-processing/resize-worker.mjs"));
try {
const reply = new Promise<{ type: string; result: unknown }>((resolve, reject) => {
worker.once("message", resolve);
worker.once("error", reject);
});
worker.postMessage({ inputBytes: new Uint8Array(bytes), mimeType: "image/png", options });
assert.deepEqual(await reply, { type: "pi:image-resize-response", result: await resizeImageInProcess(bytes, "image/png", options) });
assert.deepEqual(await resizeImage(bytes, "image/png", options), await resizeImageInProcess(bytes, "image/png", options));
} finally { await worker.terminate(); }
});
test("missing cwd-relative worker falls back in-process without altering the caller bytes", async () => {
const cwd = process.cwd();
const dir = await mkdtemp(path.join(os.tmpdir(), "lh-image-worker-fallback-"));
const bytes = png();
const options = { maxWidth: 64, maxHeight: 64 };
const expected = await resizeImageInProcess(bytes, "image/png", options);
try {
process.chdir(dir);
assert.deepEqual(await resizeImage(bytes, "image/png", options), expected);
} finally {
process.chdir(cwd);
await rm(dir, { recursive: true, force: true });
}
});
+105 -1
View File
@@ -8,9 +8,10 @@
*/
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 { realHeadless } from "../src/agent/subagents/headless.ts";
import { MAX_PDF_CANVAS_PIXELS, MAX_PDF_CANVAS_SIDE, pdfCanvasGeometry, realHeadless } from "../src/agent/subagents/headless.ts";
import { standardFontsDir } from "../src/agent/subagents/pdf-ops.ts";
const FIXTURE = path.join(import.meta.dirname, "fixtures", "sample-3page.pdf");
@@ -31,6 +32,83 @@ function pngFacts(buf: Buffer): { w: number; h: number; idats: number } {
return { w, h, idats };
}
/** Synthetic page only — no personal PDF or external fixture generator. */
function oversizedPdf(): Buffer {
const content = "0 0 1 rg 0 0 20000 10000 re f\n";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20000 10000] /Rotate 90 /Resources << >> /Contents 4 0 R >>",
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}endstream`,
];
let pdf = "%PDF-1.7\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(pdf);
}
describe("PDF pre-allocation canvas geometry", () => {
it("uses decimal 16 megapixels and an 8192-pixel side limit", () => {
assert.equal(MAX_PDF_CANVAS_PIXELS, 16_000_000);
assert.equal(MAX_PDF_CANVAS_SIDE, 8192);
});
it("preserves ordinary requested DPI, including fractional ceil dimensions and low DPI", () => {
for (const dpi of [11, 72, 144, 150, 300]) {
const geometry = pdfCanvasGeometry(595.28, 841.89, dpi);
assert.deepEqual(geometry, {
scale: dpi / 72,
width: Math.ceil(595.28 * dpi / 72),
height: Math.ceil(841.89 * dpi / 72),
});
}
});
it("bounds extreme aspect ratios, huge geometry, sides, and rounded pixel area without upscaling", () => {
const cases: readonly (readonly [number, number, number])[] = [
[1e12, 0.000001, 300],
[0.000001, 1e12, 300],
[1e308, 1e308, Number.MAX_VALUE],
[8192.000001, 10, 72],
[6100, 3900, 72], // ceil at the area-limited scale exceeds 16MP
[4000.000001, 4000.000001, 72],
[4000, 4000, 72], // exactly 16MP stays unchanged
];
for (const [width, height, dpi] of cases) {
const result = pdfCanvasGeometry(width, height, dpi);
assert.ok(result.scale > 0 && result.scale <= dpi / 72);
assert.equal(result.width, Math.max(1, Math.ceil(width * result.scale)));
assert.equal(result.height, Math.max(1, Math.ceil(height * result.scale)));
assert.ok(result.width <= MAX_PDF_CANVAS_SIDE && result.height <= MAX_PDF_CANVAS_SIDE);
assert.ok(result.width * result.height <= MAX_PDF_CANVAS_PIXELS);
}
assert.deepEqual(pdfCanvasGeometry(4000, 4000, 72), { scale: 1, width: 4000, height: 4000 });
});
it("uses rotated viewport dimensions symmetrically", () => {
const normal = pdfCanvasGeometry(20000, 10000, 144);
const rotated = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(rotated.scale, normal.scale);
assert.equal(rotated.width, normal.height);
assert.equal(rotated.height, normal.width);
});
it("rejects nonfinite or nonpositive dimensions and DPI cleanly", () => {
for (const invalid of [0, -1, NaN, Infinity, -Infinity]) {
assert.throws(() => pdfCanvasGeometry(invalid, 792, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, invalid, 72), /dimensions must be finite and positive/);
assert.throws(() => pdfCanvasGeometry(612, 792, invalid), /DPI must be finite and positive/);
}
});
});
describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
it("the shared font directory ends in pdfjs's required slash on every OS", () => {
const fonts = standardFontsDir();
@@ -63,6 +141,32 @@ describe("native pdf_render (pdfjs paints, @napi-rs/canvas hosts)", () => {
assert.ok(Math.abs(fb.h - 2 * fa.h) <= 2, `144dpi height ≈ 2× 72dpi (${fa.h} → ${fb.h})`);
});
it("bounds a synthetic oversized rotated PDF before native canvas allocation", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pdf-canvas-test-"));
try {
const fixture = path.join(dir, "oversized.pdf");
fs.writeFileSync(fixture, oversizedPdf());
const [page] = await realHeadless.renderPdfPages(fixture, [1], { dpi: 144 });
assert.ok(page);
const facts = pngFacts(page.png);
const expected = pdfCanvasGeometry(10000, 20000, 144);
assert.equal(facts.w, expected.width);
assert.equal(facts.h, expected.height);
assert.ok(facts.h > facts.w, "PDF rotation is included before scaling");
assert.ok(facts.w <= 8192 && facts.h <= 8192);
assert.ok(facts.w * facts.h <= 16_000_000);
assert.equal(facts.idats, 1, "raw renderer still returns canonical PNG");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("rejects invalid DPI before opening the PDF", async () => {
for (const dpi of [0, -1, NaN, Infinity, -Infinity, Number.MIN_VALUE]) {
await assert.rejects(realHeadless.renderPdfPages("unused.pdf", [1], { dpi }), /DPI must be finite and positive/);
}
});
it("skips out-of-range page numbers silently", async () => {
const pages = await realHeadless.renderPdfPages(FIXTURE, [0, 2, 99], { dpi: 72 });
assert.deepEqual(
+3
View File
@@ -45,6 +45,9 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
"node_modules/pdfjs-dist/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*",
"node_modules/@napi-rs/canvas-*/**/*",
"src/agent/image-processing/*.mjs",
"src/agent/image-processing/LICENSE",
"node_modules/@silvia-odwyer/photon-node/**/*",
"node_modules/@resvg/resvg-js-*/**/*",
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
+7 -3
View File
@@ -10,10 +10,12 @@ 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, describe, it } from "node:test";
import { createCanvas } from "@napi-rs/canvas";
import { readTool } from "../src/agent/tools/read.ts";
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-read-test-"));
after(() => fs.rmSync(tmp, { recursive: true, force: true }));
function write(name: string, content: string): string {
const file = path.join(tmp, name);
@@ -100,8 +102,10 @@ describe("read tool — text semantics", () => {
describe("read tool — image semantics (native content parts)", () => {
it("returns native text + image parts for image files, details null (output-less part)", async () => {
const file = path.join(tmp, "dot.png");
// minimal valid PNG header bytes are enough — detection is by extension
fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
// Detection now sniffs bytes and the native processor decodes the image.
const canvas = createCanvas(1, 1);
canvas.getContext("2d").fillRect(0, 0, 1, 1);
fs.writeFileSync(file, canvas.toBuffer("image/png"));
const result = await readTool.execute!("test", { path: file });
assert.equal(result.content.length, 2);
const parts = result.content as Array<{ type: string; text?: string; data?: string; mimeType?: string }>;