remote-codex 0.1.10 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/apps/supervisor-api/dist/index.js +11159 -27875
  2. package/apps/supervisor-web/dist/assets/{highlighted-body-OFNGDK62-CyMcatlD.js → highlighted-body-OFNGDK62-ChrwAL9u.js} +1 -1
  3. package/apps/supervisor-web/dist/assets/index-DHf2HOXx.js +381 -0
  4. package/apps/supervisor-web/dist/assets/index-DpWxXCgt.css +32 -0
  5. package/apps/supervisor-web/dist/assets/{xterm-DbYWMNQ0.js → xterm-D4sevve4.js} +1 -1
  6. package/apps/supervisor-web/dist/index.html +2 -2
  7. package/package.json +2 -3
  8. package/packages/agent-runtime/src/index.ts +4 -0
  9. package/packages/agent-runtime/src/management-errors.ts +11 -0
  10. package/packages/agent-runtime/src/model-pricing.ts +312 -0
  11. package/packages/agent-runtime/src/registry.ts +19 -4
  12. package/packages/agent-runtime/src/runtime-errors.ts +97 -0
  13. package/packages/agent-runtime/src/types.ts +36 -3
  14. package/packages/agent-runtime/src/unavailable-runtime.ts +169 -0
  15. package/packages/claude/src/runtimeAdapter.test.ts +95 -6
  16. package/packages/claude/src/runtimeAdapter.ts +421 -65
  17. package/packages/codex/src/historyItems.test.ts +110 -0
  18. package/packages/codex/src/historyItems.ts +96 -15
  19. package/packages/codex/src/hookHistory.test.ts +59 -0
  20. package/packages/codex/src/index.ts +7 -0
  21. package/packages/codex/src/local-session-store.ts +390 -0
  22. package/packages/codex/src/management/codex-management-service.ts +454 -0
  23. package/packages/codex/src/management/codexHostConfig.test.ts +88 -0
  24. package/packages/codex/src/management/codexHostConfig.ts +188 -0
  25. package/packages/codex/src/management/errors.ts +20 -0
  26. package/packages/codex/src/modelPricing.test.ts +184 -0
  27. package/packages/codex/src/modelPricing.ts +9 -0
  28. package/packages/codex/src/runtime-errors.test.ts +72 -0
  29. package/packages/codex/src/runtime-errors.ts +37 -0
  30. package/packages/codex/src/runtimeAdapter.ts +15 -0
  31. package/packages/codex/src/thread-title.ts +1 -0
  32. package/packages/opencode/src/historyItems.test.ts +504 -0
  33. package/packages/opencode/src/historyItems.ts +896 -0
  34. package/packages/opencode/src/index.ts +2 -0
  35. package/packages/opencode/src/runtimeAdapter.test.ts +1355 -0
  36. package/packages/opencode/src/runtimeAdapter.ts +1469 -0
  37. package/packages/shared/src/agent-providers.ts +56 -0
  38. package/packages/shared/src/index.ts +170 -35
  39. package/apps/supervisor-web/dist/assets/index-BlAhoIuq.js +0 -379
  40. package/apps/supervisor-web/dist/assets/index-DI0NRNgr.css +0 -32
