Skip to content

Commit be9dca1

Browse files
committed
test(reads): pin continuation recovery contract for truncated reads
Red tests for CL-8980: verbatim handle-following must yield the next window across session resume, dead handles must name source plus offset instead of a bare missing blob, never-handles must stay missing-blob errors, compaction stubs must preserve the resume recipe, spent replays stay single-next-call errors, and guard denials stay isError results outside decline classification.
1 parent e28dce8 commit be9dca1

2 files changed

Lines changed: 509 additions & 0 deletions

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2+
import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import type { ToolCall, ToolResult } from "@intx/types/runtime";
6+
import { defined } from "../../tests/helpers/defined.js";
7+
import {
8+
APPROVER_REJECTION_MARKER,
9+
BLOCKED_BY_POLICY_PREFIX,
10+
DENIED_BY_POLICY_MARKER,
11+
NO_MATCHING_GRANTS_MARKER,
12+
OPERATOR_DECLINED_MARKER,
13+
} from "../permission/decline-markers.js";
14+
import type { PermissionGate } from "../permission/gate.js";
15+
import { gateToolCall } from "./permission-plugin.js";
16+
import { readFileGuardPlugin } from "./read-file-guard-plugin.js";
17+
18+
// CL-8980 RED: continuation recovery. Truncated reads mint a one-shot
19+
// in-memory handle; after resume (fresh plugin instance), prune, or
20+
// compaction the record is dropped and the URI is indistinguishable from a
21+
// missing spill, with no source/offset named. These tests pin the recovery
22+
// contract: verbatim handle-following yields the next window across resume,
23+
// dead handles name source + offset (never a bare missing-blob), never-handles
24+
// still fail as missing blobs, spent replays stay errors with one followable
25+
// next call, and guard denials stay isError results (never throws, never a
26+
// decline classification).
27+
28+
const neverAbort = () => new AbortController().signal;
29+
30+
let dir: string;
31+
32+
beforeAll(async () => {
33+
dir = await mkdtemp(join(tmpdir(), "read-continuation-8980-"));
34+
});
35+
36+
afterAll(async () => {
37+
await rm(dir, { recursive: true, force: true });
38+
});
39+
40+
async function fixture(name: string, content: string): Promise<string> {
41+
const p = join(dir, name);
42+
await writeFile(p, content);
43+
return p;
44+
}
45+
46+
const fallback = async (call: ToolCall): Promise<ToolResult> => ({
47+
callId: call.id,
48+
content: "FALLBACK",
49+
});
50+
51+
function freshGuard(blobReader?: {
52+
read: (uri: string) => Promise<Uint8Array>;
53+
}) {
54+
const plugin = readFileGuardPlugin(
55+
dir,
56+
blobReader !== undefined ? { blobReader } : {},
57+
);
58+
const middleware = defined(plugin.middleware)(fallback);
59+
return (call: ToolCall) => middleware(call, neverAbort());
60+
}
61+
62+
function extractHandle(content: string): string {
63+
const match = /Use path="(tool-output:\/\/\/[^"]+)"/.exec(content);
64+
expect(match).not.toBeNull();
65+
return (match as RegExpExecArray)[1] as string;
66+
}
67+
68+
function tenLines(name: string): string {
69+
return Array.from({ length: 10 }, (_, i) => `${name}-line-${i}`).join("\n");
70+
}
71+
72+
// Decline-classification markers the director matches by substring. No
73+
// continuation isError may carry any of them, or a recoverable read failure
74+
// would be misread as an operator decision (src/agent/director.ts must stay
75+
// out of this path).
76+
const DECLINE_MARKERS = [
77+
DENIED_BY_POLICY_MARKER,
78+
NO_MATCHING_GRANTS_MARKER,
79+
OPERATOR_DECLINED_MARKER,
80+
APPROVER_REJECTION_MARKER,
81+
];
82+
83+
function expectNotDeclined(content: string): void {
84+
for (const marker of DECLINE_MARKERS) {
85+
expect(content).not.toContain(marker);
86+
}
87+
}
88+
89+
describe("CL-8980 continuation recovery (file source)", () => {
90+
test("verbatim follow after session resume yields the next window", async () => {
91+
const absolutePath = await fixture("resume.txt", tenLines("resume"));
92+
const first = await freshGuard()({
93+
id: "r1",
94+
name: "read_file",
95+
arguments: { path: "resume.txt", limit: 4 },
96+
});
97+
expect(first.isError).toBeFalsy();
98+
const handle = extractHandle(String(first.content));
99+
100+
// A resumed session rebuilds the plugin: brand-new instance, empty
101+
// in-memory cursor map. Following the notice verbatim must still yield
102+
// the next window, not a missing-blob dead end.
103+
const resumed = await freshGuard()({
104+
id: "r2",
105+
name: "read_file",
106+
arguments: { path: handle },
107+
});
108+
expect(resumed.isError).toBeFalsy();
109+
expect(String(resumed.content)).toContain("resume-line-4");
110+
expect(String(resumed.content)).not.toContain(absolutePath);
111+
expectNotDeclined(String(resumed.content));
112+
});
113+
114+
test("verbatim follow chains across a second hop after resume", async () => {
115+
await fixture("chain.txt", tenLines("chain"));
116+
const first = await freshGuard()({
117+
id: "c1",
118+
name: "read_file",
119+
arguments: { path: "chain.txt", limit: 4 },
120+
});
121+
const handle1 = extractHandle(String(first.content));
122+
123+
const second = await freshGuard()({
124+
id: "c2",
125+
name: "read_file",
126+
arguments: { path: handle1 },
127+
});
128+
expect(second.isError).toBeFalsy();
129+
expect(String(second.content)).toContain("chain-line-4");
130+
const handle2 = extractHandle(String(second.content));
131+
expect(handle2).not.toBe(handle1);
132+
133+
const third = await freshGuard()({
134+
id: "c3",
135+
name: "read_file",
136+
arguments: { path: handle2 },
137+
});
138+
expect(third.isError).toBeFalsy();
139+
expect(String(third.content)).toContain("chain-line-8");
140+
});
141+
142+
test("dead file handle names the source and offset, never a bare missing blob", async () => {
143+
const absolutePath = await fixture("gone.txt", tenLines("gone"));
144+
const first = await freshGuard()({
145+
id: "d1",
146+
name: "read_file",
147+
arguments: { path: "gone.txt", limit: 4 },
148+
});
149+
const handle = extractHandle(String(first.content));
150+
await unlink(absolutePath);
151+
152+
const dead = await freshGuard()({
153+
id: "d2",
154+
name: "read_file",
155+
arguments: { path: handle },
156+
});
157+
expect(dead.isError).toBe(true);
158+
const text = String(dead.content);
159+
expect(text).toContain(absolutePath);
160+
expect(text).toMatch(/offset=4\b/);
161+
expect(text).not.toContain("Blob not found");
162+
expect(text).not.toContain("no blob reader is configured");
163+
expectNotDeclined(text);
164+
});
165+
166+
test("spent-handle replay stays an error with one followable next call", async () => {
167+
const absolutePath = await fixture("spent.txt", tenLines("spent"));
168+
const plugin = readFileGuardPlugin(dir, {});
169+
const middleware = defined(plugin.middleware)(fallback);
170+
const first = await middleware(
171+
{ id: "s1", name: "read_file", arguments: { path: "spent.txt", limit: 4 } },
172+
neverAbort(),
173+
);
174+
const handle = extractHandle(String(first.content));
175+
const second = await middleware(
176+
{ id: "s2", name: "read_file", arguments: { path: handle } },
177+
neverAbort(),
178+
);
179+
expect(second.isError).toBeFalsy();
180+
181+
const replay = await middleware(
182+
{ id: "s3", name: "read_file", arguments: { path: handle } },
183+
neverAbort(),
184+
);
185+
expect(replay.isError).toBe(true);
186+
const text = String(replay.content);
187+
expect(text).toContain("already used");
188+
expect(text).toContain(absolutePath);
189+
expect(text).toMatch(/offset=4\b/);
190+
// Exactly one followable next call, not a menu of guesses.
191+
expect(text.match(/offset=/g)).toHaveLength(1);
192+
expectNotDeclined(text);
193+
});
194+
});
195+
196+
describe("CL-8980 continuation recovery (blob source)", () => {
197+
const enc = new TextEncoder();
198+
const rows = Array.from({ length: 8_000 }, (_, i) => `row-${i}`).join("\n");
199+
200+
test("verbatim follow after resume yields the next window without the old map", async () => {
201+
const store = new Map<string, Uint8Array>([["spill-resume", enc.encode(rows)]]);
202+
const opening = freshGuard({
203+
read: async (uri: string) => {
204+
const key = uri.slice("tool-output:///".length);
205+
const bytes = store.get(key);
206+
if (bytes === undefined) throw new Error(`Blob not found for key: ${uri}`);
207+
return bytes;
208+
},
209+
});
210+
const first = await opening({
211+
id: "b1",
212+
name: "read_file",
213+
arguments: { path: "tool-output:///spill-resume", limit: 5 },
214+
});
215+
expect(first.isError).toBeFalsy();
216+
const handle = extractHandle(String(first.content));
217+
218+
// Resumed session: new plugin instance, same durable spill store.
219+
const resumed = freshGuard({
220+
read: async (uri: string) => {
221+
const key = uri.slice("tool-output:///".length);
222+
const bytes = store.get(key);
223+
if (bytes === undefined) throw new Error(`Blob not found for key: ${uri}`);
224+
return bytes;
225+
},
226+
});
227+
const second = await resumed({
228+
id: "b2",
229+
name: "read_file",
230+
arguments: { path: handle },
231+
});
232+
expect(second.isError).toBeFalsy();
233+
expect(String(second.content)).toContain("row-5");
234+
});
235+
236+
test("dead spill handle names the spill URI and offset, never a bare missing blob", async () => {
237+
const live = new Map<string, Uint8Array>([["spill-dead", enc.encode(rows)]]);
238+
const opening = freshGuard({
239+
read: async (uri: string) => {
240+
const key = uri.slice("tool-output:///".length);
241+
const bytes = live.get(key);
242+
if (bytes === undefined) throw new Error(`Blob not found for key: ${uri}`);
243+
return bytes;
244+
},
245+
});
246+
const first = await opening({
247+
id: "e1",
248+
name: "read_file",
249+
arguments: { path: "tool-output:///spill-dead", limit: 5 },
250+
});
251+
const handle = extractHandle(String(first.content));
252+
253+
// The spill is gone (pruned store) by the time the handle is followed.
254+
const pruned = freshGuard({
255+
read: async (uri: string) => {
256+
throw new Error(`Blob not found for key: ${uri}`);
257+
},
258+
});
259+
const dead = await pruned({
260+
id: "e2",
261+
name: "read_file",
262+
arguments: { path: handle },
263+
});
264+
expect(dead.isError).toBe(true);
265+
const text = String(dead.content);
266+
expect(text).toContain("tool-output:///spill-dead");
267+
expect(text).toMatch(/offset=\d+\b/);
268+
expect(text).not.toMatch(/Blob not found for key: tool-output:\/\/\/[0-9a-f-]+/);
269+
expectNotDeclined(text);
270+
});
271+
272+
test("a URI that was never a continuation handle still fails as a missing blob", async () => {
273+
const result = await freshGuard({
274+
read: async (uri: string) => {
275+
throw new Error(`Blob not found for key: ${uri}`);
276+
},
277+
})({
278+
id: "u1",
279+
name: "read_file",
280+
arguments: { path: "tool-output:///never-minted" },
281+
});
282+
expect(result.isError).toBe(true);
283+
expect(String(result.content)).toContain("Blob not found for key");
284+
expect(String(result.content)).not.toContain("already used");
285+
expectNotDeclined(String(result.content));
286+
});
287+
});
288+
289+
describe("CL-8980 guard-denied continuation stays isError (never throws)", () => {
290+
test("a denied continuation follow returns isError with the reason", async () => {
291+
const gate = {
292+
isReactorGated: () => false,
293+
evaluate: async () => ({
294+
allowed: false as const,
295+
reason: "test policy: cursor follows need approval",
296+
}),
297+
} as unknown as PermissionGate;
298+
const call: ToolCall = {
299+
id: "g1",
300+
name: "read_file",
301+
arguments: { path: "tool-output:///cursor-deadbeef" },
302+
};
303+
let result: ToolResult | undefined;
304+
await expect(
305+
(async () => {
306+
result = await gateToolCall(gate, call, neverAbort(), async () => {
307+
throw new Error("must not reach the tool when denied");
308+
});
309+
})(),
310+
).resolves.toBeUndefined();
311+
expect(defined(result).isError).toBe(true);
312+
expect(String(defined(result).content)).toContain(
313+
`${BLOCKED_BY_POLICY_PREFIX}test policy: cursor follows need approval`,
314+
);
315+
expectNotDeclined(String(defined(result).content));
316+
});
317+
});

0 commit comments

Comments
 (0)