fix(coding-agent): wrap hashline multi-section aggregate in shared snapshot cap

The hashline multi-section path in `executeHashlineSingle` returned
`perFileResults: rendered.map(r => r.perFileResult)` directly. Each
per-section result had already been individually pruned by
`renderSection`, but the whole array bypassed the shared aggregate
budget added in 3987969 — a single hashline payload touching many files
with sub-32 KB snapshots each could still serialize unbounded snapshot
bytes into one session JSONL line.

Wrap the multi-section return in `pruneOversizedEditSnapshots`, which
delegates to `capPerFileSnapshots` and enforces the shared cap walking
left-to-right; early sections keep their ACP diff visualization, later
sections in a many-file batch degrade to text-only.

End-to-end regression test seeds five real on-disk files (~10 KB
combined snapshots each), runs a multi-section hashline SWAP via
`executeHashlineSingle`, and asserts the aggregate result holds the
cumulative kept snapshot bytes under `MAX_EDIT_SNAPSHOT_TEXT_CHARS`
with at least one section pruned.
This commit is contained in:
roboomp
2026-06-29 05:37:35 +00:00
parent 398796949f
commit c21cb6325a
2 changed files with 62 additions and 2 deletions
@@ -269,10 +269,10 @@ export async function executeHashlineSingle(
.join("\n\n"),
},
],
details: {
details: pruneOversizedEditSnapshots({
diff: rendered.map(r => r.toolResult.details?.diff ?? "").join("\n"),
perFileResults: rendered.map(r => r.perFileResult),
},
}),
};
}
@@ -2,13 +2,17 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { formatHashlineHeader } from "@oh-my-pi/hashline";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import {
canonicalSnapshotKey,
DEFAULT_FUZZY_THRESHOLD,
EditTool,
type EditToolDetails,
executeHashlineSingle,
executePatchSingle,
executeReplaceSingle,
getFileSnapshotStore,
MAX_EDIT_SNAPSHOT_TEXT_CHARS,
pruneOversizedEditSnapshots,
} from "@oh-my-pi/pi-coding-agent/edit";
@@ -217,3 +221,59 @@ describe("EditTool single-path aggregation across mixed-size entries", () => {
expect(details.diff.length).toBeGreaterThan(0);
});
});
describe("executeHashlineSingle multi-section aggregate cap", () => {
test("strips per-file snapshots once the shared budget is spent", async () => {
// Five files, each ~10 KB combined oldText+newText after a one-line
// swap. Each entry fits the per-entry 32 KB budget individually but the
// 50 KB cumulative bytes bust the shared aggregate budget — without
// the wrapping fix from #3787 review every per-file snapshot would
// survive to the session JSONL.
const fileCount = 5;
const session = {
cwd: tempDir,
settings: Settings.isolated(),
} as unknown as ToolSession;
const tags: string[] = [];
const filler = "filler line of content xxxx yyyy zzzz\n".repeat(120); // ~5 KB
for (let i = 0; i < fileCount; i++) {
const filePath = path.join(tempDir, `f${i}.ts`);
const source = `header${i}\n${filler}`;
await Bun.write(filePath, source);
const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(filePath), source);
tags.push(tag);
}
const sections = tags.map((tag, i) =>
[formatHashlineHeader(`f${i}.ts`, tag), "SWAP 1.=1:", `+HEADER${i}`].join("\n"),
);
const input = sections.join("\n");
const result = await executeHashlineSingle({
session,
input,
writethrough: async (targetPath, content) => {
await Bun.write(targetPath, content);
return undefined;
},
beginDeferredDiagnosticsForPath: noopBeginDeferred,
});
const details = result.details as EditToolDetails;
expect(details.perFileResults).toBeDefined();
expect(details.perFileResults!.length).toBe(fileCount);
const kept = details.perFileResults!.filter(e => e.oldText !== undefined);
const pruned = details.perFileResults!.filter(e => e.snapshotsPruned === true);
expect(kept.length).toBeGreaterThan(0);
expect(pruned.length).toBeGreaterThan(0);
expect(kept.length + pruned.length).toBe(fileCount);
const totalKept = details.perFileResults!.reduce(
(acc, e) => acc + (e.oldText?.length ?? 0) + (e.newText?.length ?? 0),
0,
);
expect(totalKept).toBeLessThanOrEqual(MAX_EDIT_SNAPSHOT_TEXT_CHARS);
});
});