Compare commits

...

10 Commits

Author SHA1 Message Date
pash
b23ff97ddc Merge remote-tracking branch 'origin/main' into codex/session-transcript-oom-guard
# Conflicts:
#	src/docker-build-cache.test.ts
#	src/scripts/test-projects.test.ts
2026-04-26 17:02:51 -07:00
pash
e705246619 Fix core support boundary test expectations 2026-04-26 17:00:59 -07:00
pash
f936f16cc5 Merge remote-tracking branch 'origin/main' into codex/session-transcript-oom-guard 2026-04-26 16:59:22 -07:00
pash
fd48faa4ed Fix qa-lab merge CI and compaction review notes 2026-04-26 16:53:15 -07:00
pash
29f1cae867 Fix qa-lab private qa-channel import 2026-04-26 16:43:55 -07:00
pash
f58dd36a1d Harden session truncation concurrency guards 2026-04-26 16:34:05 -07:00
pash
33e3dccbea Fix update CLI service state test mocks 2026-04-26 16:29:55 -07:00
pash
6fc954539f Harden session truncation rewrite 2026-04-26 16:20:29 -07:00
pash
fc13a0135e Fix stale e2e Docker cache test 2026-04-26 16:14:58 -07:00
pash
0ced62f512 Fix transcript truncation OOM guard 2026-04-26 16:10:10 -07:00
21 changed files with 630 additions and 137 deletions

View File

@@ -1,4 +1,4 @@
4d1995e41b659e484afb5a48d6fca0558337123200a4a537f556ca38e8e829e7 config-baseline.json
3245c9a013c55ee8a24db52d5e88c42bc86e26f822d4a144fc7f37fc71e05fa8 config-baseline.core.json
3e6dd8292d9350b0ccc243f81f7b6e95494fc769c01c084d8d6d6e9e1f668a14 config-baseline.json
e040e5818afe66d71fc8a7ae1653f1e8c252cc5b51480ef3b4ae1269682b9ade config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
f9e0174988718959fe1923a54496ec5b9262721fe1e7306f32ccb1316d9d9c3f config-baseline.plugin.json
74b74cb18ac37c0acaa765f398f1f9edbcee4c43567f02d45c89598a1e13afb4 config-baseline.plugin.json

View File

@@ -21,8 +21,12 @@ calls paired with their matching `toolResult` entries. If a split point lands
inside a tool block, OpenClaw moves the boundary so the pair stays together and
the current unsummarized tail is preserved.
The full conversation history stays on disk. Compaction only changes what the
model sees on the next turn.
By default, OpenClaw also rewrites the session transcript after compaction and
removes the message entries that were summarized. The persisted summary and
recent unsummarized tail remain on disk. Set
`agents.defaults.compaction.truncateAfterCompaction` to `false` if you need the
older behavior where compaction only changed what the model saw on the next
turn and left the full transcript intact.
## Auto-compaction

View File

@@ -193,7 +193,12 @@ Notable entry types:
- `compaction`: persisted compaction summary with `firstKeptEntryId` and `tokensBefore`
- `branch_summary`: persisted summary when navigating a tree branch
OpenClaw intentionally does **not** “fix up” transcripts; the Gateway uses `SessionManager` to read/write them.
OpenClaw uses `SessionManager` for normal transcript reads/writes. After
compaction, the Gateway now defaults to a bounded transcript rewrite that drops
message entries already covered by the persisted compaction summary while
keeping non-message session state and the recent unsummarized tail. Set
`agents.defaults.compaction.truncateAfterCompaction` to `false` to preserve the
legacy append-only behavior.
---

View File

@@ -87,6 +87,10 @@ function createDefaultSessionMessages(): unknown[] {
}
export const sessionMessages: unknown[] = createDefaultSessionMessages();
export const sessionAbortCompactionMock: Mock<(reason?: unknown) => void> = vi.fn();
export const truncateSessionAfterCompactionMock = vi.fn(async () => ({
truncated: false,
entriesRemoved: 0,
}));
export const createOpenClawCodingToolsMock = vi.fn(() => []);
export const resolveEmbeddedAgentStreamFnMock: Mock<
(params?: unknown) => MockEmbeddedAgentStreamFn
@@ -126,6 +130,11 @@ export function resetCompactSessionStateMocks(): void {
estimateTokensMock.mockReturnValue(10);
sessionMessages.splice(0, sessionMessages.length, ...createDefaultSessionMessages());
sessionAbortCompactionMock.mockReset();
truncateSessionAfterCompactionMock.mockReset();
truncateSessionAfterCompactionMock.mockResolvedValue({
truncated: false,
entriesRemoved: 0,
});
resolveEmbeddedAgentStreamFnMock.mockReset();
resolveEmbeddedAgentStreamFnMock.mockImplementation((_params?: unknown) => vi.fn());
registerProviderStreamForModelMock.mockReset();
@@ -315,6 +324,10 @@ export async function loadCompactHooksHarness(): Promise<{
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
}));
vi.doMock("./session-truncation.js", () => ({
truncateSessionAfterCompaction: truncateSessionAfterCompactionMock,
}));
vi.doMock("../../context-engine/init.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
}));

View File

