fix(agents): timestamp recovered Claude CLI history (#119528)

* fix(agents): timestamp recovered Claude CLI history

* fix(agents): reject noncanonical recovered timestamps

Punchcard-Session: brisk-meadow-river-2e

---------

Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
clawsweeper[bot]
2026-08-12 08:11:48 +08:00
committed by GitHub
parent 25e48de95b
commit 88dba70ec5
3 changed files with 161 additions and 45 deletions
+12 -3
View File
@@ -4104,10 +4104,11 @@ describe("prepareCliRunContext", () => {
});
it("arms raw-transcript reseed for a missing claude-cli transcript so prior conversation is redelivered", async () => {
const recoveredAt = "2020-01-02T03:04:05.000Z";
fixture.appendTranscript({
id: "msg-1",
parentId: null,
timestamp: new Date(1).toISOString(),
timestamp: recoveredAt,
message: {
role: "user",
content: "prior claude-cli ask",
@@ -4138,8 +4139,16 @@ describe("prepareCliRunContext", () => {
mode: "invalidate",
invalidatedReason: "missing-transcript",
});
expect(context.openClawHistoryPrompt).toContain("prior claude-cli ask");
expect(context.openClawHistoryPrompt).toContain("latest ask");
expect(context.openClawHistoryPrompt).toContain(`[${recoveredAt}] User: prior claude-cli ask`);
expect(context.openClawHistoryPrompt).not.toContain(
"[1970-01-01T00:00:00.001Z] User: prior claude-cli ask",
);
expect(context.openClawHistoryPrompt).toContain(
"Recovered history may be stale; verify current and time-sensitive facts before acting.",
);
expect(context.openClawHistoryPrompt).toContain(
"<next_user_message>\nlatest ask\n</next_user_message>",
);
});
it("prepares node-placed Claude resumes without Gateway MCP, skills, or transcript checks", async () => {
+88 -24
View File
@@ -23,8 +23,18 @@ const MAX_CLI_SESSION_HISTORY_FILE_BYTES = 5 * 1024 * 1024;
const MAX_CLI_SESSION_HISTORY_MESSAGES = MAX_AGENT_HOOK_HISTORY_MESSAGES;
const MAX_CLI_SESSION_RESEED_HISTORY_CHARS = 12 * 1024;
const MAX_AUTO_CLI_SESSION_RESEED_HISTORY_CHARS = 256 * 1024;
const RESEED_CURRENCY_GUIDANCE =
"[Recovered history may be stale; verify current and time-sensitive facts before acting.]";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function withReseedGuidanceBudget(historyChars: number): number {
return RESEED_CURRENCY_GUIDANCE.length + "\n".length + historyChars;
}
function extractReseedHistory(prompt: string | undefined): string {
return prompt?.match(/<conversation_history>\n([\s\S]*?)\n<\/conversation_history>/)?.[1] ?? "";
}
function createSessionTranscript(params: {
rootDir: string;
sessionId: string;
@@ -703,9 +713,12 @@ describe("loadCliSessionReseedMessages", () => {
role: "user",
content: `raw-${MAX_CLI_SESSION_HISTORY_MESSAGES + 24}`,
});
expect(buildCliSessionHistoryPrompt({ messages: reseed, prompt: "next" })).toContain(
"raw-25",
expect(requireRecord(reseed[0], "first raw reseed message").timestamp).toBe(
"1970-01-01T00:00:00.026Z",
);
const prompt = buildCliSessionHistoryPrompt({ messages: reseed, prompt: "next" });
expect(prompt).toContain("[1970-01-01T00:00:00.026Z] User: raw-25");
expect(prompt).toContain(RESEED_CURRENCY_GUIDANCE);
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
@@ -826,9 +839,15 @@ describe("loadCliSessionReseedMessages", () => {
expect(reseed).toHaveLength(2);
expectCompactionSummary(reseed[0], "safe compacted summary");
expectMessageFields(reseed[1], { role: "user", content: "post-compaction ask" });
expect(buildCliSessionHistoryPrompt({ messages: reseed, prompt: "next" })).toContain(
"Compaction summary: safe compacted summary",
expect(reseed.map((message) => requireRecord(message, "reseed message").timestamp)).toEqual(
["1970-01-01T00:00:00.002Z", "1970-01-01T00:00:00.003Z"],
);
const prompt = buildCliSessionHistoryPrompt({ messages: reseed, prompt: "next" });
expect(prompt).toContain(
"[1970-01-01T00:00:00.002Z] Compaction summary: safe compacted summary",
);
expect(prompt).toContain("[1970-01-01T00:00:00.003Z] User: post-compaction ask");
expect(prompt).toContain(RESEED_CURRENCY_GUIDANCE);
});
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
@@ -851,6 +870,29 @@ describe("buildCliSessionHistoryPrompt", () => {
expect(prompt).toContain("<next_user_message>\nnew ask\n</next_user_message>");
});
it("renders canonical saved timestamps and omits invalid or noncanonical timestamps", () => {
const prompt = buildCliSessionHistoryPrompt({
messages: [
{ role: "user", content: "dated ask", timestamp: "2026-06-17T16:00:00.000Z" },
{ role: "assistant", content: "zero date answer", timestamp: "0" },
{ role: "user", content: "year-only ask", timestamp: "2026" },
{ role: "assistant", content: "invalid date answer", timestamp: "not-a-date" },
{ role: "user", content: "offset date ask", timestamp: "2026-06-17T12:00:00-04:00" },
{ role: "assistant", content: "undated answer" },
],
prompt: "new ask",
});
expect(prompt).toContain("[2026-06-17T16:00:00.000Z] User: dated ask");
expect(prompt).toMatch(
/Assistant: zero date answer[\s\S]*User: year-only ask[\s\S]*Assistant: invalid date answer[\s\S]*User: offset date ask[\s\S]*Assistant: undated answer/u,
);
expect(prompt).not.toMatch(
/\[(?:2000-01-01T00:00:00\.000Z|2026-01-01T00:00:00\.000Z|not-a-date|2026-06-17T12:00:00-04:00)\]/u,
);
expect(prompt).toContain(RESEED_CURRENCY_GUIDANCE);
});
it("skips reseed text when the transcript has no renderable conversation", () => {
expect(
buildCliSessionHistoryPrompt({
@@ -861,13 +903,14 @@ describe("buildCliSessionHistoryPrompt", () => {
});
it("caps rendered reseed history before adding the next user message", () => {
const maxHistoryChars = withReseedGuidanceBudget(80);
const prompt = buildCliSessionHistoryPrompt({
messages: [
{ role: "user", content: "x".repeat(100) },
{ role: "assistant", content: "y".repeat(100) },
],
prompt: "current ask must survive",
maxHistoryChars: 20,
maxHistoryChars,
});
expect(prompt).toContain("[OpenClaw reseed history truncated; older turns dropped]");
@@ -875,17 +918,18 @@ describe("buildCliSessionHistoryPrompt", () => {
// Older 100-char prefix must be dropped by the tail slice; the
// post-cap rendered tail is shorter than the dropped prefix.
expect(prompt).not.toContain("x".repeat(80));
expect(extractReseedHistory(prompt).length).toBeLessThanOrEqual(maxHistoryChars);
});
it("keeps a whole code point when the retained history tail starts inside an emoji", () => {
const prompt = buildCliSessionHistoryPrompt({
messages: [{ role: "user", content: "prefix😀tail" }],
prompt: "next",
maxHistoryChars: 5,
maxHistoryChars: withReseedGuidanceBudget(5),
});
expect(prompt).toContain(
"<conversation_history>\n[OpenClaw reseed history truncated; older turns dropped]\ntail\n</conversation_history>",
`<conversation_history>\n${RESEED_CURRENCY_GUIDANCE}\ntail\n</conversation_history>`,
);
});
@@ -953,6 +997,9 @@ describe("buildCliSessionHistoryPrompt", () => {
// dropped so the cap is honored.
expect(prompt).not.toContain("z".repeat(8000));
expect(prompt).toContain("<next_user_message>\nnext ask\n</next_user_message>");
expect(extractReseedHistory(prompt).length).toBeLessThanOrEqual(
MAX_CLI_SESSION_RESEED_HISTORY_CHARS,
);
});
it("caps oversize compaction summary while preserving recent post-summary tail", () => {
@@ -966,7 +1013,8 @@ describe("buildCliSessionHistoryPrompt", () => {
// The summary must itself be truncated to fit the budget while still
// preserving the recent post-summary exact turns.
const summaryText = "OVERSIZE_SUMMARY_MARKER ".repeat(50).trim();
const maxHistoryChars = 200;
const historyBudget = 200;
const maxHistoryChars = withReseedGuidanceBudget(historyBudget);
const prompt = buildCliSessionHistoryPrompt({
messages: [
{ role: "compactionSummary", summary: summaryText },
@@ -1009,30 +1057,25 @@ describe("buildCliSessionHistoryPrompt", () => {
const prompt = buildCliSessionHistoryPrompt({
messages: [{ role: "compactionSummary", summary: `aa😀${"z".repeat(100)}` }],
prompt: "next",
maxHistoryChars: 80,
maxHistoryChars: withReseedGuidanceBudget(80),
});
expect(prompt).toContain(
"<conversation_history>\n[OpenClaw reseed history truncated; older turns dropped]\nCompaction summary: aa\n</conversation_history>",
`<conversation_history>\n${RESEED_CURRENCY_GUIDANCE}\n[OpenClaw reseed history truncated; older turns dropped]\nCompaction summary: aa\n</conversation_history>`,
);
});
it("honors the cap when the summary block plus marker crosses it", () => {
// Edge case: `summaryRendered.length < maxHistoryChars` (the gate that
// routes to the oversize-summary branch is not taken) BUT
// `summaryBlock.length >= maxHistoryChars` once the `\n\n` separator
// is appended, making `remainingBudget <= 0`. Without summary
// truncation in that branch, the rendered history block is
// `summary + separator + marker` — well over `maxHistoryChars`. A
// 199-char rendered summary under a 200-char cap would otherwise
// produce a 257-char history block.
const maxHistoryChars = 200;
// `renderHistoryMessage` prefixes "Compaction summary: " (20 chars)
// before the summary text, so a 179-char summary renders to 199 chars
// — strictly less than the cap, but `summaryBlock = rendered + "\n\n"`
// is 201 chars and `remainingBudget` is negative.
// Edge case: the summary fits but leaves too little room for the
// truncation marker plus a useful exact tail. Rebalance the summary and
// tail instead of exceeding the cap or silently dropping the marker.
const historyBudget = 200;
const maxHistoryChars = withReseedGuidanceBudget(historyBudget);
const remainingBudget = 10;
const summaryPrefix = "Compaction summary: ";
const summaryText = "S".repeat(maxHistoryChars - 1 - summaryPrefix.length);
const summaryText = "S".repeat(
historyBudget - remainingBudget - "\n\n".length - summaryPrefix.length,
);
const prompt = buildCliSessionHistoryPrompt({
messages: [
{ role: "compactionSummary", summary: summaryText },
@@ -1056,4 +1099,25 @@ describe("buildCliSessionHistoryPrompt", () => {
expect(prompt).toContain("POST_SUMMARY_TAIL_USER");
expect(prompt).toContain("POST_SUMMARY_TAIL_ASSISTANT");
});
it("keeps fitting post-summary history without a false truncation marker", () => {
const historyBudget = 200;
const remainingBudget = 10;
const summaryPrefix = "Compaction summary: ";
const summaryText = "S".repeat(
historyBudget - remainingBudget - "\n\n".length - summaryPrefix.length,
);
const prompt = buildCliSessionHistoryPrompt({
messages: [
{ role: "compactionSummary", summary: summaryText },
{ role: "user", content: "tail" },
],
prompt: "next ask",
maxHistoryChars: withReseedGuidanceBudget(historyBudget),
});
expect(prompt).toContain(`Compaction summary: ${summaryText}`);
expect(prompt).toContain("User: tail");
expect(prompt).not.toContain("[OpenClaw reseed history truncated; older turns dropped]");
});
});
+61 -18
View File
@@ -4,6 +4,8 @@
*/
import fsp from "node:fs/promises";
import path from "node:path";
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import {
resolveSessionFilePathCore,
@@ -38,11 +40,14 @@ const MAX_AUTO_CLI_SESSION_RESEED_HISTORY_CHARS = 256 * 1024;
const CLI_SESSION_RESEED_HISTORY_CONTEXT_SHARE = 0.08;
const CHARS_PER_TOKEN_ESTIMATE = 4;
const CLI_SESSION_HISTORY_HEADER_READ_BYTES = 64 * 1024;
const CLI_SESSION_RESEED_CURRENCY_GUIDANCE =
"[Recovered history may be stale; verify current and time-sensitive facts before acting.]";
type HistoryMessage = {
role?: unknown;
content?: unknown;
summary?: unknown;
timestamp?: unknown;
};
type HistoryEntry = {
type?: unknown;
@@ -123,6 +128,20 @@ function coerceHistoryTimestamp(value: unknown): number | string {
return 0;
}
function projectReseedMessage(message: unknown, timestamp: unknown): unknown {
// The transcript row owns persistence time; nested provider timestamps can
// be stale or absent when history is recovered into a fresh CLI session.
return isRecord(message) ? { ...message, timestamp } : message;
}
function formatHistoryTimestamp(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const timestamp = timestampMsToIsoString(Date.parse(value));
return timestamp === value ? timestamp : undefined;
}
function historyEntryToContextEngineMessage(entry: HistoryEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message as AgentMessage;
@@ -175,7 +194,11 @@ function renderHistoryMessage(message: unknown): string | undefined {
entry.role === "compactionSummary" && typeof entry.summary === "string"
? entry.summary.trim()
: coerceHistoryText(entry.content);
return text ? `${role}: ${text}` : undefined;
if (!text) {
return undefined;
}
const timestamp = formatHistoryTimestamp(entry.timestamp);
return `${timestamp ? `[${timestamp}] ` : ""}${role}: ${text}`;
}
/** Builds a reseed prompt that carries prior OpenClaw transcript context. */
@@ -185,6 +208,10 @@ export function buildCliSessionHistoryPrompt(params: {
maxHistoryChars?: number;
}): string | undefined {
const maxHistoryChars = params.maxHistoryChars ?? MAX_CLI_SESSION_RESEED_HISTORY_CHARS;
const historyBudget = maxHistoryChars - CLI_SESSION_RESEED_CURRENCY_GUIDANCE.length - "\n".length;
if (historyBudget <= 0) {
return undefined;
}
// loadCliSessionReseedMessages deliberately places a `compactionSummary`
// entry first when the session was compacted, so the compacted prior
@@ -209,13 +236,25 @@ export function buildCliSessionHistoryPrompt(params: {
.trim();
const truncationMarker = "[OpenClaw reseed history truncated; older turns dropped]";
const renderTruncatedTail = (raw: string, budget: number): string => {
if (budget <= truncationMarker.length + "\n".length) {
return sliceUtf16Safe(raw, -budget).trimStart();
}
const tailBudget = budget - truncationMarker.length - "\n".length;
return `${truncationMarker}\n${sliceUtf16Safe(raw, -tailBudget).trimStart()}`;
};
const renderTruncatedSummaryWithTail = (renderedSummary: string): string => {
if (historyBudget <= truncationMarker.length + "\n".length) {
return tailRaw.length > 0
? sliceUtf16Safe(tailRaw, -historyBudget).trimStart()
: truncateUtf16Safe(renderedSummary, historyBudget).trimEnd();
}
const tailBudget =
tailRaw.length > 0 ? Math.min(tailRaw.length, Math.floor(maxHistoryChars / 2)) : 0;
tailRaw.length > 0 ? Math.min(tailRaw.length, Math.floor(historyBudget / 2)) : 0;
const separatorBudget = tailBudget > 0 ? 2 : 1;
const summaryBudget = Math.max(
0,
maxHistoryChars - truncationMarker.length - separatorBudget - tailBudget,
historyBudget - truncationMarker.length - separatorBudget - tailBudget,
);
const summaryTruncated = truncateUtf16Safe(renderedSummary, summaryBudget).trimEnd();
const tailTruncated = tailBudget > 0 ? sliceUtf16Safe(tailRaw, -tailBudget).trimStart() : "";
@@ -229,7 +268,7 @@ export function buildCliSessionHistoryPrompt(params: {
// cap, the summary itself must be truncated — pinning a summary that
// blows past `maxHistoryChars` would defeat the cap that prevents
// reseeding fresh CLI sessions with unexpectedly huge prompts.
if (summaryRendered.length >= maxHistoryChars) {
if (summaryRendered.length >= historyBudget) {
// Truncate the summary to fit the budget (less the marker line),
// keeping the head. Still reserve budget for the post-summary tail so
// recent exact turns survive even when the summary itself is oversize.
@@ -238,16 +277,16 @@ export function buildCliSessionHistoryPrompt(params: {
renderedHistory = summaryRendered;
} else {
const summaryBlock = `${summaryRendered}\n\n`;
const remainingBudget = maxHistoryChars - summaryBlock.length;
if (remainingBudget <= 0) {
// The summary plus separator already consumes the cap. Reuse the
// oversize-summary path so recent post-summary turns still get
// reserved tail budget instead of being dropped wholesale.
renderedHistory = renderTruncatedSummaryWithTail(summaryRendered);
} else if (tailRaw.length > remainingBudget) {
renderedHistory = `${summaryBlock}${truncationMarker}\n${sliceUtf16Safe(tailRaw, -remainingBudget).trimStart()}`;
} else {
const remainingBudget = historyBudget - summaryBlock.length;
if (tailRaw.length <= remainingBudget) {
renderedHistory = `${summaryBlock}${tailRaw}`;
} else if (remainingBudget <= truncationMarker.length + "\n".length) {
// The summary leaves too little room to announce truncation. Reuse
// the oversize-summary path so the marker and recent exact turns
// both retain budget.
renderedHistory = renderTruncatedSummaryWithTail(summaryRendered);
} else {
renderedHistory = `${summaryBlock}${renderTruncatedTail(tailRaw, remainingBudget)}`;
}
}
} else {
@@ -255,9 +294,7 @@ export function buildCliSessionHistoryPrompt(params: {
// and lead with the marker so it correctly describes what follows
// (older turns dropped, recent tail retained).
renderedHistory =
tailRaw.length > maxHistoryChars
? `${truncationMarker}\n${sliceUtf16Safe(tailRaw, -maxHistoryChars).trimStart()}`
: tailRaw;
tailRaw.length > historyBudget ? renderTruncatedTail(tailRaw, historyBudget) : tailRaw;
}
if (!renderedHistory) {
@@ -269,6 +306,7 @@ export function buildCliSessionHistoryPrompt(params: {
"Treat it as authoritative context for this fresh CLI session.",
"",
"<conversation_history>",
CLI_SESSION_RESEED_CURRENCY_GUIDANCE,
renderedHistory,
"</conversation_history>",
"",
@@ -640,7 +678,9 @@ export async function loadCliSessionReseedMessages(params: {
}
const rawTail = entries.flatMap((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "message" ? [candidate.message] : [];
return candidate.type === "message"
? [projectReseedMessage(candidate.message, candidate.timestamp)]
: [];
});
return limitAgentHookHistoryMessages(rawTail, MAX_CLI_SESSION_HISTORY_MESSAGES);
};
@@ -660,12 +700,15 @@ export async function loadCliSessionReseedMessages(params: {
const tailMessages = entries.slice(latestCompactionIndex + 1).flatMap((entry) => {
const candidate = entry as HistoryEntry;
return candidate.type === "message" ? [candidate.message] : [];
return candidate.type === "message"
? [projectReseedMessage(candidate.message, candidate.timestamp)]
: [];
});
return [
{
role: "compactionSummary",
summary,
timestamp: compaction.timestamp,
},
...limitAgentHookHistoryMessages(tailMessages, MAX_CLI_SESSION_HISTORY_MESSAGES - 1),
];