diff --git a/src/session/compaction-handoff.test.ts b/src/session/compaction-handoff.test.ts index 377e78636..731b055a9 100644 --- a/src/session/compaction-handoff.test.ts +++ b/src/session/compaction-handoff.test.ts @@ -292,6 +292,26 @@ describe("extractHandoffArtifact", () => { expect(rendered).toContain(token); expect(rendered).toContain(`Output: ${token}`); }); + + test("harvests a later standing token that is a substring of an in-window token", () => { + const inWindow = "FILES_DONE=src/a.ts.bak"; + const later = "FILES_DONE=src/a.ts"; + const prefix = `${"W".repeat(500 - inWindow.length - 1)} `; + const goal = `${prefix}${inWindow} ${later}`; + const window = goal.replace(/\s+/g, " ").trim().slice(0, 500); + expect(window.endsWith(inWindow)).toBe(true); + expect(window.includes(later)).toBe(true); + expect(window.includes(`${later} `)).toBe(false); + + const { artifact, spine } = extractHandoffArtifact( + [userTurn(goal)], + "narrative", + ); + expect(artifact.goal).toBe(goal.replace(/\s+/g, " ").trim()); + expect(artifact.goal.endsWith(` ${later}`)).toBe(true); + expect(spine.goal).toContain(inWindow); + expect(spine.goal.endsWith(` ${later}`)).toBe(true); + }); }); describe("recoverEvidenceMarkers", () => { @@ -698,6 +718,37 @@ describe("iterative folding", () => { expect(again.spineText).toContain(token); }); + test("without-file re-fold keeps an operator Goal that ends with ellipsis", () => { + const goal = "Fix the tests..."; + const first = buildHandoffFold([userTurn(goal)], "narrative"); + expect(first.spineText).toContain(`Goal: ${goal}`); + expect(first.artifact.goal).toBe(goal); + + const withoutFile = buildHandoffFold( + [spineTurn(first.spineText), userTurn("Continue.")], + "narrative", + ); + expect(withoutFile.artifact.goal).toBe(goal); + expect(withoutFile.spineText).toContain(`Goal: ${goal}`); + }); + + test("without-file re-fold keeps FILES_DONE=src/foo... as one token", () => { + const token = "FILES_DONE=src/foo..."; + const first = buildHandoffFold([userTurn(token)], "narrative"); + expect(first.spineText).toContain(`Goal: ${token}`); + expect(first.spineText).toContain(`Output: ${token}`); + expect(first.artifact.goal).toBe(token); + + const withoutFile = buildHandoffFold( + [spineTurn(first.spineText), userTurn("Continue.")], + "narrative", + ); + expect(withoutFile.artifact.goal).toBe(token); + expect(withoutFile.spineText).toContain(`Goal: ${token}`); + expect(withoutFile.spineText).toContain(`Output: ${token}`); + expect(withoutFile.artifact.goal).not.toContain(`${token} ${token}`); + }); + 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 a70932f3c..dfe4bcd01 100644 --- a/src/session/compaction-handoff.ts +++ b/src/session/compaction-handoff.ts @@ -127,12 +127,39 @@ function capGoal(text: string, maxChars: number): string { if (start < end && tokenEnd > end) end = tokenEnd; } let capped = line.slice(0, end); + const present = new Set(outputFormatTokens(capped)); for (const token of tokens) { - if (!capped.includes(token)) capped = `${capped} ${token}`; + if (present.has(token)) continue; + capped = `${capped} ${token}`; + present.add(token); } return capped; } +function hasStandingOutputToken(goal: string, token: string): boolean { + return outputFormatTokens(goal).includes(token); +} + +function trailingEllipsisIsStandingToken(goal: string): boolean { + if (!goal.endsWith(SPINE_CUT_SENTINEL)) return false; + return outputFormatTokens(goal).some( + (token) => token.endsWith(SPINE_CUT_SENTINEL) && goal.endsWith(token), + ); +} + +function mergeStandingOutputToken( + goal: string | undefined, + token: string, +): string { + if (goal === undefined || goal.length === 0) return token; + if (hasStandingOutputToken(goal, token)) return goal; + const prefix = + goal.endsWith(SPINE_CUT_SENTINEL) && !trailingEllipsisIsStandingToken(goal) + ? goal.slice(0, -SPINE_CUT_SENTINEL.length) + : goal; + return `${prefix} ${token}`; +} + function cutSpineGoal(text: string): string { const line = text.replace(/\s+/g, " ").trim(); if (line.length <= SPINE_GOAL_CHARS) return line; @@ -271,17 +298,17 @@ function parseSpineText(text: string): CarriedFacts { for (const line of text.split("\n")) { const trimmed = line.trim(); if (trimmed.startsWith("Goal: ")) { - let goal = trimmed.slice("Goal: ".length).trim(); - if (goal.endsWith(SPINE_CUT_SENTINEL)) - goal = goal.slice(0, -SPINE_CUT_SENTINEL.length); + // Keep operator-authored trailing "..." (Fix the tests..., + // FILES_DONE=src/foo...). The spine cut sentinel is stripped only when + // mergeStandingOutputToken restores an Output token onto a truncated + // Goal prefix. + const goal = trimmed.slice("Goal: ".length).trim(); if (goal.length > 0) carried.goal = goal; } else if (trimmed.startsWith(HANDOFF_OUTPUT_LINE_PREFIX)) { for (const token of outputFormatTokens( trimmed.slice(HANDOFF_OUTPUT_LINE_PREFIX.length), )) { - if (carried.goal === undefined) carried.goal = token; - else if (!carried.goal.includes(token)) - carried.goal = `${carried.goal} ${token}`; + carried.goal = mergeStandingOutputToken(carried.goal, token); } } else if (trimmed.startsWith("Constraints: ")) { for (const constraint of trimmed