fix(coding-agent): cap perFileResults snapshots with shared aggregate budget
The per-entry 32 KB cap let a many-file batch (apply_patch / hashline touching N files) accumulate unbounded snapshot bytes because each `perFileResults` entry was checked independently. 100 files × 30 KB each kept everything (~3 MB) even though the whole array still serializes into one session JSONL line. `capPerFileSnapshots` walks entries left-to-right with one shared `MAX_EDIT_SNAPSHOT_TEXT_CHARS` budget. Each per-entry payload is still capped individually by `pruneSnapshot`; if an entry's surviving bytes would push the running aggregate past the cap, the entry is stripped and stamped with `snapshotsPruned: true`. Early entries keep their ACP diff visualization; later entries in a large batch degrade to text-only exactly like over-sized single edits. Regression test exercises five equal-size entries that each fit the per-entry budget but bust it cumulatively, asserting only the first two keep snapshots and the trailing three carry the pruned marker.
This commit is contained in:
@@ -14,11 +14,15 @@
|
||||
import type { EditToolDetails, EditToolPerFileResult } from "./renderer";
|
||||
|
||||
/**
|
||||
* Combined `oldText` + `newText` character budget per edit-tool result.
|
||||
* Combined `oldText` + `newText` character budget for a single edit-tool
|
||||
* result. Applies both per-entry (one file at a time) and as an aggregate
|
||||
* across `perFileResults` (so a many-small-files batch can't accumulate
|
||||
* unbounded snapshot bytes — see #3787 review).
|
||||
*
|
||||
* Picked so typical code-file edits keep ACP diff visualization while
|
||||
* pathological cases (large generated files, full-file rewrites) drop the raw
|
||||
* snapshots before they hit the session JSONL.
|
||||
* pathological cases (large generated files, full-file rewrites, or
|
||||
* many-file batches) drop the raw snapshots before they hit the
|
||||
* session JSONL.
|
||||
*/
|
||||
export const MAX_EDIT_SNAPSHOT_TEXT_CHARS = 32_768;
|
||||
|
||||
@@ -32,6 +36,28 @@ function pruneSnapshot<T extends WithSnapshot>(details: T): T {
|
||||
return { ...rest, snapshotsPruned: true } as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `perFileResults` in order with a shared budget. Each per-entry payload
|
||||
* is first capped individually by {@link pruneSnapshot}; if its kept bytes
|
||||
* would push the running aggregate past the cap, strip and mark this entry
|
||||
* too. Early entries get to keep their diff visualization; later entries in
|
||||
* a large batch degrade to text-only.
|
||||
*/
|
||||
function capPerFileSnapshots<T extends WithSnapshot>(entries: T[]): T[] {
|
||||
let remaining = MAX_EDIT_SNAPSHOT_TEXT_CHARS;
|
||||
return entries.map(entry => {
|
||||
const perEntry = pruneSnapshot(entry);
|
||||
const kept = (perEntry.oldText?.length ?? 0) + (perEntry.newText?.length ?? 0);
|
||||
if (kept === 0) return perEntry;
|
||||
if (kept <= remaining) {
|
||||
remaining -= kept;
|
||||
return perEntry;
|
||||
}
|
||||
const { oldText: _old, newText: _new, ...rest } = perEntry;
|
||||
return { ...rest, snapshotsPruned: true } as T;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune oversized `oldText` / `newText` from an edit-tool details payload,
|
||||
* recursing into `perFileResults` when present. Per-file overload comes first
|
||||
@@ -45,7 +71,7 @@ export function pruneOversizedEditSnapshots(
|
||||
): EditToolDetails | EditToolPerFileResult {
|
||||
const pruned = pruneSnapshot(details);
|
||||
if ("perFileResults" in pruned && pruned.perFileResults) {
|
||||
return { ...pruned, perFileResults: pruned.perFileResults.map(pruneSnapshot) };
|
||||
return { ...pruned, perFileResults: capPerFileSnapshots(pruned.perFileResults) };
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,35 @@ describe("pruneOversizedEditSnapshots", () => {
|
||||
newText: small,
|
||||
});
|
||||
});
|
||||
|
||||
test("caps cumulative perFileResults snapshots at the shared aggregate budget", () => {
|
||||
// Each entry is individually under the per-entry budget but their sum
|
||||
// busts it: walking left-to-right, the first two fit, the rest must be
|
||||
// stripped so a many-small-files batch can't accumulate unbounded bytes.
|
||||
const entrySize = Math.floor(MAX_EDIT_SNAPSHOT_TEXT_CHARS / 4);
|
||||
const chunk = "y".repeat(entrySize);
|
||||
const entries = Array.from({ length: 5 }, (_, i) => ({
|
||||
path: `/f${i}`,
|
||||
diff: `d${i}`,
|
||||
oldText: chunk,
|
||||
newText: chunk,
|
||||
}));
|
||||
const result = pruneOversizedEditSnapshots({ diff: "agg", perFileResults: entries });
|
||||
|
||||
const kept = result.perFileResults!.filter(e => e.oldText !== undefined);
|
||||
const pruned = result.perFileResults!.filter(e => e.snapshotsPruned === true);
|
||||
expect(kept.length).toBe(2);
|
||||
expect(pruned.length).toBe(3);
|
||||
|
||||
// Total kept snapshot bytes never exceed the shared cap.
|
||||
const totalKept = result.perFileResults!.reduce(
|
||||
(acc, e) => acc + (e.oldText?.length ?? 0) + (e.newText?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
expect(totalKept).toBeLessThanOrEqual(MAX_EDIT_SNAPSHOT_TEXT_CHARS);
|
||||
// Pruned entries keep their diff/path so the renderer still works.
|
||||
expect(pruned[0]).toMatchObject({ path: "/f2", diff: "d2", snapshotsPruned: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("executePatchSingle on oversized files", () => {
|
||||
|
||||
Reference in New Issue
Block a user