fix(session-memory): fallback to rotated transcript after /new

When /new rotates <session>.jsonl to <session>.jsonl.reset.*, the session-memory hook may read an empty active transcript and write header-only memory entries.

Add fallback logic to read the latest .jsonl.reset.* sibling when the primary file has no usable content.

Also add a unit test covering the rotated transcript path.

Fixes #18088
Refs #17563
This commit is contained in:
Tomas Hajek
2026-02-16 22:38:30 +00:00
committed by Peter Steinberger
parent 769f7631d5
commit 19ae7a4e17
2 changed files with 87 additions and 2 deletions

View File

@@ -255,6 +255,51 @@ describe("session-memory hook", () => {
expect(memoryContent).toContain("assistant: Fourth message");
});
it("falls back to latest .jsonl.reset.* transcript when active file is empty", async () => {
const tempDir = await makeTempWorkspace("openclaw-session-memory-");
const sessionsDir = path.join(tempDir, "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const activeSessionFile = await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl",
content: "",
});
// Simulate /new rotation where useful content is now in .reset.* file
const resetContent = createMockSessionContent([
{ role: "user", content: "Message from rotated transcript" },
{ role: "assistant", content: "Recovered from reset fallback" },
]);
await writeWorkspaceFile({
dir: sessionsDir,
name: "test-session.jsonl.reset.2026-02-16T22-26-33.000Z",
content: resetContent,
});
const cfg = {
agents: { defaults: { workspace: tempDir } },
} satisfies OpenClawConfig;
const event = createHookEvent("command", "new", "agent:main:main", {
cfg,
previousSessionEntry: {
sessionId: "test-123",
sessionFile: activeSessionFile,
},
});
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files.length).toBe(1);
const memoryContent = await fs.readFile(path.join(memoryDir, files[0]), "utf-8");
expect(memoryContent).toContain("user: Message from rotated transcript");
expect(memoryContent).toContain("assistant: Recovered from reset fallback");
});
it("handles empty session files gracefully", async () => {
// Should not throw
const { files } = await runNewWithPreviousSession({ sessionContent: "" });

View File

@@ -67,6 +67,46 @@ async function getRecentSessionContent(
}
}
/**
* Try the active transcript first; if /new already rotated it,
* fallback to the latest .jsonl.reset.* sibling.
*/
async function getRecentSessionContentWithResetFallback(
sessionFilePath: string,
messageCount: number = 15,
): Promise<string | null> {
const primary = await getRecentSessionContent(sessionFilePath, messageCount);
if (primary) {
return primary;
}
try {
const dir = path.dirname(sessionFilePath);
const base = path.basename(sessionFilePath);
const resetPrefix = `${base}.reset.`;
const files = await fs.readdir(dir);
const resetCandidates = files.filter((name) => name.startsWith(resetPrefix)).toSorted();
if (resetCandidates.length === 0) {
return primary;
}
const latestResetPath = path.join(dir, resetCandidates[resetCandidates.length - 1]);
const fallback = await getRecentSessionContent(latestResetPath, messageCount);
if (fallback) {
log.debug("Loaded session content from reset fallback", {
sessionFilePath,
latestResetPath,
});
}
return fallback || primary;
} catch {
return primary;
}
}
/**
* Save session context to memory when /new command is triggered
*/
@@ -119,8 +159,8 @@ const saveSessionToMemory: HookHandler = async (event) => {
let sessionContent: string | null = null;
if (sessionFile) {
// Get recent conversation content
sessionContent = await getRecentSessionContent(sessionFile, messageCount);
// Get recent conversation content, with fallback to rotated reset transcript.
sessionContent = await getRecentSessionContentWithResetFallback(sessionFile, messageCount);
log.debug("Session content loaded", {
length: sessionContent?.length ?? 0,
messageCount,