Skip to content

Commit 14cf030

Browse files
committed
fix(session): keep prefix-sharing handoff facts distinct
Production iterative folds merge full prior-file text, so prefix unique collapsed distinct constraints, decisions, and verification that only shared a string start.
1 parent 7927afc commit 14cf030

2 files changed

Lines changed: 171 additions & 7 deletions

File tree

‎src/session/compaction-handoff.test.ts‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,38 @@ function fileReadTurns(
6262
];
6363
}
6464

65+
function shellTurns(
66+
id: string,
67+
command: string,
68+
result: string,
69+
isError = false,
70+
): ConversationTurn[] {
71+
return [
72+
makeTurn({
73+
role: "assistant",
74+
content: [
75+
{
76+
type: "tool_call",
77+
id,
78+
name: "run_shell",
79+
arguments: { command },
80+
},
81+
],
82+
}),
83+
makeTurn({
84+
role: "user",
85+
content: [
86+
{
87+
type: "tool_result",
88+
callId: id,
89+
isError,
90+
content: [{ type: "text", text: result }],
91+
},
92+
],
93+
}),
94+
];
95+
}
96+
6597
function userTurn(text: string): ConversationTurn {
6698
return makeTurn({ role: "user", content: [{ type: "text", text }] });
6799
}
@@ -550,6 +582,83 @@ describe("iterative folding", () => {
550582
);
551583
});
552584

585+
test("iterative union keeps prefix-sharing constraints distinct", () => {
586+
const first = buildHandoffFold(
587+
[
588+
userTurn(
589+
"Ship the widget.\nMust never write to /tmp\nNever touch src/auth",
590+
),
591+
],
592+
"narrative",
593+
);
594+
const second = buildHandoffFold(
595+
[
596+
spineTurn(first.spineText),
597+
userTurn(
598+
"Continue.\nMust never write to /tmp/cache\nNever touch src/auth.ts",
599+
),
600+
],
601+
"narrative",
602+
{ priorFileText: new TextDecoder().decode(first.blob.bytes) },
603+
);
604+
expect(second.artifact.constraints).toEqual(
605+
expect.arrayContaining([
606+
"Must never write to /tmp",
607+
"Must never write to /tmp/cache",
608+
"Never touch src/auth",
609+
"Never touch src/auth.ts",
610+
]),
611+
);
612+
});
613+
614+
test("iterative union keeps prefix-sharing decisions distinct", () => {
615+
const first = buildHandoffFold(
616+
[
617+
userTurn("Ship the widget."),
618+
userTurn("Use src/auth"),
619+
userTurn("Keep going."),
620+
],
621+
"narrative",
622+
);
623+
const second = buildHandoffFold(
624+
[
625+
spineTurn(first.spineText),
626+
userTurn("Use src/auth.ts"),
627+
userTurn("Keep going."),
628+
],
629+
"narrative",
630+
{ priorFileText: new TextDecoder().decode(first.blob.bytes) },
631+
);
632+
expect(second.artifact.decisions).toEqual(
633+
expect.arrayContaining(["Use src/auth", "Use src/auth.ts"]),
634+
);
635+
});
636+
637+
test("iterative union keeps prefix-sharing verification distinct", () => {
638+
const first = buildHandoffFold(
639+
[
640+
userTurn("Run the suite."),
641+
...shellTurns("c1", "bun test", "1 pass, 0 fail"),
642+
],
643+
"narrative",
644+
);
645+
const second = buildHandoffFold(
646+
[
647+
spineTurn(first.spineText),
648+
userTurn("Run the module tests."),
649+
...shellTurns("c2", "bun test src/foo.test.ts", "1 pass, 0 fail"),
650+
],
651+
"narrative",
652+
{ priorFileText: new TextDecoder().decode(first.blob.bytes) },
653+
);
654+
expect(second.artifact.verification).toEqual(
655+
expect.arrayContaining([
656+
"PASS: bun test",
657+
"PASS: bun test src/foo.test.ts",
658+
]),
659+
);
660+
});
661+
553662
test("narrative ## Goal/Files in the prior summary do not overwrite schema", () => {
554663
const first = buildHandoffFold(foldedRegion(), "First fold narrative.");
555664
const poisoned = renderHandoffFile(
@@ -590,6 +699,38 @@ describe("iterative folding", () => {
590699
"Never touch src/legacy.",
591700
);
592701
});
702+
703+
test("partial prior-file constraints do not drop extra carried spine constraints", () => {
704+
const first = buildHandoffFold(
705+
[
706+
userTurn(
707+
"Ship the widget.\nMust never write to /tmp\nNever touch src/auth",
708+
),
709+
],
710+
"narrative",
711+
);
712+
const kept = first.artifact.constraints.filter((constraint) =>
713+
constraint.includes("/tmp"),
714+
);
715+
expect(kept.length).toBeGreaterThan(0);
716+
expect(first.artifact.constraints.length).toBeGreaterThan(kept.length);
717+
const partial = renderHandoffFile(
718+
{ ...first.artifact, constraints: kept },
719+
"narrative",
720+
handoffBlobUri(HANDOFF_LATEST_KEY),
721+
);
722+
const second = buildHandoffFold(
723+
[spineTurn(first.spineText), userTurn("Continue.")],
724+
"narrative",
725+
{ priorFileText: partial },
726+
);
727+
expect(second.artifact.constraints).toEqual(
728+
expect.arrayContaining([
729+
"Must never write to /tmp",
730+
"Never touch src/auth",
731+
]),
732+
);
733+
});
593734
});
594735

