openclaw/extensions/opencode/session-catalog.test.ts
Peter Steinberger 1fb9644341
refactor(opencode): consolidate session catalog test fixtures (#118323)
* test(opencode): consolidate session catalog fixtures

* test(opencode): preserve continuation node runtime
2026-08-02 20:33:42 -07:00

858 lines
31 KiB
TypeScript

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 { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import type { SessionTranscriptWriteLockContext } from "openclaw/plugin-sdk/session-transcript-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
type ResolveAcpSessionAvailability =
(typeof import("openclaw/plugin-sdk/acp-runtime"))["resolveAcpSessionAvailability"];
type SessionCatalogProvider = Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0];
type NodeHostCommand = Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0];
type NodeInvokePolicy = Parameters<OpenClawPluginApi["registerNodeInvokePolicy"]>[0];
type CatalogListParams = Parameters<SessionCatalogProvider["list"]>[0];
type CatalogReadParams = Parameters<SessionCatalogProvider["read"]>[0];
type CreateSessionEntryParams = Parameters<
OpenClawPluginApi["runtime"]["agent"]["session"]["createSessionEntry"]
>[0];
const nodeHostMocks = vi.hoisted(() => ({
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
}));
const acpRuntimeMocks = vi.hoisted(() => ({
resolveAcpSessionAvailability: vi.fn<ResolveAcpSessionAvailability>(() => ({ available: true })),
}));
const childProcessMocks = vi.hoisted(() => ({
children: [] as ChildProcess[],
spawn: vi.fn(),
}));
const transcriptMocks = vi.hoisted(() => ({
messages: [] as Array<Record<string, unknown>>,
}));
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
childProcessMocks.spawn.mockImplementation((...args: Parameters<typeof actual.spawn>) => {
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<typeof import("openclaw/plugin-sdk/acp-runtime")>()),
resolveAcpSessionAvailability: acpRuntimeMocks.resolveAcpSessionAvailability,
}));
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
return {
...actual,
withSessionTranscriptWriteLock: async (
_params: unknown,
run: (context: Pick<SessionTranscriptWriteLockContext, "appendMessage">) => Promise<void>,
) => {
await run({
appendMessage: async ({ message, idempotencyLookup }) => {
const record = message as Record<string, unknown>;
const key = record.idempotencyKey;
if (
idempotencyLookup === "scan" &&
typeof key === "string" &&
transcriptMocks.messages.some((candidate) => candidate.idempotencyKey === key)
) {
return;
}
transcriptMocks.messages.push(record);
},
});
},
};
});
vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/node-host")>();
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;
const pairedNodeLocator = { hostId: "node:node-1", threadId: "ses_remote" } as const;
const removeDirectory = (directory: string) => fs.rm(directory, { recursive: true, force: true });
function captureOpenCodeSessionRegistrations(
pluginConfig: OpenClawPluginApi["pluginConfig"] = {},
overrides: Record<string, unknown> = {},
) {
const catalogs: SessionCatalogProvider[] = [];
const commands: NodeHostCommand[] = [];
const policies: NodeInvokePolicy[] = [];
registerOpenCodeSessionCatalog(
createTestPluginApi({
id: "opencode",
pluginConfig,
runtime: {
nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) },
} as unknown as OpenClawPluginApi["runtime"],
...(overrides as Partial<OpenClawPluginApi>),
registerSessionCatalog: (catalog: SessionCatalogProvider) => catalogs.push(catalog),
registerNodeHostCommand: (command: NodeHostCommand) => commands.push(command),
registerNodeInvokePolicy: (policy: NodeInvokePolicy) => policies.push(policy),
}),
);
return { catalogs, commands, policies, provider: catalogs[0] };
}
function pairedNodeSession(overrides: Record<string, unknown> = {}) {
return {
threadId: "ses_remote",
status: "stored",
archived: false,
canContinue: false,
canArchive: false,
...overrides,
};
}
const nodePayload = (payload: unknown) => ({ payloadJSON: JSON.stringify(payload) });
function pairedNodeSessionPage(...sessions: Array<Record<string, unknown>>) {
return nodePayload({ sessions });
}
const pairedNode = (commands: string[], overrides: Record<string, unknown> = {}) => ({
nodeId: "node-1",
connected: true,
commands,
...overrides,
});
function capturePairedNodeCatalog(nodes: Array<Record<string, unknown>>, invoke: unknown) {
const listNodes = vi.fn().mockResolvedValue({ nodes });
const { provider } = captureOpenCodeSessionRegistrations(
{},
{
runtime: { nodes: { list: listNodes, invoke } },
},
);
return { listNodes, provider };
}
const listPairedNode = (
provider: SessionCatalogProvider,
params: Omit<CatalogListParams, "hostIds"> = {},
) => provider.list({ hostIds: [pairedNodeLocator.hostId], ...params });
const readPairedNode = (
provider: SessionCatalogProvider,
params: Omit<CatalogReadParams, "hostId" | "threadId"> = {},
) => provider.read({ ...pairedNodeLocator, ...params });
const readTestTranscript = (
params: Omit<Parameters<typeof readLocalOpenCodeTranscriptPage>[0], "threadId"> = {},
) => readLocalOpenCodeTranscriptPage({ threadId: "ses_test", ...params });
async function expectRejects(promise: Promise<unknown>, message: string) {
await expect(promise).rejects.toThrow(message);
}
const nodeInvokeRequest = (command: string, params: Record<string, unknown>) => ({
nodeId: "node-1",
command,
params,
timeoutMs: 35_000,
scopes: ["operator.write"],
});
async function expectPairedNodeListError(
provider: SessionCatalogProvider,
params: Omit<CatalogListParams, "hostIds"> = {},
) {
await expect(listPairedNode(provider, params)).resolves.toEqual([
expect.objectContaining({
error: { code: "NODE_INVOKE_FAILED", message: expect.any(String) },
}),
]);
}
function captureOpenCodeContinuationCatalog() {
const entries: Array<{ sessionKey: string; entry: Record<string, unknown> }> = [];
const createSessionEntry = vi.fn(async (params: CreateSessionEntryParams) => {
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;
}
});
const session = { createSessionEntry, listSessionEntries: vi.fn(() => entries) };
const runtime = {
config: { current: () => ({}) },
nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) },
agent: { session },
};
const { provider } = captureOpenCodeSessionRegistrations(
{},
{ id: "opencode", config: {}, runtime },
);
return { createSessionEntry, provider: provider! };
}
async function installFakeOpenCode(
assistantText = "hi",
sessionTitle = "Catalog session",
toolInput: unknown = { command: "pwd" },
): Promise<string> {
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 model = { providerID: "anthropic", modelID: "claude" };
const exported = {
info: session,
messages: [
{
info: { id: "msg_user", role: "user", time: { created: session.created }, model },
parts: [{ id: "prt_user", type: "text", text: "hello" }],
},
{
info: {
id: "msg_assistant",
role: "assistant",
time: { created: session.updated },
...model,
},
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<void> {
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<void> {
if (!child || !isProcessRunning(child.pid)) {
return;
}
const closed = once(child, "close");
child.kill("SIGKILL");
await closed;
}
function restoreEnv(name: string, value: string | undefined) {
Reflect.deleteProperty(process.env, name);
if (value !== undefined) {
process.env[name] = value;
}
}
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;
restoreEnv("PATHEXT", originalPathExt);
restoreEnv("CATALOG_UNRELATED_ENV", originalUnrelatedEnv);
await Promise.all(temporaryDirectories.splice(0).map(removeDirectory));
});
const itWithCli = it.runIf(process.platform !== "win32");
describe("OpenCode session catalog", () => {
itWithCli("lists and reads sessions through the official CLI JSON surfaces", async () => {
await installFakeOpenCode();
const listed = await listLocalOpenCodeSessionPage({ limit: 20 });
const expectedSession = {
threadId: "ses_test",
name: "Catalog session",
cwd: "/workspace",
source: "opencode-cli",
canContinue: true,
};
expect(listed).toEqual({ sessions: [expect.objectContaining(expectedSession)] });
const transcript = await readTestTranscript({ 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 readTestTranscript({ limit: 2 });
expect(latest.items.map((item) => item.type)).toEqual(["toolCall", "toolResult"]);
expect(latest.nextCursor).toBeTruthy();
const older = await readTestTranscript({ 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 expectRejects(readTestTranscript({ cursor }), "cursor is invalid");
}
await expectRejects(listLocalOpenCodeSessionPage({ cursor: " " }), "cursor is invalid");
await expectRejects(readTestTranscript({ cursor: 123 }), "cursor is invalid");
await expectRejects(
readLocalOpenCodeTranscriptPage({ threadId: "--help" }),
"threadId is invalid",
);
const { provider } = captureOpenCodeSessionRegistrations();
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)] }),
]);
});
itWithCli(
"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();
}
},
);
itWithCli("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 expectRejects(
provider.continueSession!({ hostId: "gateway", threadId: "ses_test" }),
"ACP runtime backend is unavailable",
);
});
itWithCli("keeps oversized transcript items below the node payload budget", async () => {
await installFakeOpenCode("x".repeat(600 * 1024));
const transcript = await readTestTranscript({ 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);
});
itWithCli("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",
});
});
itWithCli("rejects paired-node and unknown OpenCode session continuation", async () => {
await installFakeOpenCode();
const { createSessionEntry, provider } = captureOpenCodeContinuationCatalog();
await expectRejects(
provider.continueSession!({ hostId: "node:remote", threadId: "ses_test" }),
"paired-node OpenCode session rows are view-only",
);
await expectRejects(
provider.continueSession!({ hostId: "gateway", threadId: "missing" }),
"OpenCode session is unavailable",
);
expect(createSessionEntry).not.toHaveBeenCalled();
});
itWithCli("keeps truncated tool input on a valid UTF-16 boundary", async () => {
await installFakeOpenCode("hi", "Catalog session", {
value: `${"x".repeat(19_989)}🎉`,
});
const transcript = await readTestTranscript({ 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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u,
);
});
itWithCli("auto-detects the CLI and honors the node-local Web UI switch", async () => {
const directory = await installFakeOpenCode();
const { commands } = captureOpenCodeSessionRegistrations();
const commandsAvailable = (config: unknown, PATH: string) =>
commands.every((command) => command.isAvailable?.({ config, env: { PATH } } as never));
expect(commands.map((command) => command.command)).toEqual([
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
]);
expect(commandsAvailable({}, directory)).toBe(true);
expect(
commandsAvailable(
{
plugins: {
entries: { opencode: { config: { sessionCatalog: { enabled: false } } } },
},
},
directory,
),
).toBe(false);
expect(commandsAvailable({}, path.join(directory, "missing"))).toBe(false);
});
itWithCli(
"opens validated local sessions with the upstream terminal resume contract",
async () => {
await installFakeOpenCode();
const { provider } = captureOpenCodeSessionRegistrations();
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 expectRejects(
provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }),
"OpenCode session is unavailable",
);
},
);
itWithCli("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 () => {
const page = pairedNodeSessionPage({
...pairedNodeSession(),
cwd: "/remote/workspace",
canContinue: true,
});
const invoke = vi.fn().mockResolvedValue(page);
const nodes = [pairedNode([OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND])];
const requestListNodes = vi.fn().mockResolvedValue({ nodes });
const { listNodes: runtimeListNodes, provider } = capturePairedNodeCatalog(nodes, invoke);
await expect(
listPairedNode(provider!, { 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,
nodeInvokeRequest(OPENCODE_SESSIONS_LIST_COMMAND, { searchTerm: "remote" }),
);
await expect(provider!.openTerminal!(pairedNodeLocator)).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(
nodeInvokeRequest(OPENCODE_SESSIONS_LIST_COMMAND, {
searchTerm: "ses_remote",
limit: 100,
}),
);
});
it("does not register the catalog when explicitly disabled", () => {
const { provider: _provider, ...registrations } = captureOpenCodeSessionRegistrations({
sessionCatalog: { enabled: false },
});
expect(registrations).toEqual({ catalogs: [], commands: [], policies: [] });
});
it("bridges paired-node list and read requests without undefined transport fields", async () => {
const invoke = vi
.fn()
.mockResolvedValueOnce(pairedNodeSessionPage(pairedNodeSession({ source: "opencode-cli" })))
.mockResolvedValueOnce(
nodePayload({
threadId: "ses_remote",
items: [{ type: "agentMessage", text: "remote answer" }],
}),
);
const { provider: catalog } = capturePairedNodeCatalog(
[
pairedNode([OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND], {
displayName: "Remote",
}),
],
invoke,
);
expect(catalog).toBeDefined();
await listPairedNode(catalog!);
await readPairedNode(catalog!);
expect(invoke).toHaveBeenNthCalledWith(
1,
nodeInvokeRequest(OPENCODE_SESSIONS_LIST_COMMAND, {}),
);
expect(invoke).toHaveBeenNthCalledWith(
2,
nodeInvokeRequest(OPENCODE_SESSION_READ_COMMAND, { threadId: "ses_remote" }),
);
for (const threadId of [123, "--help"]) {
invoke.mockResolvedValueOnce(pairedNodeSessionPage(pairedNodeSession({ threadId })));
await expectPairedNodeListError(catalog!);
}
invoke.mockResolvedValueOnce(
nodePayload({
threadId: "ses_remote",
items: [{ type: "invalid", text: "bad" }],
}),
);
await expect(readPairedNode(catalog!)).rejects.toThrow("invalid transcript page");
invoke.mockClear();
await expect(readPairedNode(catalog!, { cursor: "" })).rejects.toThrow("cursor is invalid");
await expectPairedNodeListError(catalog!, { cursors: { "node:node-1": "" } });
expect(invoke).not.toHaveBeenCalled();
invoke.mockResolvedValueOnce(nodePayload({ sessions: [], nextCursor: " wrapped " }));
await expectPairedNodeListError(catalog!);
invoke.mockResolvedValueOnce(
nodePayload({ threadId: "ses_remote", items: [], nextCursor: " wrapped " }),
);
await expect(readPairedNode(catalog!)).rejects.toThrow("invalid cursor");
const exactCursor = Buffer.from(JSON.stringify({ offset: 1 }), "utf8").toString("base64url");
invoke.mockResolvedValueOnce(nodePayload({ sessions: [] }));
await listPairedNode(catalog!, { cursors: { "node:node-1": exactCursor } });
expect(invoke).toHaveBeenLastCalledWith(
expect.objectContaining({ params: { cursor: exactCursor } }),
);
invoke.mockResolvedValueOnce(nodePayload({ threadId: "ses_remote", items: [] }));
await readPairedNode(catalog!, { 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 expectRejects(listing, `OpenCode ${streamName} stream failed: ${streamName} EPIPE`);
await new Promise<void>((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 releaseSlow: ((value: unknown) => void) | undefined;
const slow = new Promise<unknown>((resolve) => {
releaseSlow = resolve;
});
const page = (threadId: string) => pairedNodeSessionPage(pairedNodeSession({ threadId }));
const invoke = vi.fn(({ nodeId }: { nodeId: string }) =>
nodeId === "node-a" ? slow : Promise.resolve(page("session-b")),
);
const { provider } = capturePairedNodeCatalog(
["node-a", "node-b"].map((nodeId) =>
pairedNode([OPENCODE_SESSIONS_LIST_COMMAND], { nodeId }),
),
invoke,
);
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)] }),
]);
});
});