Skip to content

Commit baf8f17

Browse files
authored
feat: add opencode backend and dynamic detection (#19)
* feat: add opencode backend and dynamic detection * fix: address backend selection review issues * fix: address second review issues * fix: handle opencode abnormal exits
1 parent 4b77595 commit baf8f17

23 files changed

Lines changed: 990 additions & 46 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { Readable } from 'node:stream';
3+
4+
vi.mock('node:child_process', () => ({
5+
spawn: vi.fn(),
6+
spawnSync: vi.fn(() => ({ status: 0 })),
7+
}));
8+
9+
import { spawn } from 'node:child_process';
10+
import type { AgentStreamEvent } from '../../agent/session.js';
11+
import { createOpencodeArgs, extractOpencodeEvents } from '../../agent/backends/opencode.js';
12+
13+
async function collect(gen: AsyncGenerator<AgentStreamEvent>): Promise<AgentStreamEvent[]> {
14+
const events: AgentStreamEvent[] = [];
15+
for await (const event of gen) {
16+
events.push(event);
17+
}
18+
return events;
19+
}
20+
21+
function makeProcess(stdout: string, stderr = '', exitCode = 0) {
22+
const stdoutStream = Readable.from([stdout]);
23+
const stderrStream = Readable.from([stderr]);
24+
return {
25+
stdout: stdoutStream,
26+
stderr: stderrStream,
27+
stdin: null,
28+
killed: false,
29+
kill: vi.fn(),
30+
once: vi.fn((event: string, cb: (...args: unknown[]) => void) => {
31+
if (event === 'close') {
32+
setTimeout(() => cb(exitCode, null), 0);
33+
}
34+
}),
35+
};
36+
}
37+
38+
describe('opencode backend', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
});
42+
43+
it('builds run arguments for a fresh session', () => {
44+
const args = createOpencodeArgs({
45+
mode: 'code',
46+
prompt: 'ship it',
47+
model: 'openai/gpt-5',
48+
effort: 'high',
49+
});
50+
51+
expect(args).toEqual([
52+
'run',
53+
'--format',
54+
'json',
55+
'--model',
56+
'openai/gpt-5',
57+
'--variant',
58+
'high',
59+
'ship it',
60+
]);
61+
});
62+
63+
it('builds resume arguments when resuming a session', () => {
64+
const args = createOpencodeArgs({
65+
mode: 'ask',
66+
prompt: 'continue',
67+
resumeSessionId: 'ses_123',
68+
});
69+
70+
expect(args).toEqual([
71+
'run',
72+
'--format',
73+
'json',
74+
'--session',
75+
'ses_123',
76+
'continue',
77+
]);
78+
});
79+
80+
it('does not add an invalid subagent override in ask mode', () => {
81+
const args = createOpencodeArgs({
82+
mode: 'ask',
83+
prompt: 'why?',
84+
});
85+
86+
expect(args).toEqual([
87+
'run',
88+
'--format',
89+
'json',
90+
'why?',
91+
]);
92+
});
93+
94+
it('extracts session, tool, and text events from opencode JSONL output', () => {
95+
expect(extractOpencodeEvents({
96+
type: 'step_start',
97+
sessionID: 'ses_123',
98+
})).toEqual([
99+
{ type: 'session', sessionId: 'ses_123', status: 'confirmed' },
100+
]);
101+
102+
expect(extractOpencodeEvents({
103+
type: 'tool_use',
104+
sessionID: 'ses_123',
105+
part: {
106+
type: 'tool',
107+
tool: 'bash',
108+
state: {
109+
input: {
110+
command: 'pwd',
111+
},
112+
},
113+
},
114+
})).toEqual([
115+
{ type: 'tool', summary: 'running Bash {"command":"pwd"}' },
116+
]);
117+
118+
expect(extractOpencodeEvents({
119+
type: 'text',
120+
sessionID: 'ses_123',
121+
part: {
122+
type: 'text',
123+
text: 'Hello!',
124+
},
125+
})).toEqual([
126+
{ type: 'text', delta: 'Hello!' },
127+
]);
128+
});
129+
130+
it('marks session events as resumed when extracting resumed runs', () => {
131+
expect(extractOpencodeEvents({
132+
type: 'step_start',
133+
sessionID: 'ses_123',
134+
}, {
135+
resumeSessionId: 'ses_123',
136+
})).toEqual([
137+
{ type: 'session', sessionId: 'ses_123', status: 'resumed' },
138+
]);
139+
});
140+
141+
it('emits a structured invalidation event for authoritative resume failures', () => {
142+
expect(extractOpencodeEvents({
143+
type: 'error',
144+
error: {
145+
message: 'Resume session not found',
146+
},
147+
}, {
148+
resumeSessionId: 'ses_123',
149+
})).toEqual([
150+
{
151+
type: 'session-invalidated',
152+
sessionId: 'ses_123',
153+
reason: 'Resume session not found',
154+
},
155+
{ type: 'error', error: 'Resume session not found' },
156+
]);
157+
});
158+
159+
it('emits environment, tool, text, and done events from the stream', async () => {
160+
vi.mocked(spawn).mockReturnValue(
161+
makeProcess([
162+
JSON.stringify({
163+
type: 'step_start',
164+
sessionID: 'ses_123',
165+
part: { type: 'step-start' },
166+
}),
167+
JSON.stringify({
168+
type: 'tool_use',
169+
sessionID: 'ses_123',
170+
part: {
171+
type: 'tool',
172+
tool: 'bash',
173+
state: {
174+
input: { command: 'pwd' },
175+
},
176+
},
177+
}),
178+
JSON.stringify({
179+
type: 'text',
180+
sessionID: 'ses_123',
181+
part: { type: 'text', text: 'Done.' },
182+
}),
183+
].join('\n')) as any,
184+
);
185+
186+
const { opencodeBackend } = await import('../../agent/backends/opencode.js');
187+
const events = await collect(opencodeBackend.stream({
188+
mode: 'code',
189+
prompt: 'ship it',
190+
cwd: '/tmp/project',
191+
}));
192+
193+
expect(events[0]).toEqual({
194+
type: 'environment',
195+
environment: {
196+
backend: 'opencode',
197+
mode: 'code',
198+
model: {
199+
requested: undefined,
200+
resolved: undefined,
201+
},
202+
cwd: {
203+
value: '/tmp/project',
204+
source: 'explicit',
205+
},
206+
git: {
207+
isRepo: false,
208+
},
209+
},
210+
});
211+
expect(events.slice(1)).toEqual([
212+
{ type: 'session', sessionId: 'ses_123', status: 'confirmed' },
213+
{ type: 'tool', summary: 'running Bash {"command":"pwd"}' },
214+
{ type: 'text', delta: 'Done.' },
215+
{ type: 'done', result: 'Done.', sessionId: 'ses_123' },
216+
]);
217+
218+
expect(vi.mocked(spawn).mock.calls[0]).toEqual([
219+
expect.any(String),
220+
['run', '--format', 'json', 'ship it'],
221+
expect.objectContaining({
222+
cwd: '/tmp/project',
223+
}),
224+
]);
225+
});
226+
227+
it('emits an error event on non-zero exit', async () => {
228+
vi.mocked(spawn).mockReturnValue(
229+
makeProcess('', 'ProviderModelNotFoundError: openai/gpt-does-not-exist', 1) as any,
230+
);
231+
232+
const { opencodeBackend } = await import('../../agent/backends/opencode.js');
233+
const events = await collect(opencodeBackend.stream({
234+
mode: 'code',
235+
prompt: 'ship it',
236+
}));
237+
238+
expect(events).toEqual([
239+
expect.objectContaining({
240+
type: 'environment',
241+
}),
242+
{
243+
type: 'error',
244+
error: 'ProviderModelNotFoundError: openai/gpt-does-not-exist',
245+
},
246+
]);
247+
});
248+
249+
it('emits an error event when the process only writes to stderr', async () => {
250+
vi.mocked(spawn).mockReturnValue(
251+
makeProcess('', 'ProviderModelNotFoundError: openai/gpt-does-not-exist', 0) as any,
252+
);
253+
254+
const { opencodeBackend } = await import('../../agent/backends/opencode.js');
255+
const events = await collect(opencodeBackend.stream({
256+
mode: 'code',
257+
prompt: 'ship it',
258+
}));
259+
260+
expect(events).toEqual([
261+
expect.objectContaining({
262+
type: 'environment',
263+
}),
264+
{
265+
type: 'error',
266+
error: 'ProviderModelNotFoundError: openai/gpt-does-not-exist',
267+
},
268+
]);
269+
});
270+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import {
3+
getAvailableBackendNames,
4+
getRegisteredBackendNames,
5+
isRegisteredBackendName,
6+
registerBackend,
7+
resetBackendRegistryForTests,
8+
type AgentBackend,
9+
} from '../backend.js';
10+
11+
function createBackend(name: string, available = true): AgentBackend {
12+
return {
13+
name,
14+
isAvailable: () => available,
15+
async *stream() {
16+
yield { type: 'done', result: `${name}:ok` } as const;
17+
},
18+
};
19+
}
20+
21+
describe('backend registry', () => {
22+
afterEach(() => {
23+
resetBackendRegistryForTests();
24+
});
25+
26+
it('supports dynamically registered backend names', () => {
27+
registerBackend(createBackend('opencode'));
28+
29+
expect(getRegisteredBackendNames()).toEqual(['opencode']);
30+
expect(isRegisteredBackendName('opencode')).toBe(true);
31+
expect(isRegisteredBackendName('claude')).toBe(false);
32+
});
33+
34+
it('lists only currently available backends', async () => {
35+
registerBackend(createBackend('claude'));
36+
registerBackend(createBackend('opencode', false));
37+
38+
await expect(getAvailableBackendNames()).resolves.toEqual(['claude']);
39+
});
40+
});

