diff --git a/src/backends/opencode.ts b/src/backends/opencode.ts index 195019b..ecbc5c4 100644 --- a/src/backends/opencode.ts +++ b/src/backends/opencode.ts @@ -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 @@ -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() + 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)) @@ -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) @@ -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] } @@ -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') + }, + } +} ` /** @@ -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 }) } /** diff --git a/tests/opencode-tool-start-real.test.ts b/tests/opencode-tool-start-real.test.ts new file mode 100644 index 0000000..e2c8f5d --- /dev/null +++ b/tests/opencode-tool-start-real.test.ts @@ -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, 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((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((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) +})