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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user