packages/core/src/agent/__tests__/runtime.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, it } from 'vitest';
2-
import type { AgentBackend } from '../backend.js';
2+
import { registerBackend, type AgentBackend } from '../backend.js';
33
import {
44
interruptConversationRun,
55
isConversationRunning,
@@ -15,6 +15,7 @@ import {
1515
function createBackend(events: Array<unknown>): AgentBackend {
1616
return {
1717
name: 'claude',
18+
isAvailable: () => true,
1819
async *stream(options) {
1920
for (const event of events) {
2021
if (options.abortSignal?.aborted) {
@@ -83,6 +84,7 @@ describe('conversation runtime', () => {
8384
prompt: 'Build a report exporter',
8485
backend: {
8586
name: 'claude',
87+
isAvailable: () => true,
8688
async *stream(options) {
8789
receivedPrompt = options.prompt;
8890
yield { type: 'done', result: 'ok' } as const;
@@ -107,6 +109,7 @@ describe('conversation runtime', () => {
107109
prompt: 'Explain the attached document',
108110
backend: {
109111
name: 'claude',
112+
isAvailable: () => true,
110113
async *stream(options) {
111114
receivedPrompt = options.prompt;
112115
yield { type: 'done', result: 'ok' } as const;
@@ -121,6 +124,32 @@ describe('conversation runtime', () => {
121124
expect(receivedPrompt).toBe('Explain the attached document');
122125
expect(receivedPrompt).not.toContain('```artifacts');
123126
});
127+
128+
it('surfaces unavailable registered backends as error events', async () => {
129+
registerBackend({
130+
name: 'offline-test-backend',
131+
isAvailable: () => false,
132+
async *stream() {
133+
yield { type: 'done', result: 'should not run' } as const;
134+
},
135+
});
136+
137+
const events = runConversationSession('conv-offline', {
138+
mode: 'ask',
139+
prompt: 'hi',
140+
backend: 'offline-test-backend',
141+
});
142+
143+
const received = [];
144+
for await (const event of events) {
145+
received.push(event);
146+
}
147+
148+
expect(received).toEqual([
149+
{ type: 'error', error: 'Backend not available: offline-test-backend' },
150+
]);
151+
expect(isConversationRunning('conv-offline')).toBe(false);
152+
});
124153
});
125154

126155
describe('core exports', () => {

0 commit comments

Comments
 (0)