595736
describe("tool-body dumps", () => {

‎src/session/compaction-handoff.ts‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,15 +323,24 @@ function parseHandoffFile(text: string): Partial<HandoffArtifact> {
323323
}
324324

325325
// Prefer the full prior-file text over a spine-truncated prefix of the same
326-
// fact. Distinct facts append until the cap. Prefix collapse is only for
327-
// known truncated spine fragments (80-char cuts); paths and commands use
328-
// exact equality so `src/auth` and `src/auth.ts` stay distinct.
326+
// fact. Distinct facts append until the cap. Production prior-file text is
327+
// full, so union is exact: `Must never write to /tmp` and `.../tmp/cache`
328+
// stay distinct. Prefix collapse is only for known 80-char spine cuts when
329+
// merging carried spine fragments into the fat file.
330+
function isSpineTruncationOf(fragment: string, full: string): boolean {
331+
return (
332+
fragment.length === MAX_SPINE_ITEM_CHARS &&
333+
full.length > fragment.length &&
334+
full.startsWith(fragment)
335+
);
336+
}
337+
329338
function mergeUnique(
330339
primary: readonly string[],
331340
extra: readonly string[],
332341
cap: number,
333342
maxChars = MAX_ITEM_CHARS,
334-
mode: "prefix" | "exact" = "prefix",
343+
mode: "prefix" | "exact" = "exact",
335344
): string[] {
336345
const merged: string[] = [];
337346
const consider = (raw: string): void => {
@@ -342,7 +351,9 @@ function mergeUnique(
342351
const related = merged.findIndex((entry) =>
343352
mode === "exact"
344353
? entry === item
345-
: entry === item || entry.startsWith(item) || item.startsWith(entry),
354+
: entry === item ||
355+
isSpineTruncationOf(item, entry) ||
356+
isSpineTruncationOf(entry, item),
346357
);
347358
if (related >= 0) {
348359
const existing = merged[related];
@@ -546,12 +557,24 @@ export function extractHandoffArtifact(
546557
);
547558

548559
const mergedConstraints = mergeUnique(
549-
priorFile.constraints ?? carried.constraints,
560+
mergeUnique(
561+
priorFile.constraints ?? [],
562+
carried.constraints,
563+
MAX_CONSTRAINTS,
564+
MAX_ITEM_CHARS,
565+
"prefix",
566+
),
550567
freshConstraints,
551568
MAX_CONSTRAINTS,
552569
);
553570
const mergedDecisions = mergeUnique(
554-
priorFile.decisions ?? carried.decisions,
571+
mergeUnique(
572+
priorFile.decisions ?? [],
573+
carried.decisions,
574+
MAX_DECISIONS,
575+
MAX_ITEM_CHARS,
576+
"prefix",
577+
),
555578
freshDecisions,
556579
MAX_DECISIONS,
557580
);

0 commit comments

Comments
 (0)