Skip to content

Commit c1807be

Browse files
committed
fix(compaction): keep standing output tokens whole across goal caps
1 parent 41bf765 commit c1807be

2 files changed

Lines changed: 117 additions & 10 deletions

File tree

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,31 @@ describe("extractHandoffArtifact", () => {
267267
"lint: unused import in src/auth.ts",
268268
);
269269
});
270+
271+
test("keeps a standing output token that straddles the 500-character file-goal cap", () => {
272+
const token = "FILES_DONE=src/session/compaction-handoff.ts";
273+
const prefix = `${"W".repeat(500 - "FILES_DO".length - 1)} `;
274+
const goal = `${prefix}${token}`;
275+
const sliced = goal.replace(/\s+/g, " ").trim().slice(0, 500);
276+
expect(sliced.endsWith("FILES_DO")).toBe(true);
277+
expect(sliced.includes("FILES_DONE")).toBe(false);
278+
279+
const { artifact, spine } = extractHandoffArtifact(
280+
[userTurn(goal)],
281+
"narrative",
282+
);
283+
expect(artifact.goal).toContain(token);
284+
expect(artifact.goal).not.toBe(sliced);
285+
expect(artifact.goal.endsWith("FILES_DO")).toBe(false);
286+
expect(spine.goal).toContain(token);
287+
288+
const rendered = renderHandoffSpine(
289+
spine,
290+
handoffBlobUri(HANDOFF_LATEST_KEY),
291+
);
292+
expect(rendered).toContain(token);
293+
expect(rendered).toContain(`Output: ${token}`);
294+
});
270295
});
271296

