From cd392b947c9fbd0640f41fbefdf05fce44ab4347 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 14:06:17 +0100 Subject: [PATCH] test: dedupe memory and context suites --- .../memory-host-sdk/src/host/internal.test.ts | 41 +-- .../src/host/qmd-process.test.ts | 27 +- .../src/host/session-files.test.ts | 18 +- src/agents/harness/native-hook-relay.test.ts | 2 +- src/context-engine/context-engine.test.ts | 245 ++++++++------- src/memory-host-sdk/host/internal.test.ts | 102 +++--- .../host/session-files.test.ts | 292 +++++++----------- 7 files changed, 344 insertions(+), 383 deletions(-) diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index ff40cc255e1..e67adf45789 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -265,26 +265,31 @@ describe("buildFileEntry", () => { expect(built?.structuredInputBytes).toBeGreaterThan(0); }); - it("skips lazy multimodal indexing when the file grows after discovery", async () => { - const tmpDir = getTmpDir(); - const target = path.join(tmpDir, "diagram.png"); - await fs.writeFile(target, Buffer.from("png")); + it("skips lazy multimodal indexing when file state changes after discovery", async () => { + for (const testCase of [ + { + name: "grows", + mutate: async (target: string, entrySize: number) => { + await fs.writeFile(target, Buffer.alloc(entrySize + 32, 1)); + }, + }, + { + name: "bytes change", + mutate: async (target: string) => { + await fs.writeFile(target, Buffer.from("gif")); + }, + }, + ] as const) { + const tmpDir = getTmpDir(); + const target = path.join(tmpDir, `${testCase.name}.png`); + await fs.writeFile(target, Buffer.from("png")); - const entry = await buildFileEntry(target, tmpDir, multimodal); - await fs.writeFile(target, Buffer.alloc(entry!.size + 32, 1)); + const entry = await buildFileEntry(target, tmpDir, multimodal); + expect(entry, testCase.name).not.toBeNull(); + await testCase.mutate(target, entry!.size); - await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull(); - }); - - it("skips lazy multimodal indexing when file bytes change after discovery", async () => { - const tmpDir = getTmpDir(); - const target = path.join(tmpDir, "diagram.png"); - await fs.writeFile(target, Buffer.from("png")); - - const entry = await buildFileEntry(target, tmpDir, multimodal); - await fs.writeFile(target, Buffer.from("gif")); - - await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull(); + await expect(buildMultimodalChunkForIndexing(entry!), testCase.name).resolves.toBeNull(); + } }); }); diff --git a/packages/memory-host-sdk/src/host/qmd-process.test.ts b/packages/memory-host-sdk/src/host/qmd-process.test.ts index f706f525af3..7609c4f32d3 100644 --- a/packages/memory-host-sdk/src/host/qmd-process.test.ts +++ b/packages/memory-host-sdk/src/host/qmd-process.test.ts @@ -2,7 +2,7 @@ import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const spawnMock = vi.hoisted(() => vi.fn()); @@ -24,25 +24,36 @@ function createMockChild() { return child; } +let fixtureRoot = ""; let tempDir = ""; let platformSpy: { mockRestore(): void } | null = null; +let fixtureId = 0; const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; -beforeEach(async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-")); +beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-win-spawn-")); platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); }); -afterEach(async () => { +afterAll(async () => { platformSpy?.mockRestore(); + platformSpy = null; + if (fixtureRoot) { + await fs.rm(fixtureRoot, { recursive: true, force: true }); + } +}); + +beforeEach(async () => { + tempDir = path.join(fixtureRoot, `case-${fixtureId++}`); + await fs.mkdir(tempDir, { recursive: true }); +}); + +afterEach(() => { process.env.PATH = originalPath; process.env.PATHEXT = originalPathExt; spawnMock.mockReset(); - if (tempDir) { - await fs.rm(tempDir, { recursive: true, force: true }); - tempDir = ""; - } + tempDir = ""; }); describe("resolveCliSpawnInvocation", () => { diff --git a/packages/memory-host-sdk/src/host/session-files.test.ts b/packages/memory-host-sdk/src/host/session-files.test.ts index 7ad7ee8c32a..447ed2db407 100644 --- a/packages/memory-host-sdk/src/host/session-files.test.ts +++ b/packages/memory-host-sdk/src/host/session-files.test.ts @@ -1,25 +1,35 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { buildSessionEntry, listSessionFilesForAgent } from "./session-files.js"; +let fixtureRoot: string; let tmpDir: string; let originalStateDir: string | undefined; +let fixtureId = 0; + +beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-")); +}); + +afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); +}); beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-")); + tmpDir = path.join(fixtureRoot, `case-${fixtureId++}`); + await fs.mkdir(tmpDir, { recursive: true }); originalStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = tmpDir; }); -afterEach(async () => { +afterEach(() => { if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { process.env.OPENCLAW_STATE_DIR = originalStateDir; } - await fs.rm(tmpDir, { recursive: true, force: true }); }); describe("listSessionFilesForAgent", () => { diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index 65ad3dce095..5f50aa80f69 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -234,7 +234,7 @@ describe("native hook relay registry", () => { runId: "run-1", }); - for (const event of ["pre_tool_use", "post_tool_use", "permission_request"] as const) { + for (const event of ["pre_tool_use", "post_tool_use"] as const) { await expect( invokeNativeHookRelay({ provider: "codex", diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index bd1f9d44322..fee4864f92a 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -63,6 +63,12 @@ function makeMockMessage(role: "user" | "assistant" = "user", text = "hello"): A return { role, content: text, timestamp: Date.now() } as AgentMessage; } +let uniqueEngineIdCounter = 0; +function uniqueEngineId(prefix: string): string { + uniqueEngineIdCounter += 1; + return `${prefix}-${uniqueEngineIdCounter}`; +} + function registerPromptTrackingEngine(engineId: string) { const calls: Array> = []; registerContextEngine(engineId, () => ({ @@ -701,13 +707,84 @@ describe("Invalid engine fallback", () => { vi.restoreAllMocks(); }); - it("falls back to default engine when requested engine is not registered", async () => { - const engine = await resolveContextEngine(configWithSlot("does-not-exist")); - expect(engine.info.id).toBe("legacy"); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("does-not-exist")); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("falling back to default engine"), - ); + it("falls back to default engine for missing or invalid requested engines", async () => { + const cases = [ + { + name: "missing registration", + engineId: uniqueEngineId("does-not-exist"), + register: () => undefined, + expectedError: "does-not-exist", + }, + { + name: "factory throws", + engineId: uniqueEngineId("factory-throw"), + register: (engineId: string) => { + registerContextEngine(engineId, () => { + throw new Error("plugin version mismatch"); + }); + }, + expectedError: "plugin version mismatch", + }, + { + name: "missing info metadata", + engineId: uniqueEngineId("invalid-info"), + register: (engineId: string) => { + registerContextEngine( + engineId, + () => + ({ + async ingest() { + return { ingested: false }; + }, + async assemble({ messages }: { messages: AgentMessage[] }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }) as unknown as ContextEngine, + ); + }, + expectedError: "missing info", + }, + { + name: "missing lifecycle methods", + engineId: uniqueEngineId("invalid-methods"), + register: (engineId: string) => { + registerContextEngine( + engineId, + () => + ({ + info: { id: engineId, name: "Broken Engine" }, + async ingest() { + return { ingested: false }; + }, + }) as unknown as ContextEngine, + ); + }, + expectedError: "missing assemble(), missing compact()", + }, + { + name: "contract validation throws", + engineId: uniqueEngineId("validation-throw"), + register: (engineId: string) => { + registerContextEngine(engineId, () => 42n as unknown as ContextEngine); + }, + expectedError: "contract validation threw", + }, + ] as const; + + for (const testCase of cases) { + vi.mocked(console.error).mockClear(); + testCase.register(testCase.engineId); + + const engine = await resolveContextEngine(configWithSlot(testCase.engineId)); + + expect(engine.info.id, testCase.name).toBe("legacy"); + expect(console.error, testCase.name).toHaveBeenCalledWith( + expect.stringContaining(testCase.expectedError), + ); + } }); it("throws when the default engine itself is not registered", async () => { @@ -759,43 +836,6 @@ describe("Invalid engine fallback", () => { ); }); - it("falls back to default engine when factory throws", async () => { - const engineId = `factory-throw-${Date.now().toString(36)}`; - registerContextEngine(engineId, () => { - throw new Error("plugin version mismatch"); - }); - - const engine = await resolveContextEngine(configWithSlot(engineId)); - expect(engine.info.id).toBe("legacy"); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("plugin version mismatch")); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("falling back to default engine"), - ); - }); - - it("falls back to default engine when resolved engine omits info metadata", async () => { - const engineId = `invalid-info-${Date.now().toString(36)}`; - registerContextEngine( - engineId, - () => - ({ - async ingest() { - return { ingested: false }; - }, - async assemble({ messages }: { messages: AgentMessage[] }) { - return { messages, estimatedTokens: 0 }; - }, - async compact() { - return { ok: true, compacted: false }; - }, - }) as unknown as ContextEngine, - ); - - const engine = await resolveContextEngine(configWithSlot(engineId)); - expect(engine.info.id).toBe("legacy"); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining("missing info")); - }); - it("accepts resolved engines whose info.id differs from the registered slot id (#66601)", async () => { // Regression for openclaw/openclaw#66601: third-party plugins like // lossless-claw register under an external slot id ("lossless-claw") but @@ -831,40 +871,6 @@ describe("Invalid engine fallback", () => { }); expect(result.estimatedTokens).toBe(0); }); - - it("falls back to default engine when resolved engine omits lifecycle methods", async () => { - const engineId = `invalid-methods-${Date.now().toString(36)}`; - registerContextEngine( - engineId, - () => - ({ - info: { id: engineId, name: "Broken Engine" }, - async ingest() { - return { ingested: false }; - }, - }) as unknown as ContextEngine, - ); - - const engine = await resolveContextEngine(configWithSlot(engineId)); - expect(engine.info.id).toBe("legacy"); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("missing assemble(), missing compact()"), - ); - }); - - it("falls back to default engine when contract validation itself throws", async () => { - const engineId = `validation-throw-${Date.now().toString(36)}`; - // BigInt cannot be JSON.stringify'd — triggers a throw inside - // describeResolvedContextEngineContractError when the factory returns - // a non-object value that passes the typeof !== "object" branch. - registerContextEngine(engineId, () => 42n as unknown as ContextEngine); - - const engine = await resolveContextEngine(configWithSlot(engineId)); - expect(engine.info.id).toBe("legacy"); - expect(console.error).toHaveBeenCalledWith( - expect.stringContaining("contract validation threw"), - ); - }); }); // ═══════════════════════════════════════════════════════════════════════════ @@ -913,54 +919,47 @@ describe("LegacyContextEngine parity", () => { // ═══════════════════════════════════════════════════════════════════════════ describe("assemble() prompt forwarding", () => { - it("forwards prompt to the underlying engine", async () => { - const engineId = `prompt-fwd-${Date.now().toString(36)}`; - const calls = registerPromptTrackingEngine(engineId); + it("forwards prompt only when callers provide one", async () => { + const cases = [ + { + name: "provided", + params: { prompt: "hello" }, + expectedPrompt: "hello", + }, + { + name: "omitted", + params: {}, + expectedPrompt: null, + }, + { + name: "conditional spread undefined", + params: (() => { + const callerPrompt: string | undefined = undefined; + return callerPrompt !== undefined ? { prompt: callerPrompt } : {}; + })(), + expectedPrompt: null, + }, + ] as const; - const engine = await resolveContextEngine(configWithSlot(engineId)); - await engine.assemble({ - sessionId: "s1", - messages: [makeMockMessage("user", "hello")], - prompt: "hello", - }); + for (const testCase of cases) { + const engineId = uniqueEngineId(`prompt-${testCase.name.replace(/\s+/g, "-")}`); + const calls = registerPromptTrackingEngine(engineId); - expect(calls).toHaveLength(1); - expect(calls[0]).toHaveProperty("prompt", "hello"); - }); + const engine = await resolveContextEngine(configWithSlot(engineId)); + await engine.assemble({ + sessionId: "s1", + messages: [makeMockMessage("user", "hello")], + ...testCase.params, + }); - it("omits prompt when not provided", async () => { - const engineId = `prompt-omit-${Date.now().toString(36)}`; - const calls = registerPromptTrackingEngine(engineId); - - const engine = await resolveContextEngine(configWithSlot(engineId)); - await engine.assemble({ - sessionId: "s1", - messages: [makeMockMessage("user", "hello")], - }); - - expect(calls).toHaveLength(1); - expect(calls[0]).not.toHaveProperty("prompt"); - }); - - it("does not leak prompt key when caller spreads undefined", async () => { - // Guards against the pattern `{ prompt: params.prompt }` when params.prompt - // is undefined — JavaScript keeps the key present with value undefined, - // which breaks engines that guard with `'prompt' in params`. - const engineId = `prompt-undef-${Date.now().toString(36)}`; - const calls = registerPromptTrackingEngine(engineId); - - const engine = await resolveContextEngine(configWithSlot(engineId)); - // Simulate the attempt.ts call-site pattern: conditional spread - const callerPrompt: string | undefined = undefined; - await engine.assemble({ - sessionId: "s1", - messages: [makeMockMessage("user", "hello")], - ...(callerPrompt !== undefined ? { prompt: callerPrompt } : {}), - }); - - expect(calls).toHaveLength(1); - expect(calls[0]).not.toHaveProperty("prompt"); - expect(Object.keys(calls[0] as object)).not.toContain("prompt"); + expect(calls, testCase.name).toHaveLength(1); + if (testCase.expectedPrompt === null) { + expect(calls[0], testCase.name).not.toHaveProperty("prompt"); + expect(Object.keys(calls[0] as object), testCase.name).not.toContain("prompt"); + } else { + expect(calls[0], testCase.name).toHaveProperty("prompt", testCase.expectedPrompt); + } + } }); it("retries strict legacy assemble without sessionKey and prompt", async () => { diff --git a/src/memory-host-sdk/host/internal.test.ts b/src/memory-host-sdk/host/internal.test.ts index 934261e5fc7..603cc87f67e 100644 --- a/src/memory-host-sdk/host/internal.test.ts +++ b/src/memory-host-sdk/host/internal.test.ts @@ -152,32 +152,28 @@ describe("listMemoryFiles", () => { } }); - it("skips root-memory repair backups from extra workspace paths", async () => { - const tmpDir = getTmpDir(); - await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); - const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23"); - await fs.mkdir(repairDir, { recursive: true }); - await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory"); + it("skips root-memory repair backups from workspace and explicit extra paths", async () => { + for (const testCase of [ + { + name: "workspace extra path", + extraPaths: (tmpDir: string) => [tmpDir], + }, + { + name: "explicit repair root", + extraPaths: (tmpDir: string) => [path.join(tmpDir, ".openclaw-repair", "root-memory")], + }, + ] as const) { + const tmpDir = getTmpDir(); + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23"); + await fs.mkdir(repairDir, { recursive: true }); + await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory"); - const files = await listMemoryFiles(tmpDir, [tmpDir]); + const files = await listMemoryFiles(tmpDir, testCase.extraPaths(tmpDir)); - expect(files).toHaveLength(1); - expect(files[0]).toBe(path.join(tmpDir, "MEMORY.md")); - }); - - it("skips explicit root-memory repair directories from extra paths", async () => { - const tmpDir = getTmpDir(); - await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); - const repairDir = path.join(tmpDir, ".openclaw-repair", "root-memory", "2026-04-23"); - await fs.mkdir(repairDir, { recursive: true }); - await fs.writeFile(path.join(repairDir, "memory.md"), "# Archived legacy memory"); - - const files = await listMemoryFiles(tmpDir, [ - path.join(tmpDir, ".openclaw-repair", "root-memory"), - ]); - - expect(files).toHaveLength(1); - expect(files[0]).toBe(path.join(tmpDir, "MEMORY.md")); + expect(files, testCase.name).toHaveLength(1); + expect(files[0], testCase.name).toBe(path.join(tmpDir, "MEMORY.md")); + } }); it("handles relative paths in additional paths", async () => { @@ -324,37 +320,37 @@ describe("buildFileEntry", () => { expect(built?.structuredInputBytes).toBeGreaterThan(0); }); - it("skips lazy multimodal indexing when the file grows after discovery", async () => { - const tmpDir = getTmpDir(); - const target = path.join(tmpDir, "diagram.png"); - await fs.writeFile(target, Buffer.from("png")); + it("skips lazy multimodal indexing when file state changes after discovery", async () => { + for (const testCase of [ + { + name: "grows", + mutate: async (target: string, entrySize: number) => { + await fs.writeFile(target, Buffer.alloc(entrySize + 32, 1)); + }, + }, + { + name: "bytes change", + mutate: async (target: string) => { + await fs.writeFile(target, Buffer.from("gif")); + }, + }, + { + name: "disappears", + mutate: async (target: string) => { + await fs.rm(target); + }, + }, + ] as const) { + const tmpDir = getTmpDir(); + const target = path.join(tmpDir, `${testCase.name}.png`); + await fs.writeFile(target, Buffer.from("png")); - const entry = await buildFileEntry(target, tmpDir, multimodal); - await fs.writeFile(target, Buffer.alloc(entry!.size + 32, 1)); + const entry = await buildFileEntry(target, tmpDir, multimodal); + expect(entry, testCase.name).not.toBeNull(); + await testCase.mutate(target, entry!.size); - await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull(); - }); - - it("skips lazy multimodal indexing when file bytes change after discovery", async () => { - const tmpDir = getTmpDir(); - const target = path.join(tmpDir, "diagram.png"); - await fs.writeFile(target, Buffer.from("png")); - - const entry = await buildFileEntry(target, tmpDir, multimodal); - await fs.writeFile(target, Buffer.from("gif")); - - await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull(); - }); - - it("skips lazy multimodal indexing when the file disappears before loading bytes", async () => { - const tmpDir = getTmpDir(); - const target = path.join(tmpDir, "diagram.png"); - await fs.writeFile(target, Buffer.from("png")); - - const entry = await buildFileEntry(target, tmpDir, multimodal); - await fs.rm(target); - - await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull(); + await expect(buildMultimodalChunkForIndexing(entry!), testCase.name).resolves.toBeNull(); + } }); }); diff --git a/src/memory-host-sdk/host/session-files.test.ts b/src/memory-host-sdk/host/session-files.test.ts index 2cd1db2762e..2273f643f86 100644 --- a/src/memory-host-sdk/host/session-files.test.ts +++ b/src/memory-host-sdk/host/session-files.test.ts @@ -47,6 +47,12 @@ function expectNoUnpairedSurrogates(value: string): void { } } +async function writeSessionJsonl(fileName: string, records: readonly unknown[]): Promise { + const filePath = path.join(tmpDir, fileName); + await fs.writeFile(filePath, records.map((record) => JSON.stringify(record)).join("\n")); + return filePath; +} + describe("listSessionFilesForAgent", () => { it("includes reset and deleted transcripts in session file listing", async () => { const sessionsDir = path.join(tmpDir, "agents", "main", "sessions"); @@ -483,142 +489,118 @@ describe("buildSessionEntry", () => { expect(entry?.lineMap).toEqual([1, 2]); }); - it("drops generated system wrapper user messages but keeps the assistant reply", async () => { - // Cross-message coupling (drop-next-assistant-when-prior-user-matched) was - // removed because user-typed text can match the same patterns; see - // PR #70737 review (aisle-research-bot). Real assistant content stays in - // the corpus regardless of what the prior user message looked like. - const jsonlLines = [ - JSON.stringify({ - type: "message", - message: { - role: "user", - content: - "System (untrusted): [2026-04-15 14:45:20 PDT] Exec completed (quiet-fo, code 0) :: Converted: 1", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - content: "Handled internally.", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "user", - content: "What changed in the sync?", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - content: "One new session was converted.", - }, - }), - ]; - const filePath = path.join(tmpDir, "system-wrapper-session.jsonl"); - await fs.writeFile(filePath, jsonlLines.join("\n")); + it("drops generated runtime chatter while preserving real follow-up content", async () => { + const cases = [ + { + name: "system wrapper", + fileName: "system-wrapper-session.jsonl", + records: [ + { + type: "message", + message: { + role: "user", + content: + "System (untrusted): [2026-04-15 14:45:20 PDT] Exec completed (quiet-fo, code 0) :: Converted: 1", + }, + }, + { type: "message", message: { role: "assistant", content: "Handled internally." } }, + { type: "message", message: { role: "user", content: "What changed in the sync?" } }, + { + type: "message", + message: { role: "assistant", content: "One new session was converted." }, + }, + ], + content: [ + "Assistant: Handled internally.", + "User: What changed in the sync?", + "Assistant: One new session was converted.", + ].join("\n"), + lineMap: [2, 3, 4], + }, + { + name: "cron prompt", + fileName: "cron-prompt-session.jsonl", + records: [ + { + type: "message", + message: { role: "user", content: "[cron:job-1 Example] Run the nightly sync" }, + }, + { + type: "message", + message: { role: "assistant", content: "Running the nightly sync now." }, + }, + { + type: "message", + message: { role: "user", content: "Did the nightly sync actually change anything?" }, + }, + { + type: "message", + message: { role: "assistant", content: "No, everything was already current." }, + }, + ], + content: [ + "Assistant: Running the nightly sync now.", + "User: Did the nightly sync actually change anything?", + "Assistant: No, everything was already current.", + ].join("\n"), + lineMap: [2, 3, 4], + }, + { + name: "heartbeat ack", + fileName: "heartbeat-session.jsonl", + records: [ + { + type: "message", + message: { + role: "user", + content: + "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", + }, + }, + { type: "message", message: { role: "assistant", content: "HEARTBEAT_OK" } }, + { + type: "message", + message: { role: "user", content: "Summarize what changed in the inbox today." }, + }, + ], + content: "User: Summarize what changed in the inbox today.", + lineMap: [3], + }, + { + name: "internal runtime context", + fileName: "internal-context-session.jsonl", + records: [ + { + type: "message", + message: { + role: "user", + content: [ + "<<>>", + "OpenClaw runtime context (internal):", + "This context is runtime-generated, not user-authored. Keep internal details private.", + "", + "[Internal task completion event]", + "source: subagent", + "<<>>", + ].join("\n"), + }, + }, + { type: "message", message: { role: "assistant", content: "NO_REPLY" } }, + { type: "message", message: { role: "user", content: "Actual user text" } }, + ], + content: "User: Actual user text", + lineMap: [3], + }, + ] as const; - const entry = await buildSessionEntry(filePath); + for (const testCase of cases) { + const filePath = await writeSessionJsonl(testCase.fileName, testCase.records); + const entry = await buildSessionEntry(filePath); - expect(entry).not.toBeNull(); - expect(entry?.content).toBe( - [ - "Assistant: Handled internally.", - "User: What changed in the sync?", - "Assistant: One new session was converted.", - ].join("\n"), - ); - expect(entry?.lineMap).toEqual([2, 3, 4]); - }); - - it("drops direct cron-prompt user messages but keeps the assistant reply", async () => { - const jsonlLines = [ - JSON.stringify({ - type: "message", - message: { - role: "user", - content: "[cron:job-1 Example] Run the nightly sync", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - content: "Running the nightly sync now.", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "user", - content: "Did the nightly sync actually change anything?", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - content: "No, everything was already current.", - }, - }), - ]; - const filePath = path.join(tmpDir, "cron-prompt-session.jsonl"); - await fs.writeFile(filePath, jsonlLines.join("\n")); - - const entry = await buildSessionEntry(filePath); - - expect(entry).not.toBeNull(); - expect(entry?.content).toBe( - [ - "Assistant: Running the nightly sync now.", - "User: Did the nightly sync actually change anything?", - "Assistant: No, everything was already current.", - ].join("\n"), - ); - expect(entry?.lineMap).toEqual([2, 3, 4]); - }); - - it("drops heartbeat prompt and the HEARTBEAT_OK ack via assistant-side detection", async () => { - // The ack is dropped because `HEARTBEAT_OK` is recognised as an - // assistant-side machinery token, not because the prior user message was - // a heartbeat prompt. A real reply to a similarly-shaped user message - // would still survive. - const jsonlLines = [ - JSON.stringify({ - type: "message", - message: { - role: "user", - content: - "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - content: "HEARTBEAT_OK", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "user", - content: "Summarize what changed in the inbox today.", - }, - }), - ]; - const filePath = path.join(tmpDir, "heartbeat-session.jsonl"); - await fs.writeFile(filePath, jsonlLines.join("\n")); - - const entry = await buildSessionEntry(filePath); - - expect(entry).not.toBeNull(); - expect(entry?.content).toBe("User: Summarize what changed in the inbox today."); - expect(entry?.lineMap).toEqual([3]); + expect(entry, testCase.name).not.toBeNull(); + expect(entry?.content, testCase.name).toBe(testCase.content); + expect(entry?.lineMap, testCase.name).toEqual(testCase.lineMap); + } }); it("does not let a user-typed `[cron:...]` prompt suppress the next assistant reply (regression: PR #70737 review)", async () => { @@ -673,48 +655,6 @@ describe("buildSessionEntry", () => { expect(checkpointEntry?.lineMap).toEqual([]); }); - it("strips internal runtime context blocks before flattening session text", async () => { - const jsonlLines = [ - JSON.stringify({ - type: "message", - message: { - role: "user", - content: [ - "<<>>", - "OpenClaw runtime context (internal):", - "This context is runtime-generated, not user-authored. Keep internal details private.", - "", - "[Internal task completion event]", - "source: subagent", - "<<>>", - ].join("\n"), - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "assistant", - content: "NO_REPLY", - }, - }), - JSON.stringify({ - type: "message", - message: { - role: "user", - content: "Actual user text", - }, - }), - ]; - const filePath = path.join(tmpDir, "internal-context-session.jsonl"); - await fs.writeFile(filePath, jsonlLines.join("\n")); - - const entry = await buildSessionEntry(filePath); - - expect(entry).not.toBeNull(); - expect(entry?.content).toBe("User: Actual user text"); - expect(entry?.lineMap).toEqual([3]); - }); - it("does not flag transcripts when dreaming markers only appear mid-string", async () => { const jsonlLines = [ JSON.stringify({