Skip to content

Commit 4dbc20b

Browse files
committed
fix(session): accept only a mutated compaction tail
1 parent 47210c0 commit 4dbc20b

2 files changed

Lines changed: 146 additions & 42 deletions

File tree

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

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ function turn(text: string): ConversationTurn {
4242
return { role: "user", content: [{ type: "text", text }], timestamp: 1 };
4343
}
4444

45+
function joinedUserTurn(texts: readonly string[]): ConversationTurn {
46+
return {
47+
role: "user",
48+
content: texts.map((text) => ({ type: "text", text })),
49+
timestamp: 1,
50+
};
51+
}
52+
53+
function isCompactionMarkerTurn(candidate: ConversationTurn): boolean {
54+
const block = candidate.content[0];
55+
if (block === undefined || block.type !== "text") return false;
56+
return (
57+
block.text.startsWith("[Compacted prior context]") ||
58+
block.text === "[compact]" ||
59+
block.text === "[compaction]"
60+
);
61+
}
62+
4563
function jsonl(turns: ConversationTurn[]): string {
4664
return turns.map((t) => JSON.stringify(t)).join("\n") + "\n";
4765
}
@@ -813,20 +831,23 @@ describe("concurrent sessions write disjoint transcripts", () => {
813831
await first.commit({ message: "original" });
814832

815833
const second = await createOptimizedContextStore(dir);
816-
const compacted = [turn("[Compacted prior context]"), turn("c"), turn("d")];
834+
const compacted = [
835+
turn("[Compacted prior context]"),
836+
joinedUserTurn(["c", "d"]),
837+
];
817838
await second.writeTurns([...compacted]);
818839
await second.writeMetadata(EMPTY_CHECKPOINT_METADATA);
819840
await second.commit({ message: "compact" });
820841

821842
expect(versionedTurnsFiles(dir)).toEqual([]);
822-
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual(
823-
turnTexts(compacted),
843+
expect(JSON.stringify(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toBe(
844+
JSON.stringify(compacted),
824845
);
825-
expect(turnTexts((await second.load()).turns)).toEqual(
826-
turnTexts(compacted),
846+
expect(JSON.stringify((await second.load()).turns)).toBe(
847+
JSON.stringify(compacted),
827848
);
828-
expect(turnTexts((await loadRecentTurns(dir, 10)).turns)).toEqual(
829-
turnTexts(compacted),
849+
expect(JSON.stringify((await loadRecentTurns(dir, 10)).turns)).toBe(
850+
JSON.stringify(compacted),
830851
);
831852
});
832853

@@ -848,6 +869,81 @@ describe("concurrent sessions write disjoint transcripts", () => {
848869
await expectSharedTurnAttacksToDivert([turn("[Compacted prior context]")]);
849870
});
850871

872+
test("a summary plus an unmodified suffix does not replace the transcript", async () => {
873+
const dir = tempDir();
874+
const owner = await createOptimizedContextStore(dir);
875+
const original = Array.from({ length: 12 }, (_, index) =>
876+
turn(`unique-turn-${index}`),
877+
);
878+
await owner.writeTurns([...original]);
879+
await owner.writeMetadata(EMPTY_CHECKPOINT_METADATA);
880+
await owner.commit({ message: "seed" });
881+
fs.unlinkSync(path.join(dir, "session-owner.json"));
882+
883+
const attacker = await createOptimizedContextStore(dir);
884+
await attacker.writeTurns([
885+
turn("[Compacted prior context]\nforged"),
886+
turn("unique-turn-10"),
887+
turn("unique-turn-11"),
888+
]);
889+
await attacker.writeMetadata(EMPTY_CHECKPOINT_METADATA);
890+
await attacker.commit({ message: "attack" });
891+
892+
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual(
893+
turnTexts(original),
894+
);
895+
expect(turnTexts((await attacker.load()).turns)).toEqual(
896+
turnTexts(original),
897+
);
898+
expect(versionedTurnsFiles(dir)).toHaveLength(1);
899+
});
900+
901+
test("a coalesced plain-text fold stays on the live transcript", async () => {
902+
const original = [
903+
turn("H".repeat(20000)),
904+
turn("older"),
905+
turn("tail-a"),
906+
turn("tail-b"),
907+
turn("tail-c"),
908+
];
909+
const folded = (await createPruningCompactor().apply(original, {} as never))
910+
.output;
911+
const kept = folded.filter(
912+
(candidate) => !isCompactionMarkerTurn(candidate),
913+
);
914+
expect(kept).toHaveLength(1);
915+
expect(
916+
kept[0]?.content.map((block) =>
917+
block.type === "text" ? block.text : block.type,
918+
),
919+
).toEqual(["older", "tail-a", "tail-b", "tail-c"]);
920+
921+
for (const dropMarker of [true, false]) {
922+
const dir = tempDir();
923+
const first = await createOptimizedContextStore(dir);
924+
await first.writeTurns([...original]);
925+
await first.writeMetadata(EMPTY_CHECKPOINT_METADATA);
926+
await first.commit({ message: "original" });
927+
if (dropMarker) fs.unlinkSync(path.join(dir, "session-owner.json"));
928+
const second = await createOptimizedContextStore(dir);
929+
await second.writeTurns(folded);
930+
await second.writeMetadata(EMPTY_CHECKPOINT_METADATA);
931+
await second.commit({ message: "fold" });
932+
const later = [...folded, turn("after-fold")];
933+
await second.writeTurns(later);
934+
await second.writeMetadata(EMPTY_CHECKPOINT_METADATA);
935+
await second.commit({ message: "later" });
936+
937+
expect(versionedTurnsFiles(dir)).toEqual([]);
938+
expect(
939+
JSON.stringify(readTurnsFromFile(path.join(dir, TURNS_FILE))),
940+
).toBe(JSON.stringify(later));
941+
expect(JSON.stringify((await second.load()).turns)).toBe(
942+
JSON.stringify(later),
943+
);
944+
}
945+
});
946+
851947
test("a foreign transcript without an owner id still diverts", async () => {
852948
const dir = tempDir();
853949
const first = await createOptimizedContextStore(dir);
@@ -983,17 +1079,20 @@ describe("concurrent sessions write disjoint transcripts", () => {
9831079
const upgraded = await createOptimizedContextStore(dir, {
9841080
ownerId: "session-S",
9851081
});
986-
const compacted = [turn("[Compacted prior context]"), turn("c"), turn("d")];
1082+
const compacted = [
1083+
turn("[Compacted prior context]"),
1084+
joinedUserTurn(["c", "d"]),
1085+
];
9871086
await upgraded.writeTurns([...compacted]);
9881087
await upgraded.writeMetadata(EMPTY_CHECKPOINT_METADATA);
9891088
await upgraded.commit({ message: "upgrade compact" });
9901089

9911090
expect(versionedTurnsFiles(dir)).toEqual([]);
992-
expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual(
993-
turnTexts(compacted),
1091+
expect(JSON.stringify(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toBe(
1092+
JSON.stringify(compacted),
9941093
);
995-
expect(turnTexts((await upgraded.load()).turns)).toEqual(
996-
turnTexts(compacted),
1094+
expect(JSON.stringify((await upgraded.load()).turns)).toBe(
1095+
JSON.stringify(compacted),
9971096
);
9981097
});
9991098

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

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -555,14 +555,6 @@ function blocksProject(
555555
return true;
556556
}
557557

558-
function turnText(turn: ConversationTurn): string {
559-
let text = "";
560-
for (const block of turn.content) {
561-
if (block.type === "text") text += block.text;
562-
}
563-
return text;
564-
}
565-
566558
function isPlainTextTurn(turn: ConversationTurn): boolean {
567559
return !turn.content.some(
568560
(block) => block.type === "tool_call" || block.type === "tool_result",
@@ -580,49 +572,62 @@ function projectsFrom(
580572
return blocksProject(source.content, out.content);
581573
}
582574

583-
function coalescedProjects(
584-
first: ConversationTurn,
585-
second: ConversationTurn,
575+
function sameTurnBody(a: ConversationTurn, b: ConversationTurn): boolean {
576+
return (
577+
a.role === b.role && JSON.stringify(a.content) === JSON.stringify(b.content)
578+
);
579+
}
580+
581+
function coalescedSpan(
582+
live: readonly ConversationTurn[],
583+
end: number,
586584
out: ConversationTurn,
587-
): boolean {
588-
if (first.role !== out.role || second.role !== out.role) return false;
589-
if (
590-
!isPlainTextTurn(first) ||
591-
!isPlainTextTurn(second) ||
592-
!isPlainTextTurn(out)
593-
) {
594-
return false;
585+
): number {
586+
if (!isPlainTextTurn(out)) return 0;
587+
const target = JSON.stringify(out.content);
588+
const parts: ConversationTurn["content"][number][] = [];
589+
for (let start = end - 1; start >= 0; start--) {
590+
const source = live[start];
591+
if (
592+
source === undefined ||
593+
source.role !== out.role ||
594+
!isPlainTextTurn(source)
595+
) {
596+
return 0;
597+
}
598+
parts.unshift(...source.content);
599+
const span = end - start;
600+
if (span >= 2 && JSON.stringify(parts) === target) return span;
601+
if (parts.length > out.content.length) return 0;
595602
}
596-
return turnText(out) === turnText(first) + turnText(second);
603+
return 0;
597604
}
598605

599606
function tailProjectsFrom(
600607
live: readonly ConversationTurn[],
601608
rest: readonly ConversationTurn[],
602609
): boolean {
603-
if (rest.length < 2 || rest.length > live.length) return false;
610+
if (rest.length === 0 || rest.length > live.length) return false;
604611
let cursor = live.length;
612+
let mutated = false;
605613
for (let index = rest.length - 1; index >= 0; index--) {
606614
const out = rest[index];
607615
if (out === undefined || cursor <= 0) return false;
608616
const candidate = live[cursor - 1];
609617
if (candidate !== undefined && projectsFrom(candidate, out)) {
618+
if (!sameTurnBody(candidate, out)) mutated = true;
610619
cursor--;
611620
continue;
612621
}
613-
const earlier = live[cursor - 2];
614-
const later = live[cursor - 1];
615-
if (
616-
earlier !== undefined &&
617-
later !== undefined &&
618-
coalescedProjects(earlier, later, out)
619-
) {
620-
cursor -= 2;
622+
const span = coalescedSpan(live, cursor, out);
623+
if (span >= 2) {
624+
mutated = true;
625+
cursor -= span;
621626
continue;
622627
}
623628
return false;
624629
}
625-
return cursor > 0;
630+
return cursor > 0 && mutated;
626631
}
627632

628633
function isCompactionOf(

0 commit comments

Comments
 (0)