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), ];