Skip to content

Commit d4de77f

Browse files
committed
test(read-file): prove large-file paging reaches the end
Deep pages past the scan ceiling, path+offset chains, blob resume without re-scan, overlong-line windowing, truncation pass-through, loop-detector and registry pins. All five paging tests fail before the fix; pins hold.
1 parent 6d07b36 commit d4de77f

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

‎src/plugins/read-file-guard-plugin.test.ts‎

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
readFileBounded,
1616
readFileGuardPlugin,
1717
} from "./read-file-guard-plugin.js";
18+
import { resultTruncationPlugin } from "./result-truncation-plugin.js";
1819

1920
const neverAbort = () => new AbortController().signal;
2021

@@ -291,6 +292,234 @@ describe("readFileBounded", () => {
291292
});
292293
});
293294

295+
describe("CL-8979 large-file pagination", () => {
296+
const BIG_LINES = 45_000;
297+
const bigRow = (i: number): string => `L${i}-` + "p".repeat(243);
298+
299+
async function bigFixture(name: string): Promise<string> {
300+
const rows = Array.from({ length: BIG_LINES }, (_, i) => bigRow(i));
301+
return fixture(name, `${rows.join("\n")}\n`);
302+
}
303+
304+
function continueOffset(content: string): number | null {
305+
const match = /Use offset=(\d+) to continue/.exec(content);
306+
return match === null ? null : Number(match[1]);
307+
}
308+
309+
function bodyRows(content: string): string[] {
310+
const body = content.split("\n\n")[0] ?? "";
311+
return body
312+
.split("\n")
313+
.filter((line) => line.trim().length > 0)
314+
.map((line) => line.replace(/^\s*\d+\t/, ""));
315+
}
316+
317+
function chainRunner(): (
318+
id: string,
319+
args: Record<string, unknown>,
320+
) => Promise<ToolResult> {
321+
const plugin = readFileGuardPlugin(dir, {});
322+
const middleware = plugin.middleware;
323+
if (middleware === undefined) throw new Error("expected middleware");
324+
const fallback = async (call: ToolCall): Promise<ToolResult> => ({
325+
callId: call.id,
326+
content: "FALLBACK",
327+
});
328+
return (id, args) =>
329+
middleware(fallback)(
330+
{ id, name: "read_file", arguments: args },
331+
neverAbort(),
332+
);
333+
}
334+
335+
function blobChainRunner(
336+
readBlob: (key: string) => Promise<Uint8Array>,
337+
): (id: string, args: Record<string, unknown>) => Promise<ToolResult> {
338+
const plugin = readFileGuardPlugin(dir, {
339+
blobReader: createBlobReader({ readBlob }),
340+
});
341+
const middleware = plugin.middleware;
342+
if (middleware === undefined) throw new Error("expected middleware");
343+
const fallback = async (call: ToolCall): Promise<ToolResult> => ({
344+
callId: call.id,
345+
content: "FALLBACK",
346+
});
347+
return (id, args) =>
348+
middleware(fallback)(
349+
{ id, name: "read_file", arguments: args },
350+
neverAbort(),
351+
);
352+
}
353+
354+
test("reads a deep page of a file larger than the scan ceiling", async () => {
355+
const p = await bigFixture("cl8979-big.txt");
356+
const res = await readFileBounded(p, 43_000, 5, neverAbort());
357+
expect(res.isError).toBeUndefined();
358+
expect(String(res.content)).toContain(bigRow(43_000));
359+
expect(String(res.content)).not.toContain("scan limit");
360+
});
361+
362+
test(
363+
"chains plain path+offset continuation on one path through to the last line",
364+
async () => {
365+
const name = "cl8979-chain.txt";
366+
await bigFixture(name);
367+
const run = chainRunner();
368+
const collected: string[] = [];
369+
let offset = 0;
370+
let hops = 0;
371+
for (;;) {
372+
const result = await run(`chain-${hops}`, {
373+
path: name,
374+
limit: 200,
375+
offset,
376+
});
377+
hops += 1;
378+
const content = String(result.content);
379+
expect(result.isError).toBeFalsy();
380+
expect(content).not.toContain("scan limit");
381+
collected.push(...bodyRows(content));
382+
const next = continueOffset(content);
383+
if (next === null) break;
384+
offset = next;
385+
expect(hops).toBeLessThan(2000);
386+
}
387+
expect(hops).toBeGreaterThan(1);
388+
expect(collected.length).toBe(BIG_LINES);
389+
expect(collected).toEqual(
390+
Array.from({ length: BIG_LINES }, (_, i) => bigRow(i)),
391+
);
392+
},
393+
120_000,
394+
);
395+
396+
test(
397+
"chains cursor resumption on a blob past the scan ceiling without re-scanning",
398+
async () => {
399+
const rows = Array.from({ length: BIG_LINES }, (_, i) => bigRow(i));
400+
const bytes = new TextEncoder().encode(`${rows.join("\n")}\n`);
401+
const run = blobChainRunner(async (key) => {
402+
if (key === "cl8979-blob") return bytes;
403+
throw new Error(`missing ${key}`);
404+
});
405+
const collected: string[] = [];
406+
let path = "tool-output:///cl8979-blob";
407+
let hops = 0;
408+
let sawOffsetFooter = false;
409+
let sawCursorAlias = false;
410+
for (;;) {
411+
const result = await run(`blob-${hops}`, { path, limit: 200 });
412+
hops += 1;
413+
const content = String(result.content);
414+
expect(result.isError).toBeFalsy();
415+
expect(content).not.toContain("scan limit");
416+
collected.push(...bodyRows(content));
417+
const cursor = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(content);
418+
const offset = continueOffset(content);
419+
if (offset !== null) {
420+
sawOffsetFooter = true;
421+
path = "tool-output:///cl8979-blob";
422+
}
423+
if (cursor !== null) {
424+
sawCursorAlias = true;
425+
path = cursor[1] as string;
426+
}
427+
if (offset === null && cursor === null) break;
428+
expect(hops).toBeLessThan(2000);
429+
}
430+
expect(hops).toBeGreaterThan(1);
431+
expect(sawOffsetFooter).toBe(true);
432+
expect(sawCursorAlias).toBe(true);
433+
expect(collected.length).toBe(BIG_LINES);
434+
expect(collected[BIG_LINES - 1]).toBe(bigRow(BIG_LINES - 1));
435+
},
436+
120_000,
437+
);
438+
439+
test("windows an overlong single file line so the tail is reachable", async () => {
440+
const payload = `HEAD-${"y".repeat(100_000)}-TAIL`;
441+
const p = await fixture("cl8979-giant.txt", `${payload}\nEND\n`);
442+
let offset = 0;
443+
let hops = 0;
444+
let collected = "";
445+
for (;;) {
446+
const res = await readFileBounded(p, offset, 10, neverAbort());
447+
hops += 1;
448+
expect(res.isError).toBeUndefined();
449+
const content = String(res.content);
450+
expect(content).not.toContain("line truncated");
451+
collected += `${content}\n`;
452+
const rows = bodyRows(content);
453+
expect(rows.length).toBeGreaterThan(1);
454+
const next = continueOffset(content);
455+
if (next === null) break;
456+
offset = next;
457+
expect(hops).toBeLessThan(100);
458+
}
459+
expect(collected).toContain("HEAD-");
460+
expect(collected).toContain("-TAIL");
461+
expect(collected).toContain("END");
462+
});
463+
464+
test("a large-file page passes the result-truncation layer byte-identical", async () => {
465+
const name = "cl8979-page.txt";
466+
const rows = Array.from(
467+
{ length: 3_000 },
468+
(_, i) => `cell-${i}-` + "v".repeat(50),
469+
);
470+
await fixture(name, `${rows.join("\n")}\n`);
471+
const plugin = readFileGuardPlugin(dir, {});
472+
const guardMiddleware = plugin.middleware;
473+
if (guardMiddleware === undefined) throw new Error("expected middleware");
474+
const fallback = async (call: ToolCall): Promise<ToolResult> => ({
475+
callId: call.id,
476+
content: "FALLBACK",
477+
});
478+
const guard = guardMiddleware(fallback);
479+
const guardOnly = await guard(
480+
{ id: "page-1", name: "read_file", arguments: { path: name } },
481+
neverAbort(),
482+
);
483+
expect(guardOnly.isError).toBeFalsy();
484+
expect(String(guardOnly.content)).toContain("to continue");
485+
const spilled = new Map<string, Uint8Array>();
486+
const truncPlugin = resultTruncationPlugin({
487+
getBlobWriter: () => async (key: string, payload: Uint8Array) => {
488+
spilled.set(key, payload);
489+
},
490+
});
491+
const truncMiddleware = truncPlugin.middleware;
492+
if (truncMiddleware === undefined) throw new Error("expected middleware");
493+
const composed = truncMiddleware(guard);
494+
const res = await composed(
495+
{ id: "page-1", name: "read_file", arguments: { path: name } },
496+
neverAbort(),
497+
);
498+
expect(String(res.content)).toBe(String(guardOnly.content));
499+
expect(spilled.size).toBe(0);
500+
});
501+
502+
test("an aborted read rejects with a timeout, not a fallback page", async () => {
503+
const p = await fixture("cl8979-abort.txt", "x".repeat(1000));
504+
const ctl = new AbortController();
505+
ctl.abort();
506+
const read = readFileBounded(p, 0, 2000, ctl.signal);
507+
await expect(read).rejects.toThrow("[timed out before completing]");
508+
});
509+
510+
test("a binary file still surfaces a refusal instead of a fallback page", async () => {
511+
await fixture(
512+
"cl8979-bin.dat",
513+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x00]),
514+
);
515+
const run = chainRunner();
516+
const result = await run("bin-1", { path: "cl8979-bin.dat" });
517+
expect(result.isError).toBe(true);
518+
expect(String(result.content)).toMatch(/binary/);
519+
expect(String(result.content)).not.toBe("FALLBACK");
520+
});
521+
});
522+
294523
describe("readFileGuardPlugin", () => {
295524
const fallback = async (call: ToolCall): Promise<ToolResult> => ({
296525
callId: call.id,

‎src/subagent/index.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,27 @@ describe("sub-agent stop helpers", () => {
686686
).toBeNull();
687687
});
688688

689+
test("evaluateSubAgentStop does not stop for chunked same-file reads with advancing offsets", () => {
690+
let thrash = EMPTY_THRASH_STATE;
691+
for (let i = 0; i < 12; i++) {
692+
thrash = nextThrashState(thrash, [
693+
{
694+
type: "tool_call",
695+
name: "read_file",
696+
arguments: { path: "src/big.ts", offset: i * 50, limit: 50 },
697+
},
698+
]);
699+
}
700+
expect(thrash.readCounts.size).toBe(12);
701+
expect(
702+
evaluateSubAgentStop({
703+
hasToolCalls: true,
704+
lastAssistantText: "",
705+
thrashState: thrash,
706+
}),
707+
).toBeNull();
708+
});
709+
689710
test("evaluateSubAgentStop keeps running while the worker is still calling tools", () => {
690711
expect(
691712
evaluateSubAgentStop({

‎vendor/intx-tools-posix/src/tools-posix.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { realpathSync } from "node:fs";
1313
import { createBlobReader, type BlobReader } from "@intx/types/runtime";
1414
import { createPosixTools, composeMiddleware } from "./index";
1515
import type { PosixTools, ToolHandler, ToolPlugin } from "./index";
16+
import { TOOL_DEFINITIONS } from "./registry";
1617
import { matchGlob, shouldSkip } from "./glob-match";
1718

1819
let tmpDir: string;
@@ -1267,3 +1268,12 @@ describe("plugin wiring", () => {
12671268
);
12681269
});
12691270
});
1271+
1272+
describe("read_file registry contract", () => {
1273+
test("documents the offset/limit page-and-continue contract", () => {
1274+
const def = TOOL_DEFINITIONS.find((entry) => entry.name === "read_file");
1275+
expect(def).toBeDefined();
1276+
expect(def?.description ?? "").toMatch(/Use offset=/);
1277+
expect(def?.description ?? "").toMatch(/page/i);
1278+
});
1279+
});

0 commit comments

Comments
 (0)