import type { ChildProcess } from "node:child_process"; import { once } from "node:events"; 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"; type ResolveAcpSessionAvailability = (typeof import("openclaw/plugin-sdk/acp-runtime"))["resolveAcpSessionAvailability"]; const nodeHostMocks = vi.hoisted(() => ({ runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })), })); const acpRuntimeMocks = vi.hoisted(() => ({ resolveAcpSessionAvailability: vi.fn(() => ({ available: true })), })); const childProcessMocks = vi.hoisted(() => ({ children: [] as ChildProcess[], spawn: vi.fn(), })); const transcriptMocks = vi.hoisted(() => ({ messages: [] as Array>, })); vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); childProcessMocks.spawn.mockImplementation((...args: Parameters) => { const child = actual.spawn(...args); childProcessMocks.children.push(child); return child; }); return { ...actual, spawn: childProcessMocks.spawn }; }); vi.mock("openclaw/plugin-sdk/acp-runtime", async (importOriginal) => ({ ...(await importOriginal()), resolveAcpSessionAvailability: acpRuntimeMocks.resolveAcpSessionAvailability, })); vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, withSessionTranscriptWriteLock: async ( _params: unknown, run: (context: { appendMessage: (params: { message: Record; idempotencyLookup?: string; }) => Promise; }) => Promise, ) => { await run({ appendMessage: async ({ message, idempotencyLookup }) => { const key = message.idempotencyKey; if ( idempotencyLookup === "scan" && typeof key === "string" && transcriptMocks.messages.some((candidate) => candidate.idempotencyKey === key) ) { return; } transcriptMocks.messages.push(message); }, }); }, }; }); vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand, resolveNodeHostExecutable: ( command: string, options: { env?: NodeJS.ProcessEnv; pathEnv?: string; includeExtensionless?: boolean; }, ) => { const env = options.env ?? process.env; return actual.resolveNodeHostExecutable(command, { env, pathEnv: options.pathEnv ?? env.PATH ?? env.Path ?? "", includeExtensionless: options.includeExtensionless, strategy: "direct", }); }, }; }); import { registerOpenCodeSessionCatalog } from "./session-catalog-plugin.js"; import { OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND, } from "./session-catalog-shared.js"; import { listLocalOpenCodeSessionPage, readLocalOpenCodeTranscriptPage, } from "./session-catalog.js"; const temporaryDirectories: string[] = []; const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; const originalUnrelatedEnv = process.env.CATALOG_UNRELATED_ENV; function captureOpenCodeSessionRegistrations(pluginConfig: unknown = {}) { const catalogs: Array[0]> = []; const commands: Array[0]> = []; const policies: Array[0]> = []; registerOpenCodeSessionCatalog({ pluginConfig, runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } }, registerSessionCatalog: (catalog: Parameters[0]) => catalogs.push(catalog), registerNodeHostCommand: ( command: Parameters[0], ) => commands.push(command), registerNodeInvokePolicy: ( policy: Parameters[0], ) => policies.push(policy), } as unknown as OpenClawPluginApi); return { catalogs, commands, policies }; } function captureOpenCodeContinuationCatalog() { let provider: Parameters[0] | undefined; const entries: Array<{ sessionKey: string; entry: Record }> = []; const createSessionEntry = vi.fn( async ( params: Parameters[0], ) => { const sessionKey = `agent:${params.agentId ?? "main"}:${params.key}`; const entry = { sessionId: "adopted-opencode-session", updatedAt: Date.now(), pluginOwnerId: "opencode", initializationPending: true as const, ...(params.label ? { label: params.label } : {}), ...(params.spawnedCwd ? { spawnedCwd: params.spawnedCwd } : {}), pluginExtensions: params.initialEntry.pluginExtensions, }; entries.push({ sessionKey, entry }); const created = { key: sessionKey, agentId: params.agentId ?? "main", sessionId: entry.sessionId, entry, }; try { const finalPatch = await params.afterCreate?.(created); entry.pluginExtensions = finalPatch?.pluginExtensions ?? entry.pluginExtensions; delete (entry as { initializationPending?: true }).initializationPending; return created; } catch (error) { entries.splice( entries.findIndex((candidate) => candidate.entry === entry), 1, ); throw error; } }, ); registerOpenCodeSessionCatalog({ id: "opencode", pluginConfig: {}, config: {}, runtime: { config: { current: () => ({}) }, nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) }, agent: { session: { createSessionEntry, listSessionEntries: vi.fn(() => entries), }, }, }, registerSessionCatalog: (value: NonNullable) => { provider = value; }, registerNodeHostCommand: vi.fn(), registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); return { createSessionEntry, entries, provider: provider! }; } async function installFakeOpenCode( assistantText = "hi", sessionTitle = "Catalog session", toolInput: unknown = { command: "pwd" }, ): 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: sessionTitle, 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: toolInput, 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(args[2].includes("event_sequence") ? ${JSON.stringify( JSON.stringify([ { id: "ses_test", seq: 4, }, ]), )} : ${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; } async function installHangingOpenCode(): Promise { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-opencode-stream-")); temporaryDirectories.push(directory); const executableName = process.platform === "win32" ? "opencode.js" : "opencode"; await fs.writeFile( path.join(directory, executableName), `${process.platform === "win32" ? "" : "#!/usr/bin/env node\n"}setTimeout(() => process.stdout.write("ready\\n"), 50); setInterval(() => {}, 1_000); `, ); if (process.platform !== "win32") { await fs.chmod(path.join(directory, executableName), 0o755); } process.env.PATH = `${directory}${path.delimiter}${originalPath ?? ""}`; if (process.platform === "win32") { // The production resolver converts a PATHEXT-resolved .js command into // process.execPath plus the script path, so this remains a direct real-child spawn. process.env.PATHEXT = `.JS;${originalPathExt ?? ".EXE;.CMD;.BAT;.COM"}`; } } function isProcessRunning(pid: number | undefined): boolean { if (!pid) { return false; } try { process.kill(pid, 0); return true; } catch { return false; } } async function stopChild(child: ChildProcess | undefined): Promise { if (!child || !isProcessRunning(child.pid)) { return; } const closed = once(child, "close"); child.kill("SIGKILL"); await closed; } afterEach(async () => { acpRuntimeMocks.resolveAcpSessionAvailability.mockReset().mockReturnValue({ available: true }); nodeHostMocks.runNodePtyCommand.mockClear(); childProcessMocks.spawn.mockClear(); transcriptMocks.messages.length = 0; await Promise.all(childProcessMocks.children.splice(0).map((child) => stopChild(child))); process.env.PATH = originalPath; if (originalPathExt === undefined) { delete process.env.PATHEXT; } else { process.env.PATHEXT = originalPathExt; } 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: true, }), ], }); 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"]); const nonEmitted = Buffer.from(JSON.stringify({ offset: 2, extra: true }), "utf8").toString( "base64url", ); const unsafeOffset = Buffer.from( JSON.stringify({ offset: Number.MAX_SAFE_INTEGER + 1 }), "utf8", ).toString("base64url"); for (const cursor of [ `${latest.nextCursor}$`, `${latest.nextCursor}=`, ` ${latest.nextCursor} `, nonEmitted, unsafeOffset, ]) { await expect( readLocalOpenCodeTranscriptPage({ threadId: "ses_test", cursor, }), ).rejects.toThrow("cursor is invalid"); } 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({})).resolves.toEqual([ expect.objectContaining({ hostId: "gateway", sessions: [expect.any(Object)] }), ]); }, ); it.runIf(process.platform !== "win32")( "memoizes the CLI database query across cadence and invalidates by config identity", async () => { await installFakeOpenCode(); let now = 1_000; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); const configIdentity = {}; try { await listLocalOpenCodeSessionPage({ limit: 20 }, { configIdentity }); await listLocalOpenCodeSessionPage({ limit: 20 }, { configIdentity }); expect(childProcessMocks.spawn).toHaveBeenCalledOnce(); now += 31_999; await listLocalOpenCodeSessionPage({ limit: 20 }, { configIdentity }); expect(childProcessMocks.spawn).toHaveBeenCalledOnce(); await listLocalOpenCodeSessionPage({ limit: 20 }, { configIdentity, forceRefresh: true }); expect(childProcessMocks.spawn).toHaveBeenCalledTimes(2); await listLocalOpenCodeSessionPage({ limit: 20 }, { configIdentity: {} }); expect(childProcessMocks.spawn).toHaveBeenCalledTimes(3); now += 32_001; await listLocalOpenCodeSessionPage({ limit: 20 }, { configIdentity }); expect(childProcessMocks.spawn).toHaveBeenCalledTimes(4); } finally { nowSpy.mockRestore(); } }, ); it.runIf(process.platform !== "win32")( "hides and rejects Continue when ACP cannot resume OpenCode", async () => { await installFakeOpenCode(); acpRuntimeMocks.resolveAcpSessionAvailability.mockReturnValue({ available: false, message: "ACP runtime backend is unavailable", }); const { provider } = captureOpenCodeContinuationCatalog(); await expect(provider.list({ hostIds: ["gateway"] })).resolves.toEqual([ expect.objectContaining({ sessions: [expect.objectContaining({ threadId: "ses_test", canContinue: false })], }), ]); await expect( provider.continueSession!({ hostId: "gateway", threadId: "ses_test" }), ).rejects.toThrow("ACP runtime backend is unavailable"); }, ); 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")( "adopts local OpenCode sessions once with the native ACP resume binding", async () => { await installFakeOpenCode(); const { createSessionEntry, provider } = captureOpenCodeContinuationCatalog(); const [first, concurrent] = await Promise.all([ provider.continueSession!({ hostId: "gateway", threadId: "ses_test" }), provider.continueSession!({ hostId: "gateway", threadId: "ses_test" }), ]); const second = await provider.continueSession!({ hostId: "gateway", threadId: "ses_test", }); expect(first).toEqual(concurrent); expect(second).toEqual(first); expect(first.upstream).toEqual({ kind: "opencode-cli", ref: { threadId: "ses_test" }, marker: { seq: 4, lastHumanMessageId: "msg_user", }, }); expect(createSessionEntry).toHaveBeenCalledTimes(1); expect(createSessionEntry).toHaveBeenCalledWith( expect.objectContaining({ label: "Catalog session", spawnedCwd: "/workspace", initialEntry: { acpBackendId: "acpx", acpSessionBinding: { acpAgentId: "opencode", agentSessionId: "ses_test" }, pluginExtensions: { opencode: { sessionCatalog: { sourceThreadId: "ses_test" } }, }, }, }), ); expect( transcriptMocks.messages.map((message) => typeof message.content === "string" ? message.content : (message.content as Array<{ text: string }>)[0]?.text, ), ).toEqual([ "hello", "Thinking\n\nthinking", "hi", 'Tool call\n\nbash\n{"command":"pwd"}', "Tool result\n\n/workspace", ]); expect(transcriptMocks.messages[0]?.["__openclaw"]).toEqual({ mirrorOrigin: "opencode-catalog-import", }); }, ); it.runIf(process.platform !== "win32")( "rejects paired-node and unknown OpenCode session continuation", async () => { await installFakeOpenCode(); const { createSessionEntry, provider } = captureOpenCodeContinuationCatalog(); await expect( provider.continueSession!({ hostId: "node:remote", threadId: "ses_test" }), ).rejects.toThrow("paired-node OpenCode session rows are view-only"); await expect( provider.continueSession!({ hostId: "gateway", threadId: "missing" }), ).rejects.toThrow("OpenCode session is unavailable"); expect(createSessionEntry).not.toHaveBeenCalled(); }, ); it.runIf(process.platform !== "win32")( "keeps truncated tool input on a valid UTF-16 boundary", async () => { await installFakeOpenCode("hi", "Catalog session", { value: `${"x".repeat(19_989)}🎉`, }); const transcript = await readLocalOpenCodeTranscriptPage({ threadId: "ses_test", limit: 20, }); const toolCall = transcript.items.find((item) => item.type === "toolCall"); expect(toolCall?.text).toMatch(/…$/u); expect(toolCall?.text).not.toMatch( /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { const directory = await installFakeOpenCode(); const { commands } = captureOpenCodeSessionRegistrations(); expect(commands.map((command) => command.command)).toEqual([ OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND, OPENCODE_TERMINAL_RESUME_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.runIf(process.platform !== "win32")( "opens validated local sessions with the upstream terminal resume contract", async () => { await installFakeOpenCode(); 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!.list({ hostIds: ["gateway"] })).resolves.toEqual([ expect.objectContaining({ sessions: [expect.objectContaining({ threadId: "ses_test", canOpenTerminal: true })], }), ]); await expect( provider!.openTerminal!({ hostId: "gateway", threadId: "ses_test" }), ).resolves.toEqual({ kind: "local", argv: [expect.stringMatching(/opencode$/u), "--session", "ses_test"], cwd: "/workspace", title: "opencode --session ses_test…", }); await expect( provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }), ).rejects.toThrow("OpenCode session is unavailable"); }, ); it.runIf(process.platform !== "win32")( "runs only catalog-validated OpenCode sessions through the node PTY", async () => { await installFakeOpenCode(); const { commands, policies } = captureOpenCodeSessionRegistrations(); const terminal = commands.find( (command) => command.command === OPENCODE_TERMINAL_RESUME_COMMAND, ); const io = { signal: new AbortController().signal, onInput: vi.fn(), emitChunk: vi.fn(), }; await expect( terminal!.handle?.( JSON.stringify({ threadId: "ses_test", cols: 100, rows: 30 }), io as never, ), ).resolves.toBe(JSON.stringify({ exitCode: 0 })); expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( { file: expect.stringMatching(/opencode$/u), args: ["--session", "ses_test"], cwd: "/workspace", cols: 100, rows: 30, }, io, ); await expect( terminal!.handle?.( JSON.stringify({ threadId: "--help", cols: 100, rows: 30 }), io as never, ), ).rejects.toThrow("threadId is invalid"); const invokeNode = vi.fn(() => ({ ok: false as const, error: "unexpected" })); const policy = policies[0]!; expect( policy.handle({ command: OPENCODE_TERMINAL_RESUME_COMMAND, invokeNode } as never), ).toEqual({ ok: true }); expect( policy.handle({ command: OPENCODE_SESSIONS_LIST_COMMAND, invokeNode } as never), ).toEqual({ ok: false, error: "unexpected" }); }, ); it("marks paired-node sessions terminal-capable only when the resume command is advertised", async () => { let provider: Parameters[0] | undefined; const page = { payloadJSON: JSON.stringify({ sessions: [ { threadId: "ses_remote", cwd: "/remote/workspace", status: "stored", archived: false, canContinue: true, canArchive: false, }, ], }), }; const invoke = vi.fn().mockResolvedValue(page); const nodes = [ { nodeId: "node-1", connected: true, commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND], }, ]; const runtimeListNodes = vi.fn().mockResolvedValue({ nodes }); const requestListNodes = vi.fn().mockResolvedValue({ nodes }); registerOpenCodeSessionCatalog({ pluginConfig: {}, runtime: { nodes: { list: runtimeListNodes, invoke, }, }, registerSessionCatalog: (value: NonNullable) => { provider = value; }, registerNodeHostCommand: vi.fn(), registerNodeInvokePolicy: vi.fn(), } as unknown as OpenClawPluginApi); await expect( provider!.list({ hostIds: ["node:node-1"], search: "remote", listNodes: requestListNodes, }), ).resolves.toEqual([ expect.objectContaining({ sessions: [ expect.objectContaining({ threadId: "ses_remote", canContinue: false, canOpenTerminal: true, }), ], }), ]); expect(requestListNodes).toHaveBeenCalledOnce(); expect(runtimeListNodes).not.toHaveBeenCalled(); expect(invoke).toHaveBeenNthCalledWith(1, { nodeId: "node-1", command: OPENCODE_SESSIONS_LIST_COMMAND, params: { searchTerm: "remote" }, timeoutMs: 35_000, scopes: ["operator.write"], }); await expect( provider!.openTerminal!({ hostId: "node:node-1", threadId: "ses_remote" }), ).resolves.toEqual({ kind: "node", nodeId: "node-1", command: OPENCODE_TERMINAL_RESUME_COMMAND, paramsJSON: JSON.stringify({ threadId: "ses_remote" }), cwd: "/remote/workspace", title: "opencode --session ses_remote…", }); expect(invoke).toHaveBeenLastCalledWith({ nodeId: "node-1", command: OPENCODE_SESSIONS_LIST_COMMAND, params: { searchTerm: "ses_remote", limit: 100 }, timeoutMs: 35_000, scopes: ["operator.write"], }); }); it("does not register the catalog when explicitly disabled", () => { const registrations = captureOpenCodeSessionRegistrations({ sessionCatalog: { enabled: false }, }); expect(registrations).toEqual({ catalogs: [], commands: [], policies: [] }); }); 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"] }); 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", ); invoke.mockClear(); await expect( catalog!.read({ hostId: "node:node-1", threadId: "ses_remote", cursor: "" }), ).rejects.toThrow("cursor is invalid"); await expect( catalog!.list({ hostIds: ["node:node-1"], cursors: { "node:node-1": "" }, }), ).resolves.toEqual([ expect.objectContaining({ error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) }, }), ]); expect(invoke).not.toHaveBeenCalled(); invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ sessions: [], nextCursor: " wrapped " }), }); 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: [], nextCursor: " wrapped ", }), }); await expect(catalog!.read({ hostId: "node:node-1", threadId: "ses_remote" })).rejects.toThrow( "invalid cursor", ); const exactCursor = Buffer.from(JSON.stringify({ offset: 1 }), "utf8").toString("base64url"); invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ sessions: [] }) }); await catalog!.list({ hostIds: ["node:node-1"], cursors: { "node:node-1": exactCursor }, }); expect(invoke).toHaveBeenLastCalledWith( expect.objectContaining({ params: { cursor: exactCursor } }), ); invoke.mockResolvedValueOnce({ payloadJSON: JSON.stringify({ threadId: "ses_remote", items: [] }), }); await catalog!.read({ hostId: "node:node-1", threadId: "ses_remote", cursor: exactCursor, }); expect(invoke).toHaveBeenLastCalledWith( expect.objectContaining({ params: { threadId: "ses_remote", cursor: exactCursor } }), ); }); it.each(["stdout", "stderr"] as const)( "rejects and reaps the real OpenCode child when its %s pipe fails", async (streamName) => { await installHangingOpenCode(); const uncaughtException = vi.fn(); process.on("uncaughtExceptionMonitor", uncaughtException); let child: ChildProcess | undefined; try { const listing = listLocalOpenCodeSessionPage({ limit: 20 }); await vi.waitFor(() => expect(childProcessMocks.spawn).toHaveBeenCalledTimes(1)); child = childProcessMocks.children[0]; expect(child?.pid).toBeTypeOf("number"); await once(child!.stdout!, "data"); child![streamName]!.destroy(new Error(`${streamName} EPIPE`)); await expect(listing).rejects.toThrow( `OpenCode ${streamName} stream failed: ${streamName} EPIPE`, ); await new Promise((resolve) => { setImmediate(resolve); }); expect(uncaughtException).not.toHaveBeenCalled(); expect(isProcessRunning(child!.pid)).toBe(false); } finally { process.off("uncaughtExceptionMonitor", uncaughtException); await stopChild(child); } }, ); 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({ hostIds: ["node:node-a", "node:node-b"] }); 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)] }), ]); }); });