Skip to content

Commit 5c4a733

Browse files
fix(cli-runner): keep recent tail when reseed history exceeds maxHistoryChars (#83117)
* fix(cli-runner): keep recent tail when reseed history exceeds maxHistoryChars `buildCliSessionHistoryPrompt` was prefix-slicing the rendered history, dropping the most recent assistant turns from the reseed prompt. After #80934 made the Claude-CLI reseed default-on, every Claude-CLI user is exposed to this on session_expired when the rendered transcript exceeds 12288 chars. The truncation marker landed mid-word in real reproductions. Fix: - Tail-slice (keep the recent suffix, drop the older prefix) - Pin the compaction summary as a prefix when present, only cap the post-summary transcript (loadCliSessionReseedMessages deliberately places the summary first) - When the summary alone exceeds maxHistoryChars, head-slice the summary itself to honor the cap; drop the post-summary tail in that case - Move the truncation marker to the lead since what follows is the recent tail, not what was dropped Closes #83157 * fix(cli-runner): retain recent tail with oversize summaries * fix(cli-runner): cap summary block plus marker against maxHistoryChars ClawSweeper P2 on #83117 flagged that when `summaryRendered.length` is less than `maxHistoryChars` but `summaryBlock.length` (summary + `\n\n` separator) meets or exceeds it, the `remainingBudget <= 0` arm of `buildCliSessionHistoryPrompt` appends the truncation marker after the already-full summary block. A 199-char rendered summary under a 200-char cap produced a 257-char history block — defeating the cap that prevents reseeding fresh CLI sessions with unexpectedly huge prompts. Fix the budget edge by truncating the summary in this branch as well so `summary + separator + marker` stays within `maxHistoryChars`. The tail still drops (the summary alone consumes the budget) and the marker still leads its own line so the prompt announces what was discarded. Mirrors the existing oversize-summary branch's pattern of head-slicing the summary against an explicit budget that reserves marker + separator. Add a focused regression in `session-history.test.ts` covering exactly the gap the finding called out: `summaryRendered.length < maxHistoryChars` with a non-empty post-summary tail. Asserts the rendered history block stays within `maxHistoryChars` and the truncation marker is present. * fix(cli-runner): keep tail for near-cap summaries --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 846f566 commit 5c4a733

2 files changed

Lines changed: 245 additions & 27 deletions

File tree

src/agents/cli-runner/session-history.test.ts

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -577,13 +577,163 @@ describe("buildCliSessionHistoryPrompt", () => {
577577

578578
it("caps rendered reseed history before adding the next user message", () => {
579579
const prompt = buildCliSessionHistoryPrompt({
580-
messages: [{ role: "compactionSummary", summary: "x".repeat(100) }],
580+
messages: [
581+
{ role: "user", content: "x".repeat(100) },
582+
{ role: "assistant", content: "y".repeat(100) },
583+
],
581584
prompt: "current ask must survive",
582585
maxHistoryChars: 20,
583586
});
584587

585-
expect(prompt).toContain("[OpenClaw reseed history truncated]");
588+
expect(prompt).toContain("[OpenClaw reseed history truncated; older turns dropped]");
586589
expect(prompt).toContain("<next_user_message>\ncurrent ask must survive\n</next_user_message>");
590+
// Older 100-char prefix must be dropped by the tail slice; the
591+
// post-cap rendered tail is shorter than the dropped prefix.
587592
expect(prompt).not.toContain("x".repeat(80));
588593
});
594+
595+
it("keeps the most recent turns when rendered history exceeds the cap", () => {
596+
// Older turns plus a final marker turn whose content is exactly what a
597+
// head-slice would drop first. Asserting the marker survives in the
598+
// rendered prompt locks in tail-slice semantics: a session-recovery
599+
// feature must keep the latest context, not the oldest.
600+
const prompt = buildCliSessionHistoryPrompt({
601+
messages: [
602+
{ role: "user", content: "x".repeat(8000) },
603+
{ role: "assistant", content: "y".repeat(8000) },
604+
{ role: "user", content: "FINAL_USER_MARKER" },
605+
{ role: "assistant", content: "FINAL_ASSISTANT_MARKER" },
606+
],
607+
prompt: "next ask",
608+
});
609+
610+
expect(prompt).toBeDefined();
611+
expect(prompt).toContain("FINAL_USER_MARKER");
612+
expect(prompt).toContain("FINAL_ASSISTANT_MARKER");
613+
expect(prompt).toContain("[OpenClaw reseed history truncated; older turns dropped]");
614+
// The oldest 8000-char block must have been dropped — a head-slice
615+
// would have kept it instead of the recent tail.
616+
expect(prompt).not.toContain("x".repeat(8000));
617+
expect(prompt).toContain("<next_user_message>\nnext ask\n</next_user_message>");
618+
});
619+
620+
it("preserves the compaction summary when the post-summary transcript exceeds the cap", () => {
621+
// loadCliSessionReseedMessages places a compactionSummary entry first
622+
// so the compacted prior context survives reseed. A blind tail slice
623+
// of the joined history would drop that summary whenever the
624+
// post-summary tail alone exceeds the cap. The structure-aware
625+
// truncation pins the summary as a prefix and caps only the tail.
626+
const prompt = buildCliSessionHistoryPrompt({
627+
messages: [
628+
{ role: "compactionSummary", summary: "COMPACTION_SUMMARY_MARKER pinned context" },
629+
{ role: "user", content: "z".repeat(8000) },
630+
{ role: "assistant", content: "w".repeat(8000) },
631+
{ role: "user", content: "POST_SUMMARY_FINAL_USER" },
632+
{ role: "assistant", content: "POST_SUMMARY_FINAL_ASSISTANT" },
633+
],
634+
prompt: "next ask",
635+
});
636+
637+
expect(prompt).toBeDefined();
638+
// Compaction summary must be pinned as a prefix, not sliced away.
639+
expect(prompt).toContain("Compaction summary: COMPACTION_SUMMARY_MARKER pinned context");
640+
// Recent tail still preserved within the post-summary budget.
641+
expect(prompt).toContain("POST_SUMMARY_FINAL_USER");
642+
expect(prompt).toContain("POST_SUMMARY_FINAL_ASSISTANT");
643+
expect(prompt).toContain("[OpenClaw reseed history truncated; older turns dropped]");
644+
// Head of post-summary tail (oldest 8000-char `z` block) must be
645+
// dropped so the cap is honored.
646+
expect(prompt).not.toContain("z".repeat(8000));
647+
expect(prompt).toContain("<next_user_message>\nnext ask\n</next_user_message>");
648+
});
649+
650+
it("caps oversize compaction summary while preserving recent post-summary tail", () => {
651+
// Two regressions covered here:
652+
// 1. `tailRaw.slice(-0)` would return the entire tail (JS quirk:
653+
// `String.prototype.slice(-0) === slice(0)`), defeating the cap when
654+
// the summary block consumes the budget.
655+
// 2. Pinning the full summary as-is when the summary itself exceeds
656+
// `maxHistoryChars` would blow past the cap that prevents
657+
// reseeding fresh CLI sessions with unexpectedly huge prompts.
658+
// The summary must itself be truncated to fit the budget while still
659+
// preserving the recent post-summary exact turns.
660+
const summaryText = "OVERSIZE_SUMMARY_MARKER ".repeat(50).trim();
661+
const maxHistoryChars = 200;
662+
const prompt = buildCliSessionHistoryPrompt({
663+
messages: [
664+
{ role: "compactionSummary", summary: summaryText },
665+
{ role: "user", content: "POST_SUMMARY_USER_DROPPED" },
666+
{ role: "assistant", content: "POST_SUMMARY_ASSISTANT_DROPPED" },
667+
],
668+
prompt: "next ask",
669+
// Cap well below the rendered summary block so the summary itself
670+
// must be truncated and the tail budget would naively be 0.
671+
maxHistoryChars,
672+
});
673+
674+
expect(prompt).toBeDefined();
675+
// The truncated summary still leads with recognizable load-bearing
676+
// text — head-slicing preserves the orientation/intro of the summary.
677+
expect(prompt).toContain("OVERSIZE_SUMMARY_MARKER");
678+
expect(prompt).toContain("Compaction summary:");
679+
// The leading truncation marker is present so the prompt announces
680+
// what was discarded.
681+
expect(prompt).toContain("[OpenClaw reseed history truncated; older turns dropped]");
682+
// The cap is honored: the rendered <conversation_history> block
683+
// must not blow past `maxHistoryChars` plus a small wrapper allowance.
684+
const historyMatch = prompt?.match(
685+
/<conversation_history>\n([\s\S]*?)\n<\/conversation_history>/,
686+
);
687+
expect(historyMatch).not.toBeNull();
688+
const renderedHistory = historyMatch?.[1] ?? "";
689+
expect(renderedHistory.length).toBeLessThanOrEqual(maxHistoryChars);
690+
// The full untruncated summary must NOT appear — that would defeat
691+
// the cap.
692+
expect(prompt).not.toContain(summaryText);
693+
// Post-summary exact turns are newer than the summary and must still
694+
// survive inside the reserved tail budget.
695+
expect(prompt).toContain("POST_SUMMARY_USER_DROPPED");
696+
expect(prompt).toContain("POST_SUMMARY_ASSISTANT_DROPPED");
697+
expect(prompt).toContain("<next_user_message>\nnext ask\n</next_user_message>");
698+
});
699+
700+
it("honors the cap when the summary block plus marker crosses it", () => {
701+
// Edge case: `summaryRendered.length < maxHistoryChars` (the gate that
702+
// routes to the oversize-summary branch is not taken) BUT
703+
// `summaryBlock.length >= maxHistoryChars` once the `\n\n` separator
704+
// is appended, making `remainingBudget <= 0`. Without summary
705+
// truncation in that branch, the rendered history block is
706+
// `summary + separator + marker` — well over `maxHistoryChars`. A
707+
// 199-char rendered summary under a 200-char cap would otherwise
708+
// produce a 257-char history block.
709+
const maxHistoryChars = 200;
710+
// `renderHistoryMessage` prefixes "Compaction summary: " (20 chars)
711+
// before the summary text, so a 179-char summary renders to 199 chars
712+
// — strictly less than the cap, but `summaryBlock = rendered + "\n\n"`
713+
// is 201 chars and `remainingBudget` is negative.
714+
const summaryPrefix = "Compaction summary: ";
715+
const summaryText = "S".repeat(maxHistoryChars - 1 - summaryPrefix.length);
716+
const prompt = buildCliSessionHistoryPrompt({
717+
messages: [
718+
{ role: "compactionSummary", summary: summaryText },
719+
{ role: "user", content: "POST_SUMMARY_TAIL_USER" },
720+
{ role: "assistant", content: "POST_SUMMARY_TAIL_ASSISTANT" },
721+
],
722+
prompt: "next ask",
723+
maxHistoryChars,
724+
});
725+
726+
expect(prompt).toBeDefined();
727+
const historyMatch = prompt?.match(
728+
/<conversation_history>\n([\s\S]*?)\n<\/conversation_history>/,
729+
);
730+
expect(historyMatch).not.toBeNull();
731+
const renderedHistory = historyMatch?.[1] ?? "";
732+
expect(renderedHistory.length).toBeLessThanOrEqual(maxHistoryChars);
733+
// Marker is still present so the prompt announces what was discarded.
734+
expect(prompt).toContain("[OpenClaw reseed history truncated; older turns dropped]");
735+
// Near-cap summaries still reserve room for the newest exact turns.
736+
expect(prompt).toContain("POST_SUMMARY_TAIL_USER");
737+
expect(prompt).toContain("POST_SUMMARY_TAIL_ASSISTANT");
738+
});
589739
});

src/agents/cli-runner/session-history.ts

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -114,41 +114,109 @@ function loadContextEngineMessagesFromEntries(entries: unknown[]): AgentMessage[
114114
});
115115
}
116116

