Skip to content

Commit d34ea6f

Browse files
committed
feat(reads): make truncated-read continuations durable across resume
Truncated reads minted opaque one-shot handles that died with the plugin instance: after resume, prune, or compaction the URI was a bare missing blob with no source or offset. Handles are now self-describing (source, offset, window, nonce ride in the URI), so a verbatim follow serves the next window with no in-memory record; dead handles fail as isError naming source plus offset; never-handles still fail as missing blobs; spent replays stay single-use errors; notices carry a plain path plus offset fallback; compaction stubs preserve resume recipes.
1 parent be9dca1 commit d34ea6f

5 files changed

Lines changed: 343 additions & 38 deletions

File tree

‎src/plugins/read-file-continuation-recovery.test.ts‎

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,15 +99,19 @@ describe("CL-8980 continuation recovery (file source)", () => {
9999

100100
// A resumed session rebuilds the plugin: brand-new instance, empty
101101
// in-memory cursor map. Following the notice verbatim must still yield
102-
// the next window, not a missing-blob dead end.
102+
// the next window, not a missing-blob dead end. Same window size so the
103+
// read stays paged and mints the next handle.
103104
const resumed = await freshGuard()({
104105
id: "r2",
105106
name: "read_file",
106-
arguments: { path: handle },
107+
arguments: { path: handle, limit: 4 },
107108
});
108109
expect(resumed.isError).toBeFalsy();
109110
expect(String(resumed.content)).toContain("resume-line-4");
110-
expect(String(resumed.content)).not.toContain(absolutePath);
111+
// CL-8980 keeps a plain path+offset fallback alongside the handle so a
112+
// lost handle is never a dead end; verbatim follows still use the handle.
113+
expect(String(resumed.content)).toContain(`path="${absolutePath}"`);
114+
expect(String(resumed.content)).not.toMatch(/Use path="[^"]*resume\.txt"/);
111115
expectNotDeclined(String(resumed.content));
112116
});
113117

@@ -123,7 +127,7 @@ describe("CL-8980 continuation recovery (file source)", () => {
123127
const second = await freshGuard()({
124128
id: "c2",
125129
name: "read_file",
126-
arguments: { path: handle1 },
130+
arguments: { path: handle1, limit: 4 },
127131
});
128132
expect(second.isError).toBeFalsy();
129133
expect(String(second.content)).toContain("chain-line-4");
@@ -133,7 +137,7 @@ describe("CL-8980 continuation recovery (file source)", () => {
133137
const third = await freshGuard()({
134138
id: "c3",
135139
name: "read_file",
136-
arguments: { path: handle2 },
140+
arguments: { path: handle2, limit: 4 },
137141
});
138142
expect(third.isError).toBeFalsy();
139143
expect(String(third.content)).toContain("chain-line-8");
@@ -168,7 +172,11 @@ describe("CL-8980 continuation recovery (file source)", () => {
168172
const plugin = readFileGuardPlugin(dir, {});
169173
const middleware = defined(plugin.middleware)(fallback);
170174
const first = await middleware(
171-
{ id: "s1", name: "read_file", arguments: { path: "spent.txt", limit: 4 } },
175+
{
176+
id: "s1",
177+
name: "read_file",
178+
arguments: { path: "spent.txt", limit: 4 },
179+
},
172180
neverAbort(),
173181
);
174182
const handle = extractHandle(String(first.content));
@@ -198,12 +206,15 @@ describe("CL-8980 continuation recovery (blob source)", () => {
198206
const rows = Array.from({ length: 8_000 }, (_, i) => `row-${i}`).join("\n");
199207

200208
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)]]);
209+
const store = new Map<string, Uint8Array>([
210+
["spill-resume", enc.encode(rows)],
211+
]);
202212
const opening = freshGuard({
203213
read: async (uri: string) => {
204214
const key = uri.slice("tool-output:///".length);
205215
const bytes = store.get(key);
206-
if (bytes === undefined) throw new Error(`Blob not found for key: ${uri}`);
216+
if (bytes === undefined)
217+
throw new Error(`Blob not found for key: ${uri}`);
207218
return bytes;
208219
},
209220
});
@@ -220,7 +231,8 @@ describe("CL-8980 continuation recovery (blob source)", () => {
220231
read: async (uri: string) => {
221232
const key = uri.slice("tool-output:///".length);
222233
const bytes = store.get(key);
223-
if (bytes === undefined) throw new Error(`Blob not found for key: ${uri}`);
234+
if (bytes === undefined)
235+
throw new Error(`Blob not found for key: ${uri}`);
224236
return bytes;
225237
},
226238
});
@@ -234,12 +246,15 @@ describe("CL-8980 continuation recovery (blob source)", () => {
234246
});
235247

236248
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)]]);
249+
const live = new Map<string, Uint8Array>([
250+
["spill-dead", enc.encode(rows)],
251+
]);
238252
const opening = freshGuard({
239253
read: async (uri: string) => {
240254
const key = uri.slice("tool-output:///".length);
241255
const bytes = live.get(key);
242-
if (bytes === undefined) throw new Error(`Blob not found for key: ${uri}`);
256+
if (bytes === undefined)
257+
throw new Error(`Blob not found for key: ${uri}`);
243258
return bytes;
244259
},
245260
});
@@ -265,7 +280,9 @@ describe("CL-8980 continuation recovery (blob source)", () => {
265280
const text = String(dead.content);
266281
expect(text).toContain("tool-output:///spill-dead");
267282
expect(text).toMatch(/offset=\d+\b/);
268-
expect(text).not.toMatch(/Blob not found for key: tool-output:\/\/\/[0-9a-f-]+/);
283+
expect(text).not.toMatch(
284+
/Blob not found for key: tool-output:\/\/\/[0-9a-f-]+/,
285+
);
269286
expectNotDeclined(text);
270287
});
271288

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,15 @@ describe("readFileGuardPlugin", () => {
507507
);
508508
expect(result.content).not.toContain("Use offset=");
509509
expect(String(result.content)).toContain('Use path="tool-output:///');
510-
// The literal source path never reappears as the thing to read next.
511-
expect(String(result.content)).not.toContain("many-lines.txt");
510+
// CL-8980 keeps a plain path+offset fallback alongside the handle so a
511+
// lost handle is never a dead end — but the primary next call is the
512+
// handle, never the original path.
513+
expect(String(result.content)).toMatch(
514+
/\(Fallback: read_file path="[^"]*many-lines\.txt" offset=4\.\)/,
515+
);
516+
expect(String(result.content)).not.toMatch(
517+
/Use path="[^"]*many-lines\.txt"/,
518+
);
512519
});
513520

514521
test("following the minted cursor resumes and eventually reads a large file to completion without any repeat call on the original path (CL-6961)", async () => {

0 commit comments

Comments
 (0)