From 24da2c39f3b550d20f780fc7a87742f0b0b2caaf Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 2 Apr 2026 10:52:01 +0100 Subject: [PATCH] refactor: isolate session transcript coverage --- src/acp/runtime/session-meta.test.ts | 7 +- src/acp/runtime/session-meta.ts | 10 +- src/config/sessions/sessions.test.ts | 211 +----------------------- src/config/sessions/transcript.test.ts | 219 +++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 217 deletions(-) create mode 100644 src/config/sessions/transcript.test.ts diff --git a/src/acp/runtime/session-meta.test.ts b/src/acp/runtime/session-meta.test.ts index 9de6ec46731..e46488ea549 100644 --- a/src/acp/runtime/session-meta.test.ts +++ b/src/acp/runtime/session-meta.test.ts @@ -10,12 +10,13 @@ const hoisted = vi.hoisted(() => { }; }); -vi.mock("../../config/sessions.js", () => ({ +vi.mock("../../config/sessions/store-load.js", () => ({ loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), +})); + +vi.mock("../../config/sessions/targets.js", () => ({ resolveAllAgentSessionStoreTargets: (cfg: OpenClawConfig, opts: unknown) => hoisted.resolveAllAgentSessionStoreTargetsMock(cfg, opts), - resolveStorePath: vi.fn(() => "/tmp/sessions.json"), - updateSessionStore: vi.fn(), })); let listAcpSessionEntries: typeof import("./session-meta.js").listAcpSessionEntries; diff --git a/src/acp/runtime/session-meta.ts b/src/acp/runtime/session-meta.ts index fc94a1f0c05..9a07b4bfc2a 100644 --- a/src/acp/runtime/session-meta.ts +++ b/src/acp/runtime/session-meta.ts @@ -1,11 +1,9 @@ import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; -import { - loadSessionStore, - resolveAllAgentSessionStoreTargets, - resolveStorePath, - updateSessionStore, -} from "../../config/sessions.js"; +import { resolveStorePath } from "../../config/sessions/paths.js"; +import { loadSessionStore } from "../../config/sessions/store-load.js"; +import { updateSessionStore } from "../../config/sessions/store.runtime.js"; +import { resolveAllAgentSessionStoreTargets } from "../../config/sessions/targets.js"; import { mergeSessionEntry, type SessionAcpMeta, diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 9577fd8a8e5..33999eb8156 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -5,15 +5,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { upsertAcpSessionMeta } from "../../acp/runtime/session-meta.js"; import * as jsonFiles from "../../infra/json-files.js"; -import * as transcriptEvents from "../../sessions/transcript-events.js"; import type { OpenClawConfig } from "../config.js"; -import { - clearSessionStoreCacheForTest, - loadSessionStore, - mergeSessionEntry, - resolveAndPersistSessionFile, - updateSessionStore, -} from "../sessions.js"; import type { SessionConfig } from "../types.base.js"; import { resolveSessionFilePath, @@ -22,8 +14,9 @@ import { validateSessionId, } from "./paths.js"; import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js"; -import { appendAssistantMessageToSessionTranscript } from "./transcript.js"; -import type { SessionEntry } from "./types.js"; +import { resolveAndPersistSessionFile } from "./session-file.js"; +import { clearSessionStoreCacheForTest, loadSessionStore, updateSessionStore } from "./store.js"; +import { mergeSessionEntry, type SessionEntry } from "./types.js"; function useTempSessionsFixture(prefix: string) { let tempDir = ""; @@ -379,204 +372,6 @@ describe("session store lock (Promise chain mutex)", () => { }); }); -describe("appendAssistantMessageToSessionTranscript", () => { - const fixture = useTempSessionsFixture("transcript-test-"); - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - - function writeTranscriptStore() { - fs.writeFileSync( - fixture.storePath(), - JSON.stringify({ - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }), - "utf-8", - ); - } - - it("creates transcript file and appends message for valid session", async () => { - writeTranscriptStore(); - - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - storePath: fixture.storePath(), - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(fs.existsSync(result.sessionFile)).toBe(true); - const sessionFileMode = fs.statSync(result.sessionFile).mode & 0o777; - if (process.platform !== "win32") { - expect(sessionFileMode).toBe(0o600); - } - - const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); - expect(lines.length).toBe(2); - - const header = JSON.parse(lines[0]); - expect(header.type).toBe("session"); - expect(header.id).toBe(sessionId); - - const messageLine = JSON.parse(lines[1]); - expect(messageLine.type).toBe("message"); - expect(messageLine.message.role).toBe("assistant"); - expect(messageLine.message.content[0].type).toBe("text"); - expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); - } - }); - - it("emits transcript update events for delivery mirrors", async () => { - const sessionId = "test-session-id"; - const sessionKey = "test-session"; - const store = { - [sessionKey]: { - sessionId, - chatType: "direct", - channel: "discord", - }, - }; - fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); - const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate"); - - await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - storePath: fixture.storePath(), - }); - - const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); - expect(emitSpy).toHaveBeenCalledWith( - expect.objectContaining({ - sessionFile, - sessionKey, - messageId: expect.any(String), - message: expect.objectContaining({ - role: "assistant", - provider: "openclaw", - model: "delivery-mirror", - content: [{ type: "text", text: "Hello from delivery mirror!" }], - }), - }), - ); - emitSpy.mockRestore(); - }); - - it("does not append a duplicate delivery mirror for the same idempotency key", async () => { - writeTranscriptStore(); - - await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - idempotencyKey: "mirror:test-source-message", - storePath: fixture.storePath(), - }); - await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - idempotencyKey: "mirror:test-source-message", - storePath: fixture.storePath(), - }); - - const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); - const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); - expect(lines.length).toBe(2); - - const messageLine = JSON.parse(lines[1]); - expect(messageLine.message.idempotencyKey).toBe("mirror:test-source-message"); - expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); - }); - - it("finds session entry using normalized (lowercased) key", async () => { - const sessionId = "test-session-normalized"; - // Store key is lowercase (as written by updateSessionStore/normalizeStoreSessionKey) - const storeKey = "agent:main:bluebubbles:direct:+15551234567"; - const store = { - [storeKey]: { - sessionId, - chatType: "direct", - channel: "bluebubbles", - }, - }; - fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); - - // Pass a mixed-case key — append should still find the entry via normalization - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "agent:main:BlueBubbles:direct:+15551234567", - text: "Hello normalized!", - storePath: fixture.storePath(), - }); - - expect(result.ok).toBe(true); - }); - - it("finds Slack session entry using normalized (lowercased) key", async () => { - const sessionId = "test-slack-session"; - // Slack session keys include channel type and target ID; store key is lowercase - const storeKey = "agent:main:slack:direct:u12345abc"; - const store = { - [storeKey]: { - sessionId, - chatType: "direct", - channel: "slack", - }, - }; - fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); - - // Pass a mixed-case key (as resolveSlackSession might produce) — normalization should match - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey: "agent:main:slack:direct:U12345ABC", - text: "Hello Slack user!", - storePath: fixture.storePath(), - }); - - expect(result.ok).toBe(true); - }); - - it("ignores malformed transcript lines when checking mirror idempotency", async () => { - writeTranscriptStore(); - - const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); - fs.writeFileSync( - sessionFile, - [ - JSON.stringify({ - type: "session", - version: 1, - id: sessionId, - timestamp: new Date().toISOString(), - cwd: process.cwd(), - }), - "{not-json", - JSON.stringify({ - type: "message", - message: { - role: "assistant", - idempotencyKey: "mirror:test-source-message", - content: [{ type: "text", text: "Hello from delivery mirror!" }], - }, - }), - ].join("\n") + "\n", - "utf-8", - ); - - const result = await appendAssistantMessageToSessionTranscript({ - sessionKey, - text: "Hello from delivery mirror!", - idempotencyKey: "mirror:test-source-message", - storePath: fixture.storePath(), - }); - - expect(result.ok).toBe(true); - const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); - expect(lines.length).toBe(3); - }); -}); - describe("resolveAndPersistSessionFile", () => { const fixture = useTempSessionsFixture("session-file-test-"); diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts new file mode 100644 index 00000000000..1cc8d1ef127 --- /dev/null +++ b/src/config/sessions/transcript.test.ts @@ -0,0 +1,219 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as transcriptEvents from "../../sessions/transcript-events.js"; +import { resolveSessionTranscriptPathInDir } from "./paths.js"; +import { appendAssistantMessageToSessionTranscript } from "./transcript.js"; + +function useTempSessionsFixture(prefix: string) { + let tempDir = ""; + let storePath = ""; + let sessionsDir = ""; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + sessionsDir = path.join(tempDir, "agents", "main", "sessions"); + fs.mkdirSync(sessionsDir, { recursive: true }); + storePath = path.join(sessionsDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + return { + storePath: () => storePath, + sessionsDir: () => sessionsDir, + }; +} + +describe("appendAssistantMessageToSessionTranscript", () => { + const fixture = useTempSessionsFixture("transcript-test-"); + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + + function writeTranscriptStore() { + fs.writeFileSync( + fixture.storePath(), + JSON.stringify({ + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }), + "utf-8", + ); + } + + it("creates transcript file and appends message for valid session", async () => { + writeTranscriptStore(); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + storePath: fixture.storePath(), + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(fs.existsSync(result.sessionFile)).toBe(true); + const sessionFileMode = fs.statSync(result.sessionFile).mode & 0o777; + if (process.platform !== "win32") { + expect(sessionFileMode).toBe(0o600); + } + + const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const header = JSON.parse(lines[0]); + expect(header.type).toBe("session"); + expect(header.id).toBe(sessionId); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.type).toBe("message"); + expect(messageLine.message.role).toBe("assistant"); + expect(messageLine.message.content[0].type).toBe("text"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + } + }); + + it("emits transcript update events for delivery mirrors", async () => { + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate"); + + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + storePath: fixture.storePath(), + }); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionFile, + sessionKey, + messageId: expect.any(String), + message: expect.objectContaining({ + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "Hello from delivery mirror!" }], + }), + }), + ); + emitSpy.mockRestore(); + }); + + it("does not append a duplicate delivery mirror for the same idempotency key", async () => { + writeTranscriptStore(); + + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.message.idempotencyKey).toBe("mirror:test-source-message"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + }); + + it("finds session entry using normalized (lowercased) key", async () => { + const storeKey = "agent:main:bluebubbles:direct:+15551234567"; + const store = { + [storeKey]: { + sessionId: "test-session-normalized", + chatType: "direct", + channel: "bluebubbles", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "agent:main:BlueBubbles:direct:+15551234567", + text: "Hello normalized!", + storePath: fixture.storePath(), + }); + + expect(result.ok).toBe(true); + }); + + it("finds Slack session entry using normalized (lowercased) key", async () => { + const storeKey = "agent:main:slack:direct:u12345abc"; + const store = { + [storeKey]: { + sessionId: "test-slack-session", + chatType: "direct", + channel: "slack", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey: "agent:main:slack:direct:U12345ABC", + text: "Hello Slack user!", + storePath: fixture.storePath(), + }); + + expect(result.ok).toBe(true); + }); + + it("ignores malformed transcript lines when checking mirror idempotency", async () => { + writeTranscriptStore(); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + fs.writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: 1, + id: sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }), + "{not-json", + JSON.stringify({ + type: "message", + message: { + role: "assistant", + idempotencyKey: "mirror:test-source-message", + content: [{ type: "text", text: "Hello from delivery mirror!" }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + expect(result.ok).toBe(true); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(3); + }); +});