117+
function renderHistoryMessage(message: unknown): string | undefined {
118+
if (!message || typeof message !== "object") {
119+
return undefined;
120+
}
121+
const entry = message as HistoryMessage;
122+
const role =
123+
entry.role === "assistant"
124+
? "Assistant"
125+
: entry.role === "user"
126+
? "User"
127+
: entry.role === "compactionSummary"
128+
? "Compaction summary"
129+
: undefined;
130+
if (!role) {
131+
return undefined;
132+
}
133+
const text =
134+
entry.role === "compactionSummary" && typeof entry.summary === "string"
135+
? entry.summary.trim()
136+
: coerceHistoryText(entry.content);
137+
return text ? `${role}: ${text}` : undefined;
138+
}
139+
117140
export function buildCliSessionHistoryPrompt(params: {
118141
messages: unknown[];
119142
prompt: string;
120143
maxHistoryChars?: number;
121144
}): string | undefined {
122145
const maxHistoryChars = params.maxHistoryChars ?? MAX_CLI_SESSION_RESEED_HISTORY_CHARS;
123-
const renderedHistoryRaw = params.messages
146+
147+
// loadCliSessionReseedMessages deliberately places a `compactionSummary`
148+
// entry first when the session was compacted, so the compacted prior
149+
// context survives reseed. Pin that summary as a prefix and only
150+
// tail-truncate the post-summary transcript — a blind tail-slice of the
151+
// joined history would drop the summary whenever the post-summary tail
152+
// alone exceeds the cap.
153+
const firstEntry = params.messages[0];
154+
const firstIsCompaction =
155+
!!firstEntry &&
156+
typeof firstEntry === "object" &&
157+
(firstEntry as HistoryMessage).role === "compactionSummary";
158+
const summaryRendered = firstIsCompaction ? renderHistoryMessage(firstEntry) : undefined;
159+
const tailMessages = firstIsCompaction ? params.messages.slice(1) : params.messages;
160+
161+
const tailRaw = tailMessages
124162
.flatMap((message) => {
125-
if (!message || typeof message !== "object") {
126-
return [];
127-
}
128-
const entry = message as HistoryMessage;
129-
const role =
130-
entry.role === "assistant"
131-
? "Assistant"
132-
: entry.role === "user"
133-
? "User"
134-
: entry.role === "compactionSummary"
135-
? "Compaction summary"
136-
: undefined;
137-
if (!role) {
138-
return [];
139-
}
140-
const text =
141-
entry.role === "compactionSummary" && typeof entry.summary === "string"
142-
? entry.summary.trim()
143-
: coerceHistoryText(entry.content);
144-
return text ? [`${role}: ${text}`] : [];
163+
const rendered = renderHistoryMessage(message);
164+
return rendered ? [rendered] : [];
145165
})
146166
.join("\n\n")
147167
.trim();
148-
const renderedHistory =
149-
renderedHistoryRaw.length > maxHistoryChars
150-
? `${renderedHistoryRaw.slice(0, maxHistoryChars).trimEnd()}\n[OpenClaw reseed history truncated]`
151-
: renderedHistoryRaw;
168+
169+
const truncationMarker = "[OpenClaw reseed history truncated; older turns dropped]";
170+
const renderTruncatedSummaryWithTail = (renderedSummary: string): string => {
171+
const tailBudget =
172+
tailRaw.length > 0 ? Math.min(tailRaw.length, Math.floor(maxHistoryChars / 2)) : 0;
173+
const separatorBudget = tailBudget > 0 ? 2 : 1;
174+
const summaryBudget = Math.max(
175+
0,
176+
maxHistoryChars - truncationMarker.length - separatorBudget - tailBudget,
177+
);
178+
const summaryTruncated = renderedSummary.slice(0, summaryBudget).trimEnd();
179+
const tailTruncated = tailBudget > 0 ? tailRaw.slice(-tailBudget).trimStart() : "";
180+
return [truncationMarker, summaryTruncated, tailTruncated].filter(Boolean).join("\n");
181+
};
182+
183+
let renderedHistory: string;
184+
if (summaryRendered) {
185+
// Reserve the summary from the budget so the post-summary tail cap is
186+
// the remaining headroom. If the summary alone meets or exceeds the
187+
// cap, the summary itself must be truncated — pinning a summary that
188+
// blows past `maxHistoryChars` would defeat the cap that prevents
189+
// reseeding fresh CLI sessions with unexpectedly huge prompts.
190+
if (summaryRendered.length >= maxHistoryChars) {
191+
// Truncate the summary to fit the budget (less the marker line),
192+
// keeping the head. Still reserve budget for the post-summary tail so
193+
// recent exact turns survive even when the summary itself is oversize.
194+
renderedHistory = renderTruncatedSummaryWithTail(summaryRendered);
195+
} else if (tailRaw.length === 0) {
196+
renderedHistory = summaryRendered;
197+
} else {
198+
const summaryBlock = `${summaryRendered}\n\n`;
199+
const remainingBudget = maxHistoryChars - summaryBlock.length;
200+
if (remainingBudget <= 0) {
201+
// The summary plus separator already consumes the cap. Reuse the
202+
// oversize-summary path so recent post-summary turns still get
203+
// reserved tail budget instead of being dropped wholesale.
204+
renderedHistory = renderTruncatedSummaryWithTail(summaryRendered);
205+
} else if (tailRaw.length > remainingBudget) {
206+
renderedHistory = `${summaryBlock}${truncationMarker}\n${tailRaw.slice(-remainingBudget).trimStart()}`;
207+
} else {
208+
renderedHistory = `${summaryBlock}${tailRaw}`;
209+
}
210+
}
211+
} else {
212+
// No compaction summary to pin: tail-slice the full rendered history
213+
// and lead with the marker so it correctly describes what follows
214+
// (older turns dropped, recent tail retained).
215+
renderedHistory =
216+
tailRaw.length > maxHistoryChars
217+
? `${truncationMarker}\n${tailRaw.slice(-maxHistoryChars).trimStart()}`
218+
: tailRaw;
219+
}
152220

153221
if (!renderedHistory) {
154222
return undefined;

0 commit comments

Comments
 (0)