mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-25 16:12:13 +00:00
Merged via squash.
Prepared head SHA: fa50b63580
Co-authored-by: thirumaleshp <85149081+thirumaleshp@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
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 { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
|
import { truncateSessionAfterCompaction } from "./session-truncation.js";
|
|
|
|
let tmpDir: string;
|
|
|
|
async function createTmpDir(): Promise<string> {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-truncation-test-"));
|
|
return tmpDir;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
if (tmpDir) {
|
|
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
function makeAssistant(text: string, timestamp: number) {
|
|
return makeAgentAssistantMessage({
|
|
content: [{ type: "text", text }],
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
function createSessionWithCompaction(sessionDir: string): string {
|
|
const sm = SessionManager.create(sessionDir, sessionDir);
|
|
// Add messages before compaction
|
|
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
|
sm.appendMessage(makeAssistant("hi there", 2));
|
|
sm.appendMessage({ role: "user", content: "do something", timestamp: 3 });
|
|
sm.appendMessage(makeAssistant("done", 4));
|
|
|
|
// Add compaction (summarizing the above)
|
|
const branch = sm.getBranch();
|
|
const firstKeptId = branch[branch.length - 1].id;
|
|
sm.appendCompaction("Summary of conversation so far.", firstKeptId, 5000);
|
|
|
|
// Add messages after compaction
|
|
sm.appendMessage({ role: "user", content: "next task", timestamp: 5 });
|
|
sm.appendMessage(makeAssistant("working on it", 6));
|
|
|
|
return sm.getSessionFile()!;
|
|
}
|
|
|
|
describe("truncateSessionAfterCompaction", () => {
|
|
it("removes entries before compaction and keeps entries after (#39953)", async () => {
|
|
const dir = await createTmpDir();
|
|
const sessionFile = createSessionWithCompaction(dir);
|
|
|
|
// Verify pre-truncation state
|
|
const smBefore = SessionManager.open(sessionFile);
|
|
const entriesBefore = smBefore.getEntries().length;
|
|
expect(entriesBefore).toBeGreaterThan(5); // 4 messages + compaction + 2 messages
|
|
|
|
const result = await truncateSessionAfterCompaction({ sessionFile });
|
|
|
|
expect(result.truncated).toBe(true);
|
|
expect(result.entriesRemoved).toBeGreaterThan(0);
|
|
expect(result.bytesAfter).toBeLessThan(result.bytesBefore!);
|
|
|
|
// Verify post-truncation: file is still a valid session
|
|
const smAfter = SessionManager.open(sessionFile);
|
|
const entriesAfter = smAfter.getEntries().length;
|
|
expect(entriesAfter).toBeLessThan(entriesBefore);
|
|
|
|
// The branch should contain the firstKeptEntryId message (unsummarized
|
|
// tail), compaction, and post-compaction messages
|
|
const branchAfter = smAfter.getBranch();
|
|
// The firstKeptEntryId message is preserved as the new root
|
|
expect(branchAfter[0].type).toBe("message");
|
|
expect(branchAfter[0].parentId).toBeNull();
|
|
expect(branchAfter[1].type).toBe("compaction");
|
|
|
|
// Session context should still work
|
|
const ctx = smAfter.buildSessionContext();
|
|
expect(ctx.messages.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("skips truncation when no compaction entry exists", async () => {
|
|
const dir = await createTmpDir();
|
|
const sm = SessionManager.create(dir, dir);
|
|
// appendMessage implicitly creates the session file
|
|
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
|
sm.appendMessage(makeAssistant("hi", 2));
|
|
sm.appendMessage({ role: "user", content: "bye", timestamp: 3 });
|
|
const sessionFile = sm.getSessionFile()!;
|
|
|
|
const result = await truncateSessionAfterCompaction({ sessionFile });
|
|
|
|
expect(result.truncated).toBe(false);
|
|
expect(result.reason).toBe("no compaction entry found");
|
|
});
|
|
|
|
it("is idempotent — second truncation is a no-op", async () => {
|
|
const dir = await createTmpDir();
|
|
const sessionFile = createSessionWithCompaction(dir);
|
|
|
|
const first = await truncateSessionAfterCompaction({ sessionFile });
|
|
expect(first.truncated).toBe(true);
|
|
|
|
// Run again — no message entries left to remove
|
|
const second = await truncateSessionAfterCompaction({ sessionFile });
|
|
expect(second.truncated).toBe(false);
|
|
});
|
|
|
|
it("archives original file when archivePath is provided (#39953)", async () => {
|
|
const dir = await createTmpDir();
|
|
const sessionFile = createSessionWithCompaction(dir);
|
|
const archivePath = path.join(dir, "archive", "backup.jsonl");
|
|
|
|
const result = await truncateSessionAfterCompaction({ sessionFile, archivePath });
|
|
|
|
expect(result.truncated).toBe(true);
|
|
const archiveExists = await fs
|
|
.stat(archivePath)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
expect(archiveExists).toBe(true);
|
|
|
|
// Archive should be larger than truncated file (it has the full history)
|
|
const archiveSize = (await fs.stat(archivePath)).size;
|
|
const truncatedSize = (await fs.stat(sessionFile)).size;
|
|
expect(archiveSize).toBeGreaterThan(truncatedSize);
|
|
});
|
|
|
|
it("handles multiple compaction cycles (#39953)", async () => {
|
|
const dir = await createTmpDir();
|
|
const sm = SessionManager.create(dir, dir);
|
|
|
|
// First cycle: messages + compaction
|
|
sm.appendMessage({ role: "user", content: "cycle 1 message 1", timestamp: 1 });
|
|
sm.appendMessage(makeAssistant("response 1", 2));
|
|
const branch1 = sm.getBranch();
|
|
sm.appendCompaction("Summary of cycle 1.", branch1[branch1.length - 1].id, 3000);
|
|
|
|
// Second cycle: more messages + another compaction
|
|
sm.appendMessage({ role: "user", content: "cycle 2 message 1", timestamp: 3 });
|
|
sm.appendMessage(makeAssistant("response 2", 4));
|
|
const branch2 = sm.getBranch();
|
|
sm.appendCompaction("Summary of cycles 1 and 2.", branch2[branch2.length - 1].id, 6000);
|
|
|
|
// Post-compaction messages
|
|
sm.appendMessage({ role: "user", content: "final question", timestamp: 5 });
|
|
|
|
const sessionFile = sm.getSessionFile()!;
|
|
const entriesBefore = sm.getEntries().length;
|
|
|
|
const result = await truncateSessionAfterCompaction({ sessionFile });
|
|
|
|
expect(result.truncated).toBe(true);
|
|
|
|
// Should preserve both compactions (older compactions are non-message state)
|
|
// but remove the summarized message entries
|
|
const smAfter = SessionManager.open(sessionFile);
|
|
const branchAfter = smAfter.getBranch();
|
|
expect(branchAfter[0].type).toBe("compaction");
|
|
|
|
// Both compaction entries are preserved (non-message state is kept)
|
|
const compactionEntries = branchAfter.filter((e) => e.type === "compaction");
|
|
expect(compactionEntries).toHaveLength(2);
|
|
|
|
// But message entries before the latest compaction were removed
|
|
const entriesAfter = smAfter.getEntries().length;
|
|
expect(entriesAfter).toBeLessThan(entriesBefore);
|
|
|
|
// Only the firstKeptEntryId message should remain before the latest compaction
|
|
const latestCompIdx = branchAfter.findIndex(
|
|
(e) => e.type === "compaction" && e === compactionEntries[compactionEntries.length - 1],
|
|
);
|
|
const messagesBeforeLatest = branchAfter
|
|
.slice(0, latestCompIdx)
|
|
.filter((e) => e.type === "message");
|
|
expect(messagesBeforeLatest).toHaveLength(1);
|
|
});
|
|
|
|
it("preserves non-message session state during truncation", async () => {
|
|
const dir = await createTmpDir();
|
|
const sm = SessionManager.create(dir, dir);
|
|
|
|
// Messages before compaction
|
|
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
|
sm.appendMessage(makeAssistant("hi", 2));
|
|
|
|
// Non-message state entries interleaved with messages
|
|
sm.appendModelChange("anthropic", "claude-sonnet-4-5-20250514");
|
|
sm.appendThinkingLevelChange("high");
|
|
sm.appendCustomEntry("my-extension", { key: "value" });
|
|
sm.appendSessionInfo("my session");
|
|
|
|
sm.appendMessage({ role: "user", content: "do task", timestamp: 3 });
|
|
sm.appendMessage(makeAssistant("done", 4));
|
|
|
|
// Compaction summarizing the conversation
|
|
const branch = sm.getBranch();
|
|
const firstKeptId = branch[branch.length - 1].id;
|
|
sm.appendCompaction("Summary.", firstKeptId, 5000);
|
|
|
|
// Post-compaction messages
|
|
sm.appendMessage({ role: "user", content: "next", timestamp: 5 });
|
|
|
|
const sessionFile = sm.getSessionFile()!;
|
|
const result = await truncateSessionAfterCompaction({ sessionFile });
|
|
|
|
expect(result.truncated).toBe(true);
|
|
|
|
// Verify non-message entries are preserved
|
|
const smAfter = SessionManager.open(sessionFile);
|
|
const allAfter = smAfter.getEntries();
|
|
const types = allAfter.map((e) => e.type);
|
|
|
|
expect(types).toContain("model_change");
|
|
expect(types).toContain("thinking_level_change");
|
|
expect(types).toContain("custom");
|
|
expect(types).toContain("session_info");
|
|
expect(types).toContain("compaction");
|
|
|
|
// Only the firstKeptEntryId message should remain before the compaction
|
|
// (all other messages before it were summarized and removed)
|
|
const branchAfter = smAfter.getBranch();
|
|
const compIdx = branchAfter.findIndex((e) => e.type === "compaction");
|
|
const msgsBefore = branchAfter.slice(0, compIdx).filter((e) => e.type === "message");
|
|
expect(msgsBefore).toHaveLength(1);
|
|
|
|
// Session context should still work
|
|
const ctx = smAfter.buildSessionContext();
|
|
expect(ctx.messages.length).toBeGreaterThan(0);
|
|
// Non-message state entries are preserved in the truncated file
|
|
expect(ctx.model).toBeDefined();
|
|
expect(ctx.thinkingLevel).toBe("high");
|
|
});
|
|
|
|
it("drops label entries whose target message was truncated", async () => {
|
|
const dir = await createTmpDir();
|
|
const sm = SessionManager.create(dir, dir);
|
|
|
|
// Messages before compaction
|
|
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
|
sm.appendMessage(makeAssistant("hi", 2));
|
|
sm.appendMessage({ role: "user", content: "do task", timestamp: 3 });
|
|
sm.appendMessage(makeAssistant("done", 4));
|
|
|
|
// Capture a pre-compaction message that will be summarized away.
|
|
const branch = sm.getBranch();
|
|
const preCompactionMsgId = branch[1].id; // "hi" message
|
|
|
|
// Compaction summarizing the conversation
|
|
const firstKeptId = branch[branch.length - 1].id;
|
|
sm.appendCompaction("Summary.", firstKeptId, 5000);
|
|
|
|
// Post-compaction messages
|
|
sm.appendMessage({ role: "user", content: "next", timestamp: 5 });
|
|
sm.appendLabelChange(preCompactionMsgId, "my-label");
|
|
|
|
const sessionFile = sm.getSessionFile()!;
|
|
const labelEntry = sm.getEntries().find((entry) => entry.type === "label");
|
|
expect(labelEntry?.parentId).not.toBe(preCompactionMsgId);
|
|
|
|
const smBefore = SessionManager.open(sessionFile);
|
|
expect(smBefore.getLabel(preCompactionMsgId)).toBe("my-label");
|
|
|
|
const result = await truncateSessionAfterCompaction({ sessionFile });
|
|
|
|
expect(result.truncated).toBe(true);
|
|
|
|
// Verify label metadata was dropped with the removed target message.
|
|
const smAfter = SessionManager.open(sessionFile);
|
|
const allAfter = smAfter.getEntries();
|
|
const labels = allAfter.filter((e) => e.type === "label");
|
|
expect(labels).toHaveLength(0);
|
|
expect(smAfter.getLabel(preCompactionMsgId)).toBeUndefined();
|
|
});
|
|
|
|
it("preserves the firstKeptEntryId unsummarized tail", async () => {
|
|
const dir = await createTmpDir();
|
|
const sm = SessionManager.create(dir, dir);
|
|
|
|
// Build a conversation where firstKeptEntryId is NOT the last message
|
|
sm.appendMessage({ role: "user", content: "msg1", timestamp: 1 });
|
|
sm.appendMessage(makeAssistant("resp1", 2));
|
|
sm.appendMessage({ role: "user", content: "msg2", timestamp: 3 });
|
|
sm.appendMessage(makeAssistant("resp2", 4));
|
|
|
|
const branch = sm.getBranch();
|
|
// Set firstKeptEntryId to the second message — so msg1 is summarized
|
|
// but msg2, resp2, and everything after are the unsummarized tail.
|
|
const firstKeptId = branch[1].id; // "resp1"
|
|
sm.appendCompaction("Summary of msg1.", firstKeptId, 2000);
|
|
|
|
sm.appendMessage({ role: "user", content: "next", timestamp: 5 });
|
|
|
|
const sessionFile = sm.getSessionFile()!;
|
|
const result = await truncateSessionAfterCompaction({ sessionFile });
|
|
|
|
expect(result.truncated).toBe(true);
|
|
// Only msg1 was summarized (1 entry removed)
|
|
expect(result.entriesRemoved).toBe(1);
|
|
|
|
// Verify the unsummarized tail is preserved
|
|
const smAfter = SessionManager.open(sessionFile);
|
|
const branchAfter = smAfter.getBranch();
|
|
const types = branchAfter.map((e) => e.type);
|
|
// resp1 (firstKeptEntryId), msg2, resp2, compaction, next
|
|
expect(types).toEqual(["message", "message", "message", "compaction", "message"]);
|
|
|
|
// buildSessionContext should include the unsummarized tail
|
|
const ctx = smAfter.buildSessionContext();
|
|
expect(ctx.messages.length).toBeGreaterThan(2);
|
|
});
|
|
|
|
it("preserves unsummarized sibling branches during truncation", async () => {
|
|
const dir = await createTmpDir();
|
|
const sm = SessionManager.create(dir, dir);
|
|
|
|
// Build main conversation
|
|
sm.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
|
sm.appendMessage(makeAssistant("hi there", 2));
|
|
|
|
// Save a branch point
|
|
const branchPoint = sm.getBranch();
|
|
const branchFromId = branchPoint[branchPoint.length - 1].id;
|
|
|
|
// Continue main branch
|
|
sm.appendMessage({ role: "user", content: "do task A", timestamp: 3 });
|
|
sm.appendMessage(makeAssistant("done A", 4));
|
|
|
|
// Create a sibling branch from the earlier point
|
|
sm.branch(branchFromId);
|
|
sm.appendMessage({ role: "user", content: "do task B instead", timestamp: 5 });
|
|
const siblingMsg = sm.appendMessage(makeAssistant("done B", 6));
|
|
|
|
// Go back to main branch tip and add compaction there
|
|
sm.branch(branchFromId);
|
|
sm.appendMessage({ role: "user", content: "do task A", timestamp: 3 });
|
|
sm.appendMessage(makeAssistant("done A take 2", 7));
|
|
const mainBranch = sm.getBranch();
|
|
const firstKeptId = mainBranch[mainBranch.length - 1].id;
|
|
sm.appendCompaction("Summary of main branch.", firstKeptId, 5000);
|
|
sm.appendMessage({ role: "user", content: "next", timestamp: 8 });
|
|
|
|
const sessionFile = sm.getSessionFile()!;
|
|
|
|
const entriesBefore = sm.getEntries();
|
|
|
|
const result = await truncateSessionAfterCompaction({ sessionFile });
|
|
|
|
expect(result.truncated).toBe(true);
|
|
|
|
// Verify sibling branch is preserved in the full entry list
|
|
const smAfter = SessionManager.open(sessionFile);
|
|
const allAfter = smAfter.getEntries();
|
|
|
|
// The sibling branch message should still exist
|
|
const siblingAfter = allAfter.find((e) => e.id === siblingMsg);
|
|
expect(siblingAfter).toBeDefined();
|
|
|
|
// The tree should have entries from both branches
|
|
const tree = smAfter.getTree();
|
|
expect(tree.length).toBeGreaterThan(0);
|
|
|
|
// Total entries should be less (main branch messages removed) but not zero
|
|
expect(allAfter.length).toBeGreaterThan(0);
|
|
expect(allAfter.length).toBeLessThan(entriesBefore.length);
|
|
});
|
|
});
|