Skip to content

Commit b167db3

Browse files
TheGreatAxioscorbits-builder
andauthored
CL-9026: stop writing duplicate prompt snapshot when identical to live turns (#1178)
* test(session): add failing prompt-dedupe coverage * fix(session): skip duplicate prompt snapshot writes * fix(session): git-rm prompt.jsonl after identical write extraCommitPaths stripped vendor roots from the remove list because base.commit() git.adds files that still exist. It never git.removes missing ones, so a committed-then-identical prompt snapshot stayed in HEAD. --------- Co-authored-by: corbits-builder <builder@corbits.local>
1 parent ccce53b commit b167db3

2 files changed

Lines changed: 236 additions & 5 deletions

File tree

‎src/session/optimized-context-store.test.ts‎

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,3 +960,162 @@ describe("createOptimizedContextStore unpublished rewrite", () => {
960960
expect(turnTexts((await store.load()).turns)).toEqual(["era-2"]);
961961
});
962962
});
963+
964+
describe("createOptimizedContextStore prompt dedupe (CL-9026)", () => {
965+
const PROMPT_FILE = "prompt.jsonl";
966+
967+
function toolResultTurn(body: string): ConversationTurn {
968+
return {
969+
role: "user",
970+
content: [
971+
{
972+
type: "tool_result",
973+
callId: "call-1",
974+
content: [{ type: "text", text: body }],
975+
},
976+
],
977+
timestamp: 1,
978+
};
979+
}
980+
981+
function cloneTurns(turns: ConversationTurn[]): ConversationTurn[] {
982+
return turns.map((t) => JSON.parse(JSON.stringify(t)) as ConversationTurn);
983+
}
984+
985+
test("identical writePrompt writes no prompt segment", async () => {
986+
const dir = tempDir();
987+
const store = await createOptimizedContextStore(dir);
988+
const turns = [turn("a"), turn("b")];
989+
await store.writeTurns([...turns]);
990+
await store.writePrompt([...turns]);
991+
await store.writePrompt(cloneTurns(turns));
992+
993+
expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false);
994+
expect(await listSegmentFiles(dir, PROMPT_FILE)).toEqual([]);
995+
996+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
997+
await store.commit({ message: "identical prompt is a no-op" });
998+
expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE);
999+
1000+
// A fresh instance compares against on-disk live turns and also skips.
1001+
const fresh = await createOptimizedContextStore(dir);
1002+
await fresh.writePrompt(cloneTurns(turns));
1003+
expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false);
1004+
});
1005+
1006+
test("differing writePrompt still writes the prompt snapshot", async () => {
1007+
const dir = tempDir();
1008+
const store = await createOptimizedContextStore(dir);
1009+
const live = [turn("a"), toolResultTurn("x".repeat(1000))];
1010+
await store.writeTurns([...live]);
1011+
1012+
const shrunk = [turn("a"), toolResultTurn("summarized")];
1013+
await store.writePrompt(shrunk);
1014+
expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(true);
1015+
expect(fs.readFileSync(path.join(dir, PROMPT_FILE), "utf-8")).toBe(
1016+
jsonl(shrunk),
1017+
);
1018+
1019+
// An appended ephemeral turn also changes the prompt and still writes.
1020+
const ephemeralDir = tempDir();
1021+
const ephemeralStore = await createOptimizedContextStore(ephemeralDir);
1022+
await ephemeralStore.writeTurns([...live]);
1023+
const withEphemeral = [...cloneTurns(live), turn("ephemeral")];
1024+
await ephemeralStore.writePrompt(withEphemeral);
1025+
expect(fs.readFileSync(path.join(ephemeralDir, PROMPT_FILE), "utf-8")).toBe(
1026+
jsonl(withEphemeral),
1027+
);
1028+
});
1029+
1030+
test("turns-only store loads live turns and commits no prompt file", async () => {
1031+
const dir = tempDir();
1032+
const store = await createOptimizedContextStore(dir);
1033+
await store.writeTurns([turn("a"), turn("b")]);
1034+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
1035+
const first = await store.commit({ message: "turns only" });
1036+
1037+
expect(turnTexts((await store.load()).turns)).toEqual(["a", "b"]);
1038+
1039+
const fresh = await createOptimizedContextStore(dir);
1040+
expect(turnTexts((await fresh.load()).turns)).toEqual(["a", "b"]);
1041+
const second = await fresh.commit({ message: "turns-only no-op" });
1042+
expect(second.hash).toBe(first.hash);
1043+
expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false);
1044+
expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE);
1045+
});
1046+
1047+
test("identical writePrompt after a differing one removes stale prompt segments", async () => {
1048+
const dir = tempDir();
1049+
const store = await createOptimizedContextStore(dir);
1050+
const live = [turn("a"), turn("b")];
1051+
await store.writeTurns([...live]);
1052+
await store.writePrompt([turn("a")]);
1053+
expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(true);
1054+
1055+
await store.writePrompt(cloneTurns(live));
1056+
expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false);
1057+
expect(await listSegmentFiles(dir, PROMPT_FILE)).toEqual([]);
1058+
1059+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
1060+
await store.commit({ message: "stale prompt removed" });
1061+
expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE);
1062+
expect(turnTexts((await store.load()).turns)).toEqual(["a", "b"]);
1063+
});
1064+
1065+
test("committed then identical writePrompt drops prompt.jsonl from HEAD", async () => {
1066+
const dir = tempDir();
1067+
const store = await createOptimizedContextStore(dir);
1068+
const live = [turn("a"), turn("b")];
1069+
await store.writeTurns([...live]);
1070+
await store.writePrompt([turn("a")]);
1071+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
1072+
await store.commit({ message: "differing prompt" });
1073+
expect(await gitLsTree(dir)).toContain(PROMPT_FILE);
1074+
1075+
await store.writePrompt(cloneTurns(live));
1076+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
1077+
const afterIdentical = await store.commit({ message: "identical prompt" });
1078+
expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE);
1079+
expect(turnTexts((await store.load()).turns)).toEqual(["a", "b"]);
1080+
1081+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
1082+
const empty = await store.commit({ message: "empty checkpoint" });
1083+
expect(empty.hash).toBe(afterIdentical.hash);
1084+
});
1085+
1086+
test("identical writePrompt after multi-segment differing prompt drops all prompt files from HEAD", async () => {
1087+
const dir = tempDir();
1088+
const store = await createOptimizedContextStore(dir);
1089+
const live: ConversationTurn[] = [];
1090+
const big = "x".repeat(20_000);
1091+
for (let i = 0; i < 18; i++) {
1092+
live.push(turn(`${i}-${big}`));
1093+
}
1094+
await store.writeTurns([...live]);
1095+
1096+
const differing: ConversationTurn[] = [];
1097+
for (let i = 0; i < 18; i++) {
1098+
differing.push(turn(`p-${i}-${big}`));
1099+
}
1100+
await store.writePrompt(differing);
1101+
const extraPrompt = segmentFileName(PROMPT_FILE, 1);
1102+
expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(true);
1103+
expect(fs.existsSync(path.join(dir, extraPrompt))).toBe(true);
1104+
1105+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
1106+
await store.commit({ message: "multi-segment prompt" });
1107+
const treeAfterDiffering = await gitLsTree(dir);
1108+
expect(treeAfterDiffering).toContain(PROMPT_FILE);
1109+
expect(treeAfterDiffering).toContain(extraPrompt);
1110+
1111+
await store.writePrompt(cloneTurns(live));
1112+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
1113+
await store.commit({ message: "identical drops prompt segments" });
1114+
const tree = await gitLsTree(dir);
1115+
expect(tree).not.toContain(PROMPT_FILE);
1116+
expect(tree).not.toContain(extraPrompt);
1117+
expect(turnTexts((await store.load()).turns)).toEqual(
1118+
live.map((t) => (t.content[0] as { text: string }).text),
1119+
);
1120+
});
1121+
});

