From 1d7933c50e86fe048d614befe56ac44f406c439b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 17:35:24 -0700 Subject: [PATCH] fix(compaction): strip glued spine sentinel from long standing tokens cutSpineGoal glues "..." onto a start-at-0 token longer than the cap. outputFormatTokenRe eats that sentinel, so Goal and Output harvests diverge and a without-file re-fold duplicates the token. Strip the glued sentinel when restoring Output, and do not recut a cap-length prefix that already ends with the sentinel. --- src/session/compaction-handoff.test.ts | 68 ++++++++++++++++++++++++++ src/session/compaction-handoff.ts | 22 +++++++++ 2 files changed, 90 insertions(+) diff --git a/src/session/compaction-handoff.test.ts b/src/session/compaction-handoff.test.ts index 731b055a9..77f7c474e 100644 --- a/src/session/compaction-handoff.test.ts +++ b/src/session/compaction-handoff.test.ts @@ -749,6 +749,74 @@ describe("iterative folding", () => { expect(withoutFile.artifact.goal).not.toContain(`${token} ${token}`); }); + test("without-file re-fold of a start-at-0 FILES_DONE longer than 160 keeps one token", () => { + const token = `FILES_DONE=${"src/session/compaction-handoff.ts/".repeat(8)}end.ts`; + expect(token.length).toBeGreaterThan(160); + const goal = `${token} trailing prose after the standing token`; + const first = buildHandoffFold([userTurn(goal)], "narrative"); + expect(first.artifact.goal).toBe(goal); + expect(first.spineText).toContain(`Goal: ${token}...`); + expect(first.spineText).toContain(`Output: ${token}`); + + const withoutFile = buildHandoffFold( + [spineTurn(first.spineText), userTurn("Continue.")], + "narrative", + ); + expect(withoutFile.artifact.goal).toBe(token); + expect(withoutFile.spineText).toContain(`Output: ${token}`); + expect( + withoutFile.spineText + .split("\n") + .find((line) => line.startsWith("Output: ")), + ).toBe(`Output: ${token}`); + expect(withoutFile.artifact.goal).not.toContain(`${token}... ${token}`); + expect(withoutFile.artifact.goal).not.toContain(`${token} ${token}`); + }); + + test("without-file re-fold of a start-at-0 comma-joined FILES_DONE keeps one token", () => { + const token = `FILES_DONE=${Array.from({ length: 20 }, (_, i) => `src/session/file${i}.ts`).join(",")}`; + expect(token.length).toBeGreaterThan(160); + const goal = `${token} trailing prose after the standing token`; + const first = buildHandoffFold([userTurn(goal)], "narrative"); + expect(first.artifact.goal).toBe(goal); + + const withoutFile = buildHandoffFold( + [spineTurn(first.spineText), userTurn("Continue.")], + "narrative", + ); + expect(withoutFile.artifact.goal).toBe(token); + expect( + withoutFile.spineText + .split("\n") + .find((line) => line.startsWith("Output: ")), + ).toBe(`Output: ${token}`); + expect(withoutFile.artifact.goal).not.toContain(`${token}... ${token}`); + expect(withoutFile.artifact.goal).not.toContain(`${token} ${token}`); + }); + + test("two without-file re-folds of a 159-char spaceless prefix do not grow a fourth dot", () => { + const prefix = "A".repeat(159); + const goal = `${prefix} extra words beyond the spine cap`; + const first = buildHandoffFold([userTurn(goal)], "narrative"); + expect(first.spineText).toContain(`Goal: ${prefix}...`); + expect(first.artifact.goal).toBe(goal); + + const withoutFile = buildHandoffFold( + [spineTurn(first.spineText), userTurn("Continue.")], + "narrative", + ); + const again = buildHandoffFold( + [spineTurn(withoutFile.spineText), userTurn("Continue.")], + "narrative", + ); + expect(withoutFile.spineText).toContain(`Goal: ${prefix}...`); + expect(again.spineText).toContain(`Goal: ${prefix}...`); + expect(withoutFile.spineText).not.toContain(`${prefix}....`); + expect(again.spineText).not.toContain(`${prefix}....`); + expect(withoutFile.artifact.goal).not.toContain("...."); + expect(again.artifact.goal).not.toContain("...."); + }); + test("a truncated spine fragment collapses into the full prior-file constraint", () => { const full = `Must never ship without ${"x".repeat(80)}`; const first = buildHandoffFold([userTurn(full)], "narrative"); diff --git a/src/session/compaction-handoff.ts b/src/session/compaction-handoff.ts index dfe4bcd01..95b1dfb29 100644 --- a/src/session/compaction-handoff.ts +++ b/src/session/compaction-handoff.ts @@ -153,6 +153,21 @@ function mergeStandingOutputToken( ): string { if (goal === undefined || goal.length === 0) return token; if (hasStandingOutputToken(goal, token)) return goal; + // cutSpineGoal glues "..." onto a start-at-0 token longer than the cap. + // outputFormatTokenRe (\S+) eats that sentinel, so Goal's harvested token + // is token+"..." and never equals the Output token. Strip the glued + // sentinel and keep the standing token once. + if ( + goal.endsWith(SPINE_CUT_SENTINEL) && + !token.endsWith(SPINE_CUT_SENTINEL) + ) { + const prefix = goal.slice(0, -SPINE_CUT_SENTINEL.length); + if ( + hasStandingOutputToken(prefix, token) || + outputFormatTokens(goal).includes(`${token}${SPINE_CUT_SENTINEL}`) + ) + return prefix; + } const prefix = goal.endsWith(SPINE_CUT_SENTINEL) && !trailingEllipsisIsStandingToken(goal) ? goal.slice(0, -SPINE_CUT_SENTINEL.length) @@ -163,6 +178,13 @@ function mergeStandingOutputToken( function cutSpineGoal(text: string): string { const line = text.replace(/\s+/g, " ").trim(); if (line.length <= SPINE_GOAL_CHARS) return line; + // A prior spine cut already ended with the sentinel and sits at the cap. + // Recutting a 159-char spaceless prefix plus "..." eats the first dot and + // glues another sentinel, growing "..." into "....". + if (line.endsWith(SPINE_CUT_SENTINEL)) { + const without = line.slice(0, -SPINE_CUT_SENTINEL.length); + if (without.length <= SPINE_GOAL_CHARS) return line; + } let cap = SPINE_GOAL_CHARS; for (const match of line.matchAll(outputFormatTokenRe())) { const start = match.index ?? 0;