Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
138 lines
5.0 KiB
TypeScript
138 lines
5.0 KiB
TypeScript
import { crc32, deflateSync } from "node:zlib";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { ToolResultContent } from "../src/utils/tool-result-images.ts";
|
|
import { normalizeToolResultImages } from "../src/utils/tool-result-images.ts";
|
|
|
|
const TINY_PNG_BASE64 =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
|
|
|
function pngChunk(type: string, body: Buffer): Buffer {
|
|
const header = Buffer.alloc(8);
|
|
header.writeUInt32BE(body.length, 0);
|
|
header.write(type, 4, "ascii");
|
|
const checksum = Buffer.alloc(4);
|
|
checksum.writeUInt32BE(crc32(Buffer.concat([header.subarray(4), body])), 0);
|
|
return Buffer.concat([header, body, checksum]);
|
|
}
|
|
|
|
/** Build an 8-bit grayscale PNG of arbitrary dimensions without pulling in an encoder. */
|
|
function createPng(width: number, height: number): Buffer {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(width, 0);
|
|
ihdr.writeUInt32BE(height, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 0; // color type: grayscale
|
|
const raw = Buffer.alloc((width + 1) * height);
|
|
for (let row = 0; row < height; row++) {
|
|
raw.fill(row % 256, row * (width + 1) + 1, (row + 1) * (width + 1));
|
|
}
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
pngChunk("IHDR", ihdr),
|
|
pngChunk("IDAT", deflateSync(raw)),
|
|
pngChunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
function readPngDimensions(base64Data: string): { width: number; height: number } {
|
|
const buffer = Buffer.from(base64Data, "base64");
|
|
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
}
|
|
|
|
function createTinyBmp1x1Red24bpp(): Buffer {
|
|
const buffer = Buffer.alloc(58);
|
|
buffer.write("BM", 0, "ascii");
|
|
buffer.writeUInt32LE(buffer.length, 2);
|
|
buffer.writeUInt32LE(54, 10);
|
|
buffer.writeUInt32LE(40, 14);
|
|
buffer.writeInt32LE(1, 18);
|
|
buffer.writeInt32LE(1, 22);
|
|
buffer.writeUInt16LE(1, 26);
|
|
buffer.writeUInt16LE(24, 28);
|
|
buffer.writeUInt32LE(0, 30);
|
|
buffer.writeUInt32LE(4, 34);
|
|
buffer[56] = 0xff;
|
|
return buffer;
|
|
}
|
|
|
|
function imageBlock(bytes: Buffer, mimeType: string): ToolResultContent {
|
|
return { type: "image", data: bytes.toString("base64"), mimeType };
|
|
}
|
|
|
|
describe("normalizeToolResultImages", () => {
|
|
it("returns the original array when there are no image blocks", async () => {
|
|
const content: ToolResultContent[] = [{ type: "text", text: "no images here" }];
|
|
|
|
expect(await normalizeToolResultImages(content)).toBe(content);
|
|
});
|
|
|
|
it("returns the original array when images are already within limits", async () => {
|
|
const content: ToolResultContent[] = [
|
|
{ type: "text", text: "screenshot" },
|
|
{ type: "image", data: TINY_PNG_BASE64, mimeType: "image/png" },
|
|
];
|
|
|
|
expect(await normalizeToolResultImages(content)).toBe(content);
|
|
});
|
|
|
|
it("resizes oversized images and reports the original dimensions", async () => {
|
|
const content: ToolResultContent[] = [imageBlock(createPng(2400, 4800), "image/png")];
|
|
|
|
const normalized = await normalizeToolResultImages(content);
|
|
|
|
expect(normalized).not.toBe(content);
|
|
expect(normalized).toHaveLength(2);
|
|
const image = normalized[0];
|
|
expect(image.type).toBe("image");
|
|
if (image.type !== "image") return;
|
|
const { width, height } = readPngDimensions(image.data);
|
|
expect(width).toBeLessThanOrEqual(2000);
|
|
expect(height).toBeLessThanOrEqual(2000);
|
|
const note = normalized[1];
|
|
expect(note.type).toBe("text");
|
|
if (note.type !== "text") return;
|
|
expect(note.text).toContain("original 2400x4800");
|
|
});
|
|
|
|
it("leaves oversized images alone when auto-resize is disabled", async () => {
|
|
const content: ToolResultContent[] = [imageBlock(createPng(2400, 4800), "image/png")];
|
|
|
|
const normalized = await normalizeToolResultImages(content, { autoResizeImages: false });
|
|
|
|
expect(normalized).toBe(content);
|
|
});
|
|
|
|
it("converts unsupported image formats even when auto-resize is disabled", async () => {
|
|
const content: ToolResultContent[] = [imageBlock(createTinyBmp1x1Red24bpp(), "image/bmp")];
|
|
|
|
const normalized = await normalizeToolResultImages(content, { autoResizeImages: false });
|
|
|
|
expect(normalized).not.toBe(content);
|
|
expect(normalized[0]).toMatchObject({ type: "image", mimeType: "image/png" });
|
|
expect(normalized[1]).toMatchObject({
|
|
type: "text",
|
|
text: "[Image converted from image/bmp to image/png.]",
|
|
});
|
|
});
|
|
|
|
it("keeps undecodable images instead of dropping tool output", async () => {
|
|
const content: ToolResultContent[] = [{ type: "image", data: "bm90LWFuLWltYWdl", mimeType: "image/png" }];
|
|
|
|
expect(await normalizeToolResultImages(content)).toBe(content);
|
|
});
|
|
|
|
it("preserves surrounding text blocks and their order", async () => {
|
|
const content: ToolResultContent[] = [
|
|
{ type: "text", text: "before" },
|
|
imageBlock(createPng(2400, 100), "image/png"),
|
|
{ type: "text", text: "after" },
|
|
];
|
|
|
|
const normalized = await normalizeToolResultImages(content);
|
|
|
|
expect(normalized.map((block) => block.type)).toEqual(["text", "image", "text", "text"]);
|
|
expect(normalized[0]).toEqual({ type: "text", text: "before" });
|
|
expect(normalized[3]).toEqual({ type: "text", text: "after" });
|
|
});
|
|
});
|