import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createOpenCodeSessionNodeHostCommands, isOpenCodeSessionCatalogEnabled, OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND, registerOpenCodeSessionCatalog, } from "./session-catalog-plugin.js"; import { listLocalOpenCodeSessionPage, readLocalOpenCodeTranscriptPage, } from "./session-catalog.js"; const temporaryDirectories: string[] = []; const originalPath = process.env.PATH; const originalUnrelatedEnv = process.env.CATALOG_UNRELATED_ENV; async function installFakeOpenCode(assistantText = "hi"): Promise { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-opencode-catalog-")); temporaryDirectories.push(directory); const executable = path.join(directory, "opencode"); const session = { id: "ses_test", title: "Catalog session", created: 1_700_000_000_000, updated: 1_700_000_001_000, projectId: "project", directory: "/workspace", }; const exported = { info: session, messages: [ { info: { id: "msg_user", role: "user", time: { created: 1_700_000_000_000 }, model: { providerID: "anthropic", modelID: "claude" }, }, parts: [{ id: "prt_user", type: "text", text: "hello" }], }, { info: { id: "msg_assistant", role: "assistant", time: { created: 1_700_000_001_000 }, providerID: "anthropic", modelID: "claude", }, parts: [ { id: "prt_reason", type: "reasoning", text: "thinking" }, { id: "prt_answer", type: "text", text: assistantText }, { id: "prt_tool", type: "tool", tool: "bash", state: { status: "completed", input: { command: "pwd" }, output: "/workspace" }, }, ], }, ], }; await fs.writeFile( executable, `#!/usr/bin/env node const args = process.argv.slice(2); if (process.env.CATALOG_UNRELATED_ENV) process.exit(3); if (args[0] === "--pure" && args[1] === "db" && args.includes("--format") && args.includes("json")) { process.stdout.write(${JSON.stringify(JSON.stringify([session]))}); } else if (args[0] === "--pure" && args[1] === "export" && args[2] === "ses_test") { process.stdout.write(${JSON.stringify(JSON.stringify(exported))}); } else { process.exitCode = 2; } `, ); await fs.chmod(executable, 0o755); process.env.PATH = `${directory}${path.delimiter}${originalPath ?? ""}`; process.env.CATALOG_UNRELATED_ENV = "present"; return directory; } afterEach(async () => { process.env.PATH = originalPath; if (originalUnrelatedEnv === undefined) { delete process.env.CATALOG_UNRELATED_ENV; } else { process.env.CATALOG_UNRELATED_ENV = originalUnrelatedEnv; } await Promise.all( temporaryDirectories.splice(0).map(async (directory) => { await fs.rm(directory, { recursive: true, force: true }); }), ); }); describe("OpenCode session catalog", () => { it.runIf(process.platform !== "win32")( "lists and reads sessions through the official CLI JSON surfaces", async () => { await installFakeOpenCode(); const listed = await listLocalOpenCodeSessionPage({ limit: 20 }); expect(listed).toEqual({ sessions: [ expect.objectContaining({ threadId: "ses_test", name: "Catalog session", cwd: "/workspace", source: "opencode-cli", canContinue: false, }), ], }); const transcript = await readLocalOpenCodeTranscriptPage({ threadId: "ses_test", limit: 20, }); expect(transcript.items.map((item) => [item.type, item.text])).toEqual([ ["userMessage", "hello"], ["reasoning", "thinking"], ["agentMessage", "hi"], ["toolCall", 'bash\n{"command":"pwd"}'], ["toolResult", "/workspace"], ]); const itemIds = transcript.items.flatMap((item) => (item.id ? [item.id] : [])); expect(new Set(itemIds).size).toBe(itemIds.length); const latest = await readLocalOpenCodeTranscriptPage({ threadId: "ses_test", limit: 2 }); expect(latest.items.map((item) => item.type)).toEqual(["toolCall", "toolResult"]); expect(latest.nextCursor).toBeTruthy(); const older = await readLocalOpenCodeTranscriptPage({ threadId: "ses_test", limit: 2, cursor: latest.nextCursor, }); expect(older.items.map((item) => item.type)).toEqual(["reasoning", "agentMessage"]); await expect(listLocalOpenCodeSessionPage({ cursor: " " })).rejects.toThrow( "cursor is invalid", ); await expect( readLocalOpenCodeTranscriptPage({ threadId: "ses_test", cursor: 123 }), ).rejects.toThrow("cursor is invalid"); await expect(readLocalOpenCodeTranscriptPage({ threadId: "--help" })).rejects.toThrow( "threadId is invalid", ); let provider: Parameters[0] | undefined; registerOpenCodeSessionCatalog({ pluginConfig: {}, runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, registerSessionCatalog: (value: NonNullable) => { provider = value; }, registerNodeHostCommand: vi.fn(), registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); await expect( provider!.read({ hostId: "gateway", threadId: "ses_test", limit: 2 }), ).resolves.toMatchObject({ threadId: "ses_test", items: expect.any(Array) }); await expect(provider!.list({ search: " " })).resolves.toEqual([ expect.objectContaining({ hostId: "gateway", sessions: [expect.any(Object)] }), ]); await expect(provider!.list({ search: "x".repeat(501) })).resolves.toEqual([ expect.objectContaining({ hostId: "gateway", sessions: [] }), ]); }, ); it.runIf(process.platform !== "win32")( "keeps oversized transcript items below the node payload budget", async () => { await installFakeOpenCode("x".repeat(600 * 1024)); const transcript = await readLocalOpenCodeTranscriptPage({ threadId: "ses_test", limit: 20, }); const answer = transcript.items.find((item) => item.type === "agentMessage"); expect(answer?.text?.endsWith("…")).toBe(true); expect(Buffer.byteLength(JSON.stringify(transcript), "utf8")).toBeLessThan(20 * 1024 * 1024); }, ); it.runIf(process.platform !== "win32")( "auto-detects the CLI and honors the node-local Web UI switch", async () => { const directory = await installFakeOpenCode(); const commands = createOpenCodeSessionNodeHostCommands(); expect(commands.map((command) => command.command)).toEqual([ OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND, ]); expect( commands.every((command) => command.isAvailable?.({ config: {}, env: { PATH: directory } } as never), ), ).toBe(true); expect( commands.every((command) => command.isAvailable?.({ config: { plugins: { entries: { opencode: { config: { sessionCatalog: { enabled: false } } } }, }, }, env: { PATH: directory }, } as never), ), ).toBe(false); expect( commands.every((command) => command.isAvailable?.({ config: {}, env: { PATH: path.join(directory, "missing") }, } as never), ), ).toBe(false); }, ); it("does not register the catalog when explicitly disabled", () => { const registerSessionCatalog = vi.fn(); const api = { pluginConfig: { sessionCatalog: { enabled: false } }, registerSessionCatalog, } as unknown as OpenClawPluginApi; registerOpenCodeSessionCatalog(api); expect(isOpenCodeSessionCatalogEnabled(api.pluginConfig)).toBe(false); expect(registerSessionCatalog).not.toHaveBeenCalled(); }); it("bridges paired-node list and read requests without undefined transport fields", async () => { let provider: Parameters[0] | undefined; const invoke = vi .fn() .mockResolvedValueOnce({ payloadJSON: JSON.stringify({ sessions: [ { threadId: "ses_remote", status: "stored", source: "opencode-cli", archived: false, canContinue: false, canArchive: false, }, ], }), }) .mockResolvedValueOnce({ payloadJSON: JSON.stringify({ threadId: "ses_remote", items: [{ type: "agentMessage", text: "remote answer" }], }), }); const api = { pluginConfig: {}, runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [ { nodeId: "node-1", displayName: "Remote", connected: true, commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND], }, ], }), invoke, }, }, registerSessionCatalog: (value: NonNullable) => { provider = value; }, registerNodeHostCommand: vi.fn(), registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi; registerOpenCodeSessionCatalog(api); const catalog = provider; expect(catalog).toBeDefined(); await catalog!.list({ hostIds: ["node:node-1"], search: " " }); await catalog!.read({ hostId: "node:node-1", threadId: "ses_remote" }); expect(invoke).toHaveBeenNthCalledWith(1, { nodeId: "node-1", command: OPENCODE_SESSIONS_LIST_COMMAND, params: {}, timeoutMs: 35_000, scopes: ["operator.write"], }); expect(invoke).toHaveBeenNthCalledWith(2, { nodeId: "node-1", command: OPENCODE_SESSION_READ_COMMAND, params: { threadId: "ses_remote" }, timeoutMs: 35_000, scopes: ["operator.write"], }); invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ sessions: [ { threadId: 123, status: "stored", archived: false, canContinue: false, canArchive: false, }, ], }), }); await expect(catalog!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ expect.objectContaining({ error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, }), ]); invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ sessions: [ { threadId: "--help", status: "stored", archived: false, canContinue: false, canArchive: false, }, ], }), }); await expect(catalog!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([ expect.objectContaining({ error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, }), ]); invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ threadId: "ses_remote", items: [{ type: "invalid", text: "bad" }], }), }); await expect(catalog!.read({ hostId: "node:node-1", threadId: "ses_remote" })).rejects.toThrow( "invalid transcript page", ); }); it("fans out paired-node listing instead of blocking later hosts", async () => { let provider: Parameters[0] | undefined; let releaseSlow: ((value: unknown) => void) | undefined; const slow = new Promise((resolve) => { releaseSlow = resolve; }); const page = (threadId: string) => ({ payloadJSON: JSON.stringify({ sessions: [ { threadId, status: "stored", archived: false, canContinue: false, canArchive: false, }, ], }), }); const invoke = vi.fn(({ nodeId }: { nodeId: string }) => nodeId === "node-a" ? slow : Promise.resolve(page("session-b")), ); const api = { pluginConfig: {}, runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: ["node-a", "node-b"].map((nodeId) => ({ nodeId, connected: true, commands: [OPENCODE_SESSIONS_LIST_COMMAND], })), }), invoke, }, }, registerSessionCatalog: (value: NonNullable) => { provider = value; }, registerNodeHostCommand: vi.fn(), registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi; registerOpenCodeSessionCatalog(api); const listing = provider!.list({}); await vi.waitFor(() => expect(invoke).toHaveBeenCalledTimes(2)); releaseSlow?.(page("session-a")); await expect(listing).resolves.toEqual([ expect.objectContaining({ nodeId: "node-a", sessions: [expect.any(Object)] }), expect.objectContaining({ nodeId: "node-b", sessions: [expect.any(Object)] }), ]); }); });