Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix(agents): reseed oversized CLI transcript tails
Fixes #98946
  • Loading branch information
obviyus committed Jul 2, 2026
commit d3620733967eea3d53ae5f205a8b5c09f3155fea
167 changes: 160 additions & 7 deletions src/agents/cli-runner/session-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
}
});
Expand Down
132 changes: 127 additions & 5 deletions src/agents/cli-runner/session-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
}
}
Expand Down Expand Up @@ -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;
}
Expand Down