import { EventEmitter } from "node:events"; import { createServer } from "node:http"; import { mkdir, mkdtemp, readFile, rm, stat, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { PassThrough } from "node:stream"; import type { createOpencodeClient } from "@opencode-ai/sdk/v2"; import type spawn from "cross-spawn"; import { describe, expect, it, vi } from "vitest"; import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway"; import type { RuntimeEvent } from "./runtime"; import { OpenCodeRuntime, type OpenCodeRuntimeDependencies, } from "./opencode-runtime"; type SpawnedProcess = ReturnType; function fakeChild(pid = 42): SpawnedProcess { const child = new EventEmitter() as EventEmitter & { stdout: PassThrough; stderr: PassThrough; exitCode: number | null; killed: boolean; pid: number; kill: ReturnType; unref: ReturnType; }; child.stdout = new PassThrough(); child.stderr = new PassThrough(); child.exitCode = null; child.killed = false; child.pid = pid; child.kill = vi.fn(() => { child.killed = true; queueMicrotask(() => { child.exitCode = 0; child.emit("close", 0, null); }); return true; }); child.unref = vi.fn(() => child); return child as unknown as SpawnedProcess; } function fakeClient() { return { session: { list: vi.fn().mockResolvedValue({ data: [], error: undefined }), }, } as unknown as ReturnType; } function stderrOf(child: SpawnedProcess): PassThrough { return child.stderr as PassThrough; } function closeChild(child: SpawnedProcess, code: number): void { (child as unknown as { exitCode: number | null }).exitCode = code; child.emit("close", code, null); } function options( overrides: Partial[0]> = {}, ): ConstructorParameters[0] { return { embedded: true, binaryPath: "", configPath: "", defaultWorkspace: process.cwd(), ...overrides, }; } function dependencies( child: SpawnedProcess, overrides: Partial = {}, ): { deps: Partial; spawnMock: ReturnType; detectBinary: ReturnType; validateBinary: ReturnType; createClient: ReturnType; checkServerHealth: ReturnType; } { const spawnMock = vi.fn(() => child); const detectBinary = vi.fn().mockResolvedValue({ path: "opencode", detail: "OpenCode CLI 已就绪", }); const validateBinary = vi.fn().mockResolvedValue({ path: "opencode", detail: "OpenCode CLI 已就绪", }); const createClient = vi.fn(() => fakeClient()); const checkServerHealth = vi.fn().mockResolvedValue(true); return { deps: { spawn: spawnMock as unknown as typeof spawn, detectBinary, validateBinary, createClient: createClient as unknown as typeof createOpencodeClient, checkServerHealth, platform: "linux", startupTimeoutMs: 100, ...overrides, }, spawnMock, detectBinary, validateBinary, createClient, checkServerHealth, }; } function permissionEvent( overrides: Record = {}, ): Record { return { id: "event-1", type: "permission.asked", properties: { id: "permission-1", sessionID: "session-1", permission: "bash", patterns: ["npm test"], metadata: { command: "npm test" }, always: ["npm test"], tool: { messageID: "message-1", callID: "call-1", }, ...overrides, }, }; } function completedToolEvent( callId = "call-1", tool = "bash", ): Record { return { id: `event-tool-${callId}`, type: "message.part.updated", properties: { sessionID: "session-1", part: { id: `part-${callId}`, callID: callId, type: "tool", tool, state: { status: "completed", input: { command: "npm test", token: "visible-token", }, output: "Tests passed\nAuthorization: Bearer secret-token", }, }, }, }; } function runClient(events: Record[]) { const callOrder: string[] = []; const permissionReply = vi.fn().mockResolvedValue({ data: true, error: undefined, }); const questionReply = vi.fn().mockResolvedValue({ data: true, error: undefined, }); const questionReject = vi.fn().mockResolvedValue({ data: true, error: undefined, }); const client = { session: { get: vi.fn().mockResolvedValue({ data: { id: "unrelated-session" } }), list: vi.fn().mockResolvedValue({ data: [], error: undefined }), create: vi.fn().mockResolvedValue({ data: { id: "session-1" }, error: undefined, }), update: vi.fn().mockResolvedValue({ data: { id: "session-1" }, error: undefined, }), promptAsync: vi.fn().mockImplementation(async () => { callOrder.push("prompt"); return { data: true, error: undefined }; }), abort: vi.fn().mockResolvedValue({ data: true, error: undefined, }), delete: vi.fn().mockResolvedValue({ data: true, error: undefined, }), }, event: { subscribe: vi.fn().mockImplementation(async () => { callOrder.push("subscribe"); return { stream: (async function* () { for (const event of events) { yield event; } })(), }; }), }, permission: { reply: permissionReply, }, question: { reply: questionReply, reject: questionReject, }, mcp: { add: vi.fn().mockImplementation(async (input: { name: string }) => ({ data: { [input.name]: { status: "connected" }, }, error: undefined, })), disconnect: vi.fn().mockResolvedValue({ data: true, error: undefined }), }, tool: { ids: vi.fn().mockResolvedValue({ data: ["read", "write", "bash", "task"], error: undefined, }), }, } as unknown as ReturnType; return { client, callOrder, permissionReply, questionReply, questionReject, session: client.session, event: client.event, tool: client.tool, }; } function embeddedRuntime( client: ReturnType, overrides: Partial[0]> = {}, dependencyOverrides: Partial = {}, ): OpenCodeRuntime { const child = fakeChild(); const { deps } = dependencies(child, { ...dependencyOverrides, createClient: vi.fn(() => client) as unknown as typeof createOpencodeClient, }); return new OpenCodeRuntime(options(overrides), deps); } async function collectRun( runtime: OpenCodeRuntime, workMode: "ask" | "execute" = "execute", ) { const events = []; for await (const event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "test", workMode, }, new AbortController().signal, )) { events.push(event); } return events; } describe("OpenCodeRuntime embedded launcher", () => { it('attributes todos to the current parent message and running call in reused sessions', async () => { const setup = runClient([]); let previousMessage = 'old-message'; const items = [{ content: 'task', status: 'pending', priority: 'low' }]; vi.mocked(setup.event.subscribe).mockImplementation(async () => ({ stream: (async function* () { const current = vi.mocked(setup.session.promptAsync).mock.calls.at(-1)![0]!.messageID!; const update = (parentID: string) => ({ type: 'message.updated', properties: { sessionID: 'session-1', info: { id: parentID + '-assistant', sessionID: 'session-1', role: 'assistant', parentID, time: { created: Date.now() } } } }); const tool = (parentID: string, todos: unknown[], status = 'running', callID = 'call') => ({ type: 'message.part.updated', properties: { sessionID: 'session-1', part: { id: 'part', callID, sessionID: 'session-1', messageID: parentID + '-assistant', type: 'tool', tool: 'todowrite', state: { status, input: { todos } } } } }); const todo = (sessionID: string, todos: unknown[]) => ({ type: 'todo.updated', properties: { sessionID, todos } }); yield update(previousMessage); yield tool(previousMessage, items); yield todo('session-1', items); yield update(current); yield tool(current, [], 'pending'); yield todo('session-1', items); yield tool(current, items); yield todo('child-session', items); yield todo('session-1', [{ content: 'late old task', status: 'pending' }]); yield todo('session-1', items); yield completedToolEvent('call', 'todowrite'); yield tool(current, [], 'running', 'clear'); yield todo('session-1', []); yield completedToolEvent('clear', 'todowrite'); previousMessage = current; yield { type: 'session.idle', properties: { sessionID: 'session-1' } }; })() }) as never); const runtime = embeddedRuntime(setup.client); try { for (let round = 0; round < 2; round++) { const events = await collectRun(runtime); expect(events.filter(event => event.type === 'checklist')).toEqual([ { type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items } }, { type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items: [] } } ]); } } finally { await runtime.dispose(); } }); it.each(["native", "profile"] as const)( "disables unused automatic Git snapshots with %s model configuration", async (configuration) => { const { deps, spawnMock } = dependencies(fakeChild()); const runtime = new OpenCodeRuntime( options({ modelProfile: configuration === "profile" ? { id: "00000000-0000-4000-8000-000000000011", name: "Test model", baseUrl: "https://model.example/v1", modelName: "test-model", protocol: "openai-responses", authentication: "none", supportsImageInput: false, } : undefined, }), deps, ); try { await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, }); const config = JSON.parse( spawnMock.mock.calls[0]?.[2]?.env?.OPENCODE_CONFIG_CONTENT ?? "{}", ); expect(config.snapshot).toBe(false); expect(config.permission["*"]).toBe("allow"); } finally { await runtime.dispose(); } }, ); it("forwards retry scheduling and resumes only on a matching native busy event", async () => { const next = Date.now() + 120_000; const setup = runClient([ { type: "session.status", properties: { sessionID: "another-session", status: { type: "retry", attempt: 9, message: "Unrelated retry", next, } }, }, { type: "session.status", properties: { sessionID: "session-1", status: { type: "retry", attempt: 2, message: "HTTP 503", next, } }, }, { type: "session.status", properties: { sessionID: "another-session", status: { type: "busy" } }, }, { type: "session.status", properties: { sessionID: "session-1", status: { type: "busy" } }, }, { type: "session.status", properties: { sessionID: "session-1", status: { type: "busy" } }, }, { type: "session.idle", properties: { sessionID: "session-1" } }, ]); const runtime = embeddedRuntime(setup.client); try { const events = await collectRun(runtime); const statuses = events.filter((event) => event.type === "status"); expect(statuses.map((event) => event.message)).toEqual([ "OpenCode 正在处理请求", `OpenCode 等待第 2 次重试(计划 ${ new Date(next).toLocaleTimeString("zh-CN", { hour12: false }) }):HTTP 503`, "OpenCode 已开始重试,等待后续进展", ]); expect(events.at(-1)).toMatchObject({ type: "done" }); } finally { await runtime.dispose(); } }); it("checks embedded availability without starting OpenCode", async () => { const child = fakeChild(); const { deps, spawnMock, detectBinary, validateBinary, createClient, checkServerHealth, } = dependencies(child); const runtime = new OpenCodeRuntime(options(), deps); await expect(runtime.getStatus()).resolves.toMatchObject({ available: true, detail: "OpenCode 已配置,将在首次使用时启动", }); expect(detectBinary).toHaveBeenCalledWith("opencode", "", undefined); expect(validateBinary).toHaveBeenCalledWith("opencode", "opencode"); expect(spawnMock).not.toHaveBeenCalled(); expect(createClient).not.toHaveBeenCalled(); expect(checkServerHealth).not.toHaveBeenCalled(); await runtime.dispose(); }); it("reports an unavailable embedded binary without starting OpenCode", async () => { const child = fakeChild(); const { deps, spawnMock, detectBinary, validateBinary, createClient, checkServerHealth, } = dependencies(child); detectBinary.mockResolvedValueOnce({ path: "/missing/opencode", detail: "OpenCode CLI 已就绪", }); validateBinary.mockResolvedValueOnce({ detail: "OpenCode CLI 未找到", }); const runtime = new OpenCodeRuntime( options({ binaryPath: "/missing/opencode", bundledBinaryPath: "/bundled/opencode", }), deps, ); await expect(runtime.getStatus()).resolves.toEqual({ id: "opencode", label: "OpenCode", available: false, supportsToolExecution: true, detail: "OpenCode CLI 未找到", }); expect(spawnMock).not.toHaveBeenCalled(); expect(detectBinary).toHaveBeenCalledWith( "opencode", "/missing/opencode", "/bundled/opencode", ); expect(validateBinary).toHaveBeenCalledWith( "opencode", "/missing/opencode", ); expect(createClient).not.toHaveBeenCalled(); expect(checkServerHealth).not.toHaveBeenCalled(); await runtime.dispose(); }); it("reports a missing embedded model credential without starting OpenCode", async () => { const child = fakeChild(); const { deps, spawnMock, detectBinary, validateBinary, createClient, checkServerHealth, } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { id: "00000000-0000-4000-8000-000000000011", name: "Independent model", baseUrl: "https://model.example", modelName: "private-model", protocol: "anthropic-messages", authentication: "api-key", }, }), deps, ); await expect(runtime.getStatus()).resolves.toEqual({ id: "opencode", label: "OpenCode", available: false, supportsToolExecution: true, detail: "OpenCode 独立模型连接尚未配置 API Key", }); expect(detectBinary).not.toHaveBeenCalled(); expect(validateBinary).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); expect(createClient).not.toHaveBeenCalled(); expect(checkServerHealth).not.toHaveBeenCalled(); await runtime.dispose(); }); it("uses the detected binary and passes an absolute config path only through env", async () => { const serverChild = fakeChild(314); const killerChild = fakeChild(315); const detectBinary = vi.fn().mockResolvedValue({ path: "C:\\Tools\\opencode.exe", detail: "OpenCode CLI 已就绪", }); const createClient = vi.fn(() => fakeClient()); const checkServerHealth = vi.fn().mockResolvedValue(true); const spawnMock = vi.fn((command: string) => { if (command === "taskkill.exe") { queueMicrotask(() => { closeChild(serverChild, 0); }); return killerChild; } return serverChild; }); const configPath = "./private/opencode.json"; const runtime = new OpenCodeRuntime( options({ binaryPath: "C:\\Configured\\opencode.exe", configPath, launchEnvironmentProvider: () => Object.freeze({ PATH: "C:\\GoodBuddy\\tools;C:\\Windows", OPENAI_API_KEY: "must-not-leak", ELECTRON_RUN_AS_NODE: "1", }), }), { spawn: spawnMock as unknown as typeof spawn, detectBinary, createClient: createClient as unknown as typeof createOpencodeClient, checkServerHealth, platform: "win32", }, ); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, detail: "由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程", }); expect(detectBinary).toHaveBeenCalledWith( "opencode", "C:\\Configured\\opencode.exe", undefined, ); expect(spawnMock).toHaveBeenNthCalledWith( 1, "C:\\Tools\\opencode.exe", ["serve", "--hostname=127.0.0.1", expect.stringMatching(/^--port=\d+$/u)], expect.objectContaining({ cwd: process.cwd(), shell: false, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: expect.objectContaining({ OPENCODE_CONFIG: resolve(configPath), }), }), ); const firstSpawn = spawnMock.mock.calls[0] as unknown as [string, string[]] | undefined; const serverPort = firstSpawn?.[1] ?.find((argument) => argument.startsWith("--port=")) ?.slice("--port=".length); expect(serverPort).toMatch(/^\d+$/u); const serverUrl = `http://127.0.0.1:${serverPort}`; const clientOptions = ( createClient.mock.calls as unknown as Array< [ { baseUrl?: string; directory?: string; headers?: Record; }, ] > )[0]?.[0] as | { baseUrl?: string; directory?: string; headers?: Record; } | undefined; expect(clientOptions).toMatchObject({ baseUrl: serverUrl, directory: process.cwd(), headers: { Authorization: expect.stringMatching(/^Basic /u), }, }); expect(checkServerHealth).toHaveBeenCalledWith( serverUrl, clientOptions?.headers?.Authorization, expect.any(AbortSignal), ); const spawnOptions = ( spawnMock.mock.calls as unknown as Array< [string, string[], { env?: NodeJS.ProcessEnv }] > )[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined; expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe("goodbuddy"); expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).toBeTruthy(); expect(spawnOptions?.env?.PATH).toBe("C:\\GoodBuddy\\tools;C:\\Windows"); expect(spawnOptions?.env).not.toHaveProperty("ELECTRON_RUN_AS_NODE"); expect( Buffer.from( clientOptions?.headers?.Authorization?.slice(6) ?? "", "base64", ).toString(), ).toBe(`goodbuddy:${spawnOptions?.env?.OPENCODE_SERVER_PASSWORD}`); expect(runtime.requiresToolApproval).toBe(false); await runtime.dispose(); expect(spawnMock).toHaveBeenNthCalledWith( 2, "taskkill.exe", ["/PID", "314", "/T", "/F"], { shell: false, stdio: "ignore", windowsHide: true, }, ); expect(killerChild.unref).toHaveBeenCalledOnce(); }); it("registers only assigned Skill packages in an isolated config directory", async () => { const sourceRoot = await mkdtemp( join(tmpdir(), "goodbuddy-opencode-skill-source-"), ); const skillDirectory = join(sourceRoot, "longdoc-docx"); await mkdir(join(skillDirectory, "templates"), { recursive: true, }); await writeFile( join(skillDirectory, "SKILL.md"), [ "---", "id: longdoc-docx", "name: 长文档", "description: Build a DOCX", "---", "", "# Long document", ].join("\n"), "utf8", ); await writeFile( join(skillDirectory, "templates", "document.txt"), "template", "utf8", ); const child = fakeChild(); const { deps, spawnMock } = dependencies(child); const runtime = new OpenCodeRuntime( options({ skillPackages: [ { id: "longdoc-docx", directory: skillDirectory, }, ], }), deps, ); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined; const configDirectory = spawnOptions?.env?.OPENCODE_CONFIG_DIR; expect(configDirectory).toBeTruthy(); const registrationRoot = resolve(configDirectory!, ".."); const registeredSkill = join(configDirectory!, "skills", "longdoc-docx"); try { await expect( readFile(join(registeredSkill, "templates", "document.txt"), "utf8"), ).resolves.toBe("template"); const registeredManifest = await readFile( join(registeredSkill, "SKILL.md"), "utf8", ); expect(registeredManifest).toContain("name: longdoc-docx"); expect(registeredManifest).not.toContain("id: longdoc-docx"); const config = JSON.parse( spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}", ) as Record; expect(config).toEqual({ snapshot: false, skills: { paths: [join(configDirectory!, "skills")], urls: [], }, permission: { "*": "allow", skill: { "*": "deny", "longdoc-docx": "allow", }, }, }); expect(spawnOptions?.env).toMatchObject({ OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1", OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", OPENCODE_DISABLE_PROJECT_CONFIG: "1", XDG_CACHE_HOME: join(registrationRoot, "xdg-cache"), XDG_CONFIG_HOME: join(registrationRoot, "xdg-config"), XDG_DATA_HOME: join(registrationRoot, "xdg-data"), XDG_STATE_HOME: join(registrationRoot, "xdg-state"), }); } finally { await runtime.dispose(); await rm(sourceRoot, { recursive: true, force: true }); } await expect(stat(registrationRoot)).rejects.toThrow(); }); it.each(["1.18.29", "0.0.1"])("handles cached plugin %s without ready markers", async (cachedVersion) => { const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-version-")); const bundledConfigPath = join(root, "bundle"); const sharedCacheRoot = join(root, "cache"); const target = join(sharedCacheRoot, "config", "opencode"); const packagePath = join("node_modules", "@opencode-ai", "plugin", "package.json"); for (const [directory, version] of [[bundledConfigPath, "1.18.29"], [target, cachedVersion]]) { await mkdir(join(directory!, "node_modules", "@opencode-ai", "plugin"), { recursive: true }); await writeFile(join(directory!, packagePath), JSON.stringify({ version })); } await writeFile(join(target, "cached-only"), "old cache"); const { deps } = dependencies(fakeChild()); const runtime = new OpenCodeRuntime(options({ bundledConfigPath, sharedCacheRoot }), deps); try { await expect(runtime.testConnection()).resolves.toMatchObject({ available: true }); expect(JSON.parse(await readFile(join(target, packagePath), "utf8")).version).toBe("1.18.29"); if (cachedVersion === "1.18.29") { await expect(readFile(join(target, "cached-only"), "utf8")).resolves.toBe("old cache"); } else { await expect(stat(join(target, "cached-only"))).rejects.toThrow(); } } finally { await runtime.dispose(); await rm(root, { recursive: true, force: true }); } }); it("reuses shared OpenCode config and content-addressed Skills across projects", async () => { const sourceRoot = await mkdtemp( join(tmpdir(), "goodbuddy-opencode-shared-skill-source-"), ); const sharedCacheRoot = await mkdtemp( join(tmpdir(), "goodbuddy-opencode-shared-cache-"), ); const skillDirectory = join(sourceRoot, "shared-skill"); const bundledConfigPath = join(sourceRoot, "bundled-config"); await mkdir(skillDirectory, { recursive: true }); await mkdir( join(bundledConfigPath, "node_modules", "@opencode-ai", "plugin"), { recursive: true }, ); await writeFile( join( bundledConfigPath, "node_modules", "@opencode-ai", "plugin", "package.json", ), '{"name":"@opencode-ai/plugin","version":"1.18.29"}\n', "utf8", ); await writeFile( join(skillDirectory, "SKILL.md"), [ "---", "name: Shared Skill", "description: Shared test Skill", "---", "", "# Shared Skill", ].join("\n"), "utf8", ); const skillPackages = [ { id: "shared-skill", directory: skillDirectory, digest: "shared-skill-digest", }, ]; const firstChild = fakeChild(); const first = dependencies(firstChild); const firstRuntime = new OpenCodeRuntime( options({ sharedCacheRoot, skillPackages, bundledConfigPath, }), first.deps, ); const secondChild = fakeChild(); const second = dependencies(secondChild); const secondRuntime = new OpenCodeRuntime( options({ sharedCacheRoot, skillPackages, bundledConfigPath, }), second.deps, ); try { await expect(firstRuntime.testConnection()).resolves.toMatchObject({ available: true, }); const firstEnvironment = first.spawnMock.mock.calls[0]?.[2]?.env; const firstConfigDirectory = firstEnvironment?.OPENCODE_CONFIG_DIR ?? ""; const firstConfig = JSON.parse( firstEnvironment?.OPENCODE_CONFIG_CONTENT ?? "{}", ) as { skills?: { paths?: string[] } }; const firstSkillsRoot = firstConfig.skills?.paths?.[0] ?? ""; expect(firstConfigDirectory).toBe( join(sharedCacheRoot, "config", "opencode"), ); expect(firstEnvironment?.XDG_CONFIG_HOME).toBe( join(sharedCacheRoot, "config"), ); await expect( readFile( join( firstConfigDirectory, "node_modules", "@opencode-ai", "plugin", "package.json", ), "utf8", ), ).resolves.toContain("1.18.29"); expect(firstEnvironment?.XDG_CACHE_HOME).toBe( join(sharedCacheRoot, "cache"), ); expect(firstEnvironment).toMatchObject({ OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1", }); await firstRuntime.dispose(); await expect( readFile(join(firstSkillsRoot, "shared-skill", "SKILL.md"), "utf8"), ).resolves.toContain("name: shared-skill"); await expect(secondRuntime.testConnection()).resolves.toMatchObject({ available: true, }); const secondEnvironment = second.spawnMock.mock.calls[0]?.[2]?.env; const secondConfig = JSON.parse( secondEnvironment?.OPENCODE_CONFIG_CONTENT ?? "{}", ) as { skills?: { paths?: string[] } }; expect(secondEnvironment?.OPENCODE_CONFIG_DIR).toBe(firstConfigDirectory); expect(secondConfig.skills?.paths?.[0]).toBe(firstSkillsRoot); } finally { await firstRuntime.dispose(); await secondRuntime.dispose(); await rm(sourceRoot, { recursive: true, force: true }); await rm(sharedCacheRoot, { recursive: true, force: true }); } }); it("injects an independent model profile without persisting its key", async () => { const child = fakeChild(); const { deps, spawnMock } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { id: "00000000-0000-4000-8000-000000000011", name: "独立模型", baseUrl: "https://model.example", modelName: "private-model", apiKey: "private-key", protocol: "anthropic-messages", authentication: "api-key", supportsImageInput: true, }, }), deps, ); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined; const config = JSON.parse( spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}", ) as Record; expect(config).toMatchObject({ model: "goodbuddy-anthropic/private-model", provider: { "goodbuddy-anthropic": { npm: "@ai-sdk/anthropic", options: { apiKey: "private-key", baseURL: "https://model.example/v1", }, models: { "private-model": { attachment: true, modalities: { input: ["text", "image"], output: ["text"], }, provider: { npm: "@ai-sdk/anthropic", }, }, }, }, }, }); await runtime.dispose(); }); it("isolates an explicit profile from unrelated inherited credentials", async () => { const child = fakeChild(); const { deps, spawnMock } = dependencies(child); const inheritedCredentials = { ANTHROPIC_API_KEY: "inherited-anthropic", OPENAI_API_KEY: "inherited-openai", GOOGLE_GENERATIVE_AI_API_KEY: "inherited-google", GEMINI_API_KEY: "inherited-gemini", AWS_ACCESS_KEY_ID: "inherited-aws-access", AWS_SECRET_ACCESS_KEY: "inherited-aws-secret", AWS_SESSION_TOKEN: "inherited-aws-session", AWS_PROFILE: "inherited-aws-profile", OPENROUTER_API_KEY: "inherited-openrouter", }; const previousEnvironment = Object.fromEntries( Object.keys(inheritedCredentials).map((name) => [ name, process.env[name], ]), ); Object.assign(process.env, inheritedCredentials); const runtime = new OpenCodeRuntime( options({ modelProfile: { id: "00000000-0000-4000-8000-000000000014", name: "Explicit OpenAI profile", baseUrl: "https://model.example/v1", modelName: "private-model", protocol: "openai-responses", authentication: "api-key", apiKey: "selected-openai-key", }, }), deps, ); try { await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, }); const environment = ( spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined )?.env; expect(environment?.OPENAI_API_KEY).toBe("selected-openai-key"); for (const name of Object.keys(inheritedCredentials)) { if (name !== "OPENAI_API_KEY") { expect(environment).not.toHaveProperty(name); } } } finally { await runtime.dispose(); for (const [name, value] of Object.entries(previousEnvironment)) { if (value === undefined) { delete process.env[name]; } else { process.env[name] = value; } } } }); it.each([ { label: "Chat Completions", protocol: "openai-chat-completions" as const, expectedPath: "/v1/chat/completions", unexpectedPath: "/v1/responses", }, { label: "Responses", protocol: "openai-responses" as const, expectedPath: "/v1/responses", unexpectedPath: "/v1/chat/completions", }, ])( "routes a custom-base $label profile through the bundled OpenCode provider", async ({ protocol, expectedPath, unexpectedPath }) => { const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-routing-")); const requestPaths: string[] = []; const tenantHeaders: Array = []; const requestBodies: Array> = []; const server = createServer(async (request, response) => { requestPaths.push(request.url ?? ""); tenantHeaders.push(request.headers["x-tenant-id"]); let body = ""; for await (const chunk of request) { body += String(chunk); } if (body) { requestBodies.push(JSON.parse(body) as Record); } response.writeHead(400, { "content-type": "application/json", }); response.end( JSON.stringify({ error: { message: "Intentional local routing probe", }, }), ); }); await new Promise((resolveListen, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => resolveListen()); }); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Failed to bind local routing probe"); } const isolatedEnvironment = { APPDATA: join(root, "appdata"), HOME: root, LOCALAPPDATA: join(root, "localappdata"), USERPROFILE: root, } as const; const previousEnvironment = Object.fromEntries( Object.keys(isolatedEnvironment).map((name) => [ name, process.env[name], ]), ); Object.assign(process.env, isolatedEnvironment); const runtime = new OpenCodeRuntime( options({ binaryPath: join( process.cwd(), "node_modules", "opencode-ai", "bin", "opencode.exe", ), defaultWorkspace: root, modelProfile: { id: "00000000-0000-4000-8000-000000000013", name: "Local endpoint probe", baseUrl: `http://127.0.0.1:${address.port}/v1`, modelName: "probe-model", protocol, authentication: "api-key", apiKey: "local-probe-key", requestHeaders: { "x-tenant-id": "opencode-native", }, requestBody: { goodbuddy_unsupported_probe: "must-not-send", }, }, }), { startupTimeoutMs: 20_000 }, ); const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(new Error("Routing probe timed out")), 20_000, ); try { let failure = ""; await (async () => { for await (const _event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "routing-probe", prompt: "Reply with OK", workMode: "execute", }, controller.signal, )) { // The local probe intentionally returns an upstream error. void _event; } })().catch((error) => { failure = error instanceof Error ? error.message : String(error); }); if (requestPaths.length === 0) { throw new Error(`OpenCode routing probe failed: ${failure}`); } expect(requestPaths).toContain(expectedPath); expect(requestPaths).not.toContain(unexpectedPath); expect(tenantHeaders).toContain("opencode-native"); expect(requestBodies).not.toContainEqual( expect.objectContaining({ goodbuddy_unsupported_probe: "must-not-send", }), ); } finally { clearTimeout(timeout); await runtime.dispose(); for (const [name, value] of Object.entries(previousEnvironment)) { if (value === undefined) { delete process.env[name]; } else { process.env[name] = value; } } await new Promise((resolveClose, reject) => { server.close((error) => (error ? reject(error) : resolveClose())); }); await rm(root, { recursive: true, force: true }); } }, 30_000, ); it.each([ { protocol: "openai-chat-completions" as const, authentication: "none" as const, providerId: "goodbuddy-openai-chat", providerPackage: "@ai-sdk/openai-compatible", }, { protocol: "openai-responses" as const, authentication: "api-key" as const, providerId: "goodbuddy-openai-responses", providerPackage: "@ai-sdk/openai", }, ])( "generates an explicit $protocol provider configuration", async ({ protocol, authentication, providerId, providerPackage }) => { const child = fakeChild(); const { deps, spawnMock } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { id: "00000000-0000-4000-8000-000000000012", name: "OpenAI 独立模型", baseUrl: "https://model.example/v1", modelName: "custom-model", protocol, authentication, requestHeaders: { "x-tenant-id": "opencode-tenant", }, requestBody: { temperature: 0.25, }, ...(authentication === "api-key" ? { apiKey: "private-key" } : {}), }, }), deps, ); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined; const config = JSON.parse( spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}", ) as { model?: string; provider?: Record< string, { npm?: string; options?: Record; models?: Record; } >; }; expect(config.model).toBe(`${providerId}/custom-model`); expect(config.provider?.[providerId]).toMatchObject({ npm: providerPackage, options: { baseURL: "https://model.example/v1", headers: { "x-tenant-id": "opencode-tenant", }, }, models: { "custom-model": { provider: { npm: providerPackage, }, }, }, }); if (authentication === "api-key") { expect(config.provider?.[providerId]?.options?.apiKey).toBe( "private-key", ); } else { expect(config.provider?.[providerId]?.options).not.toHaveProperty( "apiKey", ); } expect(JSON.stringify(config)).not.toContain("temperature"); await runtime.dispose(); }, ); it("isolates embedded server configuration from inherited env", async () => { const child = fakeChild(); const { deps, spawnMock } = dependencies(child); const isolatedNames = [ "OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_SERVER_PASSWORD", "OPENCODE_SERVER_USERNAME", ] as const; const inherited = Object.fromEntries( isolatedNames.map((name) => [name, process.env[name]]), ); const inheritedOtel = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; for (const name of isolatedNames) { process.env[name] = "must-not-be-inherited"; } process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://telemetry.invalid"; try { const runtime = new OpenCodeRuntime(options(), deps); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, }); const spawnOptions = spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined; expect(spawnOptions?.env?.OPENCODE_CONFIG).toBeUndefined(); expect(spawnOptions?.env?.OPENCODE_CONFIG_CONTENT).not.toBe( "must-not-be-inherited", ); expect(spawnOptions?.env?.OPENCODE_CONFIG_DIR).not.toBe( "must-not-be-inherited", ); expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe("goodbuddy"); expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).not.toBe( "must-not-be-inherited", ); expect(spawnOptions?.env).toMatchObject({ DO_NOT_TRACK: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1", OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1", OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", OPENCODE_DISABLE_LSP_DOWNLOAD: "1", OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_DISABLE_SHARE: "1", OTEL_EXPORTER_OTLP_ENDPOINT: "", OTEL_EXPORTER_OTLP_HEADERS: "", OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "", OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "", OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "", OTEL_SDK_DISABLED: "true", }); await runtime.dispose(); } finally { for (const name of isolatedNames) { const value = inherited[name]; if (value === undefined) { delete process.env[name]; } else { process.env[name] = value; } } if (inheritedOtel === undefined) { delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; } else { process.env.OTEL_EXPORTER_OTLP_ENDPOINT = inheritedOtel; } } }); it("polls the known loopback URL without waiting for stdout", async () => { const child = fakeChild(); const { deps, checkServerHealth, createClient } = dependencies(child, { startupTimeoutMs: 1_000, }); checkServerHealth.mockResolvedValueOnce(false).mockResolvedValueOnce(true); const runtime = new OpenCodeRuntime(options(), deps); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true, }); expect(checkServerHealth).toHaveBeenCalledTimes(2); const [url, authorization, signal] = checkServerHealth.mock.calls[0] ?? []; expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(authorization).toMatch(/^Basic /); expect(signal).toBeInstanceOf(AbortSignal); expect(createClient).toHaveBeenCalledWith({ baseUrl: url, directory: process.cwd(), headers: { Authorization: authorization }, }); }); it("keeps a healthy cached server and client after idle", async () => { const child = fakeChild(); const { deps, checkServerHealth, createClient, spawnMock } = dependencies(child); const runtime = new OpenCodeRuntime(options(), deps); try { await runtime.testConnection(); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true }); expect(checkServerHealth).toHaveBeenCalledTimes(2); expect(createClient).toHaveBeenCalledOnce(); expect(spawnMock).toHaveBeenCalledOnce(); expect(child.kill).not.toHaveBeenCalled(); } finally { await runtime.dispose(); } }); it("does not replace a shared server while another conversation is running", async () => { const setup = runClient([ { type: "session.idle", properties: { sessionID: "session-1" } }, ]); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); vi.mocked(setup.event.subscribe).mockImplementationOnce(async () => ({ stream: (async function* () { await gate; yield { type: "session.idle", properties: { sessionID: "session-1" } }; })(), }) as never); const child = fakeChild(); const replacement = fakeChild(43); const { deps, checkServerHealth, spawnMock } = dependencies(child, { createClient: () => setup.client, }); spawnMock.mockReturnValueOnce(child).mockReturnValue(replacement); const runtime = new OpenCodeRuntime(options(), deps); const first = collectRun(runtime); try { await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce()); checkServerHealth.mockResolvedValueOnce(false); const peerEvents: RuntimeEvent[] = []; for await (const event of runtime.run( { requestId: "peer", conversationId: "peer-conversation", prompt: "test", workMode: "execute", }, new AbortController().signal, )) peerEvents.push(event); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true }); expect(peerEvents.at(-1)).toMatchObject({ type: "done" }); expect(checkServerHealth).toHaveBeenCalledOnce(); expect(child.kill).not.toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledOnce(); release(); await expect(first).resolves.toContainEqual(expect.objectContaining({ type: "done" })); await expect(runtime.testConnection()).resolves.toMatchObject({ available: true }); expect(child.kill).toHaveBeenCalledOnce(); expect(spawnMock).toHaveBeenCalledTimes(2); } finally { release(); await first.catch(() => undefined); await runtime.dispose(); } }); it.each(["exit", "dispose"])( "settles an active request when its embedded server ends through %s", async (ending) => { const setup = runClient([]); vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({ stream: (async function* () { const signal = options!.signal!; await new Promise((_resolve, reject) => { signal.addEventListener("abort", () => reject(signal.reason), { once: true }); }); yield { type: "session.idle", properties: { sessionID: "session-1" } }; })(), }) as never); const child = fakeChild(); const { deps } = dependencies(child, { createClient: () => setup.client }); const runtime = new OpenCodeRuntime(options(), deps); const controller = new AbortController(); let settled = false; const events: RuntimeEvent[] = []; const result = (async () => { try { for await (const event of runtime.run( { requestId: "active", conversationId: "active", prompt: "test", workMode: "execute" }, controller.signal, )) events.push(event); } catch (error) { return error; } finally { settled = true; } })(); try { await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce()); if (ending === "exit") closeChild(child, 1); else await runtime.dispose(); await vi.waitFor(() => expect(settled).toBe(true), { timeout: 300 }); await expect(result).resolves.toMatchObject({ message: expect.stringMatching(/OpenCode 服务.*(?:退出|关闭)/u), }); expect(controller.signal.aborted).toBe(false); expect(events.some((event) => event.type === "done")).toBe(false); } finally { controller.abort(); await result; await runtime.dispose(); } }, ); it("cancels an event-stream retry delay when its server exits", async () => { const setup = runClient([]); let retrying = false; vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({ stream: (async function* () { retrying = true; await (options as { sseSleepFn: (milliseconds: number) => Promise }) .sseSleepFn(30_000); yield { type: "session.idle", properties: { sessionID: "session-1" } }; })(), }) as never); const child = fakeChild(); const { deps } = dependencies(child, { createClient: () => setup.client }); const runtime = new OpenCodeRuntime(options(), deps); const result = collectRun(runtime).catch((error: unknown) => error); try { await vi.waitFor(() => expect(retrying).toBe(true)); closeChild(child, 1); await expect(result).resolves.toMatchObject({ message: expect.stringContaining("OpenCode 服务已退出"), }); } finally { await runtime.dispose(); } }); it.each(["complete", "exit"])( "protects an active Compact until it ends through %s", async (ending) => { const setup = runClient([ { type: "session.idle", properties: { sessionID: "session-1" } }, ]); let release!: () => void; const summarize = vi.fn((_input, options: { signal: AbortSignal }) => new Promise((resolve, reject) => { release = () => resolve({ data: true }); options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true }); })); Object.assign(setup.client, { v2: { session: { context: vi.fn().mockResolvedValue({ data: { data: [{ type: "assistant", model: { providerID: "test", id: "test" } }] }, }) } }, }); Object.assign(setup.client.session, { summarize }); const child = fakeChild(); const { deps, checkServerHealth } = dependencies(child, { createClient: () => setup.client }); const runtime = new OpenCodeRuntime(options(), deps); try { await collectRun(runtime); const compact = runtime.compactConversation({ requestId: "compact", conversationId: "conversation-1", runtimeSelection: { provider: "opencode" }, history: [], historyMessageIds: [], }, new AbortController().signal).catch((error: unknown) => error); await vi.waitFor(() => expect(summarize).toHaveBeenCalledOnce()); const probes = checkServerHealth.mock.calls.length; await expect(runtime.testConnection()).resolves.toMatchObject({ available: true }); expect(checkServerHealth).toHaveBeenCalledTimes(probes); expect(child.kill).not.toHaveBeenCalled(); if (ending === "exit") { closeChild(child, 1); await expect(compact).resolves.toMatchObject({ message: expect.stringContaining("OpenCode 服务已退出"), }); } else { release(); await expect(compact).resolves.toMatchObject({ result: { compacted: true } }); await runtime.testConnection(); expect(checkServerHealth).toHaveBeenCalledTimes(probes + 1); } } finally { release?.(); await runtime.dispose(); } }, ); it.each(["unhealthy", "rejected", "hung"])( "replaces a %s cached server once for concurrent callers", async (failure) => { const child = fakeChild(); const replacement = fakeChild(43); const { deps, checkServerHealth, createClient, spawnMock } = dependencies(child); spawnMock.mockReturnValueOnce(child).mockReturnValue(replacement); const runtime = new OpenCodeRuntime(options(), deps); try { await runtime.testConnection(); if (failure === "hung") { checkServerHealth.mockImplementationOnce(() => new Promise(() => {})); } else if (failure === "rejected") { checkServerHealth.mockRejectedValueOnce(new Error("fetch failed")); } else { checkServerHealth.mockResolvedValueOnce(false); } const statuses = await Promise.all([ runtime.testConnection(), runtime.testConnection(), ]); expect(statuses.every((status) => status.available)).toBe(true); expect(child.kill).toHaveBeenCalledOnce(); expect(replacement.kill).not.toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledTimes(2); expect(createClient).toHaveBeenCalledTimes(2); expect(checkServerHealth).toHaveBeenCalledTimes(3); } finally { await runtime.dispose(); } }, ); it("creates a new session after replacing a stale server without replaying prompts", async () => { const harness = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" } }, ]); const child = fakeChild(); const { deps, checkServerHealth, spawnMock } = dependencies(child, { createClient: () => harness.client, }); spawnMock.mockReturnValueOnce(child).mockReturnValue(fakeChild(43)); const runtime = new OpenCodeRuntime(options(), deps); try { await collectRun(runtime, "execute"); checkServerHealth.mockResolvedValueOnce(false); await collectRun(runtime, "execute"); expect(harness.session.create).toHaveBeenCalledTimes(2); expect(harness.session.update).not.toHaveBeenCalled(); expect(harness.session.promptAsync).toHaveBeenCalledTimes(2); } finally { await runtime.dispose(); } }); it("isolates cancellation while concurrent requests wait for a cached health check", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" } }, ]); const child = fakeChild(); const { deps, checkServerHealth, spawnMock } = dependencies(child, { createClient: () => setup.client, }); const runtime = new OpenCodeRuntime(options(), deps); let resolveHealth!: (healthy: boolean) => void; const health = new Promise((resolve) => { resolveHealth = resolve; }); const runs: Promise[] = []; try { await runtime.testConnection(); const controller = new AbortController(); checkServerHealth.mockReturnValueOnce(health); const stream = runtime.run( { requestId: "cancelled", conversationId: "conversation-cancelled", prompt: "test" }, controller.signal, ); const cancelled = stream.next().catch((error: unknown) => error); runs.push(cancelled); await vi.waitFor(() => expect(checkServerHealth).toHaveBeenCalledTimes(2)); const waiting = collectRun(runtime); runs.push(waiting); // Let the second request acquire its conversation lock and join the probe. await new Promise((resolve) => setImmediate(resolve)); controller.abort(new Error("cancelled")); await expect(cancelled).resolves.toMatchObject({ message: "cancelled" }); expect(checkServerHealth.mock.calls[1]![2].aborted).toBe(false); // A third caller must still join the pending probe after A exits. const third = runtime.testConnection(); runs.push(third); expect(checkServerHealth).toHaveBeenCalledTimes(2); resolveHealth(true); await expect(waiting).resolves.toContainEqual(expect.objectContaining({ type: "done" })); await expect(third).resolves.toMatchObject({ available: true }); expect(child.kill).not.toHaveBeenCalled(); expect(spawnMock).toHaveBeenCalledOnce(); expect(checkServerHealth).toHaveBeenCalledTimes(2); } finally { resolveHealth(true); await Promise.allSettled(runs); await runtime.dispose(); } }); it("allows ARM-class startup to become healthy after ten seconds", async () => { vi.useFakeTimers(); try { const child = fakeChild(); let healthy = false; const checkServerHealth = vi.fn(async () => healthy); const { deps } = dependencies(child, { checkServerHealth, }); const runtime = new OpenCodeRuntime(options(), deps); let settled = false; const statusPromise = runtime.testConnection().finally(() => { settled = true; }); await vi.advanceTimersByTimeAsync(10_100); expect(settled).toBe(false); healthy = true; await vi.advanceTimersByTimeAsync(100); await expect(statusPromise).resolves.toMatchObject({ available: true, }); expect(checkServerHealth).toHaveBeenCalled(); await runtime.dispose(); } finally { vi.useRealTimers(); } }); it("times out, terminates the process tree, and does not expose stderr", async () => { const child = fakeChild(); const secret = "private-config-token"; const { deps } = dependencies(child, { startupTimeoutMs: 5, checkServerHealth: vi.fn().mockResolvedValue(false), }); stderrOf(child).write(secret); const runtime = new OpenCodeRuntime(options(), deps); const status = await runtime.testConnection(); expect(status).toMatchObject({ available: false, detail: "OpenCode Server 启动超时(30 秒)", }); expect(status.detail).not.toContain(secret); expect(child.kill).toHaveBeenCalledWith("SIGTERM"); }); it("reports early exit without leaking captured stderr", async () => { const child = fakeChild(); const secret = "OPENCODE_CONFIG=/secret/config.json"; let registrationRoot = ""; const { deps } = dependencies(child, { spawn: vi.fn( ( _command: string, _args: string[], spawnOptions: { env?: NodeJS.ProcessEnv }, ) => { registrationRoot = resolve( spawnOptions.env?.OPENCODE_CONFIG_DIR ?? "", "..", ); queueMicrotask(() => { stderrOf(child).write(secret); closeChild(child, 9); }); return child; }, ) as unknown as typeof spawn, }); const runtime = new OpenCodeRuntime(options(), deps); const status = await runtime.testConnection(); expect(status.detail).toBe("OpenCode Server 启动前退出(code 9)"); expect(status.detail).not.toContain(secret); expect(registrationRoot).toBeTruthy(); await expect(stat(registrationRoot)).rejects.toThrow(); }); it("terminates startup when the request is aborted", async () => { const child = fakeChild(); const { deps, spawnMock } = dependencies(child, { checkServerHealth: vi.fn().mockResolvedValue(false), }); const runtime = new OpenCodeRuntime(options(), deps); const controller = new AbortController(); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "test", }, controller.signal, ); const pending = stream.next(); await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()); controller.abort(new Error("sensitive abort reason")); await expect(pending).rejects.toThrow("OpenCode Server 启动已取消"); expect(child.kill).toHaveBeenCalledWith("SIGTERM"); }); it("keeps a shared cold start alive when only its first waiter cancels", async () => { const child = fakeChild(); let healthy = false; const { deps, spawnMock } = dependencies(child, { startupTimeoutMs: 2_000, checkServerHealth: vi.fn(async () => healthy), }); const runtime = new OpenCodeRuntime(options(), deps); const controller = new AbortController(); const pending = runtime.run({ requestId: "cancel-initializer", conversationId: "first", prompt: "test", executionWorkspace: resolve("one"), }, controller.signal).next(); const rejected = expect(pending).rejects.toThrow("启动已取消"); await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()); const peer = runtime.testConnection(); controller.abort(); await rejected; expect(child.kill).not.toHaveBeenCalled(); healthy = true; await expect(peer).resolves.toMatchObject({ available: true }); expect(spawnMock).toHaveBeenCalledOnce(); await runtime.dispose(); }); it("bounds session setup without imposing a full-run deadline", async () => { const harness = runClient([]); ( harness.session.create as unknown as ReturnType ).mockImplementation(() => new Promise(() => undefined)); const runtime = embeddedRuntime( harness.client, {}, { controlRequestTimeoutMs: 5 }, ); await expect(collectRun(runtime)).rejects.toThrow("OpenCode 创建会话超时"); await runtime.dispose(); }); it("keeps external baseUrl mode free of binary detection and spawning", async () => { const child = fakeChild(); const { deps, spawnMock, detectBinary, createClient } = dependencies(child); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), deps, ); await expect(runtime.getStatus()).resolves.toMatchObject({ available: true, detail: "已连接 http://127.0.0.1:4096", }); expect(detectBinary).not.toHaveBeenCalled(); expect(spawnMock).not.toHaveBeenCalled(); expect(createClient).toHaveBeenCalledWith({ baseUrl: "http://127.0.0.1:4096", directory: process.cwd(), }); expect(runtime.requiresToolApproval).toBe(false); }); it.each(["completed", "failed", "cancelled", "consumer-return"])( "closes the event subscription when a run is %s", async (outcome) => { const setup = runClient([]); const controller = new AbortController(); let subscriptionSignal: AbortSignal | undefined; let abortedBeforeIteratorClose: boolean | undefined; const subscribe = setup.event.subscribe as unknown as ReturnType< typeof vi.fn >; subscribe.mockImplementation( async (_input, options: { signal: AbortSignal }) => { subscriptionSignal = options.signal; return { stream: (async function* () { try { if (outcome === "cancelled") { controller.abort(new DOMException("Cancelled", "AbortError")); return; } if (outcome === "failed") return; yield { type: "session.idle", properties: { sessionID: "session-1" }, }; } finally { abortedBeforeIteratorClose = subscriptionSignal?.aborted; } })(), }; }, ); const runtime = new OpenCodeRuntime( options({ embedded: false, baseUrl: "http://127.0.0.1:4096" }), { createClient: (() => setup.client) as typeof createOpencodeClient }, ); const collect = async (): Promise => { for await (const event of runtime.run( { requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(), prompt: "test", workMode: "execute", }, controller.signal, )) { if (outcome === "consumer-return" && event.type === "done") break; } }; try { if (outcome === "failed") { await expect(collect()).rejects.toThrow("事件流意外结束"); } else if (outcome === "cancelled") { await expect(collect()).rejects.toMatchObject({ name: "AbortError" }); } else { await collect(); } expect(subscriptionSignal?.aborted).toBe(true); if (outcome !== "failed") { expect(abortedBeforeIteratorClose).toBe(true); } expect(controller.signal.aborted).toBe(outcome === "cancelled"); } finally { await runtime.dispose(); } }, ); it("serializes external runs that share one conversation session", async () => { const child = fakeChild(); let releaseFirst!: () => void; const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); let subscriptionCount = 0; const promptAsync = vi.fn().mockResolvedValue({ data: true, error: undefined, }); const client = { session: { create: vi.fn().mockResolvedValue({ data: { id: "session-1" }, error: undefined, }), update: vi.fn().mockResolvedValue({ data: { id: "session-1" }, error: undefined, }), promptAsync, abort: vi.fn().mockResolvedValue({ data: true, error: undefined, }), }, event: { subscribe: vi.fn().mockImplementation(async () => { subscriptionCount += 1; const current = subscriptionCount; return { stream: (async function* () { if (current === 1) { await firstGate; } yield { type: "session.idle", properties: { sessionID: "session-1" }, }; })(), }; }), }, tool: { ids: vi.fn().mockResolvedValue({ data: [], error: undefined, }), }, } as unknown as ReturnType; const { deps } = dependencies(child, { createClient: vi.fn( () => client, ) as unknown as typeof createOpencodeClient, }); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), deps, ); const request = { requestId: "00000000-0000-4000-8000-000000000101", conversationId: "shared-conversation", prompt: "first", workMode: "execute" as const, }; const collect = async ( stream: AsyncGenerator, ): Promise => { const events: RuntimeEvent[] = []; for await (const event of stream) { events.push(event); } return events; }; const first = collect(runtime.run(request, new AbortController().signal)); await vi.waitFor(() => expect(promptAsync).toHaveBeenCalledTimes(1)); const second = collect( runtime.run( { ...request, requestId: "00000000-0000-4000-8000-000000000102", prompt: "second", }, new AbortController().signal, ), ); await Promise.resolve(); expect(promptAsync).toHaveBeenCalledTimes(1); releaseFirst(); await first; await second; expect(promptAsync).toHaveBeenCalledTimes(2); await runtime.dispose(); }); it.each(["cancel", "stream-error"])("waits for delayed session abort before reusing a conversation after %s", async (outcome) => { const setup = runClient([]); const controller = new AbortController(); let releaseAbort!: () => void; const abortGate = new Promise((resolve) => { releaseAbort = resolve; }); let endFirst!: () => void; const firstGate = new Promise((resolve) => { endFirst = resolve; }); let secondActive = false; let secondInterrupted = false; let subscriptions = 0; vi.mocked(setup.session.abort).mockImplementation(async () => { await abortGate; if (secondActive) secondInterrupted = true; return { data: true, error: undefined } as never; }); vi.mocked(setup.event.subscribe).mockImplementation(async () => { const first = ++subscriptions === 1; return { stream: (async function* () { if (first) { await firstGate; if (outcome === "stream-error") throw new Error("stream failed"); return; } secondActive = true; await abortGate; // Let every pending abort reach the server before B completes. await new Promise((resolve) => setTimeout(resolve, 0)); if (secondInterrupted) throw new Error("B interrupted by A's late abort"); yield { type: "session.idle", properties: { sessionID: "session-1" } }; secondActive = false; })(), } as never; }); const runtime = embeddedRuntime(setup.client, { embedded: false, baseUrl: "http://127.0.0.1:4096", }); const collect = async (requestId: string, signal: AbortSignal) => { const events: RuntimeEvent[] = []; for await (const event of runtime.run({ requestId, conversationId: "shared-conversation", prompt: requestId, workMode: "execute", }, signal)) events.push(event); return events; }; const first = collect("A", controller.signal).catch((error: unknown) => error); let second: Promise | undefined; try { await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1)); if (outcome === "cancel") controller.abort(new Error("A cancelled")); endFirst(); second = collect("B", new AbortController().signal).catch((error: unknown) => error); await vi.waitFor(() => expect(setup.session.abort).toHaveBeenCalled()); await new Promise((resolve) => setTimeout(resolve, 20)); const promptsBeforeAbort = vi.mocked(setup.session.promptAsync).mock.calls.length; releaseAbort(); expect(await first).toMatchObject({ message: outcome === "cancel" ? "A cancelled" : "stream failed" }); expect(await second).toEqual(expect.arrayContaining([expect.objectContaining({ type: "done" })])); expect(promptsBeforeAbort).toBe(1); expect(setup.session.abort).toHaveBeenCalledOnce(); expect(setup.session.create).toHaveBeenCalledOnce(); expect(setup.session.promptAsync).toHaveBeenCalledTimes(2); } finally { endFirst(); releaseAbort(); await Promise.all([first, second]); await runtime.dispose(); } }); it.each(["never-returns", "rejects", "error", "false"])("replaces the session after abort %s and lets the next request finish", async (outcome) => { const setup = runClient([]); const controller = new AbortController(); let abortSignal: AbortSignal | undefined; let abortedAtStart: boolean | undefined; vi.mocked(setup.session.abort).mockImplementation(async (_input, options) => { abortSignal = options?.signal ?? undefined; abortedAtStart = abortSignal?.aborted; if (outcome === "never-returns") return new Promise(() => undefined); if (outcome === "rejects") throw new Error("abort failed"); return { data: false, error: outcome === "error" ? { message: "abort failed" } : undefined } as never; }); vi.mocked(setup.session.create) .mockResolvedValueOnce({ data: { id: "session-1" } } as never) .mockResolvedValueOnce({ data: { id: "session-2" } } as never); let subscriptions = 0; vi.mocked(setup.event.subscribe).mockImplementation(async () => { const first = ++subscriptions === 1; return { stream: (async function* () { if (first) { controller.abort(new Error("A cancelled")); return; } yield { type: "session.idle", properties: { sessionID: "session-2" } }; })(), } as never; }); const runtime = embeddedRuntime(setup.client, { embedded: false, baseUrl: "http://127.0.0.1:4096", }, { controlRequestTimeoutMs: 20 }); const collect = async (requestId: string, signal: AbortSignal) => { const events: RuntimeEvent[] = []; for await (const event of runtime.run({ requestId, conversationId: "shared-conversation", prompt: requestId, workMode: "execute", }, signal)) events.push(event); return events; }; try { let settled = false; const first = collect("A", controller.signal).catch((error: unknown) => error) .finally(() => { settled = true; }); await vi.waitFor(() => expect(settled).toBe(true)); expect(await first).toMatchObject({ message: "A cancelled" }); expect(abortedAtStart).toBe(false); expect(abortSignal?.aborted).toBe(outcome === "never-returns"); expect(setup.session.abort).toHaveBeenCalledOnce(); await expect(collect("B", new AbortController().signal)).resolves.toEqual( expect.arrayContaining([expect.objectContaining({ type: "done", sessionId: "session-2" })]), ); expect(setup.session.create).toHaveBeenCalledTimes(2); expect(setup.session.promptAsync).toHaveBeenLastCalledWith( expect.objectContaining({ sessionID: "session-2" }), expect.objectContaining({ signal: expect.any(AbortSignal) }), ); } finally { await runtime.dispose(); } }); it("loads assigned Skills before prompting", async () => { const child = fakeChild(); const promptAsync = vi.fn().mockResolvedValue({ error: undefined }); const client = { session: { create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }), promptAsync, abort: vi.fn().mockResolvedValue(undefined), }, event: { subscribe: vi.fn().mockResolvedValue({ stream: (async function* () { yield { type: "session.idle", properties: { sessionID: "session-1" }, }; })(), }), }, tool: { ids: vi.fn().mockResolvedValue({ data: ["read", "write", "goodbuddy-mcp"], error: undefined, }), }, } as unknown as ReturnType; const { deps } = dependencies(child, { createClient: vi.fn( () => client, ) as unknown as typeof createOpencodeClient, }); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, skillInstructions: "# 文档写作", }), deps, ); const events = []; for await (const event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "test", workMode: "execute", images: [ { name: "screenshot.png", mediaType: "image/png", data: "aW1hZ2U=", }, ], }, new AbortController().signal, )) { events.push(event); } expect(promptAsync).toHaveBeenCalledWith( expect.objectContaining({ system: "# 文档写作", parts: [ { type: "text", text: "test" }, { type: "file", mime: "image/png", filename: "screenshot.png", url: "data:image/png;base64,aW1hZ2U=", }, ], }), expect.objectContaining({ signal: expect.any(AbortSignal), }), ); expect(events.at(-1)).toMatchObject({ type: "done" }); await runtime.dispose(); }); it("rejects images when the explicit model connection disables image input", async () => { const child = fakeChild(); const { deps, createClient } = dependencies(child); const runtime = new OpenCodeRuntime( options({ modelProfile: { id: "00000000-0000-4000-8000-000000000011", name: "文本模型", baseUrl: "https://model.example", modelName: "text-model", protocol: "anthropic-messages", authentication: "none", supportsImageInput: false, }, }), deps, ); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "describe", images: [ { name: "screenshot.png", mediaType: "image/png", data: "aW1hZ2U=", }, ], }, new AbortController().signal, ); await expect(stream.next()).rejects.toThrow("当前模型连接未启用图像输入"); expect(createClient).not.toHaveBeenCalled(); }); }); describe("OpenCodeRuntime embedded permission mediation", () => { it("shares assigned custom MCP only with embedded Execute through a scoped loopback token", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const gateway = { getEndpoint: vi.fn(() => "http://127.0.0.1:4567/mcp"), grantCustomMcp: vi.fn(() => "custom-capability"), prepareCustomMcpTools: vi.fn(async () => [ { name: "mcp_12345678_abcdef01_private_tool", inputSchema: { type: "object" }, }, ]), revoke: vi.fn(), } as unknown as KnowledgeMcpGateway; const runtime = embeddedRuntime(setup.client, { knowledgeGateway: gateway, mcpServers: [ { id: "00000000-0000-4000-8000-000000000092", name: "Private MCP", description: "", enabled: true, allowDynamicTools: false, assignments: ["opencode"], secretConfigured: true, secret: "must-stay-in-main", transport: "http", url: "https://private.example/mcp", }, ], }); await collectRun(runtime, "execute"); expect(gateway.grantCustomMcp).toHaveBeenCalledWith( "3f496642-f47d-4e0a-8944-a32c77b0d6ef", expect.any(Array), expect.any(AbortSignal), ); expect(setup.client.mcp.add).toHaveBeenCalledWith( { directory: process.cwd(), name: expect.stringMatching(/^goodbuddy-custom-[a-f0-9]{20}$/u), config: { type: "remote", url: "http://127.0.0.1:4567/mcp", enabled: true, headers: { Authorization: "Bearer custom-capability", }, oauth: false, }, }, { signal: expect.any(AbortSignal) }, ); expect( JSON.stringify( (setup.client.mcp.add as unknown as ReturnType).mock .calls, ), ).not.toContain("must-stay-in-main"); expect( JSON.stringify( (setup.client.mcp.add as unknown as ReturnType).mock .calls, ), ).not.toContain("private.example"); expect(gateway.revoke).toHaveBeenCalledWith("custom-capability"); await runtime.dispose(); }); it.each([["ask", true] as const, ["execute", false] as const])( "does not share custom MCP with OpenCode in %s mode when embedded is %s", async (workMode, embedded) => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const gateway = { getEndpoint: vi.fn(() => "http://127.0.0.1:4567/mcp"), grantCustomMcp: vi.fn(() => "custom-capability"), prepareCustomMcpTools: vi.fn(async () => []), revoke: vi.fn(), } as unknown as KnowledgeMcpGateway; const runtime = embedded ? embeddedRuntime(setup.client, { knowledgeGateway: gateway, mcpServers: [ { id: "00000000-0000-4000-8000-000000000093", name: "Private MCP", description: "", enabled: true, allowDynamicTools: false, assignments: ["opencode"], secretConfigured: false, transport: "stdio", command: "private-command", args: [], }, ], }) : new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, knowledgeGateway: gateway, mcpServers: [ { id: "00000000-0000-4000-8000-000000000093", name: "Private MCP", description: "", enabled: true, allowDynamicTools: false, assignments: ["opencode"], secretConfigured: false, transport: "stdio", command: "private-command", args: [], }, ], }), dependencies(fakeChild(), { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, }).deps, ); await collectRun(runtime, workMode); expect(gateway.grantCustomMcp).not.toHaveBeenCalled(); expect(setup.client.mcp.add).not.toHaveBeenCalled(); await runtime.dispose(); }, ); it.each(["parent", "child"] as const)("parses %s OpenCode questions and sends selected answers back", async (owner) => { const longQuestion = `请选择实现方式\n${"发布说明内容。".repeat(6_000)}`; const setup = runClient([ { id: "question-tool-running", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-question-1", callID: "call-question-1", type: "tool", tool: "question", state: { status: "running", input: { questions: [] }, time: { start: 1 }, }, }, }, }, { id: "question-event", type: "question.asked", properties: { id: "question-1", sessionID: owner === "parent" ? "session-1" : "child-session", questions: [ { header: "实现方式", question: longQuestion, options: [ { label: "直接修改", description: "立即更新现有实现", }, { label: "先写测试", description: "先增加回归测试", }, ], multiple: false, custom: true, }, ], tool: { messageID: "message-1", callID: "call-question-1", }, }, }, { id: "question-tool-completed", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-question-1", callID: "call-question-1", type: "tool", tool: "question", state: { status: "completed", input: { questions: [] }, output: "Answered", time: { start: 1, end: 2 }, }, }, }, }, { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); vi.mocked(setup.client.session.get).mockResolvedValue({ data: { id: "child-session", parentID: "session-1" }, } as never); const runtime = embeddedRuntime(setup.client); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "test", workMode: "execute", }, new AbortController().signal, ); await expect(stream.next()).resolves.toMatchObject({ value: { type: "status" }, }); const questionEvent = await stream.next(); expect(questionEvent.value).toMatchObject({ type: "question", questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u), questions: [ { header: "实现方式", question: longQuestion, multiple: false, custom: true, }, ], }); const questionId = questionEvent.value?.type === "question" ? questionEvent.value.questionId : ""; await runtime.respondToQuestion(questionId, [["先写测试"]]); expect(setup.questionReply).toHaveBeenCalledWith( { requestID: "question-1", directory: process.cwd(), answers: [["先写测试"]], }, { signal: expect.any(AbortSignal) }, ); await expect(stream.next()).resolves.toMatchObject({ value: { type: "done" }, }); await runtime.dispose(); }); it("keeps concurrent child questions independently replyable and attributes only explicit task sessions", async () => { const question = (id: string, sessionID: string) => ({ type: "question.asked", properties: { id, sessionID, questions: [{ header: "Scope", question: `Choose ${id}`, options: [] }] }, }); const setup = runClient([ { type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "task-part", callID: "task-call", type: "tool", tool: "task", state: { status: "running", input: { subagent_type: "general", prompt: "Inspect", description: "Inspect task" }, metadata: { sessionId: "child-session" }, time: { start: 1 } }, } } }, question("first", "child-session"), question("second", "other-child"), question("first", "child-session"), { type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "task-part", callID: "task-call", type: "tool", tool: "task", state: { status: "completed", input: { subagent_type: "general", prompt: "Inspect" }, output: "Finished", time: { start: 1, end: 2 } }, } } }, { type: "session.idle", properties: { sessionID: "session-1" } }, ]); vi.mocked(setup.client.session.get).mockResolvedValue({ data: { parentID: "session-1" } } as never); const runtime = embeddedRuntime(setup.client); const stream = runtime.run({ requestId: crypto.randomUUID(), conversationId: "parallel", prompt: "test", workMode: "execute" }, new AbortController().signal); await stream.next(); const task = (await stream.next()).value; expect(task?.type).toBe("subagent"); const first = (await stream.next()).value; const second = (await stream.next()).value; if (task?.type !== "subagent" || first?.type !== "question" || second?.type !== "question") throw new Error("Missing questions"); expect(first.childTaskId).toBe(task.childTaskId); expect(second.childTaskId).toBeUndefined(); expect(first.questionId).not.toBe(second.questionId); setup.questionReply.mockRejectedValueOnce(new Error("Reply failed")); await expect(runtime.respondToQuestion(first.questionId, [["Answer"]])).rejects.toThrow("Reply failed"); await runtime.respondToQuestion(first.questionId, [["Answer"]]); await runtime.respondToQuestion(second.questionId); expect(setup.questionReply).toHaveBeenLastCalledWith(expect.objectContaining({ requestID: "first", answers: [["Answer"]] }), expect.anything()); expect(setup.questionReject).toHaveBeenCalledWith(expect.objectContaining({ requestID: "second" }), expect.anything()); const remaining = []; for await (const event of stream) remaining.push(event); expect(remaining.some((event) => event.type === "question")).toBe(false); await expect(runtime.respondToQuestion(first.questionId, [["Late"]])).rejects.toThrow(); await runtime.dispose(); }); it("namespaces identical upstream question IDs across concurrent external conversations", async () => { const setup = runClient([]); ( setup.event.subscribe as unknown as ReturnType ).mockImplementation(async () => ({ stream: (async function* () { yield { id: "question-event", type: "question.asked", properties: { id: "shared-question", sessionID: "session-1", questions: [ { header: "Choice", question: "Choose", options: [], multiple: false, custom: true, }, ], }, }; yield { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }; })(), })); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, }).deps, ); const first = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "first", workMode: "execute", }, new AbortController().signal, ); const second = runtime.run( { requestId: "4f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-2", prompt: "second", workMode: "execute", }, new AbortController().signal, ); await Promise.all([first.next(), second.next()]); const [firstQuestion, secondQuestion] = await Promise.all([ first.next(), second.next(), ]); const firstId = firstQuestion.value?.type === "question" ? firstQuestion.value.questionId : ""; const secondId = secondQuestion.value?.type === "question" ? secondQuestion.value.questionId : ""; expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u); expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u); expect(firstId).not.toBe(secondId); await Promise.all([ runtime.respondToQuestion(firstId, [["first answer"]]), runtime.respondToQuestion(secondId, [["second answer"]]), ]); expect(setup.questionReply).toHaveBeenCalledTimes(2); expect(setup.questionReply).toHaveBeenNthCalledWith( 1, { requestID: "shared-question", directory: process.cwd(), answers: [["first answer"]], }, { signal: expect.any(AbortSignal) }, ); expect(setup.questionReply).toHaveBeenNthCalledWith( 2, { requestID: "shared-question", directory: process.cwd(), answers: [["second answer"]], }, { signal: expect.any(AbortSignal) }, ); await Promise.all([first.next(), second.next()]); await runtime.dispose(); }); it("emits native Task calls as subagents instead of generic tools", async () => { const taskInput = { subagent_type: "explorer", description: "Review application architecture", prompt: "Inspect the complete source tree.", }; const setup = runClient([ { id: "task-running", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-task-1", callID: "call-task-1", type: "tool", tool: "task", state: { status: "running", input: taskInput, time: { start: 1 }, }, }, }, }, { id: "task-completed", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-task-1", callID: "call-task-1", type: "tool", tool: "task", state: { status: "completed", input: taskInput, output: "Architecture review complete.", title: "Review application architecture", metadata: {}, time: { start: 1, end: 2 }, }, }, }, }, { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(setup.client); const events = await collectRun(runtime); const subagents = events.filter((event) => event.type === "subagent"); expect(subagents).toHaveLength(2); expect(subagents[0]).toMatchObject({ expertName: "explorer", routingMode: "native", runtimeCallId: "call-task-1", state: "running", reason: "Review application architecture", }); expect(subagents[1]).toMatchObject({ childTaskId: subagents[0]?.type === "subagent" ? subagents[0].childTaskId : undefined, state: "completed", output: "Architecture review complete.", }); expect(events.some((event) => event.type === "tool")).toBe(false); await runtime.dispose(); }); it.each([ [" Custom delegate ", "Custom delegate"], ["", "OpenCode 工具:custom_delegate"], ["x".repeat(300), "x".repeat(240)], ])("keeps generic tool titles bounded: %s", async (title, summary) => { const setup = runClient([ { id: "custom-tool-completed", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-custom-1", callID: "call-custom-1", type: "tool", tool: "custom_delegate", state: { status: "completed", input: { subagent_type: "explorer", prompt: "Inspect the complete source tree.", }, output: "Custom tool result.", title, metadata: {}, time: { start: 1, end: 2 }, }, }, }, }, { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(setup.client); const events = await collectRun(runtime); expect(events).toContainEqual( expect.objectContaining({ type: "tool", callId: "call-custom-1", name: "custom_delegate", state: "completed", summary, }), ); expect(events.some((event) => event.type === "subagent")).toBe(false); await runtime.dispose(); }); it.each(["error", "interrupted"])("retains the running tool title after %s", async (ending) => { const part = { id: "part-read-1", callID: "call-read-1", type: "tool" as const, tool: "read", }; const setup = runClient([ { id: "read-running", type: "message.part.updated", properties: { sessionID: "session-1", part: { ...part, state: { status: "running", input: { filePath: "README.md" }, title: "README.md", time: { start: 1 }, }, }, }, }, ...(ending === "error" ? [{ id: "read-error", type: "message.part.updated" as const, properties: { sessionID: "session-1", part: { ...part, state: { status: "error" as const, input: { filePath: "README.md" }, error: "File not found", time: { start: 1, end: 2 }, }, }, }, }] : []), ]); const runtime = embeddedRuntime(setup.client); const events: RuntimeEvent[] = []; await expect((async () => { for await (const event of runtime.run({ requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "Read README.md", workMode: "execute", }, new AbortController().signal)) { events.push(event); } })()).rejects.toThrow(); expect(events.filter((event) => event.type === "tool")).toEqual([ expect.objectContaining({ state: "running", summary: "README.md" }), expect.objectContaining({ state: "failed", summary: "README.md" }), ]); await runtime.dispose(); }); it("does not impose a default wall-clock deadline on OpenCode runs", async () => { vi.useFakeTimers(); try { let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); const setup = runClient([]); ( setup.event.subscribe as unknown as ReturnType ).mockResolvedValue({ stream: (async function* () { await gate; yield { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }; })(), }); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, }).deps, ); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "work for more than a day", workMode: "execute", }, new AbortController().signal, ); await expect(stream.next()).resolves.toMatchObject({ value: { type: "status" }, }); const pending = stream.next(); await vi.advanceTimersByTimeAsync(25 * 60 * 60_000); expect(setup.session.abort).not.toHaveBeenCalled(); release(); await expect(pending).resolves.toMatchObject({ value: { type: "done" }, }); await runtime.dispose(); } finally { vi.useRealTimers(); } }); it("aborts an OpenCode run at its total execution deadline", async () => { const setup = runClient([]); ( setup.event.subscribe as unknown as ReturnType ).mockImplementation( async (_input: unknown, options: { signal: AbortSignal }) => ({ stream: (async function* () { await new Promise((_resolve, reject) => { options.signal.addEventListener( "abort", () => reject(options.signal.reason), { once: true }, ); }); yield { id: "unreachable", type: "session.idle", properties: { sessionID: "session-1" }, }; })(), }), ); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, executionTimeoutMs: 20, }).deps, ); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "never finish", workMode: "execute", }, new AbortController().signal, ); await expect(stream.next()).resolves.toMatchObject({ value: { type: "status" }, }); await expect(stream.next()).rejects.toThrow( "OpenCode 执行超过 20 毫秒总时限", ); expect(setup.session.abort).toHaveBeenCalled(); await runtime.dispose(); }); it("applies the total deadline while agent discovery is stalled", async () => { const setup = runClient([]); const agents = vi.fn(() => new Promise(() => undefined)); Object.assign(setup.client, { app: { agents }, }); const child = fakeChild(); const runtime = new OpenCodeRuntime( options({ customization: { defaultAgent: "build" }, }), dependencies(child, { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, executionTimeoutMs: 20, }).deps, ); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "never reach the stream", workMode: "execute", }, new AbortController().signal, ); await expect(stream.next()).rejects.toThrow( "OpenCode 执行超过 20 毫秒总时限", ); expect(agents).toHaveBeenCalledWith( { directory: process.cwd() }, { signal: expect.any(AbortSignal) }, ); expect(setup.event.subscribe).not.toHaveBeenCalled(); await runtime.dispose(); }); it("deletes a session created after timeout and does not reuse it", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); let resolveStalledCreation!: (value: { data: { id: string }; error: undefined; }) => void; ( setup.session.create as unknown as ReturnType ).mockImplementationOnce( () => new Promise((resolve) => { resolveStalledCreation = resolve; }), ); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), dependencies(fakeChild(), { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, executionTimeoutMs: 20, }).deps, ); const first = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "stalled creation", workMode: "execute", }, new AbortController().signal, ); await expect(first.next()).rejects.toThrow( "OpenCode 执行超过 20 毫秒总时限", ); resolveStalledCreation({ data: { id: "stale-session" }, error: undefined, }); await vi.waitFor(() => expect(setup.session.delete).toHaveBeenCalledWith( { sessionID: "stale-session", directory: process.cwd(), }, { signal: expect.any(AbortSignal) }, ), ); const secondEvents = await collectRun(runtime); expect(secondEvents.at(-1)).toMatchObject({ type: "done" }); expect(setup.session.create).toHaveBeenCalledTimes(2); expect(setup.session.update).not.toHaveBeenCalled(); await runtime.dispose(); }); it("truncates oversized text display without stopping the run", async () => { const setup = runClient([ { id: "first-text", type: "message.part.delta", properties: { sessionID: "session-1", partID: "part-1", field: "text", delta: "a".repeat(600_000), }, }, { id: "second-text", type: "message.part.delta", properties: { sessionID: "session-1", partID: "part-1", field: "text", delta: "b".repeat(600_000), }, }, { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(setup.client); const events = await collectRun(runtime, "execute"); const text = events .flatMap((event) => (event.type === "text" ? [event.delta] : [])) .join(""); expect(Buffer.byteLength(text)).toBe(1024 * 1024); expect(events).toContainEqual({ requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", type: "status", message: "OpenCode 输出较长,已停止继续展示文本;任务仍在继续", }); expect(events.at(-1)).toMatchObject({ type: "done" }); expect(setup.session.abort).not.toHaveBeenCalled(); await runtime.dispose(); }); it("completes more than 100 distinct tool calls in one run", async () => { const setup = runClient([ ...Array.from({ length: 101 }, (_, index) => completedToolEvent(`call-${index + 1}`), ), { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(setup.client); const events = await collectRun(runtime, "execute"); expect(events.filter((event) => event.type === "tool")).toHaveLength(101); expect(events.at(-1)).toMatchObject({ type: "done" }); await runtime.dispose(); }); it("adds only request-scoped built-in read tools for Ask and disconnects them", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const toolIds = setup.tool.ids as unknown as ReturnType; toolIds .mockResolvedValueOnce({ data: ["read", "write", "bash"], error: undefined, }) .mockResolvedValueOnce({ data: ["read", "write", "bash", "goodbuddy_knowledge_search"], error: undefined, }) .mockResolvedValue({ data: ["read", "write", "bash", "goodbuddy_knowledge_search"], error: undefined, }); const gateway = { getEndpoint: () => "http://127.0.0.1:4567/mcp", getAvailableToolNames: () => ["knowledge_search"], } as unknown as KnowledgeMcpGateway; const child = fakeChild(); const { deps } = dependencies(child, { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, }); const runtime = new OpenCodeRuntime( options({ knowledgeGateway: gateway }), deps, ); const events = []; for await (const event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "search", workMode: "ask", knowledgeCapabilityToken: "secret-capability", }, new AbortController().signal, )) { events.push(event); } expect(setup.client.mcp.add).toHaveBeenCalledWith( { directory: process.cwd(), name: expect.stringMatching(/^goodbuddy-data-[a-f0-9]{20}$/u), config: { type: "remote", url: "http://127.0.0.1:4567/mcp", enabled: true, headers: { Authorization: "Bearer secret-capability", }, oauth: false, }, }, { signal: expect.any(AbortSignal) }, ); const knowledgeMcpName = ( (setup.client.mcp.add as unknown as ReturnType).mock .calls[0]?.[0] as { name: string } ).name; const knowledgeToolId = `${knowledgeMcpName}_knowledge_search`; expect(setup.session.create).toHaveBeenCalledWith( expect.objectContaining({ permission: [ { permission: "*", pattern: "*", action: "deny" }, { permission: knowledgeToolId, pattern: "*", action: "allow", }, ], }), { signal: expect.any(AbortSignal) }, ); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { read: false, write: false, bash: false, "goodbuddy-data-*": false, "goodbuddy-custom-*": false, [knowledgeToolId]: true, }, }), expect.anything(), ); expect(setup.client.mcp.disconnect).toHaveBeenCalledWith( { name: expect.stringMatching(/^goodbuddy-data-/u), directory: process.cwd(), }, { signal: expect.any(AbortSignal) }, ); expect(events.at(-1)).toMatchObject({ type: "done" }); await runtime.dispose(); }); it("enables the deterministic MCP tool name when tool ids omit dynamic tools", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const baseline = { data: ["read", "write", "bash"], error: undefined, }; const toolIds = setup.tool.ids as unknown as ReturnType; toolIds.mockResolvedValue(baseline); const child = fakeChild(); const { deps } = dependencies(child, { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, }); const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { getEndpoint: () => "http://127.0.0.1:4567/mcp", getAvailableToolNames: () => ["knowledge_search"], } as unknown as KnowledgeMcpGateway, }), deps, ); for await (const _event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "search", workMode: "ask", knowledgeCapabilityToken: "secret-capability", }, new AbortController().signal, )) { void _event; } const knowledgeMcpName = ( (setup.client.mcp.add as unknown as ReturnType).mock .calls[0]?.[0] as { name: string } ).name; const knowledgeToolId = `${knowledgeMcpName}_knowledge_search`; expect(toolIds).toHaveBeenCalledTimes(1); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ read: false, write: false, bash: false, [knowledgeToolId]: true, }), }), expect.anything(), ); await runtime.dispose(); }); it("runs embedded conversations concurrently while serializing MCP registrations", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const toolIds = setup.tool.ids as unknown as ReturnType; const baseline = { data: ["read", "write"], error: undefined, }; const withKnowledge = { data: ["read", "write", "goodbuddy_knowledge_search"], error: undefined, }; for (const response of [ baseline, withKnowledge, withKnowledge, baseline, withKnowledge, withKnowledge, ]) { toolIds.mockResolvedValueOnce(response); } let resolveFirstAdd!: () => void; const firstAdd = new Promise((resolve) => { resolveFirstAdd = resolve; }); const mcpAdd = setup.client.mcp.add as unknown as ReturnType; mcpAdd .mockImplementationOnce(async (input: { name: string }) => { await firstAdd; return { data: { [input.name]: { status: "connected" }, }, error: undefined, }; }) .mockImplementation(async (input: { name: string }) => ({ data: { [input.name]: { status: "connected" }, }, error: undefined, })); const child = fakeChild(); const { deps } = dependencies(child, { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, }); const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { getEndpoint: () => "http://127.0.0.1:4567/mcp", getAvailableToolNames: () => ["knowledge_search"], } as unknown as KnowledgeMcpGateway, }), deps, ); const collect = async ( requestId: string, conversationId: string, token: string, ): Promise => { for await (const _event of runtime.run( { requestId, conversationId, prompt: "search", workMode: "ask", knowledgeCapabilityToken: token, }, new AbortController().signal, )) { void _event; } }; const first = collect( "3f496642-f47d-4e0a-8944-a32c77b0d6e1", "conversation-one", "first-token", ); await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(1)); const second = collect( "3f496642-f47d-4e0a-8944-a32c77b0d6e2", "conversation-two", "second-token", ); try { await new Promise((resolve) => setTimeout(resolve, 50)); expect(mcpAdd).toHaveBeenCalledTimes(1); expect(setup.session.promptAsync).not.toHaveBeenCalled(); } finally { resolveFirstAdd(); } await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2)); await Promise.all([first, second]); expect( mcpAdd.mock.calls.map( ([input]) => ( input as { config: { headers: { Authorization: string } }; } ).config.headers.Authorization, ), ).toEqual(["Bearer first-token", "Bearer second-token"]); expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(2); await runtime.dispose(); }); it.each(["knowledge", "custom"])("releases the conversation when a queued %s MCP add is cancelled", async (kind) => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" } }, ]); let releaseRegistration!: () => void; const registration = new Promise((resolve) => { releaseRegistration = resolve; }); const add = setup.client.mcp.add as unknown as ReturnType; add.mockImplementationOnce(async (input: { name: string }) => { await registration; return { data: { [input.name]: { status: "connected" } } }; }); const revoke = vi.fn(); const runtime = embeddedRuntime(setup.client, { knowledgeGateway: { getEndpoint: () => "http://127.0.0.1:4567/mcp", getAvailableToolNames: () => ["knowledge_search"], grantCustomMcp: () => "custom-token", prepareCustomMcpTools: async () => [{ name: "custom_tool" }], revoke, } as unknown as KnowledgeMcpGateway, mcpServers: [{ id: "custom", name: "Custom", description: "", transport: "http", url: "https://example.com/mcp", enabled: true, allowDynamicTools: false, assignments: ["opencode"], secretConfigured: false, }], }); const mutations = vi.spyOn(runtime as unknown as { mutateMcp: (...args: unknown[]) => Promise }, "mutateMcp"); const controller = new AbortController(); const collect = async (requestId: string, conversationId: string, signal: AbortSignal, mcp: boolean) => { const events: RuntimeEvent[] = []; for await (const event of runtime.run({ requestId, conversationId, prompt: "test", workMode: mcp && kind === "custom" ? "execute" : "ask", ...(mcp && kind === "knowledge" ? { knowledgeCapabilityToken: requestId } : {}), }, signal)) events.push(event); return events; }; const runs: Promise[] = []; try { runs.push(collect("first", "conversation-first", new AbortController().signal, true)); await vi.waitFor(() => expect(add).toHaveBeenCalledOnce()); const cancelled = collect("cancelled", "conversation-second", controller.signal, true).catch((error: unknown) => error); runs.push(cancelled); await vi.waitFor(() => expect(mutations).toHaveBeenCalledTimes(2)); controller.abort(new Error("cancelled queued add")); let cancellationFinished = false; void cancelled.then(() => { cancellationFinished = true; }); await vi.waitFor(() => expect(cancellationFinished).toBe(true)); await expect(cancelled).resolves.toMatchObject({ message: "cancelled queued add" }); if (kind === "custom") expect(revoke).toHaveBeenCalledWith("custom-token"); const next = collect("next", "conversation-second", new AbortController().signal, false); runs.push(next); await expect(next).resolves.toContainEqual(expect.objectContaining({ type: "done" })); expect(add).toHaveBeenCalledOnce(); expect(setup.client.mcp.disconnect).not.toHaveBeenCalled(); releaseRegistration(); await Promise.all(runs); expect(setup.client.mcp.disconnect).toHaveBeenCalledExactlyOnceWith( { name: add.mock.calls[0]![0].name, directory: process.cwd() }, { signal: expect.any(AbortSignal) }, ); } finally { releaseRegistration(); await Promise.allSettled(runs); await runtime.dispose(); } }); it("keeps cleanup queued behind blocked registration while sessions run concurrently", async () => { const setup = runClient([]); const releases: (() => void)[] = []; const finished = Array.from({ length: 3 }, () => new Promise((resolve) => releases.push(resolve)), ); let releaseRegistration!: () => void; const registration = new Promise((resolve) => { releaseRegistration = resolve; }); const create = setup.session.create as unknown as ReturnType; let sessionCount = 0; create.mockImplementation(async () => ({ data: { id: `session-${++sessionCount}` }, error: undefined, })); const subscribe = setup.event.subscribe as unknown as ReturnType< typeof vi.fn >; let subscriptionCount = 0; subscribe.mockImplementation(async () => { const index = subscriptionCount++; return { stream: (async function* () { await finished[index]; yield { id: `idle-${index}`, type: "session.idle", properties: { sessionID: `session-${index + 1}` }, }; })(), }; }); const add = setup.client.mcp.add as unknown as ReturnType; add.mockImplementation(async (input: { name: string }) => { if (add.mock.calls.length === 3) await registration; return { data: { [input.name]: { status: "connected" } } }; }); const disconnect = setup.client.mcp.disconnect as unknown as ReturnType< typeof vi.fn >; const runtime = embeddedRuntime(setup.client, { knowledgeGateway: { getEndpoint: () => "http://127.0.0.1:4567/mcp", getAvailableToolNames: () => ["knowledge_search"], } as unknown as KnowledgeMcpGateway, }); const events: RuntimeEvent[] = []; const collect = async (index: number): Promise => { for await (const event of runtime.run( { requestId: `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`, conversationId: `conversation-${index}`, prompt: "search", workMode: "ask", knowledgeCapabilityToken: `token-${index}`, }, new AbortController().signal, )) { events.push(event); } }; const runs: Promise[] = []; try { runs.push(collect(1)); await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1), ); runs.push(collect(2)); await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(2), ); expect(events.filter((event) => event.type === "done")).toHaveLength(0); expect(create).toHaveBeenCalledTimes(2); expect(setup.session.promptAsync).toHaveBeenNthCalledWith( 2, expect.objectContaining({ sessionID: "session-2" }), expect.anything(), ); runs.push(collect(3)); await vi.waitFor(() => expect(add).toHaveBeenCalledTimes(3)); releases[0]!(); await vi.waitFor(() => expect(events.filter((event) => event.type === "done")).toHaveLength(1), ); // Exceed the disconnect budget while another registration owns the queue. await new Promise((resolve) => setTimeout(resolve, 1_100)); expect(disconnect).not.toHaveBeenCalled(); expect(setup.session.promptAsync).toHaveBeenCalledTimes(2); releaseRegistration(); await runs[0]; expect(disconnect).toHaveBeenCalledWith( { name: add.mock.calls[0]![0].name, directory: process.cwd() }, { signal: expect.any(AbortSignal) }, ); expect(disconnect.mock.calls[0]![1].signal.aborted).toBe(false); await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(3), ); releases[1]!(); releases[2]!(); await Promise.all(runs); expect(disconnect.mock.calls.map(([input]) => input.name)).toEqual( add.mock.calls.map(([input]) => input.name), ); expect(events.filter((event) => event.type === "done")).toHaveLength(3); } finally { releaseRegistration(); releases.forEach((release) => release()); await Promise.allSettled(runs); await runtime.dispose(); } }); it("does not send a knowledge capability to external OpenCode", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = new OpenCodeRuntime( options({ embedded: false, baseUrl: "http://127.0.0.1:4096", knowledgeGateway: { getEndpoint: () => "http://127.0.0.1:4567/mcp", getAvailableToolNames: () => ["knowledge_search"], } as unknown as KnowledgeMcpGateway, }), { createClient: vi.fn( () => setup.client, ) as unknown as typeof createOpencodeClient, }, ); for await (const _event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "search", workMode: "ask", knowledgeCapabilityToken: "must-not-leave-main", }, new AbortController().signal, )) { void _event; } expect(setup.client.mcp.add).not.toHaveBeenCalled(); expect( JSON.stringify( (setup.session.promptAsync as unknown as ReturnType).mock .calls, ), ).not.toContain("must-not-leave-main"); await runtime.dispose(); }); it("allows only registered native Skills in read-only modes", async () => { const sourceRoot = await mkdtemp( join(tmpdir(), "goodbuddy-opencode-permission-skill-"), ); const skillDirectory = join(sourceRoot, "longdoc-docx"); await mkdir(skillDirectory); await writeFile( join(skillDirectory, "SKILL.md"), [ "---", "name: longdoc-docx", "description: Build a DOCX", "---", "", "# Long document", ].join("\n"), "utf8", ); const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(setup.client, { skillInstructions: "# Original path: C:\\private\\skills", skillPackages: [ { id: "longdoc-docx", directory: skillDirectory, }, ], }); try { await collectRun(runtime, "ask"); expect(setup.session.create).toHaveBeenCalledWith( { title: "GoodBuddy 对话", directory: process.cwd(), permission: [ { permission: "*", pattern: "*", action: "deny" }, { permission: "skill", pattern: "*", action: "deny" }, { permission: "skill", pattern: "longdoc-docx", action: "allow", }, ], }, { signal: expect.any(AbortSignal) }, ); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ system: undefined, tools: { read: false, write: false, bash: false, task: false, "goodbuddy-data-*": false, "goodbuddy-custom-*": false, skill: true, }, }), expect.anything(), ); } finally { await runtime.dispose(); await rm(sourceRoot, { recursive: true, force: true }); } }); it("configures Execute tools as allowed before prompting", async () => { const { client, callOrder, permissionReply, session } = runClient([ permissionEvent({ sessionID: "unrelated-session" }), permissionEvent(), permissionEvent(), completedToolEvent(), { id: "event-reasoning-part", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-reasoning", sessionID: "session-1", messageID: "message-1", type: "reasoning", text: "", time: { start: 1 }, }, }, }, { id: "event-reasoning", type: "message.part.delta", properties: { sessionID: "session-1", messageID: "message-1", partID: "part-reasoning", field: "text", delta: "reasoning output", }, }, { id: "event-text", type: "message.part.delta", properties: { sessionID: "session-1", messageID: "message-1", partID: "part-1", field: "text", delta: "approved output", }, }, { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); const events = await collectRun(runtime, "execute"); expect(callOrder).toEqual(["subscribe", "prompt"]); expect(session.create).toHaveBeenCalledWith( { title: "GoodBuddy 对话", directory: process.cwd(), permission: [ { permission: "*", pattern: "*", action: "allow" }, { permission: "goodbuddy-data-*", pattern: "*", action: "deny", }, { permission: "goodbuddy-custom-*", pattern: "*", action: "deny", }, ], }, { signal: expect.any(AbortSignal) }, ); expect(session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { "goodbuddy-data-*": false, "goodbuddy-custom-*": false, }, }), expect.anything(), ); expect(permissionReply).toHaveBeenCalledOnce(); expect(permissionReply).toHaveBeenCalledWith( { requestID: "permission-1", directory: process.cwd(), reply: "once", }, { signal: expect.any(AbortSignal) }, ); expect(events).toContainEqual( expect.objectContaining({ type: "reasoning", delta: "reasoning output", }), ); expect(events).toContainEqual( expect.objectContaining({ type: "tool", callId: "call-1", state: "pending", }), ); expect(events).toContainEqual( expect.objectContaining({ type: "tool", callId: "call-1", state: "completed", input: '{\n "command": "npm test",\n "token": "visible-token"\n}', output: "Tests passed\nAuthorization: Bearer secret-token", }), ); expect(events).toContainEqual( expect.objectContaining({ type: "text", delta: "approved output", }), ); expect( events.filter( (event) => event.type === "reasoning" || event.type === "text" || event.type === "tool", ), ).toEqual([ expect.objectContaining({ type: "tool", callId: "call-1", state: "pending", }), expect.objectContaining({ type: "tool", callId: "call-1", state: "completed", }), expect.objectContaining({ type: "reasoning", delta: "reasoning output", }), expect.objectContaining({ type: "text", delta: "approved output", }), ]); expect(events.at(-1)).toMatchObject({ type: "done" }); await runtime.dispose(); }); it("preserves markdown boundaries between native reasoning parts", async () => { const setup = runClient([ { id: "reasoning-part-1", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "reasoning-1", sessionID: "session-1", messageID: "message-1", type: "reasoning", text: "", time: { start: 1 }, }, }, }, { id: "reasoning-delta-1", type: "message.part.delta", properties: { sessionID: "session-1", messageID: "message-1", partID: "reasoning-1", field: "text", delta: "**Evaluating request limits**", }, }, { id: "reasoning-part-2", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "reasoning-2", sessionID: "session-1", messageID: "message-1", type: "reasoning", text: "", time: { start: 2 }, }, }, }, { id: "reasoning-delta-2", type: "message.part.delta", properties: { sessionID: "session-1", messageID: "message-1", partID: "reasoning-2", field: "text", delta: "**Planning limit removals**", }, }, { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(setup.client); const events = await collectRun(runtime); expect( events .filter((event) => event.type === "reasoning") .map((event) => event.type === "reasoning" ? event.delta : ""), ).toEqual([ "**Evaluating request limits**", "\n\n**Planning limit removals**", ]); await runtime.dispose(); }); it("auto-allows bounded fallback permission requests without GoodBuddy approval", async () => { const { client, permissionReply } = runClient([ permissionEvent(), permissionEvent({ id: "permission-2", patterns: ["npm run lint"], metadata: { command: "npm run lint" }, always: ["npm run lint"], tool: { messageID: "message-2", callID: "call-2", }, }), completedToolEvent("call-1"), completedToolEvent("call-2"), { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); await collectRun(runtime, "execute"); expect(permissionReply.mock.calls).toEqual([ [ { requestID: "permission-1", directory: process.cwd(), reply: "once", }, { signal: expect.any(AbortSignal) }, ], [ { requestID: "permission-2", directory: process.cwd(), reply: "once", }, { signal: expect.any(AbortSignal) }, ], ]); await runtime.dispose(); }); it.each(["ask", "execute"] as const)("settles owned descendant directory permissions in %s without touching peer sessions", async (mode) => { const { client, permissionReply } = runClient([ permissionEvent({ id: "peer-permission", sessionID: "peer-session" }), permissionEvent({ id: "child-permission", sessionID: "grandchild-session", permission: "external_directory", patterns: ["/tmp/logs/*"], }), permissionEvent({ id: "child-permission", sessionID: "grandchild-session", permission: "external_directory", patterns: ["/tmp/logs/*"], }), { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => ({ data: { id: sessionID, parentID: sessionID === "grandchild-session" ? "child-session" : sessionID === "child-session" ? "session-1" : undefined, }, })); client.session.get = sessionGet as unknown as typeof client.session.get; const runtime = embeddedRuntime(client); try { const events = await collectRun(runtime, mode); expect(events.at(-1)).toMatchObject({ type: "done" }); expect(events.filter((event) => event.type === "tool")).toEqual([]); expect(permissionReply).toHaveBeenCalledExactlyOnceWith({ requestID: "child-permission", directory: process.cwd(), reply: mode === "execute" ? "once" : "reject", }, { signal: expect.any(AbortSignal) }); expect(sessionGet).toHaveBeenCalledTimes(3); } finally { await runtime.dispose(); } }); it("fails the run when a tool reports an error before session idle", async () => { const { client, session } = runClient([ { id: "event-tool-error", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-1", callID: "call-1", type: "tool", tool: "write", state: { status: "error", error: "write failed Authorization: Bearer secret-token", }, }, }, }, { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "test", workMode: "execute", }, new AbortController().signal, ); await expect(stream.next()).resolves.toMatchObject({ value: { type: "status" }, }); await expect(stream.next()).resolves.toMatchObject({ value: { type: "tool", callId: "call-1", state: "failed", error: "write failed Authorization: Bearer secret-token", }, }); await expect(stream.next()).rejects.toThrow( "write failed Authorization: Bearer secret-token", ); expect(session.abort).toHaveBeenCalledOnce(); await runtime.dispose(); }); it("keeps a completed response when an earlier tool attempt failed", async () => { const { client, session } = runClient([ { id: "event-tool-error", type: "message.part.updated", properties: { sessionID: "session-1", part: { id: "part-1", callID: "call-1", type: "tool", tool: "read", state: { status: "error", error: "Cannot read binary file", }, }, }, }, completedToolEvent("call-2", "write"), { id: "event-text", type: "message.part.delta", properties: { sessionID: "session-1", messageID: "message-1", partID: "part-text", field: "text", delta: "PPT 已生成并保存。", }, }, { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); const events = await collectRun(runtime, "execute"); expect( events.filter( (event) => event.type === "tool" && event.callId === "call-1", ), ).toEqual([ expect.objectContaining({ state: "failed", error: "Cannot read binary file", }), expect.objectContaining({ state: "recoverable", error: "Cannot read binary file", }), ]); expect(events).toContainEqual( expect.objectContaining({ type: "text", delta: "PPT 已生成并保存。", }), ); expect(events.at(-1)).toMatchObject({ type: "done" }); expect(session.abort).not.toHaveBeenCalled(); await runtime.dispose(); }); it("surfaces a rejected async prompt instead of reporting success", async () => { const { client, session } = runClient([ { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); vi.mocked(session.promptAsync).mockResolvedValueOnce({ data: undefined, error: { data: { message: "prompt rejected Authorization: Bearer secret-token", }, }, } as never); const runtime = embeddedRuntime(client); await expect(collectRun(runtime)).rejects.toThrow( "prompt rejected Authorization: Bearer secret-token", ); await runtime.dispose(); }); it("deletes an ephemeral OpenCode session when released", async () => { const { client, session } = runClient([ { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); await collectRun(runtime); await runtime.releaseConversation("conversation-1"); expect(session.delete).toHaveBeenCalledWith( { sessionID: "session-1", directory: process.cwd(), }, { signal: expect.any(AbortSignal) }, ); await runtime.dispose(); }); it("ignores unrelated requests and rejects bounded malformed requests without prompting", async () => { const { client, permissionReply } = runClient([ permissionEvent({ sessionID: "unrelated-session" }), permissionEvent({ patterns: Array.from({ length: 33 }, () => "*"), }), { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); await collectRun(runtime, "execute"); expect(permissionReply).toHaveBeenCalledOnce(); expect(permissionReply).toHaveBeenCalledWith( { requestID: "permission-1", directory: process.cwd(), reply: "reject", }, { signal: expect.any(AbortSignal) }, ); await runtime.dispose(); }); it("fails closed when the OpenCode permission reply fails", async () => { const { client, permissionReply, session } = runClient([permissionEvent()]); permissionReply.mockResolvedValue({ data: false, error: { message: "secret server error" }, }); const runtime = embeddedRuntime(client); await expect(collectRun(runtime, "execute")).rejects.toThrow( "OpenCode 权限回复失败", ); expect(session.abort).toHaveBeenCalledWith( { sessionID: "session-1", directory: process.cwd() }, { signal: expect.any(AbortSignal) }, ); await runtime.dispose(); }); it("uses deny-all session rules and hard tool disable in Ask mode", async () => { const { client, session, tool } = runClient([ { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); await collectRun(runtime, "ask"); expect(session.create).toHaveBeenCalledWith( { title: "GoodBuddy 对话", directory: process.cwd(), permission: [{ permission: "*", pattern: "*", action: "deny" }], }, { signal: expect.any(AbortSignal) }, ); expect(tool.ids).toHaveBeenCalledWith( { directory: process.cwd() }, { signal: expect.any(AbortSignal) }, ); expect(session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { read: false, write: false, bash: false, task: false, "goodbuddy-data-*": false, "goodbuddy-custom-*": false, }, }), expect.anything(), ); await runtime.dispose(); }); it("updates reused sessions when the work mode changes", async () => { const { client, session } = runClient([ { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); await collectRun(runtime, "execute"); await collectRun(runtime, "ask"); expect(session.update).toHaveBeenCalledWith( { sessionID: "session-1", directory: process.cwd(), permission: [{ permission: "*", pattern: "*", action: "deny" }], }, { signal: expect.any(AbortSignal) }, ); await runtime.dispose(); }); it("configures external Execute sessions without whole-run approval", async () => { const { client, session, permissionReply } = runClient([ permissionEvent(), { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), { createClient: vi.fn( () => client, ) as unknown as typeof createOpencodeClient, }, ); await collectRun(runtime, "execute"); expect(runtime.requiresToolApproval).toBe(false); expect(session.create).toHaveBeenCalledWith( { title: "GoodBuddy 对话", directory: process.cwd(), permission: [{ permission: "*", pattern: "*", action: "allow" }], }, { signal: expect.any(AbortSignal) }, ); expect(permissionReply).not.toHaveBeenCalled(); await runtime.dispose(); }); }); describe("OpenCodeRuntime native customization", () => { it("maps bounded native inventory and filters GoodBuddy-owned capabilities", async () => { const sourceRoot = await mkdtemp( join(tmpdir(), "goodbuddy-opencode-native-snapshot-"), ); const assignedSkillDirectory = join(sourceRoot, "assigned-skill"); await mkdir(assignedSkillDirectory); await writeFile( join(assignedSkillDirectory, "SKILL.md"), [ "---", "name: assigned-skill", "description: Assigned test skill", "---", "", "# Assigned skill", ].join("\n"), "utf8", ); const client = { session: { list: vi.fn().mockResolvedValue({ data: [], error: undefined, }), }, app: { agents: vi.fn().mockResolvedValue({ data: [ { name: "build", description: "Primary builder", mode: "primary", native: true, hidden: false, permission: [], options: {}, }, { name: "hidden", mode: "all", hidden: true, permission: [], options: {}, }, ], }), skills: vi.fn().mockResolvedValue({ data: [ { name: "native-skill", description: "Native skill", location: "C:\\private\\native", content: "must not be exposed", }, { name: "assigned-skill", location: "C:\\private\\assigned", content: "assigned content", }, ], }), }, tool: { ids: vi.fn().mockResolvedValue({ data: [ "apply_patch", "bash", "edit", "glob", "grep", "invalid", "question", "read", "skill", "task", "todowrite", "webfetch", "websearch", "write", "goodbuddy-data-123_search", "extension_tool", ], error: undefined, }), }, command: { list: vi.fn().mockResolvedValue({ data: [ { name: "review", description: "Review changes", source: "command", template: "private command template", hints: [], }, { name: "mcp-prompt", description: "Prompt from MCP", source: "mcp", template: "Inspect $ARGUMENTS", hints: [], }, { name: "assigned-skill", source: "skill", template: "assigned skill template", hints: [], }, { name: "goodbuddy-data-123", source: "mcp", template: "temporary prompt", hints: [], }, ], }), }, lsp: { status: vi.fn().mockResolvedValue({ data: [ { id: "typescript", name: "TypeScript", root: "C:\\private\\workspace", status: "connected", }, ], }), }, formatter: { status: vi.fn().mockResolvedValue({ data: [ { name: "prettier", enabled: true, extensions: [".ts", ".tsx"], }, ], }), }, mcp: { status: vi.fn().mockResolvedValue({ data: { public: { status: "failed", error: "private failure" }, "goodbuddy-custom-123": { status: "connected" }, }, }), }, experimental: { resource: { list: vi.fn().mockResolvedValue({ data: { "public-resource": { name: "Public resource", uri: "docs://public", description: "Reference", mimeType: "text/plain", client: "public", }, temporary: { name: "Temporary resource", uri: "docs://temporary", client: "goodbuddy-data-123", }, }, }), }, }, } as unknown as ReturnType; const runtime = embeddedRuntime(client, { skillPackages: [ { id: "assigned-skill", directory: assignedSkillDirectory, }, ], }); const snapshot = await runtime.getNativeSnapshot(); expect(snapshot).toMatchObject({ available: true, inventoryStatus: "available", detail: "OpenCode 原生能力已就绪", agents: [ { id: "build", mode: "primary", native: true, hidden: false, }, { id: "hidden", hidden: true, }, ], toolsSupported: true, tools: expect.arrayContaining([ { id: "edit", name: "edit", kind: "write", source: "runtime", ask: "blocked", execute: "allowed", }, { id: "read", name: "read", kind: "read", source: "runtime", ask: "blocked", execute: "allowed", }, { id: "skill", name: "skill", kind: "agent", source: "runtime", ask: "conditional", execute: "allowed", }, { id: "extension_tool", name: "extension_tool", kind: "other", source: "unknown", ask: "blocked", execute: "allowed", }, ]), commands: [ { id: "review", source: "command", }, { id: "mcp-prompt", source: "mcp", }, ], prompts: [ { id: "mcp-prompt", prompt: "Inspect $ARGUMENTS", source: "mcp", }, ], lsp: [ { id: "typescript", name: "TypeScript", status: "connected", }, ], formatters: [ { id: "prettier", enabled: true, extensions: [".ts", ".tsx"], }, ], mcpServers: [ { id: "public", status: "failed", }, ], skills: [ { id: "native-skill", description: "Native skill", }, ], resources: [ { id: "public-resource", uri: "docs://public", server: "public", }, ], resourcesSupported: true, context: { strategy: "native", manualCompact: true, }, }); const serialized = JSON.stringify(snapshot); expect(serialized).not.toContain("private failure"); expect(serialized).not.toContain("private command template"); expect(serialized).not.toContain("must not be exposed"); expect(serialized).not.toContain("C:\\private"); expect(serialized).not.toContain("assigned-skill"); expect(serialized).not.toContain("goodbuddy-data-"); expect(serialized).not.toContain("goodbuddy-custom-"); expect(serialized).not.toContain('"invalid"'); vi.mocked(client.tool.ids).mockRejectedValueOnce( new Error("tool inventory unavailable"), ); const partialSnapshot = await runtime.getNativeSnapshot(); expect(partialSnapshot).toMatchObject({ available: true, inventoryStatus: "partial", tools: [], toolsSupported: false, }); expect(partialSnapshot.detail).toContain("工具"); await runtime.dispose(); await rm(sourceRoot, { recursive: true, force: true }); }); it("reports external OpenCode connectivity without claiming readable native inventory", async () => { const client = runClient([]).client; const runtime = new OpenCodeRuntime( options({ baseUrl: "http://127.0.0.1:4096", embedded: false, }), { createClient: vi.fn( () => client, ) as unknown as typeof createOpencodeClient, }, ); await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({ available: true, inventoryStatus: "connection-only", tools: [], toolsSupported: false, }); expect(client.tool.ids).not.toHaveBeenCalled(); await runtime.dispose(); }); it("uses an explicit valid agent over the configured default", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { name: "build", mode: "primary", hidden: false, permission: [], options: {}, }, { name: "plan", mode: "all", hidden: false, permission: [], options: {}, }, ], }), }, }); const runtime = embeddedRuntime(setup.client, { customization: { defaultAgent: "build" }, }); for await (const _event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "test", workMode: "execute", runtimeControl: { provider: "opencode", agent: "plan", }, }, new AbortController().signal, )) { void _event; } expect(setup.session.create).toHaveBeenCalledWith( expect.objectContaining({ agent: "plan" }), { signal: expect.any(AbortSignal) }, ); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ agent: "plan" }), expect.anything(), ); await runtime.dispose(); }); it("uses the configured default agent when no request override is present", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { name: "build", mode: "primary", hidden: false, permission: [], options: {}, }, ], }), }, }); const runtime = embeddedRuntime(setup.client, { customization: { defaultAgent: "build" }, }); await collectRun(runtime); expect(setup.session.create).toHaveBeenCalledWith( expect.objectContaining({ agent: "build" }), { signal: expect.any(AbortSignal) }, ); expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ agent: "build" }), expect.anything(), ); await runtime.dispose(); }); it("rejects a stale or hidden agent instead of falling back", async () => { const setup = runClient([]); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { name: "hidden", mode: "primary", hidden: true, permission: [], options: {}, }, ], }), }, }); const runtime = embeddedRuntime(setup.client); const stream = runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "test", workMode: "execute", runtimeControl: { provider: "opencode", agent: "hidden", }, }, new AbortController().signal, ); await expect(stream.next()).rejects.toThrow( "OpenCode Agent 不存在、已隐藏或不可作为主 Agent:hidden", ); expect(setup.session.create).not.toHaveBeenCalled(); expect(setup.session.promptAsync).not.toHaveBeenCalled(); await runtime.dispose(); }); it("executes validated native commands through the command API", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const command = vi.fn().mockResolvedValue({ data: { info: {}, parts: [] }, }); Object.assign(setup.client, { app: { agents: vi.fn().mockResolvedValue({ data: [ { name: "build", mode: "primary", hidden: false, permission: [], options: {}, }, ], }), }, command: { list: vi.fn().mockResolvedValue({ data: [ { name: "review", source: "command", template: "Review $ARGUMENTS", hints: [], }, ], }), }, }); Object.assign(setup.client.session, { command }); const runtime = embeddedRuntime(setup.client); const events = []; for await (const event of runtime.run( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", prompt: "must not become slash text", workMode: "execute", runtimeControl: { provider: "opencode", agent: "build", command: { name: "review", arguments: "--staged", }, }, }, new AbortController().signal, )) { events.push(event); } expect(command).toHaveBeenCalledWith( { sessionID: "session-1", directory: process.cwd(), command: "review", arguments: "--staged", agent: "build", messageID: expect.stringMatching(/^msg_/u), }, expect.objectContaining({ signal: expect.any(AbortSignal), }), ); expect(setup.session.promptAsync).not.toHaveBeenCalled(); expect(events.at(-1)).toMatchObject({ type: "done", sessionId: "session-1", }); await runtime.dispose(); }); it("compacts a managed session through the supported native API", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const context = vi.fn().mockResolvedValue({ data: { data: [ { type: "assistant", model: { providerID: "anthropic", id: "claude-sonnet", }, }, ], }, }); const summarize = vi.fn().mockResolvedValue({ data: true, error: undefined, }); Object.assign(setup.client, { v2: { session: { context }, }, session: { ...setup.client.session, summarize }, }); const runtime = embeddedRuntime(setup.client); await collectRun(runtime); vi.mocked(setup.event.subscribe).mockResolvedValueOnce({ stream: (async function* () { yield { type: "message.updated", properties: { sessionID: "session-1", info: { id: "compaction-message", sessionID: "session-1", role: "assistant", time: { created: 1, completed: 2, }, parentID: "compaction-parent", modelID: "claude-sonnet", providerID: "anthropic", mode: "compaction", agent: "build", path: { cwd: process.cwd(), root: process.cwd(), }, cost: 0, tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 30, write: 4, }, total: 124, }, finish: "stop", }, }, }; yield { type: "session.idle", properties: { sessionID: "session-1" }, }; })(), } as never); const signal = new AbortController().signal; await expect( runtime.compactConversation( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", runtimeSelection: { provider: "opencode" }, history: [], historyMessageIds: [], }, signal, ), ).resolves.toEqual({ result: { provider: "opencode", strategy: "native", compacted: true, detail: "OpenCode 已完成原生上下文压缩", }, usageEvents: [ { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", type: "model-usage", callId: "compaction-message", runtime: "opencode", provider: "anthropic", model: "claude-sonnet", inputTokens: 100, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 4, reportedTotalTokens: 124, }, ], }); expect(context).toHaveBeenCalledWith( { sessionID: "session-1" }, { signal: expect.any(AbortSignal) }, ); expect(summarize).toHaveBeenCalledWith( { sessionID: "session-1", directory: process.cwd(), providerID: "anthropic", modelID: "claude-sonnet", auto: false, }, { signal: expect.any(AbortSignal) }, ); await runtime.dispose(); }); it("reports when a managed session has no model to compact with", async () => { const setup = runClient([ { id: "idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const context = vi.fn().mockResolvedValue({ data: { data: [] }, }); const summarize = vi.fn(); Object.assign(setup.client, { v2: { session: { context }, }, session: { ...setup.client.session, summarize }, }); const runtime = embeddedRuntime(setup.client); await collectRun(runtime); await expect( runtime.compactConversation( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "conversation-1", runtimeSelection: { provider: "opencode" }, history: [], historyMessageIds: [], }, new AbortController().signal, ), ).resolves.toEqual({ result: { provider: "opencode", strategy: "native", compacted: false, detail: "当前 OpenCode 会话尚无可用于压缩的模型记录", }, }); expect(summarize).not.toHaveBeenCalled(); await runtime.dispose(); }); it("reports when no managed OpenCode session can be compacted", async () => { const runtime = new OpenCodeRuntime(options()); await expect( runtime.compactConversation( { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", conversationId: "missing-conversation", runtimeSelection: { provider: "opencode" }, history: [], historyMessageIds: [], }, new AbortController().signal, ), ).resolves.toEqual({ result: { provider: "opencode", strategy: "native", compacted: false, detail: "当前 GoodBuddy 对话尚无可压缩的 OpenCode 会话", }, }); await runtime.dispose(); }); }); describe("OpenCodeRuntime model usage", () => { it("emits one provider-reported usage event for each terminal assistant message", async () => { const assistantMessage = { id: "message-assistant-1", sessionID: "session-1", role: "assistant", time: { created: 1, completed: 2, }, parentID: "message-user-1", modelID: "claude-sonnet-provider", providerID: "anthropic", mode: "build", agent: "build", path: { cwd: process.cwd(), root: process.cwd(), }, cost: 0.01, tokens: { total: 42, input: 23, output: 11, reasoning: 3, cache: { read: 7, write: 5, }, }, }; const { client } = runClient([ { id: "event-incomplete", type: "message.updated", properties: { sessionID: "session-1", info: { ...assistantMessage, time: { created: 1 }, }, }, }, { id: "event-terminal", type: "message.updated", properties: { sessionID: "session-1", info: assistantMessage, }, }, { id: "event-terminal-duplicate", type: "message.updated", properties: { sessionID: "session-1", info: assistantMessage, }, }, { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); const events = await collectRun(runtime); expect(events.filter((event) => event.type === "model-usage")).toEqual([ { requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef", type: "model-usage", callId: "message-assistant-1", runtime: "opencode", provider: "anthropic", model: "claude-sonnet-provider", inputTokens: 23, outputTokens: 11, cacheReadTokens: 7, cacheWriteTokens: 5, reportedTotalTokens: 42, }, ]); expect(events.at(-2)).toMatchObject({ type: "model-usage" }); expect(events.at(-1)).toMatchObject({ type: "done" }); await runtime.dispose(); }); it("ignores assistant usage from another session", async () => { const { client } = runClient([ { id: "event-unrelated-usage", type: "message.updated", properties: { sessionID: "session-2", info: { id: "message-assistant-2", sessionID: "session-2", role: "assistant", time: { created: 1, completed: 2, }, parentID: "message-user-2", modelID: "unrelated-model", providerID: "unrelated-provider", mode: "build", agent: "build", path: { cwd: process.cwd(), root: process.cwd(), }, cost: 0, tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0, }, }, }, }, }, { id: "event-idle", type: "session.idle", properties: { sessionID: "session-1" }, }, ]); const runtime = embeddedRuntime(client); const events = await collectRun(runtime); expect(events.filter((event) => event.type === "model-usage")).toEqual([]); await runtime.dispose(); }); });