diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts
index 764dbf2f7417..f68c64e06e5b 100644
--- a/src/agents/cli-runner/prepare.test.ts
+++ b/src/agents/cli-runner/prepare.test.ts
@@ -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(
+ "\nlatest ask\n",
+ );
});
it("prepares node-placed Claude resumes without Gateway MCP, skills, or transcript checks", async () => {
diff --git a/src/agents/cli-runner/session-history.test.ts b/src/agents/cli-runner/session-history.test.ts
index 48e14b6b2f65..0f722871e4dc 100644
--- a/src/agents/cli-runner/session-history.test.ts
+++ b/src/agents/cli-runner/session-history.test.ts
@@ -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(/\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("\nnew ask\n");
});
+ 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(
- "\n[OpenClaw reseed history truncated; older turns dropped]\ntail\n",
+ `\n${RESEED_CURRENCY_GUIDANCE}\ntail\n`,
);
});
@@ -953,6 +997,9 @@ describe("buildCliSessionHistoryPrompt", () => {
// dropped so the cap is honored.
expect(prompt).not.toContain("z".repeat(8000));
expect(prompt).toContain("\nnext ask\n");
+ 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(
- "\n[OpenClaw reseed history truncated; older turns dropped]\nCompaction summary: aa\n",
+ `\n${RESEED_CURRENCY_GUIDANCE}\n[OpenClaw reseed history truncated; older turns dropped]\nCompaction summary: aa\n`,
);
});
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]");
+ });
});
diff --git a/src/agents/cli-runner/session-history.ts b/src/agents/cli-runner/session-history.ts
index 5b31a8c58fae..64257c099a38 100644
--- a/src/agents/cli-runner/session-history.ts
+++ b/src/agents/cli-runner/session-history.ts
@@ -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.",
"",
"",
+ CLI_SESSION_RESEED_CURRENCY_GUIDANCE,
renderedHistory,
"",
"",
@@ -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),
];