From ff0425cf4b193df3f6831287312a1f0a18cc78a2 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 02:57:05 -0400 Subject: [PATCH] feat(worker): OpenCodeAdapter runs opencode in the per-user box (no side container) --- apps/agent-worker/src/agent-events.ts | 53 +++++++ .../src/runtime/opencode-adapter.ts | 141 ++++++++++++++++++ apps/agent-worker/src/runtime/register.ts | 2 + .../tests/unit/agent-events.test.ts | 38 +++++ .../tests/unit/opencode-adapter.test.ts | 100 +++++++++++++ 5 files changed, 334 insertions(+) create mode 100644 apps/agent-worker/src/runtime/opencode-adapter.ts create mode 100644 apps/agent-worker/tests/unit/opencode-adapter.test.ts diff --git a/apps/agent-worker/src/agent-events.ts b/apps/agent-worker/src/agent-events.ts index d6f07a6..7dbec21 100644 --- a/apps/agent-worker/src/agent-events.ts +++ b/apps/agent-worker/src/agent-events.ts @@ -464,3 +464,56 @@ export const normalizeOpenCodeEvent = ( } return events; }; + +// `opencode run --format json` emits newline-delimited JSON: streamed events share +// the server's `{ type, properties }` SSE envelope, plus a final result object that +// carries the session id and the assistant's completed text. +export const normalizeOpenCodeRunLine = ( + line: string, +): NormalizedAgentEvent[] => { + if (!line.trim()) return []; + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch { + return [{ kind: 'status', status: line }]; + } + const record = asRecord(parsed); + if (!record) return [{ kind: 'status', status: line }]; + if ('type' in record && 'properties' in record) { + return normalizeOpenCodeEvent(record); + } + const events: NormalizedAgentEvent[] = []; + const sessionId = record.sessionID ?? record.sessionId; + if (typeof sessionId === 'string') { + events.push({ kind: 'session', sessionId }); + } + const parts = record.parts; + let text = ''; + if (Array.isArray(parts)) { + text = parts + .map((part) => { + const record = asRecord(part); + return record ? textFromPart(record) : ''; + }) + .join(''); + } + if (!text) { + const message = asRecord(record.message); + text = + textFromPart(record) || + (message ? stringify(message.content ?? message.text) : ''); + } + if (text) { + const externalMessageId = stringify(record.messageID ?? record.id); + events.push({ + kind: 'assistant_completed', + content: text, + ...(externalMessageId ? { externalMessageId } : {}), + }); + } + if (events.length === 0) { + events.push({ kind: 'status', status: line }); + } + return events; +}; diff --git a/apps/agent-worker/src/runtime/opencode-adapter.ts b/apps/agent-worker/src/runtime/opencode-adapter.ts new file mode 100644 index 0000000..9c7b27d --- /dev/null +++ b/apps/agent-worker/src/runtime/opencode-adapter.ts @@ -0,0 +1,141 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import type { NormalizedAgentEvent } from '../agent-events'; +import { normalizeOpenCodeRunLine } from '../agent-events'; +import { env } from '../env'; +import type { AdapterFactory, TurnResult } from './agent-runtime'; +import { + buildMarkedCommand, + killBoxProcessesByMarker, + streamExecInContainer, +} from './docker'; +import type { AdapterWorkspace } from './provider'; +import { opencodeModel, providerEnvironment } from './provider'; + +// Normalize + pretty-print an auth JSON blob before writing it with owner-only +// permissions (mirrors the CodexAdapter helper). +const writeJsonFile = async (filePath: string, content: string) => { + let normalized = content.trim(); + try { + normalized = `${JSON.stringify(JSON.parse(normalized), null, 2)}\n`; + } catch { + throw new Error('OpenCode auth JSON is not valid JSON.'); + } + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, normalized, { mode: 0o600 }); +}; + +export const createOpenCodeAdapter: AdapterFactory = (deps) => { + const execStream = deps?.execStream ?? streamExecInContainer; + + const prepareAuth = async (workspace: AdapterWorkspace) => { + // API-key profiles authenticate purely via ANTHROPIC_API_KEY/OPENAI_API_KEY + // in the environment; only `opencode_auth_json` profiles need auth.json on disk. + const profile = workspace.claim.aiProviderProfile; + if (profile?.authType !== 'opencode_auth_json') return; + if (!profile.secret) { + throw new Error('OpenCode auth profile is missing auth.json contents.'); + } + const authPath = path.join( + workspace.workdir, + '.local', + 'share', + 'opencode', + 'auth.json', + ); + await writeJsonFile(authPath, profile.secret); + }; + + const runTurn = async ( + workspace: AdapterWorkspace, + prompt: string, + onEvent: (event: NormalizedAgentEvent) => Promise, + ): Promise => { + workspace.runtime = 'opencode'; + workspace.turnMarker = `spoon-turn-${randomUUID()}`; + const marker = workspace.turnMarker; + + // NOTE: `--session` continuity is UNVERIFIED against `opencode run --help` and + // may be dropped in Task 11's smoke; if unsupported, OpenCode turns are stateless + // (acceptable — Codex/Claude carry continuity). + const argv = [ + 'opencode', + 'run', + '--format', + 'json', + '--model', + opencodeModel(workspace.claim), + ...(workspace.opencodeSessionId + ? ['--session', workspace.opencodeSessionId] + : []), + '--', + prompt, + ]; + const command = buildMarkedCommand(marker, argv); + + const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome); + const secretEnv = Object.fromEntries( + workspace.claim.secrets.map((secret) => [secret.name, secret.value]), + ); + + let capturedSessionId: string | undefined; + let capturedError: string | undefined; + let finalMessage: string | undefined; + + // execa's own `timeout` only kills the local docker-exec client, not the + // in-box process group. Schedule an explicit marker kill on timeout and clear + // it once the exec resolves. + const killTimer = setTimeout(() => { + void killBoxProcessesByMarker({ + containerName: workspace.boxName, + marker, + }); + }, env.jobTimeoutMs); + + let result; + try { + result = await execStream({ + containerName: workspace.boxName, + containerCwd: workspace.containerRepo, + command, + environment: { ...aiEnv, ...secretEnv }, + redact: workspace.redact, + timeoutMs: env.jobTimeoutMs, + onStdoutLine: async (line) => { + for (const event of normalizeOpenCodeRunLine(line)) { + if (event.kind === 'session') capturedSessionId = event.sessionId; + if (event.kind === 'error') capturedError = event.message; + if (event.kind === 'assistant_completed' && event.content) { + finalMessage = event.content; + } + await onEvent(event); + } + }, + onStderrLine: async (line) => { + if (!line.trim()) return; + await onEvent({ kind: 'status', status: line }); + }, + }); + } finally { + clearTimeout(killTimer); + } + + if (result.exitCode !== 0) { + capturedError = result.output; + } + + return { finalMessage, sessionId: capturedSessionId, error: capturedError }; + }; + + const abort = async (workspace: AdapterWorkspace) => { + if (!workspace.turnMarker) return; + await killBoxProcessesByMarker({ + containerName: workspace.boxName, + marker: workspace.turnMarker, + }); + }; + + return { name: 'opencode', prepareAuth, runTurn, abort }; +}; diff --git a/apps/agent-worker/src/runtime/register.ts b/apps/agent-worker/src/runtime/register.ts index 2ffed20..bc8def2 100644 --- a/apps/agent-worker/src/runtime/register.ts +++ b/apps/agent-worker/src/runtime/register.ts @@ -1,4 +1,6 @@ import { registerAdapter } from './agent-runtime'; import { createCodexAdapter } from './codex-adapter'; +import { createOpenCodeAdapter } from './opencode-adapter'; registerAdapter('codex', createCodexAdapter); +registerAdapter('opencode', createOpenCodeAdapter); diff --git a/apps/agent-worker/tests/unit/agent-events.test.ts b/apps/agent-worker/tests/unit/agent-events.test.ts index da4baae..a55066e 100644 --- a/apps/agent-worker/tests/unit/agent-events.test.ts +++ b/apps/agent-worker/tests/unit/agent-events.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'vitest'; import { normalizeCodexJsonLine, normalizeOpenCodeEvent, + normalizeOpenCodeRunLine, } from '../../src/agent-events'; describe('agent event normalization', () => { @@ -291,4 +292,41 @@ describe('agent event normalization', () => { externalMessageId: 'message-2', }); }); + + test('normalizes opencode run --format json output lines', () => { + expect( + normalizeOpenCodeRunLine( + JSON.stringify({ + type: 'message.part.delta', + properties: { part: { text: 'hi' }, messageID: 'm1' }, + }), + ), + ).toContainEqual({ + kind: 'assistant_delta', + content: 'hi', + externalMessageId: 'm1', + }); + + expect( + normalizeOpenCodeRunLine( + JSON.stringify({ + sessionID: 'ses_abc', + parts: [{ text: 'final answer' }], + }), + ), + ).toEqual( + expect.arrayContaining([ + { kind: 'session', sessionId: 'ses_abc' }, + expect.objectContaining({ + kind: 'assistant_completed', + content: 'final answer', + }), + ]), + ); + + expect(normalizeOpenCodeRunLine('not json at all')).toContainEqual({ + kind: 'status', + status: 'not json at all', + }); + }); }); diff --git a/apps/agent-worker/tests/unit/opencode-adapter.test.ts b/apps/agent-worker/tests/unit/opencode-adapter.test.ts new file mode 100644 index 0000000..df2afeb --- /dev/null +++ b/apps/agent-worker/tests/unit/opencode-adapter.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test, vi } from 'vitest'; + +import type { NormalizedAgentEvent } from '../../src/agent-events'; +import type { ExecStreamFn } from '../../src/runtime/agent-runtime'; +import type { AdapterWorkspace, Claim } from '../../src/runtime/provider'; + +const loadAdapter = async () => { + process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; + process.env.GITHUB_APP_ID = '123'; + process.env.GITHUB_APP_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----'; + return await import('../../src/runtime/opencode-adapter'); +}; + +const claim = { + job: { + _id: 'job-1', + prompt: 'hi', + baseBranch: 'main', + workBranch: 'spoon/x', + forkOwner: 'o', + forkRepo: 'r', + upstreamOwner: 'u', + upstreamRepo: 'r', + }, + spoon: { name: 'Spoon' }, + openai: { model: 'gpt-5', reasoningEffort: 'medium' }, + aiProviderProfile: { + id: 'p', + name: 'OpenAI', + provider: 'openai', + authType: 'api_key', + secret: 'sk-test', + model: 'gpt-5', + reasoningEffort: 'medium', + }, + github: {}, + secrets: [], +} as unknown as Claim; + +const makeWorkspace = (): AdapterWorkspace => ({ + claim, + workdir: '/tmp/spoon-opencode-adapter-test-missing', + homeDir: '/tmp/spoon-opencode-adapter-test-missing', + username: 'tester', + containerHome: '/home/tester', + containerRepo: '/home/tester/Code/spoon', + repoDir: '/tmp/spoon-opencode-adapter-test-missing/Code/spoon', + boxName: 'spoon-box-tester', + redact: (value: string) => value, + runtime: 'opencode', +}); + +describe('OpenCodeAdapter', () => { + test('streams normalized events and marks the turn via buildMarkedCommand', async () => { + const { createOpenCodeAdapter } = await loadAdapter(); + const captured: unknown[] = []; + const execStream = vi.fn(async (args) => { + captured.push(args.command); + await args.onStdoutLine?.( + JSON.stringify({ + type: 'message.part.delta', + properties: { part: { text: 'hi' }, messageID: 'm1' }, + }), + ); + await args.onStdoutLine?.( + JSON.stringify({ + sessionID: 'ses_abc', + parts: [{ text: 'final answer' }], + }), + ); + return { exitCode: 0, output: '' }; + }) as unknown as ExecStreamFn; + + const adapter = createOpenCodeAdapter({ execStream }); + const events: NormalizedAgentEvent[] = []; + const onEvent = (event: NormalizedAgentEvent): Promise => { + events.push(event); + return Promise.resolve(); + }; + + const result = await adapter.runTurn(makeWorkspace(), 'do it', onEvent); + + expect(events).toContainEqual({ + kind: 'assistant_delta', + content: 'hi', + externalMessageId: 'm1', + }); + expect(result).toBeTypeOf('object'); + expect(result.sessionId).toBe('ses_abc'); + expect(result.finalMessage).toBe('final answer'); + + // The turn ran through buildMarkedCommand (setsid bash session) carrying the + // opencode run argv in the script. + const command = captured[0] as string[]; + expect(command[0]).toBe('setsid'); + expect(command[1]).toBe('bash'); + expect(command[3]).toContain("'opencode' 'run' '--format' 'json'"); + }); +});