mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
10 Commits
v2026.5.12
...
codex/sess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b23ff97ddc | ||
|
|
e705246619 | ||
|
|
f936f16cc5 | ||
|
|
fd48faa4ed | ||
|
|
29f1cae867 | ||
|
|
f58dd36a1d | ||
|
|
33e3dccbea | ||
|
|
6fc954539f | ||
|
|
fc13a0135e | ||
|
|
0ced62f512 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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".',
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user