feat(worker): OpenCodeAdapter runs opencode in the per-user box (no side container)

This commit is contained in:
Gabriel Brown
2026-07-11 10:07:58 -04:00
parent 949a036d13
commit ff0425cf4b
5 changed files with 334 additions and 0 deletions
@@ -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',
});
});
});
@@ -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<void> => {
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'");
});
});