Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 62 additions & 21 deletions src/backends/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class OpencodeBackend implements Backend {
profileExecutionIdentity(req, session, 'opencode', variant),
)
try {
installRetrySignal(provisioned.env.OPENCODE_CONFIG_DIR)
installSignals(provisioned.env.OPENCODE_CONFIG_DIR)
} catch (error) {
provisioned.cleanup?.()
throw error
Expand Down Expand Up @@ -251,6 +251,9 @@ export class OpencodeBackend implements Backend {
let sawError: BackendFailureReason | null = null
let emittedContent = false
let emittedToolCall = false
// Calls already reported at their start, so their finished part carries only the outcome.
const startedTools = new Set<string>()
let toolSession: string | null = null
let stepUsage: CollectedUsage | undefined
let fallbackUsage: UsageReceipt | undefined
const progressIntervalMs = Math.max(10, Number(process.env.OPENCODE_PROGRESS_MS ?? 30_000))
Expand Down Expand Up @@ -290,6 +293,21 @@ export class OpencodeBackend implements Backend {
yield { ...terminalOutcome('opencode', refusal, emittedToolCall), internal_session_id: internalSessionId }
return
}
if (type === TOOL_START_SIGNAL_TYPE) {
// The plugin sees every session's parts; `run` prints only its own session's, so a
// subagent's tool would open a call that no finished part ever closes. The first start
// can precede `run`'s first event, and it is the run's own: a subagent's tool starts
// only inside a root tool that started before it.
toolSession ??= internalSessionId ?? (typeof ev.partSession === 'string' ? ev.partSession : null)
if (ev.partSession !== toolSession) continue
const started = extractToolUse(ev)
if (started && !startedTools.has(started.id)) {
startedTools.add(started.id)
yield { tool_calls: [started] }
emittedToolCall = true
}
continue
}
const eventUsage = extractUsage(ev)
if (eventUsage) {
if (isStepReceipt(ev)) stepUsage = collectUsage(stepUsage, eventUsage)
Expand Down Expand Up @@ -346,9 +364,9 @@ export class OpencodeBackend implements Backend {
emittedContent = true
}
const toolCall = extractToolUse(ev)
if (toolCall) { yield { tool_calls: [toolCall] }; emittedToolCall = true }
// opencode announces a tool part only once it has finished, so the outcome is on the
// same event as the call and can be forwarded beside it.
// `run` prints a tool part only once it has finished. A call the plugin reported at its
// start gets only its outcome here; one it missed is reported whole, call and outcome.
if (toolCall && !startedTools.has(toolCall.id)) { yield { tool_calls: [toolCall] }; emittedToolCall = true }
const toolResult = extractToolResult(ev)
if (toolResult) yield { tool_results: [toolResult] }

Expand Down Expand Up @@ -619,23 +637,46 @@ function providerStatus(value: unknown): number | undefined {
* slept in silence until this bridge's 4 h turn cap aborted it, and agent-runtime ended a 48 h run
* on that abort. The plugin below prints opencode's own retry status as one stdout line, which
* the event loop reads with the rest of the stream.
*
* It prints each tool call's start the same way. `run` prints a tool part only once it has
* finished (`part.state.status` completed or error), so a 32-minute test battery read as 32 minutes
* of model silence (magnetar-rust-20261018b), and agent-runtime could not bound a silent model
* call on this backend. The part is on opencode's bus as `message.part.updated` with status
* `running` from the moment the tool starts.
*/
const RETRY_SIGNAL_TYPE = 'cli-bridge.opencode-retry'
const RETRY_SIGNAL_PLUGIN = `// Written by cli-bridge for this turn. Prints opencode's retry status, which \`run --format json\` omits.
export const CliBridgeRetrySignal = async () => ({
event: async ({ event }) => {
const status = event?.properties?.status
if (event?.type !== 'session.status' || status?.type !== 'retry') return
process.stdout.write(JSON.stringify({
type: ${JSON.stringify(RETRY_SIGNAL_TYPE)},
sessionID: event.properties.sessionID,
attempt: status.attempt,
message: status.message,
next: status.next,
timestamp: Date.now(),
}) + '\\n')
},
})
const TOOL_START_SIGNAL_TYPE = 'cli-bridge.opencode-tool-start'
const SIGNAL_PLUGIN = `// Written by cli-bridge for this turn. Prints what \`run --format json\` omits: opencode's retry
// status, and each tool call when it starts.
export const CliBridgeSignals = async () => {
const started = new Set()
return {
event: async ({ event }) => {
const props = event?.properties
if (event?.type === 'session.status' && props?.status?.type === 'retry') {
process.stdout.write(JSON.stringify({
type: ${JSON.stringify(RETRY_SIGNAL_TYPE)},
sessionID: props.sessionID,
attempt: props.status.attempt,
message: props.status.message,
next: props.status.next,
timestamp: Date.now(),
}) + '\\n')
return
}
const part = props?.part
if (event?.type !== 'message.part.updated' || part?.type !== 'tool') return
if (part.state?.status !== 'running' || started.has(part.id)) return
started.add(part.id)
process.stdout.write(JSON.stringify({
type: ${JSON.stringify(TOOL_START_SIGNAL_TYPE)},
partSession: part.sessionID,
part: { type: 'tool', id: part.id, callID: part.callID, tool: part.tool, state: { status: 'running', input: part.state.input } },
timestamp: Date.now(),
}) + '\\n')
},
}
}
`

/**
Expand All @@ -649,11 +690,11 @@ const RETRY_WAIT_LIMIT_MS = Math.max(1_000, Number(process.env.OPENCODE_RETRY_WA
* Put the plugin in the turn's private config directory. A turn without an AgentProfile has
* none: opencode would read the operator's own config directory, which this bridge never writes.
*/
function installRetrySignal(configDir: string | undefined): void {
function installSignals(configDir: string | undefined): void {
if (configDir === undefined) return
const pluginDir = join(configDir, 'plugin')
mkdirSync(pluginDir, { recursive: true, mode: 0o755 })
writeFileSync(join(pluginDir, 'cli-bridge-retry-signal.js'), RETRY_SIGNAL_PLUGIN, { mode: 0o644 })
writeFileSync(join(pluginDir, 'cli-bridge-signals.js'), SIGNAL_PLUGIN, { mode: 0o644 })
}

/**
Expand Down
122 changes: 122 additions & 0 deletions tests/opencode-tool-start-real.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* A tool call is reported when it starts, against the REAL opencode.
*
* `opencode run --format json` prints a tool part only once it has finished, so a long tool read
* as model silence: magnetar-rust-20261018b's 32-minute prove battery left no event for 32.4
* minutes. A local endpoint scripts one `bash` call that sleeps; the call must reach the stream
* while the command still runs, and its outcome once it ends, with one call per id.
*
* Skipped when the binary is absent, and by `CLI_BRIDGE_SKIP_REAL_OPENCODE=1`.
* Measured against opencode 1.18.33.
*/
import { createServer, type Server } from 'node:http'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import type { AddressInfo } from 'node:net'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { OpencodeBackend } from '../src/backends/opencode.js'
import type { ChatRequest } from '../src/backends/types.js'
import { hostSpawner } from '../src/executors/host.js'

const BIN = process.env.OPENCODE_BIN ?? join(homedir(), '.opencode/bin/opencode')
const ENABLED = existsSync(BIN) && process.env.CLI_BRIDGE_SKIP_REAL_OPENCODE !== '1'
const SLEEP_S = 4

function sse(chunks: unknown[]): string {
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join('')}data: [DONE]\n\n`
}

function chunk(delta: Record<string, unknown>, finishReason: string | null = null): unknown {
return { id: 'c', object: 'chat.completion.chunk', created: 0, model: 'm', choices: [{ index: 0, delta, finish_reason: finishReason }] }
}

describe.skipIf(!ENABLED)('a real opencode tool call', () => {
let server: Server
const scratch = mkdtempSync(join(tmpdir(), 'opencode-tool-start-'))
const saved = { ...process.env }

beforeAll(async () => {
server = createServer((req, res) => {
let body = ''
req.on('data', (data: Buffer) => { body += data.toString('utf8') })
req.on('end', () => {
const request = JSON.parse(body) as { tools?: unknown[]; messages?: Array<{ role?: string }> }
res.writeHead(200, { 'content-type': 'text/event-stream' })
// A request without tools is opencode naming the session; one after the tool result ends the turn.
const answered = request.messages?.some((message) => message.role === 'tool') === true
if (!request.tools?.length || answered) {
res.end(sse([chunk({ role: 'assistant', content: 'finished' }), chunk({}, 'stop')]))
return
}
res.end(sse([
chunk({
role: 'assistant',
tool_calls: [{
index: 0,
id: 'call_sleep',
type: 'function',
function: { name: 'bash', arguments: JSON.stringify({ command: `sleep ${SLEEP_S} && echo slept`, description: 'Wait' }) },
}],
}),
chunk({}, 'tool_calls'),
]))
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const baseURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`
Object.assign(process.env, {
XDG_CONFIG_HOME: join(scratch, 'config'),
XDG_DATA_HOME: join(scratch, 'data'),
XDG_CACHE_HOME: join(scratch, 'cache'),
XDG_STATE_HOME: join(scratch, 'state'),
OPENCODE_CONFIG_CONTENT: JSON.stringify({
provider: {
probe: {
npm: '@ai-sdk/openai-compatible',
name: 'probe',
options: { baseURL, apiKey: 'probe' },
models: { m: { name: 'm', tool_call: true } },
},
},
}),
})
})

afterAll(async () => {
for (const key of Object.keys(process.env)) if (!(key in saved)) delete process.env[key]
Object.assign(process.env, saved)
await new Promise<void>((resolve) => server.close(() => resolve()))
rmSync(scratch, { recursive: true, force: true })
})

it('reports the call while the tool runs and its outcome when it ends, once each', async () => {
const cwd = mkdtempSync(join(scratch, 'cwd-'))
const backend = new OpencodeBackend({ bin: BIN, timeoutMs: 300_000, spawner: hostSpawner })
const req: ChatRequest = {
cwd,
session_id: 'tool-start-real',
mode: 'byob',
model: 'opencode/probe/m',
messages: [{ role: 'user', content: 'wait for the command' }],
agent_profile: { name: 'worker', harness: 'opencode', prompt: { instructions: ['Run the command you are asked to run.'] } },
} as ChatRequest

const calls: Array<{ id: string; at: number }> = []
const results: Array<{ id: string; at: number; output?: string }> = []
let finish: string | undefined
for await (const delta of backend.chat(req, null, new AbortController().signal)) {
for (const call of delta.tool_calls ?? []) calls.push({ id: call.id, at: Date.now() })
for (const result of delta.tool_results ?? []) results.push({ id: result.id, at: Date.now(), ...(result.output ? { output: result.output } : {}) })
if (delta.finish_reason !== undefined) finish = delta.finish_reason
}

expect(calls).toHaveLength(1)
expect(results).toHaveLength(1)
expect(results[0]!.id).toBe(calls[0]!.id)
expect(results[0]!.output).toMatch(/slept/)
// Reported at its start: the sleep still had most of its time to run.
expect(results[0]!.at - calls[0]!.at).toBeGreaterThanOrEqual((SLEEP_S - 1) * 1000)
expect(finish).not.toBe('error')
}, 180_000)
})