fix(agents): reseed oversized CLI transcript tails

Fixes #98946
This commit is contained in:
Ayaan Zaidi
2026-07-02 00:10:51 -07:00
parent 11441aefbe
commit 8232ea0974
2 changed files with 287 additions and 12 deletions
+160 -7
View File
@@ -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 });
}
});
+127 -5
View File
@@ -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<string | undefined> {
}
}
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<string | undefined> {
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<string>();
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<typeof parseSessionEntries> | 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;
}