mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user