@@ -23,6 +23,7 @@ import {
sessionMessages,
sessionCompactImpl,
triggerInternalHook,
truncateSessionAfterCompactionMock,
} from "./compact.hooks.harness.js";
let compactEmbeddedPiSessionDirect: typeof import("./compact.js").compactEmbeddedPiSessionDirect;
@@ -752,6 +753,33 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
);
});
it("runs after_compaction hooks before post-compaction transcript truncation", async () => {
const order: string[] = [];
hookRunner.hasHooks.mockReturnValue(true);
hookRunner.runAfterCompaction.mockImplementation(async () => {
order.push("after_compaction");
});
truncateSessionAfterCompactionMock.mockImplementation(async () => {
order.push("truncate");
return { truncated: true, entriesRemoved: 1 };
});
const result = await compactEmbeddedPiSession(
wrappedCompactionArgs({
config: {
agents: {
defaults: {
compaction: {},
},
},
},
}),
);
expect(result.ok).toBe(true);
expect(order).toEqual(["after_compaction", "truncate"]);
});
it("emits a transcript update and post-compaction memory sync on the engine-owned path", async () => {
const listener = vi.fn();
const cleanup = onSessionTranscriptUpdate(listener);

View File

@@ -31,6 +31,7 @@ import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { readPiModelContextTokens } from "./model-context-tokens.js";
import { resolveModelAsync } from "./model.js";
import { truncateSessionAfterCompaction } from "./session-truncation.js";
import type { EmbeddedPiCompactResult } from "./types.js";
/**
@@ -224,6 +225,29 @@ export async function compactEmbeddedPiSession(
});
}
}
if (
result.ok &&
result.compacted &&
params.config &&
params.config?.agents?.defaults?.compaction?.truncateAfterCompaction !== false
) {
try {
const truncResult = await truncateSessionAfterCompaction({
sessionFile: params.sessionFile,
});
if (truncResult.truncated) {
log.info(
`[compaction] post-compaction truncation removed ${truncResult.entriesRemoved} entries ` +
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
);
}
} catch (err) {
log.warn("[compaction] post-compaction truncation failed", {
errorMessage: formatErrorMessage(err),
errorStack: err instanceof Error ? err.stack : undefined,
});
}
}
return {
ok: result.ok,
compacted: result.compacted,

View File

@@ -19,7 +19,6 @@ import {
type CapturedCompactionCheckpointSnapshot,
} from "../../gateway/session-compaction-checkpoints.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { resolveHeartbeatSummaryForAgent } from "../../infra/heartbeat-summary.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { generateSecureToken } from "../../infra/secure-random.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
@@ -1168,16 +1167,13 @@ export async function compactEmbeddedPiSessionDirect(
firstKeptEntryId: effectiveFirstKeptEntryId,
});
// Truncate session file to remove compacted entries (#39953)
if (params.config?.agents?.defaults?.compaction?.truncateAfterCompaction) {
if (
params.config &&
params.config.agents?.defaults?.compaction?.truncateAfterCompaction !== false
) {
try {
const heartbeatSummary = resolveHeartbeatSummaryForAgent(
params.config,
sessionAgentId,
);
const truncResult = await truncateSessionAfterCompaction({
sessionFile: params.sessionFile,
ackMaxChars: heartbeatSummary.ackMaxChars,
heartbeatPrompt: heartbeatSummary.prompt,
});
if (truncResult.truncated) {
log.info(

View File

@@ -194,6 +194,7 @@ import {
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { truncateSessionAfterCompaction } from "../session-truncation.js";
import { resolveEmbeddedRunSkillEntries } from "../skills-runtime.js";
import {
describeEmbeddedAgentStreamStrategy,
@@ -353,6 +354,33 @@ export {
};
const MAX_BTW_SNAPSHOT_MESSAGES = 100;
const MAX_PRE_OPEN_TRUNCATION_CHECKED_SESSION_FILES = 4096;
const preOpenTruncationCheckedSessionFiles = new Map<string, number>();
function hasPreOpenTruncationCheckedSessionFile(sessionFile: string): boolean {
const normalized = path.resolve(sessionFile);
if (!preOpenTruncationCheckedSessionFiles.has(normalized)) {
return false;
}
preOpenTruncationCheckedSessionFiles.delete(normalized);
preOpenTruncationCheckedSessionFiles.set(normalized, Date.now());
return true;
}
function markPreOpenTruncationCheckedSessionFile(sessionFile: string): void {
const normalized = path.resolve(sessionFile);
preOpenTruncationCheckedSessionFiles.delete(normalized);
preOpenTruncationCheckedSessionFiles.set(normalized, Date.now());
while (
preOpenTruncationCheckedSessionFiles.size > MAX_PRE_OPEN_TRUNCATION_CHECKED_SESSION_FILES
) {
const oldest = preOpenTruncationCheckedSessionFiles.keys().next().value;
if (!oldest) {
break;
}
preOpenTruncationCheckedSessionFiles.delete(oldest);
}
}
export function resolveUnknownToolGuardThreshold(loopDetection?: {
enabled?: boolean;
@@ -1215,6 +1243,31 @@ export async function runEmbeddedAttempt(
.stat(params.sessionFile)
.then(() => true)
.catch(() => false);
if (
hadSessionFile &&
params.config &&
params.config?.agents?.defaults?.compaction?.truncateAfterCompaction !== false &&
!hasPreOpenTruncationCheckedSessionFile(params.sessionFile)
) {
try {
const truncResult = await truncateSessionAfterCompaction({
sessionFile: params.sessionFile,
});
if (truncResult.truncated) {
log.info(
`[session-truncation] pre-open cleanup removed ${truncResult.entriesRemoved} entries ` +
`(sessionKey=${params.sessionKey ?? params.sessionId})`,
);
}
} catch (err) {
log.warn("[session-truncation] pre-open cleanup failed", {
errorMessage: formatErrorMessage(err),
errorStack: err instanceof Error ? err.stack : undefined,
});
} finally {
markPreOpenTruncationCheckedSessionFile(params.sessionFile);
}
}
const transcriptPolicy = resolveAttemptTranscriptPolicy({
runtimePlan: params.runtimePlan,

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import { truncateSessionAfterCompaction } from "./session-truncation.js";
@@ -107,6 +107,79 @@ describe("truncateSessionAfterCompaction", () => {
expect(second.truncated).toBe(false);
});
it("fails closed on malformed transcript lines", async () => {
const dir = await createTmpDir();
const sessionFile = createSessionWithCompaction(dir);
const before = await fs.readFile(sessionFile, "utf-8");
await fs.appendFile(sessionFile, "not-json\n", "utf-8");
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(false);
expect(result.reason).toContain("Malformed JSONL");
expect(await fs.readFile(sessionFile, "utf-8")).toBe(`${before}not-json\n`);
});
it("breaks cyclic removed-parent chains while re-parenting kept entries", async () => {
const dir = await createTmpDir();
const sessionFile = path.join(dir, "cyclic.jsonl");
const now = new Date().toISOString();
await fs.writeFile(
sessionFile,
[
{ type: "session", version: 3, id: "session", timestamp: now, cwd: dir },
{
type: "message",
id: "a",
parentId: "b",
timestamp: now,
message: { role: "user", content: "a", timestamp: 1 },
},
{
type: "message",
id: "b",
parentId: "a",
timestamp: now,
message: { role: "assistant", content: "b", timestamp: 2 },
},
{
type: "message",
id: "c",
parentId: "b",
timestamp: now,
message: { role: "user", content: "c", timestamp: 3 },
},
{
type: "compaction",
id: "compact",
parentId: "c",
timestamp: now,
summary: "summarized cyclic prefix",
firstKeptEntryId: "c",
tokensBefore: 100,
},
{
type: "message",
id: "after",
parentId: "compact",
timestamp: now,
message: { role: "user", content: "after", timestamp: 4 },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf-8",
);
const result = await truncateSessionAfterCompaction({ sessionFile });
expect(result.truncated).toBe(true);
const smAfter = SessionManager.open(sessionFile);
const kept = smAfter.getEntry("c");
expect(kept?.parentId).toBeNull();
expect(smAfter.buildSessionContext().messages.length).toBeGreaterThan(0);
});
it("archives original file when archivePath is provided (#39953)", async () => {
const dir = await createTmpDir();
const sessionFile = createSessionWithCompaction(dir);
@@ -127,6 +200,41 @@ describe("truncateSessionAfterCompaction", () => {
expect(archiveSize).toBeGreaterThan(truncatedSize);
});
it("truncates without opening the full transcript through SessionManager", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
const largeText = "x".repeat(100_000);
sm.appendMessage({ role: "user", content: largeText, timestamp: 1 });
sm.appendMessage(makeAssistant(largeText, 2));
sm.appendMessage({ role: "user", content: largeText, timestamp: 3 });
sm.appendMessage(makeAssistant(largeText, 4));
const branch = sm.getBranch();
const firstKeptId = branch[branch.length - 1].id;
sm.appendCompaction("Summary of large history.", firstKeptId, 50_000);
sm.appendMessage({ role: "user", content: "next task", timestamp: 5 });
const sessionFile = sm.getSessionFile()!;
const bytesBefore = (await fs.stat(sessionFile)).size;
const openSpy = vi.spyOn(SessionManager, "open").mockImplementation(() => {
throw new Error("unexpected eager SessionManager.open");
});
let result: Awaited<ReturnType<typeof truncateSessionAfterCompaction>>;
try {
result = await truncateSessionAfterCompaction({ sessionFile });
} finally {
openSpy.mockRestore();
}
expect(result.truncated).toBe(true);
expect(result.bytesAfter).toBeLessThan(bytesBefore);
expect(SessionManager.open(sessionFile).buildSessionContext().messages.length).toBeGreaterThan(
0,
);
});
it("handles multiple compaction cycles (#39953)", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);

View File

@@ -1,14 +1,17 @@
import { randomUUID } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { CompactionEntry, SessionEntry } from "@mariozechner/pi-coding-agent";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import {
isHeartbeatOkResponse,
isHeartbeatUserMessage,
} from "../../auto-reply/heartbeat-filter.js";
import readline from "node:readline";
import { finished } from "node:stream/promises";
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
import { formatErrorMessage } from "../../infra/errors.js";
import { acquireSessionWriteLock } from "../session-write-lock.js";
import { log } from "./logger.js";
const MAX_SESSION_TRUNCATION_LINE_BYTES = 64 * 1024 * 1024;
const MAX_SESSION_TRUNCATION_ENTRIES = 250_000;
/**
* Truncate a session JSONL file after compaction by removing only the
* message entries that the compaction actually summarized.
@@ -39,26 +42,37 @@ export async function truncateSessionAfterCompaction(params: {
sessionFile: string;
/** Optional path to archive the pre-truncation file. */
archivePath?: string;
ackMaxChars?: number;
heartbeatPrompt?: string;
}): Promise<TruncationResult> {
const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
allowReentrant: true,
});
try {
return await truncateSessionAfterCompactionLocked(params);
} finally {
await sessionLock.release();
}
}
async function truncateSessionAfterCompactionLocked(params: {
sessionFile: string;
/** Optional path to archive the pre-truncation file. */
archivePath?: string;
}): Promise<TruncationResult> {
const { sessionFile } = params;
let sm: SessionManager;
try {
sm = SessionManager.open(sessionFile);
} catch (err) {
const reason = formatErrorMessage(err);
log.warn(`[session-truncation] Failed to open session file: ${reason}`);
return { truncated: false, entriesRemoved: 0, reason };
const scan = await scanSessionFile(sessionFile);
if (!scan.ok) {
log.warn(`[session-truncation] Failed to scan session file: ${scan.reason}`);
return { truncated: false, entriesRemoved: 0, reason: scan.reason };
}
const header = sm.getHeader();
if (!header) {
const { headerLine, entries, entryById } = scan;
if (!headerLine) {
return { truncated: false, entriesRemoved: 0, reason: "missing session header" };
}
const branch = sm.getBranch();
const branch = buildCurrentBranch(entries, entryById);
if (branch.length === 0) {
return { truncated: false, entriesRemoved: 0, reason: "empty session" };
}
@@ -85,7 +99,7 @@ export async function truncateSessionAfterCompaction(params: {
// tail" — entries from firstKeptEntryId through the compaction that
// buildSessionContext() expects to find when reconstructing the session.
// Only entries *before* firstKeptEntryId were actually summarized.
const compactionEntry = branch[latestCompactionIdx] as CompactionEntry;
const compactionEntry = branch[latestCompactionIdx];
const { firstKeptEntryId } = compactionEntry;
// Collect IDs of entries in the current branch that were actually summarized
@@ -99,10 +113,6 @@ export async function truncateSessionAfterCompaction(params: {
summarizedBranchIds.add(branch[i].id);
}
// Operate on the full transcript so sibling branches and tree metadata
// are not silently dropped.
const allEntries = sm.getEntries();
// Only remove message-type entries that the compaction actually summarized.
// Non-message session state (custom, model_change, thinking_level_change,
// session_info, custom_message) is preserved even if it sits in the
@@ -112,36 +122,21 @@ export async function truncateSessionAfterCompaction(params: {
// also dropped to avoid dangling metadata (consistent with the approach in
// tool-result-truncation.ts).
const removedIds = new Set<string>();
for (const entry of allEntries) {
for (const entry of entries) {
if (summarizedBranchIds.has(entry.id) && entry.type === "message") {
removedIds.add(entry.id);
}
}
for (let i = 0; i < branch.length - 1; i++) {
const userEntry = branch[i];
const assistantEntry = branch[i + 1];
if (
userEntry.type === "message" &&
assistantEntry.type === "message" &&
summarizedBranchIds.has(userEntry.id) &&
summarizedBranchIds.has(assistantEntry.id) &&
!removedIds.has(userEntry.id) &&
!removedIds.has(assistantEntry.id) &&
isHeartbeatUserMessage(userEntry.message, params.heartbeatPrompt) &&
isHeartbeatOkResponse(assistantEntry.message, params.ackMaxChars)
) {
removedIds.add(userEntry.id);
removedIds.add(assistantEntry.id);
i++;
}
}
// Labels bookmark targetId while parentId just records the leaf when the
// label was changed, so targetId determines whether the label is still valid.
// Branch summaries still hang off the summarized branch via parentId.
for (const entry of allEntries) {
if (entry.type === "label" && removedIds.has(entry.targetId)) {
for (const entry of entries) {
if (
entry.type === "label" &&
typeof entry.targetId === "string" &&
removedIds.has(entry.targetId)
) {
removedIds.add(entry.id);
continue;
}
@@ -158,36 +153,8 @@ export async function truncateSessionAfterCompaction(params: {
return { truncated: false, entriesRemoved: 0, reason: "no entries to remove" };
}
// Build an id→entry map for walking parent chains during re-parenting.
const entryById = new Map<string, SessionEntry>();
for (const entry of allEntries) {
entryById.set(entry.id, entry);
}
// Keep every entry that was not removed, re-parenting where necessary so
// the tree stays connected.
const keptEntries: SessionEntry[] = [];
for (const entry of allEntries) {
if (removedIds.has(entry.id)) {
continue;
}
// Walk up the parent chain to find the nearest kept ancestor.
let newParentId = entry.parentId;
while (newParentId !== null && removedIds.has(newParentId)) {
const parent = entryById.get(newParentId);
newParentId = parent?.parentId ?? null;
}
if (newParentId !== entry.parentId) {
keptEntries.push({ ...entry, parentId: newParentId });
} else {
keptEntries.push(entry);
}
}
const entriesRemoved = removedIds.size;
const totalEntriesBefore = allEntries.length;
const totalEntriesBefore = entries.length;
// Get file size before truncation
let bytesBefore = 0;
@@ -211,14 +178,26 @@ export async function truncateSessionAfterCompaction(params: {
}
}
// Write truncated file atomically (temp + rename)
const lines: string[] = [JSON.stringify(header), ...keptEntries.map((e) => JSON.stringify(e))];
const content = lines.join("\n") + "\n";
const tmpFile = `${sessionFile}.truncate-tmp`;
const tmpFile = createTruncationTmpFile(sessionFile);
try {
await fs.writeFile(tmpFile, content, "utf-8");
const rewrite = await rewriteSessionFile({
sessionFile,
tmpFile,
headerLine,
removedIds,
entryById,
});
await fs.rename(tmpFile, sessionFile);
const bytesAfter = rewrite.bytesAfter;
log.info(
`[session-truncation] Truncated session file: ` +
`entriesBefore=${totalEntriesBefore} entriesAfter=${rewrite.entriesAfter} ` +
`removed=${entriesRemoved} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter} ` +
`reduction=${bytesBefore > 0 ? ((1 - bytesAfter / bytesBefore) * 100).toFixed(1) : "?"}%`,
);
return { truncated: true, entriesRemoved, bytesBefore, bytesAfter };
} catch (err) {
// Clean up temp file on failure
try {
@@ -230,17 +209,244 @@ export async function truncateSessionAfterCompaction(params: {
log.warn(`[session-truncation] Failed to write truncated file: ${reason}`);
return { truncated: false, entriesRemoved: 0, reason };
}
}
const bytesAfter = Buffer.byteLength(content, "utf-8");
type SessionEntryMeta = {
id: string;
parentId: string | null;
type: string;
firstKeptEntryId?: string;
targetId?: string;
};
log.info(
`[session-truncation] Truncated session file: ` +
`entriesBefore=${totalEntriesBefore} entriesAfter=${keptEntries.length} ` +
`removed=${entriesRemoved} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter} ` +
`reduction=${bytesBefore > 0 ? ((1 - bytesAfter / bytesBefore) * 100).toFixed(1) : "?"}%`,
type SessionFileScanResult =
| {
ok: true;
headerLine: string | null;
entries: SessionEntryMeta[];
entryById: Map<string, SessionEntryMeta>;
}
| { ok: false; reason: string };
function normalizeEntryMeta(value: unknown): SessionEntryMeta | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
if (record.type === "session") {
return null;
}
if (typeof record.id !== "string" || !record.id) {
return null;
}
const parentId = typeof record.parentId === "string" ? record.parentId : null;
return {
id: record.id,
parentId,
type: typeof record.type === "string" ? record.type : "",
...(typeof record.firstKeptEntryId === "string"
? { firstKeptEntryId: record.firstKeptEntryId }
: {}),
...(typeof record.targetId === "string" ? { targetId: record.targetId } : {}),
};
}
async function forEachJsonlLine(
filePath: string,
callback: (line: string, lineNumber: number) => Promise<void> | void,
): Promise<void> {
const stream = createReadStream(filePath, { encoding: "utf-8" });
const lines = readline.createInterface({
input: stream,
crlfDelay: Number.POSITIVE_INFINITY,
});
let lineNumber = 0;
try {
for await (const line of lines) {
lineNumber++;
if (!line.trim()) {
continue;
}
const lineBytes = Buffer.byteLength(line, "utf-8");
if (lineBytes > MAX_SESSION_TRUNCATION_LINE_BYTES) {
throw new Error(
`Session JSONL line ${lineNumber} exceeds ${MAX_SESSION_TRUNCATION_LINE_BYTES} bytes`,
);
}
await callback(line, lineNumber);
}
} finally {
lines.close();
stream.destroy();
}
}
async function scanSessionFile(sessionFile: string): Promise<SessionFileScanResult> {
const entries: SessionEntryMeta[] = [];
const entryById = new Map<string, SessionEntryMeta>();
let headerLine: string | null = null;
try {
await forEachJsonlLine(sessionFile, (line, lineNumber) => {
const parsed = parseJsonlLine(line, lineNumber);
if (
parsed &&
typeof parsed === "object" &&
(parsed as { type?: unknown }).type === "session"
) {
headerLine ??= line;
return;
}
const meta = normalizeEntryMeta(parsed);
if (!meta) {
return;
}
if (entries.length >= MAX_SESSION_TRUNCATION_ENTRIES) {
throw new Error(
`Session transcript exceeds ${MAX_SESSION_TRUNCATION_ENTRIES} entries during truncation scan`,
);
}
entries.push(meta);
entryById.set(meta.id, meta);
});
} catch (err) {
return { ok: false, reason: formatErrorMessage(err) };
}
return { ok: true, headerLine, entries, entryById };
}
function buildCurrentBranch(
entries: SessionEntryMeta[],
entryById: Map<string, SessionEntryMeta>,
): SessionEntryMeta[] {
const branch: SessionEntryMeta[] = [];
const seen = new Set<string>();
let cursor = entries.at(-1);
while (cursor && !seen.has(cursor.id)) {
branch.push(cursor);
seen.add(cursor.id);
cursor = cursor.parentId ? entryById.get(cursor.parentId) : undefined;
}
return branch.toReversed();
}
function resolveKeptParentId(params: {
parentId: string | null;
removedIds: Set<string>;
entryById: Map<string, SessionEntryMeta>;
}): string | null {
let parentId = params.parentId;
const seen = new Set<string>();
while (parentId !== null && params.removedIds.has(parentId)) {
if (seen.has(parentId)) {
return null;
}
seen.add(parentId);
const parent = params.entryById.get(parentId);
parentId = parent?.parentId ?? null;
}
return parentId;
}
async function writeLine(stream: NodeJS.WritableStream, line: string): Promise<void> {
if (!stream.write(`${line}\n`, "utf-8")) {
await waitForDrain(stream);
}
}
function waitForDrain(stream: NodeJS.WritableStream): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
stream.removeListener("drain", onDrain);
stream.removeListener("error", onError);
};
const onDrain = () => {
cleanup();
resolve();
};
const onError = (err: unknown) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
};
stream.once("drain", onDrain);
stream.once("error", onError);
});
}
function createTruncationTmpFile(sessionFile: string): string {
return path.join(
path.dirname(sessionFile),
`.${path.basename(sessionFile)}.${randomUUID()}.truncate-tmp`,
);
}
return { truncated: true, entriesRemoved, bytesBefore, bytesAfter };
function parseJsonlLine(line: string, lineNumber: number): unknown {
try {
return JSON.parse(line);
} catch (err) {
throw new Error(
`Malformed JSONL in session transcript at line ${lineNumber}: ${formatErrorMessage(err)}`,
{ cause: err },
);
}
}
async function rewriteSessionFile(params: {
sessionFile: string;
tmpFile: string;
headerLine: string;
removedIds: Set<string>;
entryById: Map<string, SessionEntryMeta>;
}): Promise<{ entriesAfter: number; bytesAfter: number }> {
const output = createWriteStream(params.tmpFile, {
encoding: "utf-8",
flags: "wx",
mode: 0o600,
});
const outputFinished = finished(output);
let entriesAfter = 0;
let bytesAfter = 0;
try {
await writeLine(output, params.headerLine);
bytesAfter += Buffer.byteLength(`${params.headerLine}\n`, "utf-8");
await forEachJsonlLine(params.sessionFile, async (line, lineNumber) => {
const parsed = parseJsonlLine(line, lineNumber);
if (
parsed &&
typeof parsed === "object" &&
(parsed as { type?: unknown }).type === "session"
) {
return;
}
const meta = normalizeEntryMeta(parsed);
if (!meta || params.removedIds.has(meta.id)) {
return;
}
const newParentId = resolveKeptParentId({
parentId: meta.parentId,
removedIds: params.removedIds,
entryById: params.entryById,
});
const outputLine =
newParentId === meta.parentId
? line
: JSON.stringify({ ...(parsed as SessionEntry), parentId: newParentId });
await writeLine(output, outputLine);
entriesAfter++;
bytesAfter += Buffer.byteLength(`${outputLine}\n`, "utf-8");
});
} finally {
output.end();
await outputFinished;
}
return { entriesAfter, bytesAfter };
}
export type TruncationResult = {

View File

@@ -22,6 +22,7 @@ const readPackageName = vi.fn();
const readPackageVersion = vi.fn();
const resolveGlobalManager = vi.fn();
const serviceLoaded = vi.fn();
const readGatewayServiceState = vi.fn();
const prepareRestartScript = vi.fn();
const runRestartScript = vi.fn();
const mockedRunDaemonInstall = vi.fn();
@@ -164,6 +165,7 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
});
vi.mock("../daemon/service.js", () => ({
readGatewayServiceState: (...args: unknown[]) => readGatewayServiceState(...args),
resolveGatewayService: vi.fn(() => ({
isLoaded: (...args: unknown[]) => serviceLoaded(...args),
readRuntime: (...args: unknown[]) => serviceReadRuntime(...args),
@@ -361,14 +363,14 @@ describe("update-cli", () => {
};
const setupUpdatedRootRefresh = (params?: {
gatewayUpdateImpl?: () => Promise<UpdateRunResult>;
gatewayUpdateImpl?: (root: string) => Promise<UpdateRunResult>;
entrypoints?: string[];
}) => {
const root = createCaseDir("openclaw-updated-root");
const entrypoints = params?.entrypoints ?? [path.join(root, "dist", "entry.js")];
pathExists.mockImplementation(async (candidate: string) => entrypoints.includes(candidate));
if (params?.gatewayUpdateImpl) {
vi.mocked(runGatewayUpdate).mockImplementation(params.gatewayUpdateImpl);
vi.mocked(runGatewayUpdate).mockImplementation(() => params.gatewayUpdateImpl!(root));
} else {
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
@@ -456,6 +458,18 @@ describe("update-cli", () => {
pid: 4242,
state: "running",
});
readGatewayServiceState.mockImplementation(async () => {
const loaded = Boolean(await serviceLoaded());
const runtime = await serviceReadRuntime();
return {
installed: loaded,
loaded,
running: runtime?.status === "running",
env: process.env,
command: loaded ? { programArguments: ["openclaw", "gateway"] } : null,
runtime,
};
});
prepareRestartScript.mockResolvedValue("/tmp/openclaw-restart-test.sh");
runRestartScript.mockResolvedValue(undefined);
inspectPortUsage.mockResolvedValue({
@@ -542,12 +556,12 @@ describe("update-cli", () => {
expect(runDaemonRestart).not.toHaveBeenCalled();
});
it("keeps downgrade post-update work in the current process", async () => {
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
it("respawns package downgrade post-update work into the updated package root", async () => {
const { entrypoints } = setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
makeOkUpdateResult({
mode: "npm",
root: createCaseDir("openclaw-downgraded-root"),
root,
before: { version: "2026.4.14" },
after: { version: "2026.4.10" },
}),
@@ -576,11 +590,12 @@ describe("update-cli", () => {
await updateCommand({ yes: true, tag: "2026.4.10" });
expect(spawn).not.toHaveBeenCalled();
expect(syncPluginsForUpdateChannel).toHaveBeenCalled();
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
expect(runDaemonInstall).toHaveBeenCalled();
expect(probeGateway).toHaveBeenCalled();
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/node/),
[entrypoints[0], "update", "--yes"],
expect.objectContaining({ stdio: "inherit" }),
);
expect(runDaemonInstall).not.toHaveBeenCalled();
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
@@ -1872,20 +1887,23 @@ describe("update-cli", () => {
await updateCommand({ yes: true });
expect(runDaemonInstall).toHaveBeenCalledWith({
force: true,
json: undefined,
});
expect(runDaemonInstall).not.toHaveBeenCalled();
expect(runRestartScript).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(
vi
.mocked(defaultRuntime.log)
.mock.calls.map((call) => String(call[0]))
.join("\n"),
).toContain("updated install entrypoint not found");
});
it("fails a JSON package update when fallback restart leaves the old gateway running", async () => {
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
const { entrypoints } = setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
makeOkUpdateResult({
mode: "npm",
root: createCaseDir("openclaw-updated-root"),
root,
before: { version: "2026.4.23" },
after: { version: "2026.4.24" },
}),
@@ -1911,7 +1929,11 @@ describe("update-cli", () => {
await updateCommand({ yes: true, json: true });
expect(runRestartScript).not.toHaveBeenCalled();
expect(runDaemonRestart).toHaveBeenCalled();
expect(runDaemonRestart).not.toHaveBeenCalled();
expect(runCommandWithTimeout).toHaveBeenCalledWith(
[expect.stringMatching(/node/), entrypoints[0], "gateway", "restart", "--json"],
expect.any(Object),
);
expect(probeGateway).toHaveBeenCalledWith(expect.objectContaining({ includeDetails: true }));
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
expect(defaultRuntime.writeJson).not.toHaveBeenCalled();
@@ -1928,10 +1950,10 @@ describe("update-cli", () => {
it("fails a package update when the restarted gateway reports activated plugin load errors", async () => {
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
gatewayUpdateImpl: async (root) =>
makeOkUpdateResult({
mode: "npm",
root: createCaseDir("openclaw-updated-root"),
root,
before: { version: "2026.4.23" },
after: { version: "2026.4.24" },
}),

View File

@@ -67,6 +67,20 @@ describe("config compaction settings", () => {
expect(compaction?.reserveTokensFloor).toBe(9000);
});
it("defaults post-compaction transcript truncation on", () => {
const compaction = materializeCompactionConfig({});
expect(compaction?.truncateAfterCompaction).toBe(true);
});
it("preserves an explicit post-compaction transcript truncation opt-out", () => {
const compaction = materializeCompactionConfig({
truncateAfterCompaction: false,
});
expect(compaction?.truncateAfterCompaction).toBe(false);
});
it("preserves recent turn safeguard values during materialization", () => {
const compaction = materializeCompactionConfig({
mode: "safeguard",

View File

@@ -387,7 +387,12 @@ export function applyCompactionDefaults(cfg: OpenClawConfig): OpenClawConfig {
return cfg;
}
const compaction = defaults?.compaction;
if (compaction?.mode) {
const mode = compaction?.mode ?? "safeguard";
const truncateAfterCompaction = compaction?.truncateAfterCompaction ?? true;
if (
compaction?.mode === mode &&
compaction?.truncateAfterCompaction === truncateAfterCompaction
) {
return cfg;
}
@@ -399,7 +404,8 @@ export function applyCompactionDefaults(cfg: OpenClawConfig): OpenClawConfig {
...defaults,
compaction: {
...compaction,
mode: "safeguard",
mode,
truncateAfterCompaction,
},
},
},

View File

@@ -4992,7 +4992,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
type: "boolean",
title: "Truncate After Compaction",
description:
"When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: false.",
"When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: true.",
},
notifyUser: {
type: "boolean",
@@ -26857,7 +26857,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"agents.defaults.compaction.truncateAfterCompaction": {
label: "Truncate After Compaction",
help: "When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: false.",
help: "When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: true.",
tags: ["advanced"],
},
"agents.defaults.compaction.notifyUser": {

View File

@@ -1266,7 +1266,7 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.compaction.model":
"Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.",
"agents.defaults.compaction.truncateAfterCompaction":
"When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: false.",
"When enabled, rewrites the session JSONL file after compaction to remove entries that were summarized. Prevents unbounded file growth in long-running sessions with many compaction cycles. Default: true.",
"agents.defaults.compaction.notifyUser":
"When enabled, sends brief compaction notices to the user when compaction starts and when it completes (for example, '🧹 Compacting context...' and '🧹 Compaction complete'). Disabled by default to keep compaction silent and non-intrusive.",
"agents.defaults.compaction.memoryFlush":

View File

@@ -473,7 +473,7 @@ export type AgentCompactionConfig = {
/**
* Truncate the session JSONL file after compaction to remove entries that
* were summarized. Prevents unbounded file growth in long-running sessions.
* Default: false (existing behavior preserved).
* Default: true.
*/
truncateAfterCompaction?: boolean;
/**

View File

@@ -904,13 +904,21 @@ describe("test-projects args", () => {
]);
});
it("keeps extension-facing core contract changes focused by default", () => {
it("keeps extension-facing core contract changes focused by default and supports broad opt-in", () => {
const changedPaths = ["src/plugin-sdk/core.ts"];
const plans = buildVitestRunPlans(["--changed=origin/main"], process.cwd(), () => changedPaths);
const targetArgs = resolveChangedTargetArgs(
["--changed=origin/main"],
process.cwd(),
() => changedPaths,
);
expect(targetArgs).toEqual(["src/plugin-sdk/core.test.ts"]);
expect(
resolveChangedTargetArgs(["--changed=origin/main"], process.cwd(), () => changedPaths),
).toEqual(["src/plugin-sdk/core.test.ts"]);
resolveChangedTargetArgs(["--changed=origin/main"], process.cwd(), () => changedPaths, {
env: { OPENCLAW_TEST_CHANGED_BROAD: "1" },
}),
).toEqual(["src/plugin-sdk/core.test.ts", "extensions"]);
expect(plans[0]).toEqual({
config: "test/vitest/vitest.plugin-sdk.config.ts",
forwardedArgs: [],

View File

@@ -339,7 +339,6 @@ describe("collectForbiddenPackedPathErrors", () => {
"dist/plugin-sdk/qa-channel-protocol.d.ts",
"dist/qa-runtime-B9LDtssJ.js",
"docs/channels/qa-channel.md",
"docs/refactor/qa.md",
"qa/scenarios/index.md",
]),
).toEqual([
@@ -352,7 +351,6 @@ describe("collectForbiddenPackedPathErrors", () => {
'npm package must not include private QA lab artifact "dist/extensions/qa-lab/runtime-api.js".',
'npm package must not include private QA lab artifact "dist/extensions/qa-lab/src/cli.js".',
'npm package must not include private QA lab type artifact "dist/plugin-sdk/extensions/qa-lab/cli.d.ts".',
'npm package must not include private QA refactor docs "docs/refactor/qa.md".',
'npm package must not include private QA runtime chunk "dist/qa-runtime-B9LDtssJ.js".',
'npm package must not include private QA suite artifact "qa/scenarios/index.md".',
]);

View File

@@ -459,7 +459,6 @@ describe("collectForbiddenPackPaths", () => {
"dist/plugin-sdk/qa-runtime.js",
"dist/qa-runtime-B9LDtssJ.js",
"docs/channels/qa-channel.md",
"docs/refactor/qa.md",
"qa/scenarios/index.md",
]),
).toEqual([
@@ -473,7 +472,6 @@ describe("collectForbiddenPackPaths", () => {
"dist/plugin-sdk/qa-runtime.js",
"dist/qa-runtime-B9LDtssJ.js",
"docs/channels/qa-channel.md",
"docs/refactor/qa.md",
"qa/scenarios/index.md",
]);
});

View File

@@ -46,7 +46,9 @@ describe("test-install-sh-docker", () => {
);
expect(runner).toContain("resolve_update_baseline_version");
expect(runner).toContain('quiet_npm view "${PACKAGE_NAME}@${UPDATE_BASELINE_VERSION}" version');
expect(workflow).toContain("OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: latest");
expect(workflow).toContain(
"OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: ${{ inputs.update_baseline_version || 'latest' }}",
);
});
it("can reuse dist from the already-built root Docker smoke image", () => {

View File

@@ -131,6 +131,14 @@ export const sharedVitestConfig = {
find: "openclaw/extension-api",
replacement: path.join(repoRoot, "src", "extensionAPI.ts"),
},
{
find: "openclaw/plugin-sdk/qa-channel",
replacement: path.join(repoRoot, "src", "plugin-sdk", "qa-channel.ts"),
},
{
find: "openclaw/plugin-sdk/qa-channel-protocol",
replacement: path.join(repoRoot, "src", "plugin-sdk", "qa-channel-protocol.ts"),
},
...pluginSdkSubpaths.map((subpath) => ({
find: `openclaw/plugin-sdk/${subpath}`,
replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),