import { describe, expect, it } from "bun:test"; import { type OpencodeClient } from "@opencode-ai/sdk/v2"; import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { config } from "../../src/config.js"; import { OpencodeRuntimeAdapter } 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.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: ["assistant-2", "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.subscribeEvents", () => { it("subscribes to the conversation workspace directory", async () => { const calls: Array | undefined> = []; const stream = (async function* () { return; })(); const client = { event: { subscribe: async (input?: Record) => { calls.push(input); return { stream }; }, }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client, }) as OpencodeRuntimeAdapter; await expect( runtime.subscribeEvents("/tmp/conversation-workspace-1"), ).resolves.toBe(stream); expect(calls).toEqual([ { directory: "/tmp/conversation-workspace-1" }, ]); }); }); describe("OpencodeRuntimeAdapter interaction replies", () => { it("replies to permissions in the conversation workspace directory", async () => { const calls: unknown[] = []; const client = { permission: { reply: async (input: unknown) => { calls.push(input); return { data: true }; }, }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client, }) as OpencodeRuntimeAdapter; await runtime.replyPermission({ requestId: "permission-1", sessionId: "session-1", directory: "/tmp/conversation-workspace-1", reply: "once", }); expect(calls).toEqual([ { requestID: "permission-1", directory: "/tmp/conversation-workspace-1", reply: "once", }, ]); }); it("fails when OpenCode returns no permission reply data", async () => { const client = { permission: { reply: async () => ({ data: undefined }), }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client, }) as OpencodeRuntimeAdapter; await expect( runtime.replyPermission({ requestId: "permission-1", directory: "/tmp/conversation-workspace-1", reply: "once", }), ).rejects.toThrow("permission.reply returned no data"); }); }); describe("OpencodeRuntimeAdapter.createSession", () => { it("creates a real chat session inside a dedicated conversation workspace", async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-")); const calls: Array> = []; const client = { session: { create: async (input: Record) => { calls.push(input); return { data: { id: "runtime-session-1", directory: input.directory, }, }; }, }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { clientPromise: null, closeServer: null, ensureClient: async () => client, }) as OpencodeRuntimeAdapter; try { const session = await runtime.createSession("chat", { conversationWorkspace: true, workspaceRoot, }); const directory = String(calls[0]?.directory); expect(relative(workspaceRoot, directory).startsWith("..")).toBe(false); const workspaceStat = await stat(directory); expect(workspaceStat.isDirectory()).toBe(true); expect(workspaceStat.mode & 0o777).toBe(0o700); expect(session.directory).toBe(directory); expect(calls[0]?.permission).toEqual([ { permission: "read", pattern: `${directory}/**`, action: "allow" }, { permission: "edit", pattern: `${directory}/**`, action: "ask" }, ]); } finally { await rm(workspaceRoot, { force: true, recursive: true }); } }); it("removes an empty conversation workspace when session creation fails", async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-")); const client = { session: { create: async () => { throw new Error("session creation failed"); }, }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { clientPromise: null, closeServer: null, ensureClient: async () => client, }) as OpencodeRuntimeAdapter; try { await expect( runtime.createSession("chat", { conversationWorkspace: true, workspaceRoot, }), ).rejects.toThrow("session creation failed"); expect(await readdir(workspaceRoot)).toEqual([]); } finally { await rm(workspaceRoot, { force: true, recursive: true }); } }); }); describe("OpencodeRuntimeAdapter.warmup", () => { it("initializes the project session and model tools before reporting ready", async () => { const calls: string[] = []; const client = { global: { health: async () => { calls.push("health"); return { data: { healthy: true, version: "test" } }; }, }, session: { create: async () => { calls.push("session.create"); return { data: { id: "warmup-session" } }; }, delete: async ({ sessionID }: { sessionID: string }) => { calls.push(`session.delete:${sessionID}`); return { data: true }; }, }, tool: { list: async (model: { provider: string; model: string }) => { calls.push(`tool.list:${model.provider}/${model.model}`); return { data: [] }; }, }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { clientPromise: null, closeServer: null, ensureClient: async () => client, }) as OpencodeRuntimeAdapter; await runtime.warmup(); expect(calls).toEqual([ "health", "session.create", `tool.list:${config.OPENCODE_MODEL}`, "session.delete:warmup-session", ]); }); it("submits question answers through the stable question API", async () => { const calls: unknown[] = []; const client = { question: { reply: async (input: unknown) => { calls.push(input); return { data: { ok: true } }; }, }, } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { clientPromise: null, closeServer: null, ensureClient: async () => client, }) as OpencodeRuntimeAdapter; await runtime.replyQuestion({ requestId: "question-1", sessionId: "session-1", directory: "/tmp/conversation-workspace-1", answers: [["继续"]], }); expect(calls).toEqual([ { requestID: "question-1", directory: "/tmp/conversation-workspace-1", answers: [["继续"]], }, ]); }); });