import { EventEmitter } from 'node:events' import { 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 { 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' } } } } } function runClient(events: Record[]) { const callOrder: string[] = [] const permissionReply = 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 }, mcp: { add: vi.fn().mockResolvedValue({ data: true, 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, 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: 'anthropic/private-model', provider: { anthropic: { options: { apiKey: 'private-key', baseURL: 'https://model.example/v1' } } } }) 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('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-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: 'tool', callId: 'call-1', state: 'pending' }) ) expect(events).toContainEqual( expect.objectContaining({ type: 'tool', callId: 'call-1', state: 'completed' }) ) expect(events).toContainEqual( 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: [REDACTED]' } }) await expect(stream.next()).rejects.toThrow( 'write failed Authorization: [REDACTED]' ) 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: [REDACTED]' ) 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() }) })