import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS, } from "../../src/runtime/product-mcp-tools.js"; import { describe, expect, it } from "bun:test"; import { type OpenCodeClient as OpencodeClient } from "@opencode/client"; import { createServer } from "node:net"; import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { bindScopedConversationWorkspace, createConversationWorkspace, migrateConversationWorkspace, sessionWorkspaceDirectory } from "../../src/runtime/conversationWorkspace.js"; import { config } from "../../src/config.js"; import { modelCheckTargets } from "../../src/runtime/model-check.js"; import { OpencodeRuntimeAdapter, resolveEmbeddedOpencodePort, } from "../../src/runtime/opencode.js"; const createRuntimeAdapter = ( messages: unknown[], calls: { reverted: string[]; removed: string[]; } = { reverted: [], removed: [] }, ) => Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { messages: async () => messages, revertMessage: async (_sessionId: string, messageId: string) => { calls.reverted.push(messageId); }, removeMessage: async (_sessionId: string, messageId: string) => { calls.removed.push(messageId); }, }) as OpencodeRuntimeAdapter; describe("OpencodeRuntimeAdapter.removeSession", () => { it("removes its native session and workspace, rejects a foreign binding, and preserves files on failure", async () => { const root = await mkdtemp(join(tmpdir(), "tjwater-session-cleanup-")); const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; config.CONVERSATION_WORKSPACE_ROOT = root; const scope = { actorKey: "actor-a", projectKey: "project-a" }; const id = "cleanup-session"; const directory = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: id }); const foreign = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: "foreign-session" }); const calls: string[] = []; let binding = directory; let removed = false; let failRemoval = true; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: { get: async () => { if (removed) throw { _tag: "SessionNotFoundError" }; return { id, location: { directory: binding } }; }, interrupt: async () => { calls.push("interrupt"); }, active: async () => ({}), remove: async () => { if (failRemoval) throw new Error("native removal failed"); removed = true; calls.push("remove"); }, } }), }) as OpencodeRuntimeAdapter; try { binding = foreign; await expect(runtime.removeSession(id)).rejects.toThrow("another session"); expect(calls).toEqual([]); binding = directory; await expect(runtime.removeSession(id, scope)).rejects.toThrow("native removal failed"); expect((await lstat(directory)).isDirectory()).toBe(true); failRemoval = false; await runtime.removeSession(id, scope); await expect(lstat(directory)).rejects.toMatchObject({ code: "ENOENT" }); expect((await lstat(foreign)).isDirectory()).toBe(true); expect(calls).toEqual(["interrupt", "interrupt", "remove"]); await runtime.removeSession(id, scope); } finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); } }); }); describe("OpencodeRuntimeAdapter.revertToUserMessage", () => { it("skips reverting the first user message when the runtime session is empty", async () => { const calls = { reverted: [] as string[], removed: [] as string[] }; const runtime = createRuntimeAdapter([], calls); await runtime.revertToUserMessage("session-1", { userOrdinal: 1 }); expect(calls).toEqual({ reverted: [], removed: [] }); }); it("keeps ordinal mismatches visible when runtime messages exist", async () => { const runtime = createRuntimeAdapter([ { info: { id: "user-1", role: "user" } }, { info: { id: "assistant-1", role: "assistant" } }, ]); await expect( runtime.revertToUserMessage("session-1", { userOrdinal: 2 }), ).rejects.toThrow("target user message not found to revert"); }); it("reverts and removes messages from the target user message onward", async () => { const calls = { reverted: [] as string[], removed: [] as string[] }; const runtime = createRuntimeAdapter( [ { info: { id: "user-1", role: "user" } }, { info: { id: "assistant-1", role: "assistant" } }, { info: { id: "user-2", role: "user" } }, { info: { id: "assistant-2", role: "assistant" } }, ], calls, ); await runtime.revertToUserMessage("session-1", { userOrdinal: 2 }); expect(calls).toEqual({ reverted: ["user-2"], removed: ["user-2"], }); }); }); describe("OpencodeRuntimeAdapter.ensureClient", () => { it("retries bootstrap after a failed startup attempt", async () => { let attempts = 0; const client = { global: { health: async () => ({ data: { healthy: true } }) }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { clientPromise: null, closeServer: null, bootstrapClient: async () => { attempts += 1; if (attempts === 1) { throw new Error("startup failed"); } return client; }, }) as OpencodeRuntimeAdapter; await expect(runtime.ensureClient()).rejects.toThrow("startup failed"); await expect(runtime.ensureClient()).resolves.toBe(client); expect(attempts).toBe(2); }); }); describe("OpencodeRuntimeAdapter.generateText", () => { it("uses scoped one-shot generation and cleans its temporary context on success and failure", async () => { const root = await mkdtemp(join(tmpdir(), "prompt-suggestion-runtime-")); const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; config.CONVERSATION_WORKSPACE_ROOT = root; const controller = new AbortController(); const calls: unknown[] = []; let fail = false; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ rpc: { call: async () => { calls.push("catalog ready"); } }, session: { create: async (input: unknown) => { calls.push(input); return { id: "temporary-generator" }; }, generate: async (input: unknown, options: { signal?: AbortSignal }) => { calls.push({ input, signal: options.signal }); if (fail) throw new Error("inference failed"); return { text: "generated suggestions" }; }, remove: async (input: unknown) => { calls.push(input); }, prompt: () => { throw new Error("must not dispatch a chat turn"); }, }, }), }) as OpencodeRuntimeAdapter; try { expect(await runtime.generateText("recommend questions", { providerID: "test", modelID: "fast" }, controller.signal)).toBe("generated suggestions"); expect(calls[0]).toMatchObject({ model: { providerID: "test", id: "fast" } }); expect(calls[1]).toBe("catalog ready"); expect(calls[2]).toEqual({ input: { sessionID: "temporary-generator", prompt: "recommend questions" }, signal: controller.signal }); expect(calls[3]).toEqual({ sessionID: "temporary-generator" }); expect(await readdir(root)).toEqual([]); fail = true; await expect(runtime.generateText("recommend questions")).rejects.toThrow("inference failed"); expect(calls.at(-1)).toEqual({ sessionID: "temporary-generator" }); expect(await readdir(root)).toEqual([]); } finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true }); } }); }); describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => { it("selects the first preferred model actually exposed by OpenCode", async () => { const client = { rpc: { call: async () => ({ output: [] }) }, model: { list: async () => ({ data: [{ providerID: "deepseek", id: "deepseek-v4-pro", enabled: true, capabilities: { tools: true } }] }), }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client, }) as OpencodeRuntimeAdapter; await expect(runtime.resolveAvailableModel([ "deepseek/deepseek-flash", "deepseek/deepseek-v4-pro", ])).resolves.toBe("deepseek/deepseek-v4-pro"); }); }); describe("resolveEmbeddedOpencodePort", () => { it("falls back to a dynamic port when the configured port is occupied", async () => { const server = createServer(); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); if (!address || typeof address === "string") { server.close(); throw new Error("expected a TCP server address"); } try { await expect( resolveEmbeddedOpencodePort("127.0.0.1", address.port), ).resolves.toBe(0); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } }); }); describe("OpencodeRuntimeAdapter.warmup", () => { const toolIds = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"]; async function createWarmupRuntime(missing: string[] = [], invalidModel?: string, delayedMcp = false, search: { providers?: readonly string[]; selection?: false | { provider: string }; unavailable?: boolean; extraTools?: readonly string[] } = {}) { const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-")); const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; config.CONVERSATION_WORKSPACE_ROOT = root; const calls: string[] = []; let catalogReads = 0; const [providerID, id] = config.OPENCODE_MODEL.split("/"); const client = { server: { info: async () => { calls.push("health"); return { version: "2.0.24" }; } }, session: { create: async (input: { location: { directory: string } }) => { calls.push("session.create"); return { id: "warmup-session", location: input.location }; }, remove: async () => { calls.push("session.remove"); }, }, model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } }, websearch: { providers: async () => { calls.push("websearch.providers"); if (search.unavailable) throw new Error("PRIVATE_URL_AND_SECRET"); return { data: (search.providers ?? ["fixture-search"]).map((id) => ({ id, name: id })) }; } }, config: { get: async () => { calls.push("config.get"); return [{ type: "directory", path: root }, { type: "document", info: { websearch: { provider: "unavailable-lower-precedence" } } }, { type: "document", info: { websearch: search.selection ?? { provider: "fixture-search" } } }]; } }, rpc: { call: async (input: { method: string }) => { if (input.method === "check_models") { calls.push("model.check"); const targets = modelCheckTargets(config.MODEL_CONFIG).models; return { output: { models: targets.map((model) => ({ id: model.id, model_id: model.wire_id, status: model.id === invalidModel ? "not_advertised" : "valid" })), providers: [...new Set(targets.map((model) => model.provider_id))].map((id) => ({ id, status: "checked" })), } }; } if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; } calls.push("tool.list"); catalogReads++; return { output: [...toolIds, ...(search.extraTools ?? [])].filter((id) => !missing.includes(id) && !(delayedMcp && catalogReads === 1 && id === LOCAL_QUERY_TOOL_IDS[0])).map((id) => ({ id, direct: id !== "websearch", sandboxed: id === "shell", workspaceRoot: root })) }; } }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter; return { runtime, calls, cleanup: async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); } }; } it("verifies business tools and model before reporting ready and removes the warmup session", async () => { const { runtime, calls, cleanup } = await createWarmupRuntime(); try { await runtime.warmup(); expect(calls).toEqual(["health", "session.create", "tool.list", "websearch.providers", "config.get", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); } }); it("rejects a missing native search tool or unusable provider configuration before readiness and cleans up", async () => { for (const [missing, search, message] of [ [["websearch"], {}, "websearch tool is missing"], [[], { providers: [] }, "no registered search provider"], [[], { selection: false }, "web search is disabled"], [[], { selection: { provider: "absent" } }, "configured search provider is unavailable"], [[], { unavailable: true }, "web search configuration unavailable"], [[], { extraTools: ["browser_click"] }, "retired tools are still registered"], [[], { extraTools: ["mcp_tjwater_web_search"] }, "retired tools are still registered"], ] as const) { const { runtime, calls, cleanup } = await createWarmupRuntime([...missing], undefined, false, search); try { await expect(runtime.warmup()).rejects.toThrow(message); expect(calls).not.toContain("model.check"); expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); } } }); it("accepts random provider selection without sending a search query during startup", async () => { const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, false, { selection: { provider: "random" } }); try { await runtime.warmup(); expect(calls).toContain("websearch.providers"); } finally { await cleanup(); } }); it("fails readiness if a business tool was lost and still cleans up", async () => { const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]); try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis"); expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); } }); it("waits for native MCP catalog publication before reporting ready", async () => { const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, true); try { await runtime.warmup(); expect(calls.filter((call) => call === "tool.list")).toHaveLength(2); expect(calls.indexOf("model.check")).toBeGreaterThan(calls.lastIndexOf("tool.list")); } finally { await cleanup(); } }); it("rejects an invalid non-default model before readiness and cleans up the session", async () => { const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol"); try { await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-6.1-sol: not_advertised"); expect(calls).not.toContain("model.list"); expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); } }); }); describe("conversation workspace binding", () => { it("recovers scoped bindings without moving files during MCP status reads", async () => { const base = await mkdtemp(join(tmpdir(), "scoped-binding-test-")); const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces"); const original = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT); await writeFile(join(original, "files", "result.txt"), "preserved"); const owner = { actorKey: "actor-scope", projectKey: "project-scope", sessionId: "native-scope" }; const expected = join(sessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, owner), "workspace"); let directory = original; let moves = 0; let reportedDirectory: string | undefined; const client = { session: { get: async () => ({ location: { directory } }), move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; }, } }; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client, readMcpReport: async (_client: unknown, location: string) => { reportedDirectory = location; return { servers: [], configuration_errors: [] }; }, }) as OpencodeRuntimeAdapter; try { await expect(runtime.ensureSessionWorkspace(owner.sessionId, owner)).rejects.toThrow("binding unavailable"); await runtime.mcpStatus(owner.sessionId, owner); expect(reportedDirectory).toBe(expected); expect(moves).toBe(1); expect(await readFile(join(expected, "files", "result.txt"), "utf8")).toBe("preserved"); expect(await runtime.ensureSessionWorkspace(owner.sessionId, owner)).toBe(expected); expect(moves).toBe(2); } finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); } }); it("moves legacy files into data and retries a failed native directory binding without losing results", async () => { const base = await mkdtemp(join(tmpdir(), "workspace-migration-test-")); const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces"); const original = await createConversationWorkspace(join(base, ".agent-workspaces")); await writeFile(join(original, "files", "result.json"), '{"value":7}'); let directory = original; let moves = 0; const client = { session: { get: async () => ({ location: { directory } }), move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; }, } }; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter; try { await expect(runtime.ensureSessionWorkspace("legacy-session")).rejects.toThrow("binding unavailable"); const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("legacy-session"), runtime.ensureSessionWorkspace("legacy-session")]); expect(first).toBe(join(config.CONVERSATION_WORKSPACE_ROOT, basename(original))); expect(second).toBe(first); expect(moves).toBe(2); expect(await readFile(join(first, "files", "result.json"), "utf8")).toBe('{"value":7}'); expect(await Bun.file(join(original, "files", "result.json")).exists()).toBe(false); const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter; expect(await restored.ensureSessionWorkspace("legacy-session")).toBe(first); expect(moves).toBe(2); } finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); } }); it("refuses a migration collision without overwriting either workspace", async () => { const base = await mkdtemp(join(tmpdir(), "workspace-collision-test-")); try { const root = join(base, "data", "session-workspaces"); const original = await createConversationWorkspace(join(base, ".agent-workspaces")); const destination = join(root, basename(original)); await mkdir(join(destination, "files"), { recursive: true }); await writeFile(join(original, "files", "result.txt"), "original"); await writeFile(join(destination, "files", "result.txt"), "destination"); await expect(migrateConversationWorkspace(original, root)).rejects.toThrow("already exists"); expect(await readFile(join(original, "files", "result.txt"), "utf8")).toBe("original"); expect(await readFile(join(destination, "files", "result.txt"), "utf8")).toBe("destination"); } finally { await rm(base, { recursive: true, force: true }); } }); it("reads MCP status without migrating a legacy session or creating a workspace", async () => { let location: unknown; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: { get: async () => ({ location: { directory: process.cwd() } }), move: async () => { throw new Error("GET must not move sessions"); } }, rpc: { call: async (input: { location: unknown }) => { location = input.location; return { output: { servers: [], configuration_errors: [] } }; } }, }), ensureSessionWorkspace: async () => { throw new Error("GET must not create workspaces"); }, }) as OpencodeRuntimeAdapter; expect(await runtime.mcpStatus("legacy-session")).toEqual({ servers: [], configuration_errors: [] }); expect(location).toEqual({ directory: process.cwd() }); }); it("coalesces old-session migration and restores its persisted directory", async () => { const previousRoot = config.CONVERSATION_WORKSPACE_ROOT; const root = await mkdtemp(join(tmpdir(), "runtime-workspace-test-")); config.CONVERSATION_WORKSPACE_ROOT = root; let directory = process.cwd(); let moves = 0; const client = { session: { get: async () => ({ id: "old-session", location: { directory } }), move: async (input: { directory: string }) => { moves++; directory = input.directory; }, }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter; try { const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("old-session"), runtime.ensureSessionWorkspace("old-session")]); expect(first).toBe(second); expect(first).not.toBe(process.cwd()); expect(moves).toBe(1); const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter; expect(await restored.ensureSessionWorkspace("old-session")).toBe(first); expect(moves).toBe(1); directory = "/tmp"; await expect(restored.ensureSessionWorkspace("foreign-session")).rejects.toThrow("inside"); } finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true }); } }); }); describe("explicit native Shell approval", () => { it("ties the pending decision to the full command and originating native call", async () => { const events = [ { type: "connected" }, { type: "permission.replied", data: { sessionID: "other", requestID: "perm-test", reply: "once" } }, { type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "once" } }, ]; let created: unknown; const client = { event: { subscribe: () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) }, permission: { create: async (input: unknown) => { created = input; return { id: "perm-test", effect: "ask" }; } }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter; await runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal); expect(created).toMatchObject({ action: "shell", resources: ["printf hello"], save: [], source: { type: "tool", id: "call-test", messageID: "message-test" } }); events[2] = { type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "reject" } }; await expect(runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal)).rejects.toThrow("rejected"); }); });