import { EventEmitter } from 'node:events' import { createServer } from 'node:http' import { mkdtemp, rm } 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 { 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 stdoutOf(child: SpawnedProcess): PassThrough { return child.stdout as PassThrough } 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 createClient: ReturnType } { const spawnMock = vi.fn(() => child) const detectBinary = vi.fn().mockResolvedValue({ path: 'opencode', detail: 'OpenCode CLI 已就绪' }) const createClient = vi.fn(() => fakeClient()) return { deps: { spawn: spawnMock as unknown as typeof spawn, detectBinary, createClient: createClient as unknown as typeof createOpencodeClient, platform: 'linux', startupTimeoutMs: 100, ...overrides }, spawnMock, detectBinary, createClient } } 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: { 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 ): OpenCodeRuntime { const child = fakeChild() const { deps } = dependencies(child, { createClient: vi.fn( () => client ) as unknown as typeof createOpencodeClient }) setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:4010\n' ) }, 0) return new OpenCodeRuntime(options(), deps) } async function collectRun( runtime: OpenCodeRuntime, workMode: 'ask' | 'plan' | '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('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 spawnMock = vi.fn((command: string) => { if (command === 'taskkill.exe') { queueMicrotask(() => { closeChild(serverChild, 0) }) return killerChild } setTimeout(() => { stdoutOf(serverChild).write( 'opencode server listening securely on http://127.0.0.1:43210\n' ) }, 0) return serverChild }) const configPath = './private/opencode.json' const runtime = new OpenCodeRuntime( options({ binaryPath: 'C:\\Configured\\opencode.exe', configPath }), { spawn: spawnMock as unknown as typeof spawn, detectBinary, createClient: createClient as unknown as typeof createOpencodeClient, platform: 'win32' } ) await expect(runtime.getStatus()).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 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: 'http://127.0.0.1:43210', directory: process.cwd(), headers: { Authorization: expect.stringMatching(/^Basic /u) } }) 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( 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('injects an independent model profile without persisting its key', async () => { const child = fakeChild() const { deps, spawnMock } = dependencies(child) setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:3011\n' ) }, 0) 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' } }), deps ) await expect(runtime.getStatus()).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': { 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) setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:3013\n' ) }, 0) 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.getStatus()).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 server = createServer((request, response) => { requestPaths.push(request.url ?? '') request.resume() 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', process.platform === 'win32' ? 'opencode.exe' : 'opencode' ), 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' } }) ) 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) } 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) setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:3012\n' ) }, 0) const runtime = new OpenCodeRuntime( options({ modelProfile: { id: '00000000-0000-4000-8000-000000000012', name: 'OpenAI 独立模型', baseUrl: 'https://model.example/v1', modelName: 'custom-model', protocol, authentication, ...(authentication === 'api-key' ? { apiKey: 'private-key' } : {}) } }), deps ) await expect(runtime.getStatus()).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< string, { provider?: { npm?: string } } > } > } expect(config.model).toBe(`${providerId}/custom-model`) expect(config.provider?.[providerId]).toMatchObject({ npm: providerPackage, options: { baseURL: 'https://model.example/v1' }, 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') } 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_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 { setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:3010\n' ) }, 0) const runtime = new OpenCodeRuntime(options(), deps) await expect(runtime.getStatus()).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).toBeUndefined() 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_EMBEDDED_WEB_UI: '1', OPENCODE_DISABLE_LSP_DOWNLOAD: '1', OPENCODE_DISABLE_MODELS_FETCH: '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.each([ 'https://127.0.0.1:4321', 'http://0.0.0.0:4321', 'http://example.com:4321', 'http://127.0.0.1', 'http://127.0.0.1:4321/admin' ])('rejects an unsafe listening URL: %s', async (url) => { const child = fakeChild() const { deps, createClient } = dependencies(child) setTimeout(() => { stdoutOf(child).write(`opencode server listening on ${url}\n`) closeChild(child, 7) }, 0) const runtime = new OpenCodeRuntime(options(), deps) await expect(runtime.getStatus()).resolves.toMatchObject({ available: false, detail: 'OpenCode Server 启动前退出(code 7)' }) expect(createClient).not.toHaveBeenCalled() }) 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 }) stderrOf(child).write(secret) const runtime = new OpenCodeRuntime(options(), deps) const status = await runtime.getStatus() expect(status).toMatchObject({ available: false, detail: 'OpenCode Server 启动超时(10 秒)' }) 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' const { deps } = dependencies(child) setTimeout(() => { stderrOf(child).write(secret) closeChild(child, 9) }, 0) const runtime = new OpenCodeRuntime(options(), deps) const status = await runtime.getStatus() expect(status.detail).toBe('OpenCode Server 启动前退出(code 9)') expect(status.detail).not.toContain(secret) }) it('terminates startup when the request is aborted', async () => { const child = fakeChild() const { deps, spawnMock } = dependencies(child) 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 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('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' }, new AbortController().signal )) { events.push(event) } expect(promptAsync).toHaveBeenCalledWith( expect.objectContaining({ system: '# 文档写作', parts: [{ type: 'text', text: 'test' }] }), expect.objectContaining({ signal: expect.any(AbortSignal) }) ) expect(events.at(-1)).toMatchObject({ type: 'done' }) await runtime.dispose() }) }) describe('OpenCodeRuntime embedded permission mediation', () => { it('parses OpenCode questions and sends the selected answers back', async () => { const setup = runClient([ { id: 'question-event', type: 'question.asked', properties: { id: 'question-1', sessionID: 'session-1', questions: [ { header: '实现方式', question: '请选择实现方式', options: [ { label: '直接修改', description: '立即更新现有实现' }, { label: '先写测试', description: '先增加回归测试' } ], multiple: false, custom: true } ], tool: { messageID: 'message-1', callID: 'call-question-1' } } }, { id: 'idle', type: 'session.idle', properties: { sessionID: 'session-1' } } ]) 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' } }) await expect(stream.next()).resolves.toMatchObject({ value: { type: 'question', questionId: 'question-1', questions: [ { header: '实现方式', question: '请选择实现方式', multiple: false, custom: true } ] } }) await runtime.respondToQuestion('question-1', [['先写测试']]) expect(setup.questionReply).toHaveBeenCalledWith({ requestID: 'question-1', directory: process.cwd(), answers: [['先写测试']] }) await expect(stream.next()).resolves.toMatchObject({ value: { type: 'done' } }) await runtime.dispose() }) it('adds only the request-scoped knowledge MCP tool for Ask and disconnects it', 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' } as unknown as KnowledgeMcpGateway const child = fakeChild() const { deps } = dependencies(child, { createClient: vi.fn( () => setup.client ) as unknown as typeof createOpencodeClient }) setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:4010\n' ) }, 0) 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-knowledge-[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 } }) 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' } ] }) ) expect(setup.session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { read: false, write: false, bash: false, [knowledgeToolId]: true } }), expect.anything() ) expect(setup.client.mcp.disconnect).toHaveBeenCalledWith({ name: expect.stringMatching(/^goodbuddy-knowledge-/u), directory: process.cwd() }) 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 }) setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:4010\n' ) }, 0) const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { getEndpoint: () => 'http://127.0.0.1:4567/mcp' } 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('serializes overlapping embedded MCP registration and discovery', 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 }) setTimeout(() => { stdoutOf(child).write( 'opencode server listening on http://127.0.0.1:4010\n' ) }, 0) const runtime = new OpenCodeRuntime( options({ knowledgeGateway: { getEndpoint: () => 'http://127.0.0.1:4567/mcp' } 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' ) await new Promise((resolve) => setTimeout(resolve, 10)) expect(mcpAdd).toHaveBeenCalledTimes(1) resolveFirstAdd() await first await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2)) await 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('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' } 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('subscribes before prompting and auto-allows a tool request', 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: 'ask' }, { permission: 'task', pattern: '*', action: 'deny' } ] }) expect(permissionReply).toHaveBeenCalledOnce() expect(permissionReply).toHaveBeenCalledWith({ requestID: 'permission-1', directory: process.cwd(), reply: 'once' }) 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('auto-allows each bounded tool request 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' } ], [ { requestID: 'permission-2', directory: process.cwd(), reply: 'once' } ] ]) 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('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() }) 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' }) 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() }) await runtime.dispose() }) it.each(['ask', 'plan'] as const)( 'uses deny-all session rules and hard tool disable in %s mode', async (workMode) => { const { client, session, tool } = runClient([ { id: 'event-idle', type: 'session.idle', properties: { sessionID: 'session-1' } } ]) const runtime = embeddedRuntime(client) await collectRun(runtime, workMode) expect(session.create).toHaveBeenCalledWith({ title: 'GoodBuddy 对话', directory: process.cwd(), permission: [ { permission: '*', pattern: '*', action: 'deny' } ] }) expect(tool.ids).toHaveBeenCalledWith({ directory: process.cwd() }) expect(session.promptAsync).toHaveBeenCalledWith( expect.objectContaining({ tools: { read: false, write: false, bash: false, task: 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' } ] }) await runtime.dispose() }) it('leaves trusted external sessions unmodified and skips 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() }) expect(permissionReply).not.toHaveBeenCalled() 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() }) })