From 8232ea0974a53262e4aefbc865144c6a4c7e9208 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 2 Jul 2026 00:10:51 -0700 Subject: [PATCH] fix(agents): reseed oversized CLI transcript tails Fixes #98946 --- src/agents/cli-runner/session-history.test.ts | 167 +++++++++++++++++- src/agents/cli-runner/session-history.ts | 132 +++++++++++++- 2 files changed, 287 insertions(+), 12 deletions(-) diff --git a/src/agents/cli-runner/session-history.test.ts b/src/agents/cli-runner/session-history.test.ts index 9c7082e0eb7e..1524fd3713d8 100644 --- a/src/agents/cli-runner/session-history.test.ts +++ b/src/agents/cli-runner/session-history.test.ts @@ -3,8 +3,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { withEnvAsync } from "../../test-utils/env.js"; +import { cliBackendLog } from "./log.js"; import { buildCliSessionHistoryPrompt, hasCliSessionTranscript, @@ -420,7 +421,7 @@ describe("loadCliSessionHistoryMessages", () => { } }); - it("drops oversized transcript files instead of loading them into hook payloads", async () => { + it("loads a bounded tail from oversized transcript files", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-")); const sessionFile = path.join( stateDir, @@ -429,21 +430,173 @@ describe("loadCliSessionHistoryMessages", () => { "sessions", "session-oversized.jsonl", ); + const warnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); - fs.writeFileSync(sessionFile, "x".repeat(MAX_CLI_SESSION_HISTORY_FILE_BYTES + 1), "utf-8"); + fs.writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: "session-oversized", + timestamp: new Date(0).toISOString(), + cwd: stateDir, + }), + JSON.stringify({ + type: "message", + id: "old", + parentId: null, + timestamp: new Date(1).toISOString(), + message: { + role: "user", + content: "x".repeat(MAX_CLI_SESSION_HISTORY_FILE_BYTES), + timestamp: 1, + }, + }), + JSON.stringify({ + type: "message", + id: "tail", + parentId: "old", + timestamp: new Date(2).toISOString(), + message: { role: "user", content: "tail history", timestamp: 2 }, + }), + ].join("\n") + "\n", + "utf-8", + ); try { await withCliSessionState(stateDir, async () => { - expect( - await loadCliSessionHistoryMessages({ - sessionId: "session-oversized", + const history = await loadCliSessionHistoryMessages({ + sessionId: "session-oversized", + sessionFile, + sessionKey: "agent:main:main", + agentId: "main", + }); + expect(history).toHaveLength(1); + expectMessageFields(history[0], { role: "user", content: "tail history" }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("cli session history truncated to last"), + ); + }); + } finally { + warnSpy.mockRestore(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("skips oversized transcript tails when branch controls were dropped", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-")); + const sessionFile = path.join( + stateDir, + "agents", + "main", + "sessions", + "session-oversized-branch.jsonl", + ); + const warnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); + fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); + fs.writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: "session-oversized-branch", + timestamp: new Date(0).toISOString(), + cwd: stateDir, + }), + JSON.stringify({ + type: "message", + id: "root", + parentId: null, + timestamp: new Date(1).toISOString(), + message: { role: "user", content: "root", timestamp: 1 }, + }), + JSON.stringify({ + type: "leaf", + id: "active-leaf", + parentId: "side-entry", + timestamp: new Date(2).toISOString(), + targetId: "root", + }), + JSON.stringify({ + type: "message", + id: "filler", + parentId: "root", + timestamp: new Date(3).toISOString(), + message: { + role: "assistant", + content: "x".repeat(MAX_CLI_SESSION_HISTORY_FILE_BYTES), + timestamp: 3, + }, + }), + JSON.stringify({ + type: "message", + id: "side-entry", + parentId: "root", + timestamp: new Date(4).toISOString(), + message: { role: "assistant", content: "side history", timestamp: 4 }, + }), + JSON.stringify({ + type: "message", + id: "active-tail", + parentId: "root", + timestamp: new Date(5).toISOString(), + message: { role: "assistant", content: "active history", timestamp: 5 }, + }), + ].join("\n") + "\n", + "utf-8", + ); + + try { + await withCliSessionState(stateDir, async () => { + await expect( + loadCliSessionHistoryMessages({ + sessionId: "session-oversized-branch", sessionFile, sessionKey: "agent:main:main", agentId: "main", }), - ).toStrictEqual([]); + ).resolves.toStrictEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("cli session history truncated tail skipped"), + ); }); } finally { + warnSpy.mockRestore(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("warns when transcript parsing fails", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-")); + const sessionFile = path.join( + stateDir, + "agents", + "main", + "sessions", + "session-invalid-jsonl.jsonl", + ); + const warnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined); + fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); + fs.writeFileSync(sessionFile, "{not-json}\n", "utf-8"); + + try { + await withCliSessionState(stateDir, async () => { + await expect( + loadCliSessionHistoryMessages({ + sessionId: "session-invalid-jsonl", + sessionFile, + sessionKey: "agent:main:main", + agentId: "main", + }), + ).resolves.toStrictEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("cli session history parse failed:"), + ); + }); + } finally { + warnSpy.mockRestore(); fs.rmSync(stateDir, { recursive: true, force: true }); } }); diff --git a/src/agents/cli-runner/session-history.ts b/src/agents/cli-runner/session-history.ts index 16d54097bb96..52840898c378 100644 --- a/src/agents/cli-runner/session-history.ts +++ b/src/agents/cli-runner/session-history.ts @@ -8,8 +8,12 @@ import { resolveSessionFilePath, resolveSessionFilePathOptions, } from "../../config/sessions/paths.js"; -import { selectSessionTranscriptLeafControlledPath } from "../../config/sessions/transcript-tree.js"; +import { + scanSessionTranscriptTree, + selectSessionTranscriptLeafControlledPath, +} from "../../config/sessions/transcript-tree.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { isPathInside } from "../../infra/path-guards.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; import { @@ -18,6 +22,7 @@ import { } from "../harness/hook-history.js"; import type { AgentMessage } from "../runtime/index.js"; import { migrateSessionEntries, parseSessionEntries } from "../sessions/session-manager.js"; +import { cliBackendLog } from "./log.js"; /** Maximum transcript size read for CLI session history. */ export const MAX_CLI_SESSION_HISTORY_FILE_BYTES = 5 * 1024 * 1024; @@ -29,6 +34,7 @@ export const MAX_CLI_SESSION_RESEED_HISTORY_CHARS = 12 * 1024; export 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; type HistoryMessage = { role?: unknown; @@ -275,6 +281,109 @@ async function safeRealpath(filePath: string): Promise { } } +function isFileNotFoundError(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT", + ); +} + +async function readCliSessionHeaderLine(filePath: string): Promise { + const handle = await fsp.open(filePath, "r"); + try { + const buffer = Buffer.alloc(CLI_SESSION_HISTORY_HEADER_READ_BYTES); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const firstChunk = buffer.subarray(0, bytesRead).toString("utf-8"); + const lineEnd = firstChunk.indexOf("\n"); + if (lineEnd < 0) { + return undefined; + } + const line = firstChunk.slice(0, lineEnd); + const parsed = JSON.parse(line) as { type?: unknown }; + return parsed.type === "session" ? line : undefined; + } catch { + return undefined; + } finally { + await handle.close(); + } +} + +async function readBoundedCliSessionTranscript( + filePath: string, + fileSize: number, +): Promise<{ content: string; truncated: boolean }> { + if (fileSize <= MAX_CLI_SESSION_HISTORY_FILE_BYTES) { + return { content: await fsp.readFile(filePath, "utf-8"), truncated: false }; + } + + cliBackendLog.warn( + `cli session history truncated to last ${MAX_CLI_SESSION_HISTORY_FILE_BYTES} bytes: ${filePath}`, + ); + const handle = await fsp.open(filePath, "r"); + try { + const buffer = Buffer.alloc(MAX_CLI_SESSION_HISTORY_FILE_BYTES); + await handle.read(buffer, 0, buffer.length, fileSize - buffer.length); + const tail = buffer.toString("utf-8"); + const firstLineEnd = tail.indexOf("\n"); + const completeTail = firstLineEnd >= 0 ? tail.slice(firstLineEnd + 1) : ""; + const headerLine = await readCliSessionHeaderLine(filePath); + return { + content: headerLine ? `${headerLine}\n${completeTail}` : completeTail, + truncated: true, + }; + } finally { + await handle.close(); + } +} + +function isSafeTruncatedCliSessionTail(entries: readonly unknown[]): boolean { + const tree = scanSessionTranscriptTree(entries); + if (tree.hasLeafControl) { + return !tree.hasInvalidLeafControl; + } + const childParentIds = new Set(); + let truncatedRootParentId: string | undefined; + for (const node of tree.nodes) { + if (node.appendMode === "side") { + return false; + } + if (node.parentId === null) { + continue; + } + if (!tree.byId.has(node.parentId)) { + if (truncatedRootParentId !== undefined || childParentIds.size > 0) { + return false; + } + truncatedRootParentId = node.parentId; + continue; + } + if (childParentIds.has(node.parentId)) { + return false; + } + childParentIds.add(node.parentId); + } + return true; +} + +function parseCliSessionEntries( + content: string, +): ReturnType | undefined { + for (const line of content.trim().split("\n")) { + if (!line.trim()) { + continue; + } + try { + JSON.parse(line); + } catch (error) { + cliBackendLog.warn(`cli session history parse failed: ${formatErrorMessage(error)}`); + return undefined; + } + } + return parseSessionEntries(content); +} + function resolveSafeCliSessionFile(params: { sessionId: string; sessionFile: string; @@ -325,14 +434,27 @@ async function loadCliSessionEntries(params: { return []; } const stat = await fsp.stat(realSessionFile); - if (!stat.isFile() || stat.size > MAX_CLI_SESSION_HISTORY_FILE_BYTES) { + if (!stat.isFile()) { + return []; + } + const transcript = await readBoundedCliSessionTranscript(realSessionFile, stat.size); + const entries = parseCliSessionEntries(transcript.content); + if (!entries) { return []; } - const entries = parseSessionEntries(await fsp.readFile(realSessionFile, "utf-8")); migrateSessionEntries(entries); const sessionEntries = entries.filter((entry) => entry.type !== "session"); + if (transcript.truncated && !isSafeTruncatedCliSessionTail(sessionEntries)) { + cliBackendLog.warn( + `cli session history truncated tail skipped because branch controls are incomplete: ${realSessionFile}`, + ); + return []; + } return selectSessionTranscriptLeafControlledPath(sessionEntries) ?? sessionEntries; - } catch { + } catch (error) { + if (!isFileNotFoundError(error)) { + cliBackendLog.warn(`cli session history load failed: ${formatErrorMessage(error)}`); + } return []; } } @@ -361,7 +483,7 @@ export async function hasCliSessionTranscript(params: { return false; } const stat = await fsp.stat(realSessionFile); - return stat.isFile() && stat.size <= MAX_CLI_SESSION_HISTORY_FILE_BYTES; + return stat.isFile(); } catch { return false; }