a798bca87f
Co-authored-by: Yaowei Zheng <hiyouga@buaa.edu.cn> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
210 lines
8.8 KiB
TypeScript
210 lines
8.8 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
TRUNCATED_TOOL_OUTPUT_FILE_LIMIT_BYTES,
|
|
TruncatedToolOutputArchive,
|
|
} from "../src/environment/truncated-tool-output-archive.js";
|
|
|
|
let tmp: string;
|
|
|
|
beforeEach(async () => {
|
|
tmp = await mkdtemp(path.join(tmpdir(), "penguin-truncated-tool-output-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
|
});
|
|
|
|
describe("TruncatedToolOutputArchive", () => {
|
|
it("pins the production per-call budget", () => {
|
|
expect(TRUNCATED_TOOL_OUTPUT_FILE_LIMIT_BYTES).toBe(8 * 1024 * 1024 - 1);
|
|
});
|
|
|
|
it("writes an exact UTF-8 log with private permissions and leaves it Session-owned", async () => {
|
|
const ordinaryDir = path.join(tmp, "ordinary-session-dir");
|
|
const sessionDir = path.join(tmp, "archive-session-dir");
|
|
await mkdir(ordinaryDir);
|
|
const archive = new TruncatedToolOutputArchive({
|
|
rootDir: path.join(sessionDir, "output"),
|
|
fileLimitBytes: 128,
|
|
});
|
|
const capture = archive.startCapture();
|
|
capture.append("hello ");
|
|
capture.append("企鹅");
|
|
// Split one surrogate pair across deltas: the recovery file must reconstruct the same text
|
|
// instead of serializing two replacement characters.
|
|
capture.append("\ud83d");
|
|
capture.append("\udc27");
|
|
// If another high surrogate arrives first, only the old one is known to be lone; the new one
|
|
// must remain pending so it can still pair with the following low surrogate.
|
|
capture.append("|");
|
|
capture.append("\ud83d");
|
|
capture.append("\ud83d");
|
|
capture.append("\udc27");
|
|
|
|
const retained = capture as unknown as { exactChunks: Buffer[] };
|
|
expect(retained.exactChunks.length).toBeGreaterThan(0);
|
|
expect(retained.exactChunks.every((chunk) => Buffer.isBuffer(chunk))).toBe(true);
|
|
|
|
const saved = await capture.save("exec_command", "call/private");
|
|
expect(saved.status).toBe("saved");
|
|
if (saved.status !== "saved") throw new Error("expected saved output");
|
|
expect(saved.archiveTruncated).toBe(false);
|
|
expect(await readFile(saved.path, "utf8")).toBe("hello 企鹅🐧|�🐧");
|
|
if (process.platform !== "win32") {
|
|
expect((await stat(saved.path)).mode & 0o777).toBe(0o600);
|
|
expect((await stat(path.dirname(saved.path))).mode & 0o777).toBe(0o700);
|
|
expect((await stat(sessionDir)).mode & 0o777).toBe((await stat(ordinaryDir)).mode & 0o777);
|
|
}
|
|
|
|
// The archive owns no Task/runtime cleanup. The host removes this whole directory through
|
|
// the existing Session scratchpad deletion path.
|
|
expect(await readFile(saved.path, "utf8")).toContain("hello");
|
|
});
|
|
|
|
it("keeps UTF-8-safe head and tail windows when one archive exceeds its file budget", async () => {
|
|
const fileLimitBytes = 96;
|
|
const archive = new TruncatedToolOutputArchive({
|
|
rootDir: path.join(tmp, "output"),
|
|
fileLimitBytes,
|
|
});
|
|
const capture = archive.startCapture();
|
|
const source = `HEAD-${"企鹅🐧".repeat(80)}-TAIL`;
|
|
// Exercise incremental rolling-tail updates rather than one monolithic append.
|
|
capture.append(source.slice(0, 70));
|
|
capture.append(source.slice(70, 210));
|
|
capture.append(source.slice(210));
|
|
|
|
const saved = await capture.save("describe/image", "unicode-call");
|
|
expect(saved.status).toBe("saved");
|
|
if (saved.status !== "saved") throw new Error("expected saved output");
|
|
const archived = await readFile(saved.path, "utf8");
|
|
expect(saved.archiveTruncated).toBe(true);
|
|
expect(Buffer.byteLength(archived, "utf8")).toBeLessThanOrEqual(fileLimitBytes);
|
|
expect(archived).toContain("HEAD-");
|
|
expect(archived).toContain("-TAIL");
|
|
expect(archived).toContain("[archive middle truncated]");
|
|
expect(archived).not.toContain("\uFFFD");
|
|
});
|
|
|
|
it("does not retain a huge delta's backing buffer after reducing it to bounded windows", async () => {
|
|
const fileLimitBytes = 96;
|
|
const archive = new TruncatedToolOutputArchive({
|
|
rootDir: path.join(tmp, "output"),
|
|
fileLimitBytes,
|
|
});
|
|
const capture = archive.startCapture();
|
|
capture.append(`HEAD-${"x".repeat(16 * 1024 * 1024)}-TAIL`);
|
|
|
|
const retained = capture as unknown as {
|
|
exactChunks: Buffer[];
|
|
head: Buffer;
|
|
tail: Buffer;
|
|
tailLength: number;
|
|
};
|
|
expect(retained.exactChunks).toEqual([]);
|
|
expect(retained.head.length + retained.tailLength).toBeLessThanOrEqual(fileLimitBytes);
|
|
// A short subarray of the original 16 MiB Buffer would pass the length assertion while
|
|
// still pinning the entire ArrayBuffer. Node may use its fixed small-buffer pool for this
|
|
// tiny test override, but neither result may retain the huge source.
|
|
const largestBoundedBackingStore = Math.max(fileLimitBytes, Buffer.poolSize);
|
|
expect(retained.head.buffer.byteLength).toBeLessThanOrEqual(largestBoundedBackingStore);
|
|
expect(retained.tail.buffer.byteLength).toBeLessThanOrEqual(largestBoundedBackingStore);
|
|
|
|
const saved = await capture.save("tool", "huge-single-delta");
|
|
expect(saved.status).toBe("saved");
|
|
if (saved.status !== "saved") throw new Error("expected saved output");
|
|
const archived = await readFile(saved.path, "utf8");
|
|
expect(Buffer.byteLength(archived, "utf8")).toBeLessThanOrEqual(fileLimitBytes);
|
|
expect(archived).toContain("HEAD-");
|
|
expect(archived).toContain("-TAIL");
|
|
});
|
|
|
|
it("reuses fixed tail storage across many small post-promotion deltas", async () => {
|
|
const archive = new TruncatedToolOutputArchive({
|
|
rootDir: path.join(tmp, "output"),
|
|
fileLimitBytes: 128,
|
|
});
|
|
const capture = archive.startCapture();
|
|
capture.append("H".repeat(256));
|
|
const retained = capture as unknown as {
|
|
tail: Buffer;
|
|
tailLength: number;
|
|
};
|
|
const tailStorage = retained.tail;
|
|
// Vary encoded widths and split emoji pairs across deltas so ring wrap-around must preserve
|
|
// the same UTF-8 and surrogate semantics as the exact phase.
|
|
const pattern = ["企", "鹅", "\ud83d", "\udc27", "|"];
|
|
const chunks = Array.from({ length: 4096 }, (_, index) => pattern[index % pattern.length]!);
|
|
for (const chunk of chunks) capture.append(chunk);
|
|
|
|
expect(retained.tail).toBe(tailStorage);
|
|
expect(retained.tailLength).toBeLessThanOrEqual(tailStorage.length);
|
|
|
|
const saved = await capture.save("tool", "many-small-deltas");
|
|
expect(saved.status).toBe("saved");
|
|
if (saved.status !== "saved") throw new Error("expected saved output");
|
|
const archived = await readFile(saved.path, "utf8");
|
|
const archivedTail = archived.split("[archive middle truncated]\n")[1] ?? "";
|
|
expect(chunks.join("").endsWith(archivedTail)).toBe(true);
|
|
expect(archived).not.toContain("\uFFFD");
|
|
});
|
|
|
|
it("allows independent captures without imposing an aggregate Session budget", async () => {
|
|
const archive = new TruncatedToolOutputArchive({
|
|
rootDir: path.join(tmp, "output"),
|
|
fileLimitBytes: 64,
|
|
});
|
|
const captures = [archive.startCapture(), archive.startCapture(), archive.startCapture()];
|
|
captures.forEach((capture, index) => capture.append(`output-${index}`));
|
|
|
|
const saved = await Promise.all(
|
|
captures.map((capture, index) => capture.save("tool", `call-${index}`)),
|
|
);
|
|
expect(saved.every((result) => result.status === "saved")).toBe(true);
|
|
});
|
|
|
|
it("reports a write failure without throwing", async () => {
|
|
const rootFile = path.join(tmp, "not-a-directory");
|
|
await writeFile(rootFile, "occupied", "utf8");
|
|
const archive = new TruncatedToolOutputArchive({
|
|
rootDir: rootFile,
|
|
fileLimitBytes: 64,
|
|
});
|
|
const capture = archive.startCapture();
|
|
capture.append("output");
|
|
const stderr: string[] = [];
|
|
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
|
|
stderr.push(String(chunk));
|
|
return true;
|
|
});
|
|
try {
|
|
const result = await capture.save("tool", "call");
|
|
expect(result.status).toBe("failed");
|
|
if (result.status !== "failed") throw new Error("expected failed output");
|
|
expect(typeof result.code).toBe("string");
|
|
expect(stderr.join("")).toContain(
|
|
`[penguin] tool "tool" truncated output archive write failed (${result.code}).`,
|
|
);
|
|
} finally {
|
|
stderrSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("distinguishes a repeated save from an archive write failure", async () => {
|
|
const archive = new TruncatedToolOutputArchive({
|
|
rootDir: path.join(tmp, "output"),
|
|
fileLimitBytes: 64,
|
|
});
|
|
const capture = archive.startCapture();
|
|
capture.append("output");
|
|
expect((await capture.save("tool", "call")).status).toBe("saved");
|
|
await expect(capture.save("tool", "call")).resolves.toEqual({
|
|
status: "failed",
|
|
code: "ALREADY_SAVED",
|
|
});
|
|
});
|
|
});
|