@@ -0,0 +1,1355 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { OpenCodeRuntimeAdapter } from './runtimeAdapter';
7
+
8
+ const tempDirs: string[] = [];
9
+
10
+ async function tempHome() {
11
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'remote-codex-opencode-'));
12
+ tempDirs.push(dir);
13
+ return dir;
14
+ }
15
+
16
+ afterEach(async () => {
17
+ vi.restoreAllMocks();
18
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, {
19
+ recursive: true,
20
+ force: true,
21
+ })));
22
+ });
23
+
24
+ describe('OpenCodeRuntimeAdapter', () => {
25
+ it('keeps provider and variant in model selections', async () => {
26
+ const sessionCreate = vi.fn(async () => ({
27
+ id: 'session-1',
28
+ directory: '/tmp/project',
29
+ model: {
30
+ id: 'gpt-5',
31
+ providerID: 'openai',
32
+ variant: 'fast',
33
+ },
34
+ }));
35
+ const adapter = new OpenCodeRuntimeAdapter({
36
+ home: await tempHome(),
37
+ sdk: {
38
+ createOpencode: async () => ({
39
+ client: {
40
+ model: {
41
+ list: async () => ({
42
+ data: [
43
+ {
44
+ id: 'gpt-5',
45
+ providerID: 'openai',
46
+ name: 'GPT-5',
47
+ variants: [{ id: 'fast' }],
48
+ enabled: true,
49
+ },
50
+ {
51
+ id: 'sonnet-4.5',
52
+ providerID: 'anthropic',
53
+ name: 'Claude Sonnet',
54
+ variants: [{ id: 'default' }],
55
+ enabled: false,
56
+ },
57
+ ],
58
+ }),
59
+ },
60
+ session: {
61
+ list: async () => [],
62
+ create: sessionCreate,
63
+ get: async () => ({}),
64
+ messages: async () => [],
65
+ prompt: async () => ({}),
66
+ abort: async () => ({}),
67
+ },
68
+ },
69
+ server: {
70
+ url: 'http://127.0.0.1:4099',
71
+ close() {},
72
+ },
73
+ }),
74
+ },
75
+ });
76
+
77
+ await adapter.start();
78
+
79
+ const models = await adapter.listModels();
80
+ expect(models.map((model) => model.model)).toEqual([
81
+ 'openai/gpt-5@fast',
82
+ 'anthropic/sonnet-4.5@default',
83
+ ]);
84
+ expect(models[1]!.hidden).toBe(true);
85
+
86
+ await adapter.startSession({
87
+ cwd: '/tmp/project',
88
+ model: 'openai/gpt-5@fast',
89
+ approvalMode: 'guarded',
90
+ sandboxMode: 'workspace-write',
91
+ });
92
+
93
+ expect(sessionCreate).toHaveBeenCalledWith(expect.objectContaining({
94
+ directory: '/tmp/project',
95
+ model: {
96
+ id: 'gpt-5',
97
+ providerID: 'openai',
98
+ variant: 'fast',
99
+ },
100
+ }));
101
+ });
102
+
103
+ it('reads provider model catalog from the v2 config providers endpoint', async () => {
104
+ const configProviders = vi.fn(async () => ({
105
+ data: {
106
+ providers: [
107
+ {
108
+ id: 'openai',
109
+ name: 'OpenAI',
110
+ source: 'config',
111
+ models: {
112
+ 'gpt-5.5': {
113
+ id: 'gpt-5.5',
114
+ providerID: 'openai',
115
+ name: 'GPT-5.5',
116
+ capabilities: { reasoning: true },
117
+ variants: {
118
+ none: { reasoningEffort: 'none' },
119
+ low: { reasoningEffort: 'low' },
120
+ medium: { reasoningEffort: 'medium' },
121
+ high: { reasoningEffort: 'high' },
122
+ xhigh: { reasoningEffort: 'xhigh' },
123
+ },
124
+ status: 'active',
125
+ },
126
+ },
127
+ },
128
+ {
129
+ id: 'opencode',
130
+ name: 'OpenCode Zen',
131
+ models: {
132
+ 'deepseek-v4-flash-free': {
133
+ id: 'deepseek-v4-flash-free',
134
+ providerID: 'opencode',
135
+ name: 'DeepSeek V4 Flash Free',
136
+ capabilities: { reasoning: true },
137
+ variants: {
138
+ low: { reasoningEffort: 'low' },
139
+ high: { reasoningEffort: 'high' },
140
+ },
141
+ status: 'active',
142
+ },
143
+ },
144
+ },
145
+ ],
146
+ },
147
+ }));
148
+ const adapter = new OpenCodeRuntimeAdapter({
149
+ home: await tempHome(),
150
+ sdk: {
151
+ createOpencode: async () => ({
152
+ client: {
153
+ config: {
154
+ get: async () => ({
155
+ provider: {
156
+ openai: {
157
+ models: {
158
+ 'gpt-5.5': {
159
+ name: 'GPT-5.5',
160
+ variants: {
161
+ low: {},
162
+ medium: {},
163
+ high: {},
164
+ xhigh: {},
165
+ },
166
+ },
167
+ },
168
+ },
169
+ },
170
+ }),
171
+ providers: configProviders,
172
+ },
173
+ session: {
174
+ list: async () => [],
175
+ create: async () => ({}),
176
+ get: async () => ({}),
177
+ messages: async () => [],
178
+ prompt: async () => ({}),
179
+ abort: async () => ({}),
180
+ },
181
+ },
182
+ server: {
183
+ url: 'http://127.0.0.1:4099',
184
+ close() {},
185
+ },
186
+ }),
187
+ },
188
+ });
189
+
190
+ await adapter.start();
191
+
192
+ const models = await adapter.listModels();
193
+ expect(configProviders).toHaveBeenCalledWith();
194
+ expect(models).toEqual([
195
+ expect.objectContaining({
196
+ model: 'openai/gpt-5.5',
197
+ displayName: 'GPT-5.5 (OpenAI)',
198
+ defaultReasoningEffort: 'medium',
199
+ supportedReasoningEfforts: [
200
+ { reasoningEffort: 'low', description: 'Low reasoning' },
201
+ { reasoningEffort: 'medium', description: 'Medium reasoning' },
202
+ { reasoningEffort: 'high', description: 'High reasoning' },
203
+ { reasoningEffort: 'xhigh', description: 'Maximum reasoning' },
204
+ ],
205
+ }),
206
+ ]);
207
+ });
208
+
209
+ it('uses the legacy session prompt endpoint because v2 prompt is not available yet', async () => {
210
+ const sessionPrompt = vi.fn(async () => ({
211
+ info: {
212
+ id: 'assistant-message-1',
213
+ type: 'assistant',
214
+ text: 'hello',
215
+ },
216
+ parts: [],
217
+ }));
218
+ const v2Prompt = vi.fn(async () => {
219
+ throw new Error('V2 session prompt is not available yet');
220
+ });
221
+ const wait = vi.fn(async () => ({}));
222
+ const adapter = new OpenCodeRuntimeAdapter({
223
+ home: await tempHome(),
224
+ sdk: {
225
+ createOpencode: async () => ({
226
+ client: {
227
+ v2: {
228
+ session: {
229
+ messages: async () => [],
230
+ prompt: v2Prompt,
231
+ wait,
232
+ },
233
+ },
234
+ session: {
235
+ list: async () => [],
236
+ create: async () => ({
237
+ id: 'session-1',
238
+ directory: '/tmp/project',
239
+ }),
240
+ get: async () => ({
241
+ id: 'session-1',
242
+ directory: '/tmp/project',
243
+ }),
244
+ messages: async () => [],
245
+ prompt: sessionPrompt,
246
+ abort: async () => ({}),
247
+ },
248
+ },
249
+ server: {
250
+ url: 'http://127.0.0.1:4099',
251
+ close() {},
252
+ },
253
+ }),
254
+ },
255
+ });
256
+ const events: unknown[] = [];
257
+ adapter.on('event', (event) => events.push(event));
258
+
259
+ await adapter.start();
260
+ await adapter.startTurn({
261
+ providerSessionId: 'session-1',
262
+ prompt: 'hi',
263
+ model: 'opencode/big-pickle',
264
+ reasoningEffort: 'low',
265
+ workspacePath: '/tmp/project',
266
+ });
267
+
268
+ await vi.waitFor(() => {
269
+ expect(events).toContainEqual(expect.objectContaining({
270
+ type: 'turn.completed',
271
+ }));
272
+ });
273
+ expect(v2Prompt).not.toHaveBeenCalled();
274
+ expect(sessionPrompt).toHaveBeenCalledWith({
275
+ sessionID: 'session-1',
276
+ directory: '/tmp/project',
277
+ model: {
278
+ providerID: 'opencode',
279
+ modelID: 'big-pickle',
280
+ },
281
+ variant: 'low',
282
+ parts: [{ type: 'text', text: 'hi' }],
283
+ });
284
+ expect(wait).toHaveBeenCalledWith({
285
+ sessionID: 'session-1',
286
+ directory: '/tmp/project',
287
+ });
288
+ });
289
+
290
+ it('uses the OpenCode plan agent for plan collaboration mode', async () => {
291
+ const sessionPrompt = vi.fn(async () => ({
292
+ info: {
293
+ id: 'assistant-message-1',
294
+ role: 'assistant',
295
+ time: { created: 2, completed: 3 },
296
+ },
297
+ parts: [{ type: 'text', text: 'Plan ready.' }],
298
+ }));
299
+ const adapter = new OpenCodeRuntimeAdapter({
300
+ home: await tempHome(),
301
+ sdk: {
302
+ createOpencode: async () => ({
303
+ client: {
304
+ session: {
305
+ list: async () => [],
306
+ create: async () => ({
307
+ id: 'session-1',
308
+ directory: '/tmp/project',
309
+ }),
310
+ get: async () => ({
311
+ id: 'session-1',
312
+ directory: '/tmp/project',
313
+ }),
314
+ messages: async () => [
315
+ {
316
+ info: {
317
+ id: 'user-message-1',
318
+ role: 'user',
319
+ time: { created: 1 },
320
+ },
321
+ parts: [{ type: 'text', text: 'plan it' }],
322
+ },
323
+ {
324
+ info: {
325
+ id: 'assistant-message-1',
326
+ role: 'assistant',
327
+ time: { created: 2, completed: 3 },
328
+ },
329
+ parts: [{ type: 'text', text: 'Plan ready.' }],
330
+ },
331
+ ],
332
+ prompt: sessionPrompt,
333
+ abort: async () => ({}),
334
+ },
335
+ },
336
+ server: {
337
+ url: 'http://127.0.0.1:4099',
338
+ close() {},
339
+ },
340
+ }),
341
+ },
342
+ });
343
+ const events: unknown[] = [];
344
+ adapter.on('event', (event) => events.push(event));
345
+
346
+ await adapter.start();
347
+ await adapter.startTurn({
348
+ providerSessionId: 'session-1',
349
+ prompt: 'plan it',
350
+ model: 'opencode/big-pickle',
351
+ collaborationMode: 'plan',
352
+ workspacePath: '/tmp/project',
353
+ });
354
+
355
+ await vi.waitFor(() => {
356
+ expect(sessionPrompt).toHaveBeenCalledWith(expect.objectContaining({
357
+ agent: 'plan',
358
+ }));
359
+ });
360
+ await vi.waitFor(() => {
361
+ expect(events).toContainEqual(expect.objectContaining({
362
+ type: 'turn.completed',
363
+ turn: expect.objectContaining({
364
+ items: expect.arrayContaining([
365
+ expect.objectContaining({
366
+ kind: 'plan',
367
+ text: 'Plan ready.',
368
+ }),
369
+ ]),
370
+ }),
371
+ }));
372
+ });
373
+ });
374
+
375
+ it('falls back to legacy session APIs when v2 session reads or waits are unavailable', async () => {
376
+ const sessionPrompt = vi.fn(async () => ({
377
+ info: {
378
+ id: 'assistant-message-1',
379
+ type: 'assistant',
380
+ text: 'hello',
381
+ },
382
+ parts: [],
383
+ }));
384
+ const legacyMessages = vi.fn(async () => [
385
+ {
386
+ info: {
387
+ id: 'user-message-1',
388
+ role: 'user',
389
+ time: { created: 1 },
390
+ },
391
+ parts: [{ type: 'text', text: 'hi' }],
392
+ },
393
+ {
394
+ info: {
395
+ id: 'assistant-message-1',
396
+ role: 'assistant',
397
+ time: { created: 2, completed: 3 },
398
+ },
399
+ parts: [{ type: 'text', text: 'hello' }],
400
+ },
401
+ ]);
402
+ const v2Messages = vi.fn(async () => {
403
+ throw new Error('V2 session messages are not available yet');
404
+ });
405
+ const v2Wait = vi.fn(async () => {
406
+ throw new Error('V2 session wait failed');
407
+ });
408
+ const legacyWait = vi.fn(async () => ({}));
409
+ const adapter = new OpenCodeRuntimeAdapter({
410
+ home: await tempHome(),
411
+ sdk: {
412
+ createOpencode: async () => ({
413
+ client: {
414
+ v2: {
415
+ session: {
416
+ messages: v2Messages,
417
+ wait: v2Wait,
418
+ },
419
+ },
420
+ session: {
421
+ list: async () => [],
422
+ create: async () => ({
423
+ id: 'session-1',
424
+ directory: '/tmp/project',
425
+ }),
426
+ get: async () => ({
427
+ id: 'session-1',
428
+ directory: '/tmp/project',
429
+ }),
430
+ messages: legacyMessages,
431
+ prompt: sessionPrompt,
432
+ wait: legacyWait,
433
+ abort: async () => ({}),
434
+ },
435
+ },
436
+ server: {
437
+ url: 'http://127.0.0.1:4099',
438
+ close() {},
439
+ },
440
+ }),
441
+ },
442
+ });
443
+ const events: unknown[] = [];
444
+ adapter.on('event', (event) => events.push(event));
445
+
446
+ await adapter.start();
447
+ await adapter.startTurn({
448
+ providerSessionId: 'session-1',
449
+ prompt: 'hi',
450
+ model: 'opencode/big-pickle',
451
+ workspacePath: '/tmp/project',
452
+ });
453
+
454
+ await vi.waitFor(() => {
455
+ expect(events).toContainEqual(expect.objectContaining({
456
+ type: 'turn.completed',
457
+ }));
458
+ });
459
+ expect(sessionPrompt).toHaveBeenCalledOnce();
460
+ expect(v2Wait).toHaveBeenCalledOnce();
461
+ expect(legacyWait).toHaveBeenCalledWith({
462
+ sessionID: 'session-1',
463
+ directory: '/tmp/project',
464
+ });
465
+ expect(v2Messages).not.toHaveBeenCalled();
466
+ expect(legacyMessages).toHaveBeenCalledWith({
467
+ sessionID: 'session-1',
468
+ directory: '/tmp/project',
469
+ });
470
+ });
471
+
472
+ it('uses legacy messages before v2 messages because v2 can omit assistant content', async () => {
473
+ const adapter = new OpenCodeRuntimeAdapter({
474
+ home: await tempHome(),
475
+ sdk: {
476
+ createOpencode: async () => ({
477
+ client: {
478
+ v2: {
479
+ session: {
480
+ messages: async () => ({
481
+ items: [
482
+ { id: 'model-1', type: 'model-switched', model: { id: 'gpt-5.5', providerID: 'openai', variant: 'low' } },
483
+ { id: 'agent-1', type: 'agent-switched', agent: 'build' },
484
+ ],
485
+ }),
486
+ },
487
+ },
488
+ session: {
489
+ list: async () => [],
490
+ create: async () => ({}),
491
+ get: async () => ({
492
+ id: 'session-1',
493
+ directory: '/tmp/project',
494
+ }),
495
+ messages: async () => [
496
+ {
497
+ info: {
498
+ id: 'user-message-1',
499
+ role: 'user',
500
+ time: { created: 1 },
501
+ },
502
+ parts: [{ type: 'text', text: 'hi' }],
503
+ },
504
+ {
505
+ info: {
506
+ id: 'assistant-message-1',
507
+ role: 'assistant',
508
+ time: { created: 2, completed: 3 },
509
+ },
510
+ parts: [{ type: 'text', text: 'hello' }],
511
+ },
512
+ ],
513
+ prompt: async () => ({}),
514
+ abort: async () => ({}),
515
+ },
516
+ },
517
+ server: {
518
+ url: 'http://127.0.0.1:4099',
519
+ close() {},
520
+ },
521
+ }),
522
+ },
523
+ });
524
+
525
+ await adapter.start();
526
+ const detail = await adapter.readSession('session-1', { workspacePath: '/tmp/project' });
527
+
528
+ expect(detail.turns.at(-1)?.items).toContainEqual(expect.objectContaining({
529
+ kind: 'agentMessage',
530
+ text: 'hello',
531
+ }));
532
+ });
533
+
534
+ it('does not complete a new turn from an older assistant message', async () => {
535
+ vi.useFakeTimers();
536
+ const messages: unknown[] = [
537
+ {
538
+ info: {
539
+ id: 'old-user-message',
540
+ role: 'user',
541
+ time: { created: 1 },
542
+ },
543
+ parts: [{ type: 'text', text: 'old hi' }],
544
+ },
545
+ {
546
+ info: {
547
+ id: 'old-assistant-message',
548
+ role: 'assistant',
549
+ time: { created: 2, completed: 3 },
550
+ },
551
+ parts: [{ type: 'text', text: 'old reply' }],
552
+ },
553
+ ];
554
+ const adapter = new OpenCodeRuntimeAdapter({
555
+ home: await tempHome(),
556
+ sdk: {
557
+ createOpencode: async () => ({
558
+ client: {
559
+ session: {
560
+ list: async () => [],
561
+ create: async () => ({
562
+ id: 'session-1',
563
+ directory: '/tmp/project',
564
+ }),
565
+ get: async () => ({
566
+ id: 'session-1',
567
+ directory: '/tmp/project',
568
+ }),
569
+ status: async () => ({
570
+ 'session-1': { type: 'idle' },
571
+ }),
572
+ messages: async () => messages,
573
+ prompt: async () => {
574
+ messages.push({
575
+ info: {
576
+ id: 'new-user-message',
577
+ role: 'user',
578
+ time: { created: 4 },
579
+ },
580
+ parts: [{ type: 'text', text: 'new hi' }],
581
+ });
582
+ return new Promise(() => {});
583
+ },
584
+ abort: async () => ({}),
585
+ },
586
+ },
587
+ server: {
588
+ url: 'http://127.0.0.1:4099',
589
+ close() {},
590
+ },
591
+ }),
592
+ },
593
+ });
594
+ const events: unknown[] = [];
595
+ adapter.on('event', (event) => events.push(event));
596
+
597
+ await adapter.start();
598
+ await adapter.startTurn({
599
+ providerSessionId: 'session-1',
600
+ prompt: 'new hi',
601
+ model: 'opencode/big-pickle',
602
+ workspacePath: '/tmp/project',
603
+ });
604
+ await vi.advanceTimersByTimeAsync(1_000);
605
+
606
+ expect(events).not.toContainEqual(expect.objectContaining({
607
+ type: 'turn.completed',
608
+ }));
609
+ vi.useRealTimers();
610
+ });
611
+
612
+ it('emits live item events while polling OpenCode messages', async () => {
613
+ vi.useFakeTimers();
614
+ const messages: unknown[] = [
615
+ {
616
+ info: {
617
+ id: 'old-user-message',
618
+ role: 'user',
619
+ time: { created: 1 },
620
+ },
621
+ parts: [{ type: 'text', text: 'old hi' }],
622
+ },
623
+ {
624
+ info: {
625
+ id: 'old-assistant-message',
626
+ role: 'assistant',
627
+ time: { created: 2, completed: 3 },
628
+ },
629
+ parts: [{ type: 'text', text: 'old reply' }],
630
+ },
631
+ ];
632
+ const adapter = new OpenCodeRuntimeAdapter({
633
+ home: await tempHome(),
634
+ sdk: {
635
+ createOpencode: async () => ({
636
+ client: {
637
+ session: {
638
+ list: async () => [],
639
+ create: async () => ({
640
+ id: 'session-1',
641
+ directory: '/tmp/project',
642
+ }),
643
+ get: async () => ({
644
+ id: 'session-1',
645
+ directory: '/tmp/project',
646
+ }),
647
+ status: async () => ({
648
+ 'session-1': { type: 'idle' },
649
+ }),
650
+ messages: async () => messages,
651
+ prompt: async () => {
652
+ messages.push(
653
+ {
654
+ info: {
655
+ id: 'new-user-message',
656
+ role: 'user',
657
+ time: { created: 4 },
658
+ },
659
+ parts: [{ type: 'text', text: 'new hi' }],
660
+ },
661
+ {
662
+ info: {
663
+ id: 'new-assistant-message',
664
+ role: 'assistant',
665
+ time: { created: 5, completed: 6 },
666
+ },
667
+ parts: [{ id: 'new-text-part', type: 'text', text: 'new reply' }],
668
+ },
669
+ );
670
+ return new Promise(() => {});
671
+ },
672
+ abort: async () => ({}),
673
+ },
674
+ },
675
+ server: {
676
+ url: 'http://127.0.0.1:4099',
677
+ close() {},
678
+ },
679
+ }),
680
+ },
681
+ });
682
+ const events: unknown[] = [];
683
+ adapter.on('event', (event) => events.push(event));
684
+
685
+ await adapter.start();
686
+ await adapter.startTurn({
687
+ providerSessionId: 'session-1',
688
+ prompt: 'new hi',
689
+ model: 'opencode/big-pickle',
690
+ workspacePath: '/tmp/project',
691
+ });
692
+ await vi.advanceTimersByTimeAsync(1_000);
693
+
694
+ expect(events).toContainEqual(expect.objectContaining({
695
+ type: 'item.completed',
696
+ item: expect.objectContaining({
697
+ kind: 'agentMessage',
698
+ text: 'new reply',
699
+ }),
700
+ }));
701
+ vi.useRealTimers();
702
+ });
703
+
704
+ it('keeps polling after completed todo tools until an assistant message arrives', async () => {
705
+ vi.useFakeTimers();
706
+ const messages: unknown[] = [];
707
+ let busy = true;
708
+ const adapter = new OpenCodeRuntimeAdapter({
709
+ home: await tempHome(),
710
+ sdk: {
711
+ createOpencode: async () => ({
712
+ client: {
713
+ session: {
714
+ list: async () => [],
715
+ create: async () => ({
716
+ id: 'session-1',
717
+ directory: '/tmp/project',
718
+ }),
719
+ get: async () => ({
720
+ id: 'session-1',
721
+ directory: '/tmp/project',
722
+ }),
723
+ status: async () => ({
724
+ 'session-1': { type: busy ? 'busy' : 'idle' },
725
+ }),
726
+ messages: async () => messages,
727
+ prompt: async () => {
728
+ messages.push(
729
+ {
730
+ info: {
731
+ id: 'new-user-message',
732
+ role: 'user',
733
+ time: { created: 1 },
734
+ },
735
+ parts: [{ type: 'text', text: 'new hi' }],
736
+ },
737
+ {
738
+ info: {
739
+ id: 'todo-message',
740
+ role: 'assistant',
741
+ time: { created: 2 },
742
+ },
743
+ parts: [
744
+ {
745
+ id: 'todo-part',
746
+ type: 'tool',
747
+ tool: 'todowrite',
748
+ state: {
749
+ status: 'completed',
750
+ input: {
751
+ todos: [
752
+ {
753
+ content: 'Inspect code',
754
+ status: 'in_progress',
755
+ priority: 'high',
756
+ },
757
+ ],
758
+ },
759
+ output: '[]',
760
+ title: '1 todo',
761
+ metadata: {},
762
+ },
763
+ },
764
+ ],
765
+ },
766
+ );
767
+ setTimeout(() => {
768
+ messages.push({
769
+ info: {
770
+ id: 'assistant-message',
771
+ role: 'assistant',
772
+ time: { created: 3, completed: 4 },
773
+ },
774
+ parts: [{ id: 'text-part', type: 'text', text: 'done' }],
775
+ });
776
+ busy = false;
777
+ }, 1_500);
778
+ return new Promise(() => {});
779
+ },
780
+ abort: async () => ({}),
781
+ },
782
+ },
783
+ server: {
784
+ url: 'http://127.0.0.1:4099',
785
+ close() {},
786
+ },
787
+ }),
788
+ },
789
+ });
790
+ const events: unknown[] = [];
791
+ adapter.on('event', (event) => events.push(event));
792
+
793
+ await adapter.start();
794
+ await adapter.startTurn({
795
+ providerSessionId: 'session-1',
796
+ prompt: 'new hi',
797
+ model: 'opencode/big-pickle',
798
+ workspacePath: '/tmp/project',
799
+ });
800
+ await vi.advanceTimersByTimeAsync(1_000);
801
+
802
+ expect(events).toContainEqual(expect.objectContaining({
803
+ type: 'plan.updated',
804
+ explanation: null,
805
+ plan: [
806
+ {
807
+ step: 'Inspect code',
808
+ status: 'in_progress',
809
+ },
810
+ ],
811
+ }));
812
+ expect(events).not.toContainEqual(expect.objectContaining({
813
+ type: 'turn.completed',
814
+ }));
815
+
816
+ await vi.advanceTimersByTimeAsync(1_500);
817
+ await vi.waitFor(() => {
818
+ expect(events).toContainEqual(expect.objectContaining({
819
+ type: 'turn.completed',
820
+ turn: expect.objectContaining({
821
+ items: expect.arrayContaining([
822
+ expect.objectContaining({
823
+ kind: 'agentMessage',
824
+ text: 'done',
825
+ }),
826
+ ]),
827
+ }),
828
+ }));
829
+ });
830
+
831
+ vi.useRealTimers();
832
+ });
833
+
834
+ it('keeps a turn running while OpenCode status is busy after assistant text', async () => {
835
+ vi.useFakeTimers();
836
+ const messages: unknown[] = [];
837
+ let busy = true;
838
+ const adapter = new OpenCodeRuntimeAdapter({
839
+ home: await tempHome(),
840
+ sdk: {
841
+ createOpencode: async () => ({
842
+ client: {
843
+ session: {
844
+ list: async () => [],
845
+ create: async () => ({
846
+ id: 'session-1',
847
+ directory: '/tmp/project',
848
+ }),
849
+ get: async () => ({
850
+ id: 'session-1',
851
+ directory: '/tmp/project',
852
+ }),
853
+ status: async () => ({
854
+ 'session-1': { type: busy ? 'busy' : 'idle' },
855
+ }),
856
+ messages: async () => messages,
857
+ prompt: async () => {
858
+ messages.push(
859
+ {
860
+ info: {
861
+ id: 'user-message',
862
+ role: 'user',
863
+ time: { created: 1 },
864
+ },
865
+ parts: [{ type: 'text', text: 'new hi' }],
866
+ },
867
+ {
868
+ info: {
869
+ id: 'assistant-message',
870
+ role: 'assistant',
871
+ time: { created: 2 },
872
+ },
873
+ parts: [{ id: 'text-part', type: 'text', text: 'partial reply' }],
874
+ },
875
+ );
876
+ setTimeout(() => {
877
+ busy = false;
878
+ }, 1_500);
879
+ return new Promise(() => {});
880
+ },
881
+ abort: async () => ({}),
882
+ },
883
+ },
884
+ server: {
885
+ url: 'http://127.0.0.1:4099',
886
+ close() {},
887
+ },
888
+ }),
889
+ },
890
+ });
891
+ const events: unknown[] = [];
892
+ adapter.on('event', (event) => events.push(event));
893
+
894
+ await adapter.start();
895
+ await adapter.startTurn({
896
+ providerSessionId: 'session-1',
897
+ prompt: 'new hi',
898
+ model: 'opencode/big-pickle',
899
+ workspacePath: '/tmp/project',
900
+ });
901
+ await vi.advanceTimersByTimeAsync(1_000);
902
+
903
+ expect(events).toContainEqual(expect.objectContaining({
904
+ type: 'item.completed',
905
+ item: expect.objectContaining({
906
+ kind: 'agentMessage',
907
+ text: 'partial reply',
908
+ }),
909
+ }));
910
+ expect(events).not.toContainEqual(expect.objectContaining({
911
+ type: 'turn.completed',
912
+ }));
913
+
914
+ await vi.advanceTimersByTimeAsync(1_500);
915
+
916
+ expect(events).toContainEqual(expect.objectContaining({
917
+ type: 'turn.completed',
918
+ turn: expect.objectContaining({
919
+ items: expect.arrayContaining([
920
+ expect.objectContaining({
921
+ kind: 'agentMessage',
922
+ text: 'partial reply',
923
+ }),
924
+ ]),
925
+ }),
926
+ }));
927
+ vi.useRealTimers();
928
+ });
929
+
930
+ it('emits token usage from OpenCode step finish parts', async () => {
931
+ vi.useFakeTimers();
932
+ const messages: unknown[] = [];
933
+ const adapter = new OpenCodeRuntimeAdapter({
934
+ home: await tempHome(),
935
+ sdk: {
936
+ createOpencode: async () => ({
937
+ client: {
938
+ session: {
939
+ list: async () => [],
940
+ create: async () => ({
941
+ id: 'session-1',
942
+ directory: '/tmp/project',
943
+ }),
944
+ get: async () => ({
945
+ id: 'session-1',
946
+ directory: '/tmp/project',
947
+ }),
948
+ status: async () => ({
949
+ 'session-1': { type: 'idle' },
950
+ }),
951
+ messages: async () => messages,
952
+ prompt: async () => {
953
+ messages.push(
954
+ {
955
+ info: {
956
+ id: 'user-message-1',
957
+ role: 'user',
958
+ time: { created: 1 },
959
+ },
960
+ parts: [{ type: 'text', text: 'hi' }],
961
+ },
962
+ {
963
+ info: {
964
+ id: 'assistant-message-1',
965
+ role: 'assistant',
966
+ time: { created: 2, completed: 3 },
967
+ },
968
+ parts: [
969
+ { id: 'text-1', type: 'text', text: 'done' },
970
+ {
971
+ id: 'finish-1',
972
+ type: 'step-finish',
973
+ tokens: {
974
+ input: 140000,
975
+ output: 25200,
976
+ reasoning: 1200,
977
+ cache: { read: 8200, write: 0 },
978
+ contextWindow: 258400,
979
+ },
980
+ },
981
+ ],
982
+ },
983
+ );
984
+ return new Promise(() => {});
985
+ },
986
+ abort: async () => ({}),
987
+ },
988
+ },
989
+ server: {
990
+ url: 'http://127.0.0.1:4099',
991
+ close() {},
992
+ },
993
+ }),
994
+ },
995
+ });
996
+ const events: unknown[] = [];
997
+ adapter.on('event', (event) => events.push(event));
998
+
999
+ await adapter.start();
1000
+ await adapter.startTurn({
1001
+ providerSessionId: 'session-1',
1002
+ prompt: 'hi',
1003
+ model: 'openai/gpt-5.5',
1004
+ workspacePath: '/tmp/project',
1005
+ });
1006
+ await vi.advanceTimersByTimeAsync(1_000);
1007
+ await vi.waitFor(() => {
1008
+ expect(events).toContainEqual(expect.objectContaining({
1009
+ type: 'usage.updated',
1010
+ provider: 'opencode',
1011
+ usage: {
1012
+ total: {
1013
+ totalTokens: 165200,
1014
+ inputTokens: 140000,
1015
+ cachedInputTokens: 8200,
1016
+ outputTokens: 25200,
1017
+ reasoningOutputTokens: 1200,
1018
+ },
1019
+ last: {
1020
+ totalTokens: 165200,
1021
+ inputTokens: 140000,
1022
+ cachedInputTokens: 8200,
1023
+ outputTokens: 25200,
1024
+ reasoningOutputTokens: 1200,
1025
+ },
1026
+ modelContextWindow: 258400,
1027
+ cumulative: false,
1028
+ },
1029
+ }));
1030
+ });
1031
+
1032
+ vi.useRealTimers();
1033
+ });
1034
+
1035
+ it('maps sandbox modes to OpenCode session permissions', async () => {
1036
+ const sessionCreate = vi.fn(async () => ({
1037
+ id: 'session-1',
1038
+ directory: '/tmp/project',
1039
+ }));
1040
+ const sessionUpdate = vi.fn(async () => ({}));
1041
+ const sessionPrompt = vi.fn(async () => ({
1042
+ info: {
1043
+ id: 'assistant-message-1',
1044
+ role: 'assistant',
1045
+ time: { created: 2, completed: 3 },
1046
+ },
1047
+ parts: [{ type: 'text', text: 'done' }],
1048
+ }));
1049
+ const adapter = new OpenCodeRuntimeAdapter({
1050
+ home: await tempHome(),
1051
+ sdk: {
1052
+ createOpencode: async () => ({
1053
+ client: {
1054
+ session: {
1055
+ list: async () => [],
1056
+ create: sessionCreate,
1057
+ get: async () => ({
1058
+ id: 'session-1',
1059
+ directory: '/tmp/project',
1060
+ }),
1061
+ update: sessionUpdate,
1062
+ messages: async () => [
1063
+ {
1064
+ info: {
1065
+ id: 'user-message-1',
1066
+ role: 'user',
1067
+ time: { created: 1 },
1068
+ },
1069
+ parts: [{ type: 'text', text: 'hi' }],
1070
+ },
1071
+ {
1072
+ info: {
1073
+ id: 'assistant-message-1',
1074
+ role: 'assistant',
1075
+ time: { created: 2, completed: 3 },
1076
+ },
1077
+ parts: [{ type: 'text', text: 'done' }],
1078
+ },
1079
+ ],
1080
+ prompt: sessionPrompt,
1081
+ abort: async () => ({}),
1082
+ },
1083
+ },
1084
+ server: {
1085
+ url: 'http://127.0.0.1:4099',
1086
+ close() {},
1087
+ },
1088
+ }),
1089
+ },
1090
+ });
1091
+
1092
+ await adapter.start();
1093
+ expect(adapter.capabilities.controls.sandboxMode).toBe(true);
1094
+ await adapter.startSession({
1095
+ cwd: '/tmp/project',
1096
+ model: 'opencode/big-pickle',
1097
+ approvalMode: 'guarded',
1098
+ sandboxMode: 'workspace-write',
1099
+ });
1100
+ await adapter.startTurn({
1101
+ providerSessionId: 'session-1',
1102
+ prompt: 'hi',
1103
+ model: 'opencode/big-pickle',
1104
+ workspacePath: '/tmp/project',
1105
+ sandboxMode: 'danger-full-access',
1106
+ });
1107
+
1108
+ expect(sessionCreate).toHaveBeenCalledWith(expect.objectContaining({
1109
+ permission: expect.arrayContaining([
1110
+ { permission: 'edit', pattern: '*', action: 'allow' },
1111
+ { permission: 'bash', pattern: '*', action: 'ask' },
1112
+ { permission: 'external_directory', pattern: '*', action: 'ask' },
1113
+ ]),
1114
+ }));
1115
+ expect(sessionUpdate).toHaveBeenCalledWith(expect.objectContaining({
1116
+ sessionID: 'session-1',
1117
+ directory: '/tmp/project',
1118
+ permission: expect.arrayContaining([
1119
+ { permission: 'edit', pattern: '*', action: 'allow' },
1120
+ { permission: 'bash', pattern: '*', action: 'allow' },
1121
+ { permission: 'external_directory', pattern: '*', action: 'allow' },
1122
+ ]),
1123
+ }));
1124
+ });
1125
+
1126
+ it('does not append duplicate OpenCode permissions when sandbox mode is unchanged', async () => {
1127
+ const sessionUpdate = vi.fn(async () => ({}));
1128
+ const adapter = new OpenCodeRuntimeAdapter({
1129
+ home: await tempHome(),
1130
+ sdk: {
1131
+ createOpencode: async () => ({
1132
+ client: {
1133
+ session: {
1134
+ list: async () => [],
1135
+ create: async () => ({
1136
+ id: 'session-1',
1137
+ directory: '/tmp/project',
1138
+ }),
1139
+ get: async () => ({
1140
+ id: 'session-1',
1141
+ directory: '/tmp/project',
1142
+ }),
1143
+ update: sessionUpdate,
1144
+ messages: async () => [
1145
+ {
1146
+ info: {
1147
+ id: 'user-message-1',
1148
+ role: 'user',
1149
+ time: { created: 1 },
1150
+ },
1151
+ parts: [{ type: 'text', text: 'hi' }],
1152
+ },
1153
+ {
1154
+ info: {
1155
+ id: 'assistant-message-1',
1156
+ role: 'assistant',
1157
+ time: { created: 2, completed: 3 },
1158
+ },
1159
+ parts: [{ type: 'text', text: 'done' }],
1160
+ },
1161
+ ],
1162
+ prompt: async () => ({
1163
+ info: {
1164
+ id: 'assistant-message-1',
1165
+ role: 'assistant',
1166
+ time: { created: 2, completed: 3 },
1167
+ },
1168
+ parts: [{ type: 'text', text: 'done' }],
1169
+ }),
1170
+ abort: async () => ({}),
1171
+ },
1172
+ },
1173
+ server: {
1174
+ url: 'http://127.0.0.1:4099',
1175
+ close() {},
1176
+ },
1177
+ }),
1178
+ },
1179
+ });
1180
+
1181
+ await adapter.start();
1182
+ await adapter.startSession({
1183
+ cwd: '/tmp/project',
1184
+ model: 'opencode/big-pickle',
1185
+ approvalMode: 'guarded',
1186
+ sandboxMode: 'workspace-write',
1187
+ });
1188
+ await adapter.startTurn({
1189
+ providerSessionId: 'session-1',
1190
+ prompt: 'hi',
1191
+ model: 'opencode/big-pickle',
1192
+ workspacePath: '/tmp/project',
1193
+ sandboxMode: 'workspace-write',
1194
+ });
1195
+
1196
+ expect(sessionUpdate).not.toHaveBeenCalled();
1197
+ });
1198
+
1199
+ it('updates OpenCode permissions when sandbox mode changes', async () => {
1200
+ const sessionUpdate = vi.fn(async () => ({}));
1201
+ const adapter = new OpenCodeRuntimeAdapter({
1202
+ home: await tempHome(),
1203
+ sdk: {
1204
+ createOpencode: async () => ({
1205
+ client: {
1206
+ session: {
1207
+ list: async () => [],
1208
+ create: async () => ({
1209
+ id: 'session-1',
1210
+ directory: '/tmp/project',
1211
+ }),
1212
+ get: async () => ({
1213
+ id: 'session-1',
1214
+ directory: '/tmp/project',
1215
+ }),
1216
+ update: sessionUpdate,
1217
+ messages: async () => [
1218
+ {
1219
+ info: {
1220
+ id: 'user-message-1',
1221
+ role: 'user',
1222
+ time: { created: 1 },
1223
+ },
1224
+ parts: [{ type: 'text', text: 'hi' }],
1225
+ },
1226
+ {
1227
+ info: {
1228
+ id: 'assistant-message-1',
1229
+ role: 'assistant',
1230
+ time: { created: 2, completed: 3 },
1231
+ },
1232
+ parts: [{ type: 'text', text: 'done' }],
1233
+ },
1234
+ ],
1235
+ prompt: async () => ({
1236
+ info: {
1237
+ id: 'assistant-message-1',
1238
+ role: 'assistant',
1239
+ time: { created: 2, completed: 3 },
1240
+ },
1241
+ parts: [{ type: 'text', text: 'done' }],
1242
+ }),
1243
+ abort: async () => ({}),
1244
+ },
1245
+ },
1246
+ server: {
1247
+ url: 'http://127.0.0.1:4099',
1248
+ close() {},
1249
+ },
1250
+ }),
1251
+ },
1252
+ });
1253
+
1254
+ await adapter.start();
1255
+ await adapter.startSession({
1256
+ cwd: '/tmp/project',
1257
+ model: 'opencode/big-pickle',
1258
+ approvalMode: 'guarded',
1259
+ sandboxMode: 'workspace-write',
1260
+ });
1261
+ await adapter.startTurn({
1262
+ providerSessionId: 'session-1',
1263
+ prompt: 'hi',
1264
+ model: 'opencode/big-pickle',
1265
+ workspacePath: '/tmp/project',
1266
+ sandboxMode: 'danger-full-access',
1267
+ });
1268
+
1269
+ expect(sessionUpdate).toHaveBeenCalledOnce();
1270
+ });
1271
+
1272
+ it('completes turns from legacy prompt responses when session wait does not return', async () => {
1273
+ vi.useFakeTimers();
1274
+ const sessionPrompt = vi.fn(async () => ({
1275
+ info: {
1276
+ id: 'assistant-message-1',
1277
+ role: 'assistant',
1278
+ time: { created: 1_700_000_000_000, completed: 1_700_000_001_000 },
1279
+ },
1280
+ parts: [{ id: 'part-1', type: 'text', text: 'hello from opencode' }],
1281
+ }));
1282
+ const adapter = new OpenCodeRuntimeAdapter({
1283
+ home: await tempHome(),
1284
+ sdk: {
1285
+ createOpencode: async () => ({
1286
+ client: {
1287
+ v2: {
1288
+ session: {
1289
+ messages: async () => ({ items: [] }),
1290
+ wait: vi.fn(() => new Promise(() => {})),
1291
+ },
1292
+ },
1293
+ session: {
1294
+ list: async () => [],
1295
+ create: async () => ({
1296
+ id: 'session-1',
1297
+ directory: '/tmp/project',
1298
+ }),
1299
+ get: async () => ({
1300
+ id: 'session-1',
1301
+ directory: '/tmp/project',
1302
+ }),
1303
+ messages: async () => [],
1304
+ prompt: sessionPrompt,
1305
+ abort: async () => ({}),
1306
+ },
1307
+ },
1308
+ server: {
1309
+ url: 'http://127.0.0.1:4099',
1310
+ close() {},
1311
+ },
1312
+ }),
1313
+ },
1314
+ });
1315
+ const events: unknown[] = [];
1316
+ adapter.on('event', (event) => events.push(event));
1317
+
1318
+ await adapter.start();
1319
+ await adapter.startTurn({
1320
+ providerSessionId: 'session-1',
1321
+ prompt: 'hi',
1322
+ model: 'opencode/big-pickle',
1323
+ workspacePath: '/tmp/project',
1324
+ });
1325
+ await vi.advanceTimersByTimeAsync(1_500);
1326
+
1327
+ await vi.waitFor(() => {
1328
+ expect(events).toContainEqual(expect.objectContaining({
1329
+ type: 'turn.completed',
1330
+ turn: expect.objectContaining({
1331
+ items: [expect.objectContaining({
1332
+ kind: 'agentMessage',
1333
+ text: 'hello from opencode',
1334
+ })],
1335
+ }),
1336
+ }));
1337
+ });
1338
+ vi.useRealTimers();
1339
+ });
1340
+
1341
+ it('reports missing SDK as an unavailable installation instead of throwing', async () => {
1342
+ const adapter = new OpenCodeRuntimeAdapter({
1343
+ home: await tempHome(),
1344
+ });
1345
+
1346
+ await adapter.start();
1347
+
1348
+ expect(adapter.installation.installed).toBe(false);
1349
+ expect(adapter.installation.lastError).toContain('Install OpenCode support');
1350
+ expect(adapter.getStatus()).toMatchObject({
1351
+ state: 'stopped',
1352
+ transport: 'sdk',
1353
+ });
1354
+ });
1355
+ });