From c1807be2bf7d9def9b7c7f33e00721bcb0652c3e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 16:57:46 -0700 Subject: [PATCH] fix(compaction): keep standing output tokens whole across goal caps --- src/session/compaction-handoff.test.ts | 69 ++++++++++++++++++++++++++ src/session/compaction-handoff.ts | 58 ++++++++++++++++++---- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/src/session/compaction-handoff.test.ts b/src/session/compaction-handoff.test.ts index 4e0fecc89..377e78636 100644 --- a/src/session/compaction-handoff.test.ts +++ b/src/session/compaction-handoff.test.ts @@ -267,6 +267,31 @@ describe("extractHandoffArtifact", () => { "lint: unused import in src/auth.ts", ); }); + + test("keeps a standing output token that straddles the 500-character file-goal cap", () => { + const token = "FILES_DONE=src/session/compaction-handoff.ts"; + const prefix = `${"W".repeat(500 - "FILES_DO".length - 1)} `; + const goal = `${prefix}${token}`; + const sliced = goal.replace(/\s+/g, " ").trim().slice(0, 500); + expect(sliced.endsWith("FILES_DO")).toBe(true); + expect(sliced.includes("FILES_DONE")).toBe(false); + + const { artifact, spine } = extractHandoffArtifact( + [userTurn(goal)], + "narrative", + ); + expect(artifact.goal).toContain(token); + expect(artifact.goal).not.toBe(sliced); + expect(artifact.goal.endsWith("FILES_DO")).toBe(false); + expect(spine.goal).toContain(token); + + const rendered = renderHandoffSpine( + spine, + handoffBlobUri(HANDOFF_LATEST_KEY), + ); + expect(rendered).toContain(token); + expect(rendered).toContain(`Output: ${token}`); + }); }); describe("recoverEvidenceMarkers", () => { @@ -379,6 +404,40 @@ describe("renderHandoffSpine", () => { expect(rendered).toMatch(/operator ask|output contract/); }); + test("does not render Goal: ... only when the first 160 characters have no space", () => { + const goal = "W".repeat(200); + const rendered = renderHandoffSpine( + { + goal, + constraints: [], + decisions: [], + evidenceMarkers: [], + activatedTools: [], + }, + handoffBlobUri(HANDOFF_LATEST_KEY), + ); + expect(rendered).not.toMatch(/^Goal: \.\.\.$/m); + expect(rendered).toContain(`Goal: ${"W".repeat(160)}...`); + }); + + test("keeps a standing output token after a spaceless 160-character prefix", () => { + const token = "FILES_DONE=src/a.ts"; + const goal = `${"W".repeat(160)} ${token}`; + const rendered = renderHandoffSpine( + { + goal, + constraints: [], + decisions: [], + evidenceMarkers: [], + activatedTools: [], + }, + handoffBlobUri(HANDOFF_LATEST_KEY), + ); + expect(rendered).not.toMatch(/^Goal: \.\.\.$/m); + expect(rendered).toContain(`Goal: ${"W".repeat(160)}...`); + expect(rendered).toContain(`Output: ${token}`); + }); + test("leaves 79- and 80-char items unmarked and marks 81 with an ellipsis", () => { const unmarked79 = `Must never write to ${"a".repeat(59)}`; const unmarked80 = `Must never write to ${"a".repeat(60)}`; @@ -627,6 +686,16 @@ describe("iterative folding", () => { ); expect(withoutFile.spineText).not.toContain(midToken); expect(withoutFile.spineText).toContain(token); + expect(withoutFile.artifact.goal).toBe(goal); + expect(withoutFile.artifact.goal).not.toContain("..."); + + const again = buildHandoffFold( + [spineTurn(withoutFile.spineText), userTurn("Continue.")], + "narrative", + ); + expect(again.artifact.goal).toBe(goal); + expect(again.artifact.goal).not.toContain("..."); + expect(again.spineText).toContain(token); }); test("a truncated spine fragment collapses into the full prior-file constraint", () => { diff --git a/src/session/compaction-handoff.ts b/src/session/compaction-handoff.ts index 7163646ce..a70932f3c 100644 --- a/src/session/compaction-handoff.ts +++ b/src/session/compaction-handoff.ts @@ -97,8 +97,12 @@ const VERIFICATION_SIGNAL = /test|check|lint|build|typecheck|verify/i; const EVIDENCE_TOKEN = /\[\[evidence:[^[\]\r\n]+\]\]/g; // Standing reply-contract tokens (FILES_DONE=..., SUMMARY=...). The spine -// must keep these whole; a char slice is how they become FILES_DO. -const OUTPUT_FORMAT_TOKEN = /\b[A-Z][A-Z0-9_]+=\S+/g; +// and the file goal must keep these whole; a char slice is how they become +// FILES_DO. Harvest from the uncapped source so a 500-char cap cannot drop +// a token that starts just past the cut. +function outputFormatTokenRe(): RegExp { + return /\b[A-Z][A-Z0-9_]+=\S+/g; +} function oneLine(text: string, maxChars: number): string { return text.replace(/\s+/g, " ").trim().slice(0, maxChars); @@ -106,20 +110,52 @@ function oneLine(text: string, maxChars: number): string { function outputFormatTokens(text: string): string[] { const found: string[] = []; - for (const match of text.matchAll(OUTPUT_FORMAT_TOKEN)) { + for (const match of text.matchAll(outputFormatTokenRe())) { const token = match[0]; if (token !== undefined && !found.includes(token)) found.push(token); } return found; } +function capGoal(text: string, maxChars: number): string { + const line = text.replace(/\s+/g, " ").trim(); + const tokens = outputFormatTokens(line); + let end = Math.min(maxChars, line.length); + for (const match of line.matchAll(outputFormatTokenRe())) { + const start = match.index ?? 0; + const tokenEnd = start + match[0].length; + if (start < end && tokenEnd > end) end = tokenEnd; + } + let capped = line.slice(0, end); + for (const token of tokens) { + if (!capped.includes(token)) capped = `${capped} ${token}`; + } + return capped; +} + function cutSpineGoal(text: string): string { const line = text.replace(/\s+/g, " ").trim(); if (line.length <= SPINE_GOAL_CHARS) return line; - const head = line.slice(0, SPINE_GOAL_CHARS); + let cap = SPINE_GOAL_CHARS; + for (const match of line.matchAll(outputFormatTokenRe())) { + const start = match.index ?? 0; + const tokenEnd = start + match[0].length; + if (start < SPINE_GOAL_CHARS && tokenEnd > SPINE_GOAL_CHARS) { + if (start === 0) { + const kept = match[0]; + return kept.length < line.length + ? `${kept}${SPINE_CUT_SENTINEL}` + : kept; + } + cap = start; + break; + } + } + const head = line.slice(0, cap); const lastSpace = head.lastIndexOf(" "); - const kept = lastSpace > 0 ? head.slice(0, lastSpace) : ""; - return kept.length > 0 ? `${kept}${SPINE_CUT_SENTINEL}` : SPINE_CUT_SENTINEL; + const kept = lastSpace > 0 ? head.slice(0, lastSpace) : head.trimEnd(); + if (kept.length === 0) return SPINE_CUT_SENTINEL; + return `${kept}${SPINE_CUT_SENTINEL}`; } function cutSpineItem(text: string): string { @@ -235,7 +271,9 @@ function parseSpineText(text: string): CarriedFacts { for (const line of text.split("\n")) { const trimmed = line.trim(); if (trimmed.startsWith("Goal: ")) { - const goal = trimmed.slice("Goal: ".length).trim(); + let goal = trimmed.slice("Goal: ".length).trim(); + if (goal.endsWith(SPINE_CUT_SENTINEL)) + goal = goal.slice(0, -SPINE_CUT_SENTINEL.length); if (goal.length > 0) carried.goal = goal; } else if (trimmed.startsWith(HANDOFF_OUTPUT_LINE_PREFIX)) { for (const token of outputFormatTokens( @@ -524,14 +562,14 @@ export function extractHandoffArtifact( // instead of dropping it. The verify-repair appendix is stripped first: it // carries exact file lists that stay in the fat file by design (CL-8744) and // must not leak into the thin spine. - const narrativeGoal = oneLine( + const narrativeGoal = capGoal( narrative.split(VERIFY_REPAIR_HEADING)[0] ?? "", MAX_GOAL_CHARS, ); const extractedGoal = carried.goal ?? (nonEmptyUserTexts.length > 0 - ? oneLine(nonEmptyUserTexts[0] ?? "", MAX_GOAL_CHARS) + ? capGoal(nonEmptyUserTexts[0] ?? "", MAX_GOAL_CHARS) : narrativeGoal.length > 0 ? narrativeGoal : "Unknown (no user message in folded turns)"); @@ -541,7 +579,7 @@ export function extractHandoffArtifact( const lastUserText = [...nonEmptyUserTexts].pop(); const lastUserAsNext = lastUserText !== undefined && - oneLine(lastUserText, MAX_GOAL_CHARS) !== extractedGoal && + capGoal(lastUserText, MAX_GOAL_CHARS) !== extractedGoal && oneLine(lastUserText, SPINE_GOAL_CHARS) !== carried.goal; const freshDecisions: string[] = [];