1156 lines
31 KiB
TypeScript
1156 lines
31 KiB
TypeScript
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<typeof spawn>
|
||
|
||
function fakeChild(pid = 42): SpawnedProcess {
|
||
const child = new EventEmitter() as EventEmitter & {
|
||
stdout: PassThrough
|
||
stderr: PassThrough
|
||
exitCode: number | null
|
||
killed: boolean
|
||
pid: number
|
||
kill: ReturnType<typeof vi.fn>
|
||
unref: ReturnType<typeof vi.fn>
|
||
}
|
||
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<typeof createOpencodeClient>
|
||
}
|
||
|
||
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<ConstructorParameters<typeof OpenCodeRuntime>[0]> = {}
|
||
): ConstructorParameters<typeof OpenCodeRuntime>[0] {
|
||
return {
|
||
embedded: true,
|
||
binaryPath: '',
|
||
configPath: '',
|
||
defaultWorkspace: process.cwd(),
|
||
...overrides
|
||
}
|
||
}
|
||
|
||
function dependencies(
|
||
child: SpawnedProcess,
|
||
overrides: Partial<OpenCodeRuntimeDependencies> = {}
|
||
): {
|
||
deps: Partial<OpenCodeRuntimeDependencies>
|
||
spawnMock: ReturnType<typeof vi.fn>
|
||
detectBinary: ReturnType<typeof vi.fn>
|
||
createClient: ReturnType<typeof vi.fn>
|
||
} {
|
||
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<string, unknown> = {}
|
||
): Record<string, unknown> {
|
||
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<string, unknown> {
|
||
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<string, unknown>[]) {
|
||
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<typeof createOpencodeClient>
|
||
return {
|
||
client,
|
||
callOrder,
|
||
permissionReply,
|
||
session: client.session,
|
||
event: client.event,
|
||
tool: client.tool
|
||
}
|
||
}
|
||
|
||
function embeddedRuntime(
|
||
client: ReturnType<typeof createOpencodeClient>
|
||
): 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<string, string>
|
||
}
|
||
]
|
||
>
|
||
)[0]?.[0] as
|
||
| {
|
||
baseUrl?: string
|
||
directory?: string
|
||
headers?: Record<string, string>
|
||
}
|
||
| 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<string, unknown>
|
||
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<typeof createOpencodeClient>
|
||
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()
|
||
})
|
||
})
|