272297
describe("recoverEvidenceMarkers", () => {
@@ -379,6 +404,40 @@ describe("renderHandoffSpine", () => {
379404
expect(rendered).toMatch(/operator ask|output contract/);
380405
});
381406

407+
test("does not render Goal: ... only when the first 160 characters have no space", () => {
408+
const goal = "W".repeat(200);
409+
const rendered = renderHandoffSpine(
410+
{
411+
goal,
412+
constraints: [],
413+
decisions: [],
414+
evidenceMarkers: [],
415+
activatedTools: [],
416+
},
417+
handoffBlobUri(HANDOFF_LATEST_KEY),
418+
);
419+
expect(rendered).not.toMatch(/^Goal: \.\.\.$/m);
420+
expect(rendered).toContain(`Goal: ${"W".repeat(160)}...`);
421+
});
422+
423+
test("keeps a standing output token after a spaceless 160-character prefix", () => {
424+
const token = "FILES_DONE=src/a.ts";
425+
const goal = `${"W".repeat(160)} ${token}`;
426+
const rendered = renderHandoffSpine(
427+
{
428+
goal,
429+
constraints: [],
430+
decisions: [],
431+
evidenceMarkers: [],
432+
activatedTools: [],
433+
},
434+
handoffBlobUri(HANDOFF_LATEST_KEY),
435+
);
436+
expect(rendered).not.toMatch(/^Goal: \.\.\.$/m);
437+
expect(rendered).toContain(`Goal: ${"W".repeat(160)}...`);
438+
expect(rendered).toContain(`Output: ${token}`);
439+
});
440+
382441
test("leaves 79- and 80-char items unmarked and marks 81 with an ellipsis", () => {
383442
const unmarked79 = `Must never write to ${"a".repeat(59)}`;
384443
const unmarked80 = `Must never write to ${"a".repeat(60)}`;
@@ -627,6 +686,16 @@ describe("iterative folding", () => {
627686
);
628687
expect(withoutFile.spineText).not.toContain(midToken);
629688
expect(withoutFile.spineText).toContain(token);
689+
expect(withoutFile.artifact.goal).toBe(goal);
690+
expect(withoutFile.artifact.goal).not.toContain("...");
691+
692+
const again = buildHandoffFold(
693+
[spineTurn(withoutFile.spineText), userTurn("Continue.")],
694+
"narrative",
695+
);
696+
expect(again.artifact.goal).toBe(goal);
697+
expect(again.artifact.goal).not.toContain("...");
698+
expect(again.spineText).toContain(token);
630699
});
631700

632701
test("a truncated spine fragment collapses into the full prior-file constraint", () => {

‎src/session/compaction-handoff.ts‎

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,29 +97,65 @@ const VERIFICATION_SIGNAL = /test|check|lint|build|typecheck|verify/i;
9797
const EVIDENCE_TOKEN = /\[\[evidence:[^[\]\r\n]+\]\]/g;
9898

9999
// Standing reply-contract tokens (FILES_DONE=..., SUMMARY=...). The spine
100-
// must keep these whole; a char slice is how they become FILES_DO.
101-
const OUTPUT_FORMAT_TOKEN = /\b[A-Z][A-Z0-9_]+=\S+/g;
100+
// and the file goal must keep these whole; a char slice is how they become
101+
// FILES_DO. Harvest from the uncapped source so a 500-char cap cannot drop
102+
// a token that starts just past the cut.
103+
function outputFormatTokenRe(): RegExp {
104+
return /\b[A-Z][A-Z0-9_]+=\S+/g;
105+
}
102106

103107
function oneLine(text: string, maxChars: number): string {
104108
return text.replace(/\s+/g, " ").trim().slice(0, maxChars);
105109
}
106110

107111
function outputFormatTokens(text: string): string[] {
108112
const found: string[] = [];
109-
for (const match of text.matchAll(OUTPUT_FORMAT_TOKEN)) {
113+
for (const match of text.matchAll(outputFormatTokenRe())) {
110114
const token = match[0];
111115
if (token !== undefined && !found.includes(token)) found.push(token);
112116
}
113117
return found;
114118
}
115119

120+
function capGoal(text: string, maxChars: number): string {
121+
const line = text.replace(/\s+/g, " ").trim();
122+
const tokens = outputFormatTokens(line);
123+
let end = Math.min(maxChars, line.length);
124+
for (const match of line.matchAll(outputFormatTokenRe())) {
125+
const start = match.index ?? 0;
126+
const tokenEnd = start + match[0].length;
127+
if (start < end && tokenEnd > end) end = tokenEnd;
128+
}
129+
let capped = line.slice(0, end);
130+
for (const token of tokens) {
131+
if (!capped.includes(token)) capped = `${capped} ${token}`;
132+
}
133+
return capped;
134+
}
135+
116136
function cutSpineGoal(text: string): string {
117137
const line = text.replace(/\s+/g, " ").trim();
118138
if (line.length <= SPINE_GOAL_CHARS) return line;
119-
const head = line.slice(0, SPINE_GOAL_CHARS);
139+
let cap = SPINE_GOAL_CHARS;
140+
for (const match of line.matchAll(outputFormatTokenRe())) {
141+
const start = match.index ?? 0;
142+
const tokenEnd = start + match[0].length;
143+
if (start < SPINE_GOAL_CHARS && tokenEnd > SPINE_GOAL_CHARS) {
144+
if (start === 0) {
145+
const kept = match[0];
146+
return kept.length < line.length
147+
? `${kept}${SPINE_CUT_SENTINEL}`
148+
: kept;
149+
}
150+
cap = start;
151+
break;
152+
}
153+
}
154+
const head = line.slice(0, cap);
120155
const lastSpace = head.lastIndexOf(" ");
121-
const kept = lastSpace > 0 ? head.slice(0, lastSpace) : "";
122-
return kept.length > 0 ? `${kept}${SPINE_CUT_SENTINEL}` : SPINE_CUT_SENTINEL;
156+
const kept = lastSpace > 0 ? head.slice(0, lastSpace) : head.trimEnd();
157+
if (kept.length === 0) return SPINE_CUT_SENTINEL;
158+
return `${kept}${SPINE_CUT_SENTINEL}`;
123159
}
124160

125161
function cutSpineItem(text: string): string {
@@ -235,7 +271,9 @@ function parseSpineText(text: string): CarriedFacts {
235271
for (const line of text.split("\n")) {
236272
const trimmed = line.trim();
237273
if (trimmed.startsWith("Goal: ")) {
238-
const goal = trimmed.slice("Goal: ".length).trim();
274+
let goal = trimmed.slice("Goal: ".length).trim();
275+
if (goal.endsWith(SPINE_CUT_SENTINEL))
276+
goal = goal.slice(0, -SPINE_CUT_SENTINEL.length);
239277
if (goal.length > 0) carried.goal = goal;
240278
} else if (trimmed.startsWith(HANDOFF_OUTPUT_LINE_PREFIX)) {
241279
for (const token of outputFormatTokens(
@@ -524,14 +562,14 @@ export function extractHandoffArtifact(
524562
// instead of dropping it. The verify-repair appendix is stripped first: it
525563
// carries exact file lists that stay in the fat file by design (CL-8744) and
526564
// must not leak into the thin spine.
527-
const narrativeGoal = oneLine(
565+
const narrativeGoal = capGoal(
528566
narrative.split(VERIFY_REPAIR_HEADING)[0] ?? "",
529567
MAX_GOAL_CHARS,
530568
);
531569
const extractedGoal =
532570
carried.goal ??
533571
(nonEmptyUserTexts.length > 0
534-
? oneLine(nonEmptyUserTexts[0] ?? "", MAX_GOAL_CHARS)
572+
? capGoal(nonEmptyUserTexts[0] ?? "", MAX_GOAL_CHARS)
535573
: narrativeGoal.length > 0
536574
? narrativeGoal
537575
: "Unknown (no user message in folded turns)");
@@ -541,7 +579,7 @@ export function extractHandoffArtifact(
541579
const lastUserText = [...nonEmptyUserTexts].pop();
542580
const lastUserAsNext =
543581
lastUserText !== undefined &&
544-
oneLine(lastUserText, MAX_GOAL_CHARS) !== extractedGoal &&
582+
capGoal(lastUserText, MAX_GOAL_CHARS) !== extractedGoal &&
545583
oneLine(lastUserText, SPINE_GOAL_CHARS) !== carried.goal;
546584

547585
const freshDecisions: string[] = [];

0 commit comments

Comments
 (0)