import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; import { writeWorkspaceFile } from "../../../test-helpers/workspace.js"; import type { HookHandler } from "../../hooks.js"; import { createHookEvent } from "../../hooks.js"; // Avoid calling the embedded Pi agent (global command lane); keep this unit test deterministic. vi.mock("../../llm-slug-generator.js", () => ({ generateSlugViaLLM: vi.fn().mockResolvedValue("simple-math"), })); let handler: HookHandler; let suiteWorkspaceRoot = ""; let workspaceCaseCounter = 0; async function createCaseWorkspace(prefix = "case"): Promise { const dir = path.join(suiteWorkspaceRoot, `${prefix}-${workspaceCaseCounter}`); workspaceCaseCounter += 1; await fs.mkdir(dir, { recursive: true }); return dir; } beforeAll(async () => { ({ default: handler } = await import("./handler.js")); suiteWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-memory-")); }); afterAll(async () => { if (!suiteWorkspaceRoot) { return; } await fs.rm(suiteWorkspaceRoot, { recursive: true, force: true }); suiteWorkspaceRoot = ""; workspaceCaseCounter = 0; }); /** * Create a mock session JSONL file with various entry types */ function createMockSessionContent( entries: Array<{ role: string; content: string } | ({ type: string } & Record)>, ): string { return entries .map((entry) => { if ("role" in entry) { return JSON.stringify({ type: "message", message: { role: entry.role, content: entry.content, }, }); } // Non-message entry (tool call, system, etc.) return JSON.stringify(entry); }) .join("\n"); } async function runNewWithPreviousSessionEntry(params: { tempDir: string; previousSessionEntry: { sessionId: string; sessionFile?: string }; cfg?: OpenClawConfig; action?: "new" | "reset"; sessionKey?: string; workspaceDirOverride?: string; }): Promise<{ files: string[]; memoryContent: string }> { const event = createHookEvent( "command", params.action ?? "new", params.sessionKey ?? "agent:main:main", { cfg: params.cfg ?? ({ agents: { defaults: { workspace: params.tempDir } }, } satisfies OpenClawConfig), previousSessionEntry: params.previousSessionEntry, ...(params.workspaceDirOverride ? { workspaceDir: params.workspaceDirOverride } : {}), }, ); await handler(event); const memoryDir = path.join(params.tempDir, "memory"); const files = await fs.readdir(memoryDir); const memoryContent = files.length > 0 ? await fs.readFile(path.join(memoryDir, files[0]), "utf-8") : ""; return { files, memoryContent }; } async function runNewWithPreviousSession(params: { sessionContent: string; cfg?: (tempDir: string) => OpenClawConfig; action?: "new" | "reset"; }): Promise<{ tempDir: string; files: string[]; memoryContent: string }> { const tempDir = await createCaseWorkspace("workspace"); const sessionsDir = path.join(tempDir, "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); const sessionFile = await writeWorkspaceFile({ dir: sessionsDir, name: "test-session.jsonl", content: params.sessionContent, }); const cfg = params.cfg?.(tempDir) ?? ({ agents: { defaults: { workspace: tempDir } }, } satisfies OpenClawConfig); const { files, memoryContent } = await runNewWithPreviousSessionEntry({ tempDir, cfg, action: params.action, previousSessionEntry: { sessionId: "test-123", sessionFile, }, }); return { tempDir, files, memoryContent }; } function makeSessionMemoryConfig(tempDir: string, messages?: number): OpenClawConfig { return { agents: { defaults: { workspace: tempDir } }, ...(typeof messages === "number" ? { hooks: { internal: { entries: { "session-memory": { enabled: true, messages }, }, }, }, } : {}), } satisfies OpenClawConfig; } async function createSessionMemoryWorkspace(params?: { activeSession?: { name: string; content: string }; }): Promise<{ tempDir: string; sessionsDir: string; activeSessionFile?: string }> { const tempDir = await createCaseWorkspace("workspace"); const sessionsDir = path.join(tempDir, "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); if (!params?.activeSession) { return { tempDir, sessionsDir }; } const activeSessionFile = await writeWorkspaceFile({ dir: sessionsDir, name: params.activeSession.name, content: params.activeSession.content, }); return { tempDir, sessionsDir, activeSessionFile }; } async function loadMemoryFromActiveSessionPointer(params: { tempDir: string; activeSessionFile: string; }): Promise { const { memoryContent } = await runNewWithPreviousSessionEntry({ tempDir: params.tempDir, previousSessionEntry: { sessionId: "test-123", sessionFile: params.activeSessionFile, }, }); return memoryContent; } function expectMemoryConversation(params: { memoryContent: string; user: string; assistant: string; absent?: string; }) { expect(params.memoryContent).toContain(`user: ${params.user}`); expect(params.memoryContent).toContain(`assistant: ${params.assistant}`); if (params.absent) { expect(params.memoryContent).not.toContain(params.absent); } } describe("session-memory hook", () => { it("skips non-command events", async () => { const tempDir = await createCaseWorkspace("workspace"); const event = createHookEvent("agent", "bootstrap", "agent:main:main", { workspaceDir: tempDir, }); await handler(event); // Memory directory should not be created for non-command events const memoryDir = path.join(tempDir, "memory"); await expect(fs.access(memoryDir)).rejects.toThrow(); }); it("skips commands other than new", async () => { const tempDir = await createCaseWorkspace("workspace"); const event = createHookEvent("command", "help", "agent:main:main", { workspaceDir: tempDir, }); await handler(event); // Memory directory should not be created for other commands const memoryDir = path.join(tempDir, "memory"); await expect(fs.access(memoryDir)).rejects.toThrow(); }); it("creates memory file with session content on /new command", async () => { // Create a mock session file with user/assistant messages const sessionContent = createMockSessionContent([ { role: "user", content: "Hello there" }, { role: "assistant", content: "Hi! How can I help?" }, { role: "user", content: "What is 2+2?" }, { role: "assistant", content: "2+2 equals 4" }, ]); const { files, memoryContent } = await runNewWithPreviousSession({ sessionContent }); expect(files.length).toBe(1); // Read the memory file and verify content expect(memoryContent).toContain("user: Hello there"); expect(memoryContent).toContain("assistant: Hi! How can I help?"); expect(memoryContent).toContain("user: What is 2+2?"); expect(memoryContent).toContain("assistant: 2+2 equals 4"); }); it("creates memory file with session content on /reset command", async () => { const sessionContent = createMockSessionContent([ { role: "user", content: "Please reset and keep notes" }, { role: "assistant", content: "Captured before reset" }, ]); const { files, memoryContent } = await runNewWithPreviousSession({ sessionContent, action: "reset", }); expect(files.length).toBe(1); expect(memoryContent).toContain("user: Please reset and keep notes"); expect(memoryContent).toContain("assistant: Captured before reset"); }); it("prefers workspaceDir from hook context when sessionKey points at main", async () => { const mainWorkspace = await createCaseWorkspace("workspace-main"); const naviWorkspace = await createCaseWorkspace("workspace-navi"); const naviSessionsDir = path.join(naviWorkspace, "sessions"); await fs.mkdir(naviSessionsDir, { recursive: true }); const sessionFile = await writeWorkspaceFile({ dir: naviSessionsDir, name: "navi-session.jsonl", content: createMockSessionContent([ { role: "user", content: "Remember this under Navi" }, { role: "assistant", content: "Stored in the bound workspace" }, ]), }); const { files, memoryContent } = await runNewWithPreviousSessionEntry({ tempDir: naviWorkspace, cfg: { agents: { defaults: { workspace: mainWorkspace }, list: [{ id: "navi", workspace: naviWorkspace }], }, } satisfies OpenClawConfig, sessionKey: "agent:main:main", workspaceDirOverride: naviWorkspace, previousSessionEntry: { sessionId: "navi-session", sessionFile, }, }); expect(files.length).toBe(1); expect(memoryContent).toContain("user: Remember this under Navi"); expect(memoryContent).toContain("assistant: Stored in the bound workspace"); expect(memoryContent).toContain("- **Session Key**: agent:navi:main"); await expect(fs.access(path.join(mainWorkspace, "memory"))).rejects.toThrow(); }); it("filters out non-message entries (tool calls, system)", async () => { // Create session with mixed entry types const sessionContent = createMockSessionContent([ { role: "user", content: "Hello" }, { type: "tool_use", tool: "search", input: "test" }, { role: "assistant", content: "World" }, { type: "tool_result", result: "found it" }, { role: "user", content: "Thanks" }, ]); const { memoryContent } = await runNewWithPreviousSession({ sessionContent }); // Only user/assistant messages should be present expect(memoryContent).toContain("user: Hello"); expect(memoryContent).toContain("assistant: World"); expect(memoryContent).toContain("user: Thanks"); // Tool entries should not appear expect(memoryContent).not.toContain("tool_use"); expect(memoryContent).not.toContain("tool_result"); expect(memoryContent).not.toContain("search"); }); it("filters out inter-session user messages", async () => { const sessionContent = [ JSON.stringify({ type: "message", message: { role: "user", content: "Forwarded internal instruction", provenance: { kind: "inter_session", sourceTool: "sessions_send" }, }, }), JSON.stringify({ type: "message", message: { role: "assistant", content: "Acknowledged" }, }), JSON.stringify({ type: "message", message: { role: "user", content: "External follow-up" }, }), ].join("\n"); const { memoryContent } = await runNewWithPreviousSession({ sessionContent }); expect(memoryContent).not.toContain("Forwarded internal instruction"); expect(memoryContent).toContain("assistant: Acknowledged"); expect(memoryContent).toContain("user: External follow-up"); }); it("filters out command messages starting with /", async () => { const sessionContent = createMockSessionContent([ { role: "user", content: "/help" }, { role: "assistant", content: "Here is help info" }, { role: "user", content: "Normal message" }, { role: "user", content: "/new" }, ]); const { memoryContent } = await runNewWithPreviousSession({ sessionContent }); // Command messages should be filtered out expect(memoryContent).not.toContain("/help"); expect(memoryContent).not.toContain("/new"); // Normal messages should be present expect(memoryContent).toContain("assistant: Here is help info"); expect(memoryContent).toContain("user: Normal message"); }); it("respects custom messages config (limits to N messages)", async () => { // Create 10 messages const entries = []; for (let i = 1; i <= 10; i++) { entries.push({ role: "user", content: `Message ${i}` }); } const sessionContent = createMockSessionContent(entries); const { memoryContent } = await runNewWithPreviousSession({ sessionContent, cfg: (tempDir) => makeSessionMemoryConfig(tempDir, 3), }); // Only last 3 messages should be present expect(memoryContent).not.toContain("user: Message 1\n"); expect(memoryContent).not.toContain("user: Message 7\n"); expect(memoryContent).toContain("user: Message 8"); expect(memoryContent).toContain("user: Message 9"); expect(memoryContent).toContain("user: Message 10"); }); it("filters messages before slicing (fix for #2681)", async () => { // Create session with many tool entries interspersed with messages // This tests that we filter FIRST, then slice - not the other way around const entries = [ { role: "user", content: "First message" }, { type: "tool_use", tool: "test1" }, { type: "tool_result", result: "result1" }, { role: "assistant", content: "Second message" }, { type: "tool_use", tool: "test2" }, { type: "tool_result", result: "result2" }, { role: "user", content: "Third message" }, { type: "tool_use", tool: "test3" }, { type: "tool_result", result: "result3" }, { role: "assistant", content: "Fourth message" }, ]; const sessionContent = createMockSessionContent(entries); const { memoryContent } = await runNewWithPreviousSession({ sessionContent, cfg: (tempDir) => makeSessionMemoryConfig(tempDir, 3), }); // Should have exactly 3 user/assistant messages (the last 3) expect(memoryContent).not.toContain("First message"); expect(memoryContent).toContain("user: Third message"); expect(memoryContent).toContain("assistant: Second message"); expect(memoryContent).toContain("assistant: Fourth message"); }); it("falls back to latest .jsonl.reset.* transcript when active file is empty", async () => { const { tempDir, sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({ activeSession: { 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 { memoryContent } = await runNewWithPreviousSessionEntry({ tempDir, previousSessionEntry: { sessionId: "test-123", sessionFile: activeSessionFile!, }, }); expect(memoryContent).toContain("user: Message from rotated transcript"); expect(memoryContent).toContain("assistant: Recovered from reset fallback"); }); it("handles reset-path session pointers from previousSessionEntry", async () => { const { tempDir, sessionsDir } = await createSessionMemoryWorkspace(); const sessionId = "reset-pointer-session"; const resetSessionFile = await writeWorkspaceFile({ dir: sessionsDir, name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`, content: createMockSessionContent([ { role: "user", content: "Message from reset pointer" }, { role: "assistant", content: "Recovered directly from reset file" }, ]), }); const { files, memoryContent } = await runNewWithPreviousSessionEntry({ tempDir, cfg: makeSessionMemoryConfig(tempDir), previousSessionEntry: { sessionId, sessionFile: resetSessionFile, }, }); expect(files.length).toBe(1); expect(memoryContent).toContain("user: Message from reset pointer"); expect(memoryContent).toContain("assistant: Recovered directly from reset file"); }); it("recovers transcript when previousSessionEntry.sessionFile is missing", async () => { const { tempDir, sessionsDir } = await createSessionMemoryWorkspace(); const sessionId = "missing-session-file"; await writeWorkspaceFile({ dir: sessionsDir, name: `${sessionId}.jsonl`, content: "", }); await writeWorkspaceFile({ dir: sessionsDir, name: `${sessionId}.jsonl.reset.2026-02-16T22-26-33.000Z`, content: createMockSessionContent([ { role: "user", content: "Recovered with missing sessionFile pointer" }, { role: "assistant", content: "Recovered by sessionId fallback" }, ]), }); const { files, memoryContent } = await runNewWithPreviousSessionEntry({ tempDir, cfg: makeSessionMemoryConfig(tempDir), previousSessionEntry: { sessionId, }, }); expect(files.length).toBe(1); expect(memoryContent).toContain("user: Recovered with missing sessionFile pointer"); expect(memoryContent).toContain("assistant: Recovered by sessionId fallback"); }); it("prefers the newest reset transcript when multiple reset candidates exist", async () => { const { tempDir, sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({ activeSession: { name: "test-session.jsonl", content: "" }, }); await writeWorkspaceFile({ dir: sessionsDir, name: "test-session.jsonl.reset.2026-02-16T22-26-33.000Z", content: createMockSessionContent([ { role: "user", content: "Older rotated transcript" }, { role: "assistant", content: "Old summary" }, ]), }); await writeWorkspaceFile({ dir: sessionsDir, name: "test-session.jsonl.reset.2026-02-16T22-26-34.000Z", content: createMockSessionContent([ { role: "user", content: "Newest rotated transcript" }, { role: "assistant", content: "Newest summary" }, ]), }); const memoryContent = await loadMemoryFromActiveSessionPointer({ tempDir, activeSessionFile: activeSessionFile!, }); expectMemoryConversation({ memoryContent, user: "Newest rotated transcript", assistant: "Newest summary", absent: "Older rotated transcript", }); }); it("prefers active transcript when it is non-empty even with reset candidates", async () => { const { tempDir, sessionsDir, activeSessionFile } = await createSessionMemoryWorkspace({ activeSession: { name: "test-session.jsonl", content: createMockSessionContent([ { role: "user", content: "Active transcript message" }, { role: "assistant", content: "Active transcript summary" }, ]), }, }); await writeWorkspaceFile({ dir: sessionsDir, name: "test-session.jsonl.reset.2026-02-16T22-26-34.000Z", content: createMockSessionContent([ { role: "user", content: "Reset fallback message" }, { role: "assistant", content: "Reset fallback summary" }, ]), }); const memoryContent = await loadMemoryFromActiveSessionPointer({ tempDir, activeSessionFile: activeSessionFile!, }); expectMemoryConversation({ memoryContent, user: "Active transcript message", assistant: "Active transcript summary", absent: "Reset fallback message", }); }); it("handles empty session files gracefully", async () => { // Should not throw const { files } = await runNewWithPreviousSession({ sessionContent: "" }); expect(files.length).toBe(1); }); it("handles session files with fewer messages than requested", async () => { // Only 2 messages but requesting 15 (default) const sessionContent = createMockSessionContent([ { role: "user", content: "Only message 1" }, { role: "assistant", content: "Only message 2" }, ]); const { memoryContent } = await runNewWithPreviousSession({ sessionContent }); // Both messages should be included expect(memoryContent).toContain("user: Only message 1"); expect(memoryContent).toContain("assistant: Only message 2"); }); });