‎src/session/optimized-context-store.ts‎

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ export async function createSessionStores(
527527
const pendingBlobFilepaths = new Set<string>();
528528
const pendingSegmentPaths = new Set<string>();
529529
let writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE);
530-
const writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
530+
let writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
531531
let liveTurnRefs: readonly ConversationTurn[] | null = null;
532532
let unpublishedRewrite: ConversationTurn[] | null = null;
533533

@@ -606,6 +606,75 @@ export async function createSessionStores(
606606
liveTurnRefs = [...turns];
607607
}
608608

609+
function promptEqualsLiveTurns(
610+
live: readonly ConversationTurn[],
611+
prompt: readonly ConversationTurn[],
612+
): boolean {
613+
if (live.length !== prompt.length) return false;
614+
for (let index = 0; index < prompt.length; index++) {
615+
if (live[index] === prompt[index]) continue;
616+
if (JSON.stringify(live[index]) !== JSON.stringify(prompt[index]))
617+
return false;
618+
}
619+
return true;
620+
}
621+
622+
// The reactor checkpoints the materialized prompt every cycle, but most
623+
// cycles run no transform that changes it — writing an identical snapshot
624+
// next to turns.jsonl doubles disk and re-hash cost for zero information.
625+
// load() never reads prompt.jsonl (base turns + TURNS_FILE extras only),
626+
// so skipping the write leaves resume behavior unchanged; prompts that
627+
// actually differ still write exactly as before.
628+
async function writePromptIfDiffered(
629+
turns: readonly ConversationTurn[],
630+
): Promise<void> {
631+
let live: readonly ConversationTurn[] | null = null;
632+
if (unpublishedRewrite !== null) live = unpublishedRewrite;
633+
else if (liveTurnRefs !== null) live = liveTurnRefs;
634+
else {
635+
// Fresh instance with no writeTurns yet: recover live turns from disk
636+
// the same way writeTurnsLiveOrStage does. Any failure falls through
637+
// to a normal write — an unreadable baseline must not drop the snapshot.
638+
try {
639+
const extraTexts = await readExtraSegmentTexts(dir, TURNS_FILE);
640+
let baseTurns: ConversationTurn[];
641+
try {
642+
baseTurns = (await base.load()).turns;
643+
} catch {
644+
baseTurns = await readBaseTurnsFromDisk(dir);
645+
}
646+
live =
647+
extraTexts.length === 0
648+
? baseTurns
649+
: await loadTurnsWithoutMalformedToolSequence(
650+
baseTurns,
651+
extraTexts,
652+
);
653+
} catch {
654+
live = null;
655+
}
656+
}
657+
if (live === null || !promptEqualsLiveTurns(live, turns)) {
658+
await writeSegmented(writePromptSegmented, turns);
659+
return;
660+
}
661+
// Identical to live turns: converge disk to no prompt segment so a stale
662+
// snapshot from an earlier differing write cannot linger. Removals join
663+
// pendingSegmentPaths so commit stages them out of the tree.
664+
const highest = await highestSegmentIndex(dir, PROMPT_FILE);
665+
let removed = false;
666+
for (let index = 0; index <= highest; index++) {
667+
const name = segmentFileName(PROMPT_FILE, index);
668+
if (!(await pathExists(path.join(dir, name)))) continue;
669+
await fs.promises.unlink(path.join(dir, name));
670+
pendingSegmentPaths.add(name);
671+
removed = true;
672+
}
673+
if (removed) {
674+
writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE);
675+
}
676+
}
677+
609678
// Prefer the longest prefix of base + extras whose tool sequence the reactor
610679
// will accept. Orphan tails left by a fresh-writer compaction rewrite are
611680
// dropped and unlinked so the next load does not re-poison the session.
@@ -718,7 +787,9 @@ export async function createSessionStores(
718787
return [...baseTurns, ...parsedExtras.slice(0, keepExtras).flat()];
719788
},
720789
readBlob: (key, signal) => base.readBlob(key, signal),
721-
writePrompt: (turns) => writeSegmented(writePromptSegmented, turns),
790+
// Skipped identical snapshots fall back to live turns in load(), which
791+
// never reads prompt.jsonl.
792+
writePrompt: (turns) => writePromptIfDiffered(turns),
722793
writeResponse: (turn, signal) => base.writeResponse(turn, signal),
723794
writeManifest: (records, signal) => base.writeManifest(records, signal),
724795
writeTurns: (turns) => writeTurnsLiveOrStage(turns),
@@ -793,9 +864,10 @@ export async function createSessionStores(
793864
}
794865

795866
const add = extraCommitPaths([...new Set(toAdd)]);
796-
const remove = extraCommitPaths([...new Set(toRemove)]).filter(
797-
(p) => !add.includes(p),
798-
);
867+
// extraCommitPaths strips vendor roots because base.commit() git.adds
868+
// those that still exist. It does not git.remove missing ones, so an
869+
// unlinked prompt.jsonl must stay in `remove`.
870+
const remove = [...new Set(toRemove)].filter((p) => !add.includes(p));
799871
extraPaths = [...new Set([...add, ...remove])];
800872

801873
for (const filepath of add) {

0 commit comments

Comments
 (0)