@cleocode/adapters 2026.4.47 → 2026.4.49

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 (61) hide show
  1. package/dist/cant-context.d.ts +132 -1
  2. package/dist/cant-context.d.ts.map +1 -1
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +19765 -375
  6. package/dist/index.js.map +4 -4
  7. package/dist/providers/claude-code/adapter.d.ts +12 -6
  8. package/dist/providers/claude-code/adapter.d.ts.map +1 -1
  9. package/dist/providers/claude-code/hooks.d.ts.map +1 -1
  10. package/dist/providers/claude-code/spawn.d.ts.map +1 -1
  11. package/dist/providers/claude-sdk/index.d.ts +18 -0
  12. package/dist/providers/claude-sdk/index.d.ts.map +1 -0
  13. package/dist/providers/claude-sdk/mcp-registry.d.ts +40 -0
  14. package/dist/providers/claude-sdk/mcp-registry.d.ts.map +1 -0
  15. package/dist/providers/claude-sdk/session-store.d.ts +78 -0
  16. package/dist/providers/claude-sdk/session-store.d.ts.map +1 -0
  17. package/dist/providers/claude-sdk/spawn.d.ts +79 -0
  18. package/dist/providers/claude-sdk/spawn.d.ts.map +1 -0
  19. package/dist/providers/claude-sdk/tool-bridge.d.ts +38 -0
  20. package/dist/providers/claude-sdk/tool-bridge.d.ts.map +1 -0
  21. package/dist/providers/openai-sdk/adapter.d.ts +77 -0
  22. package/dist/providers/openai-sdk/adapter.d.ts.map +1 -0
  23. package/dist/providers/openai-sdk/guardrails.d.ts +67 -0
  24. package/dist/providers/openai-sdk/guardrails.d.ts.map +1 -0
  25. package/dist/providers/openai-sdk/handoff.d.ts +94 -0
  26. package/dist/providers/openai-sdk/handoff.d.ts.map +1 -0
  27. package/dist/providers/openai-sdk/index.d.ts +39 -0
  28. package/dist/providers/openai-sdk/index.d.ts.map +1 -0
  29. package/dist/providers/openai-sdk/install.d.ts +61 -0
  30. package/dist/providers/openai-sdk/install.d.ts.map +1 -0
  31. package/dist/providers/openai-sdk/spawn.d.ts +146 -0
  32. package/dist/providers/openai-sdk/spawn.d.ts.map +1 -0
  33. package/dist/providers/openai-sdk/tracing.d.ts +89 -0
  34. package/dist/providers/openai-sdk/tracing.d.ts.map +1 -0
  35. package/dist/providers/shared/conduit-trace-writer.d.ts +72 -0
  36. package/dist/providers/shared/conduit-trace-writer.d.ts.map +1 -0
  37. package/dist/providers/shared/sdk-result-mapper.d.ts +51 -0
  38. package/dist/providers/shared/sdk-result-mapper.d.ts.map +1 -0
  39. package/package.json +5 -3
  40. package/src/cant-context.ts +397 -3
  41. package/src/index.ts +24 -2
  42. package/src/providers/claude-code/adapter.ts +41 -4
  43. package/src/providers/claude-code/hooks.ts +7 -1
  44. package/src/providers/claude-code/spawn.ts +5 -1
  45. package/src/providers/claude-sdk/__tests__/spawn.test.ts +448 -0
  46. package/src/providers/claude-sdk/index.ts +18 -0
  47. package/src/providers/claude-sdk/mcp-registry.ts +96 -0
  48. package/src/providers/claude-sdk/session-store.ts +103 -0
  49. package/src/providers/claude-sdk/spawn.ts +242 -0
  50. package/src/providers/claude-sdk/tool-bridge.ts +51 -0
  51. package/src/providers/openai-sdk/__tests__/openai-sdk-spawn.test.ts +716 -0
  52. package/src/providers/openai-sdk/adapter.ts +138 -0
  53. package/src/providers/openai-sdk/guardrails.ts +158 -0
  54. package/src/providers/openai-sdk/handoff.ts +187 -0
  55. package/src/providers/openai-sdk/index.ts +55 -0
  56. package/src/providers/openai-sdk/install.ts +135 -0
  57. package/src/providers/openai-sdk/manifest.json +45 -0
  58. package/src/providers/openai-sdk/spawn.ts +300 -0
  59. package/src/providers/openai-sdk/tracing.ts +175 -0
  60. package/src/providers/shared/conduit-trace-writer.ts +101 -0
  61. package/src/providers/shared/sdk-result-mapper.ts +83 -0
@@ -0,0 +1,716 @@
1
+ /**
2
+ * Tests for the OpenAI Agents SDK spawn provider.
3
+ *
4
+ * All OpenAI SDK calls are mocked — no real API keys or network calls
5
+ * are required to run this suite.
6
+ *
7
+ * Test coverage:
8
+ * - GuardrailTests: path ACL logic and guardrail builders
9
+ * - HandoffTests: agent topology construction from tier/handoffs options
10
+ * - SpawnProviderTests: spawn(), listRunning(), terminate(), canSpawn()
11
+ * - AdapterTests: identity, capabilities, initialize, dispose, healthCheck
12
+ * - InstallProviderTests: isInstalled, install, uninstall
13
+ * - TraceProcessorTests: onSpanEnd event capture
14
+ * - HandoffIntegrationTest: lead + worker topology with mocked runner
15
+ *
16
+ * @task T582
17
+ */
18
+
19
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Hoist shared state so vi.mock factories can reference it
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const { createdAgents, mockRunState, mockFsState } = vi.hoisted(() => {
26
+ return {
27
+ createdAgents: [] as Array<{ name?: string; handoffs?: unknown[]; model?: string }>,
28
+ mockRunState: { result: { finalOutput: 'mock output' }, shouldThrow: false },
29
+ mockFsState: { exists: false, content: '' },
30
+ };
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Mock @openai/agents
35
+ // ---------------------------------------------------------------------------
36
+
37
+ vi.mock('@openai/agents', () => {
38
+ class MockAgent {
39
+ name: string;
40
+ handoffs: unknown[];
41
+ model: string;
42
+ inputGuardrails: unknown[];
43
+ instructions: string;
44
+
45
+ constructor(opts: {
46
+ name?: string;
47
+ instructions?: string;
48
+ model?: string;
49
+ handoffs?: unknown[];
50
+ inputGuardrails?: unknown[];
51
+ }) {
52
+ this.name = opts.name ?? 'agent';
53
+ this.instructions = opts.instructions ?? '';
54
+ this.model = opts.model ?? 'gpt-4.1';
55
+ this.handoffs = opts.handoffs ?? [];
56
+ this.inputGuardrails = opts.inputGuardrails ?? [];
57
+ createdAgents.push({ name: this.name, handoffs: this.handoffs, model: this.model });
58
+ }
59
+ }
60
+
61
+ const mockRunFn = async (agent: { name: string }) => {
62
+ if (mockRunState.shouldThrow) throw new Error('mock SDK error');
63
+ return { ...mockRunState.result, _agent: agent };
64
+ };
65
+
66
+ class MockRunner {
67
+ run = vi.fn(mockRunFn);
68
+ }
69
+
70
+ class MockOpenAIProvider {}
71
+
72
+ return {
73
+ Agent: MockAgent,
74
+ Runner: MockRunner,
75
+ OpenAIProvider: MockOpenAIProvider,
76
+ addTraceProcessor: vi.fn(),
77
+ setTracingDisabled: vi.fn(),
78
+ };
79
+ });
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Mock node:fs for install provider tests
83
+ // ---------------------------------------------------------------------------
84
+
85
+ vi.mock('node:fs', async (importOriginal) => {
86
+ const actual = await importOriginal<typeof import('node:fs')>();
87
+ return {
88
+ ...actual,
89
+ existsSync: vi.fn((path: string) => {
90
+ if (typeof path === 'string' && path.includes('AGENTS.md')) return mockFsState.exists;
91
+ return false;
92
+ }),
93
+ readFileSync: vi.fn(() => mockFsState.content),
94
+ writeFileSync: vi.fn(),
95
+ mkdirSync: vi.fn(),
96
+ };
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Mock cant-context for spawn tests
101
+ // ---------------------------------------------------------------------------
102
+
103
+ vi.mock('../../cant-context.js', () => ({
104
+ buildCantEnrichedPrompt: vi.fn(
105
+ async ({ basePrompt }: { basePrompt: string }) => `[CANT] ${basePrompt}`,
106
+ ),
107
+ }));
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Mock conduit-trace-writer to avoid CLI calls in unit tests
111
+ // ---------------------------------------------------------------------------
112
+
113
+ vi.mock('../../../providers/shared/conduit-trace-writer.js', () => ({
114
+ writeSpanToConduit: vi.fn(async () => ({ written: true })),
115
+ writeSpanBatchToConduit: vi.fn(async () => 0),
116
+ }));
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Imports (after mocks)
120
+ // ---------------------------------------------------------------------------
121
+
122
+ import { OpenAiSdkAdapter } from '../adapter.js';
123
+ import {
124
+ buildDefaultGuardrails,
125
+ buildPathGuardrail,
126
+ buildToolAllowlistGuardrail,
127
+ isPathAllowed,
128
+ } from '../guardrails.js';
129
+ import {
130
+ buildAgentTopology,
131
+ buildLeadAgent,
132
+ buildStandaloneAgent,
133
+ buildWorkerAgent,
134
+ WORKER_ARCHETYPES,
135
+ } from '../handoff.js';
136
+ import { OpenAiSdkInstallProvider } from '../install.js';
137
+ import { OpenAiSdkSpawnProvider } from '../spawn.js';
138
+ import { CleoConduitTraceProcessor } from '../tracing.js';
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Helpers
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function makeSpanLike(overrides: Record<string, unknown> = {}): unknown {
145
+ return {
146
+ spanId: 'span-001',
147
+ startedAt: new Date().toISOString(),
148
+ endedAt: new Date().toISOString(),
149
+ spanData: { type: 'agent', name: 'test-agent' },
150
+ ...overrides,
151
+ };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Path ACL helpers
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe('isPathAllowed', () => {
159
+ it('allows all paths when glob list is empty', () => {
160
+ expect(isPathAllowed('/any/path', [])).toBe(true);
161
+ });
162
+
163
+ it('allows a path matching a glob', () => {
164
+ expect(isPathAllowed('/mnt/projects/foo.ts', ['/mnt/projects/**'])).toBe(true);
165
+ });
166
+
167
+ it('denies a path not matching any glob', () => {
168
+ expect(isPathAllowed('/etc/passwd', ['/mnt/projects/**'])).toBe(false);
169
+ });
170
+
171
+ it('allows /tmp when /tmp/** is in the allowlist', () => {
172
+ expect(isPathAllowed('/tmp/work.ts', ['/tmp/**'])).toBe(true);
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Guardrail builders
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe('buildPathGuardrail', () => {
181
+ it('returns a guardrail named cleo_path_acl', () => {
182
+ const guard = buildPathGuardrail(['/mnt/**']);
183
+ expect(guard.name).toBe('cleo_path_acl');
184
+ });
185
+
186
+ it('passes when no path fields in input', async () => {
187
+ const guard = buildPathGuardrail(['/allowed/**']);
188
+ const result = await guard.execute({
189
+ agent: {} as never,
190
+ input: 'Tell me about the project',
191
+ context: {} as never,
192
+ });
193
+ expect(result.tripwireTriggered).toBe(false);
194
+ });
195
+
196
+ it('trips when path field violates ACL', async () => {
197
+ const guard = buildPathGuardrail(['/allowed/**']);
198
+ const result = await guard.execute({
199
+ agent: {} as never,
200
+ input: JSON.stringify({ path: '/etc/shadow' }),
201
+ context: {} as never,
202
+ });
203
+ expect(result.tripwireTriggered).toBe(true);
204
+ expect((result.outputInfo as Record<string, unknown>).deniedPath).toBe('/etc/shadow');
205
+ });
206
+
207
+ it('passes when path field is within ACL', async () => {
208
+ const guard = buildPathGuardrail(['/allowed/**']);
209
+ const result = await guard.execute({
210
+ agent: {} as never,
211
+ input: JSON.stringify({ path: '/allowed/file.ts' }),
212
+ context: {} as never,
213
+ });
214
+ expect(result.tripwireTriggered).toBe(false);
215
+ });
216
+ });
217
+
218
+ describe('buildToolAllowlistGuardrail', () => {
219
+ it('returns a guardrail named cleo_tool_allowlist', () => {
220
+ const guard = buildToolAllowlistGuardrail(['read']);
221
+ expect(guard.name).toBe('cleo_tool_allowlist');
222
+ });
223
+
224
+ it('always passes (structural enforcement)', async () => {
225
+ const guard = buildToolAllowlistGuardrail(['read', 'bash']);
226
+ const result = await guard.execute({
227
+ agent: {} as never,
228
+ input: 'run this',
229
+ context: {} as never,
230
+ });
231
+ expect(result.tripwireTriggered).toBe(false);
232
+ expect((result.outputInfo as Record<string, unknown>).checked).toBe(true);
233
+ });
234
+ });
235
+
236
+ describe('buildDefaultGuardrails', () => {
237
+ it('returns empty array when both lists are empty', () => {
238
+ const guards = buildDefaultGuardrails([], []);
239
+ expect(guards).toHaveLength(0);
240
+ });
241
+
242
+ it('includes path ACL guard when globs provided', () => {
243
+ const guards = buildDefaultGuardrails(['/mnt/**'], []);
244
+ expect(guards).toHaveLength(1);
245
+ expect(guards[0]?.name).toBe('cleo_path_acl');
246
+ });
247
+
248
+ it('includes tool allowlist guard when tools provided', () => {
249
+ const guards = buildDefaultGuardrails([], ['read']);
250
+ expect(guards).toHaveLength(1);
251
+ expect(guards[0]?.name).toBe('cleo_tool_allowlist');
252
+ });
253
+
254
+ it('includes both guards when both lists are non-empty', () => {
255
+ const guards = buildDefaultGuardrails(['/mnt/**'], ['read']);
256
+ expect(guards).toHaveLength(2);
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Handoff topology
262
+ // ---------------------------------------------------------------------------
263
+
264
+ describe('WORKER_ARCHETYPES', () => {
265
+ it('contains worker-read, worker-write, worker-bash', () => {
266
+ expect(WORKER_ARCHETYPES).toHaveProperty('worker-read');
267
+ expect(WORKER_ARCHETYPES).toHaveProperty('worker-write');
268
+ expect(WORKER_ARCHETYPES).toHaveProperty('worker-bash');
269
+ });
270
+
271
+ it('worker-read uses gpt-4.1-mini', () => {
272
+ expect(WORKER_ARCHETYPES['worker-read']?.model).toBe('gpt-4.1-mini');
273
+ });
274
+ });
275
+
276
+ describe('buildWorkerAgent', () => {
277
+ it('returns null for unknown archetype', () => {
278
+ expect(buildWorkerAgent('unknown-archetype', [])).toBeNull();
279
+ });
280
+
281
+ it('returns an Agent for a known archetype', () => {
282
+ const agent = buildWorkerAgent('worker-read', []);
283
+ expect(agent).not.toBeNull();
284
+ });
285
+ });
286
+
287
+ describe('buildLeadAgent', () => {
288
+ it('creates a lead agent with handoff workers', () => {
289
+ const worker = buildWorkerAgent('worker-read', [])!;
290
+ const lead = buildLeadAgent('You are a lead.', 'gpt-4.1', [worker], []);
291
+ expect(lead).not.toBeNull();
292
+ });
293
+ });
294
+
295
+ describe('buildStandaloneAgent', () => {
296
+ it('creates an agent instance', () => {
297
+ const agent = buildStandaloneAgent('Instructions', 'gpt-4.1-mini', []);
298
+ expect(agent).not.toBeNull();
299
+ });
300
+ });
301
+
302
+ describe('buildAgentTopology', () => {
303
+ it('returns an agent when tier is worker', () => {
304
+ const agent = buildAgentTopology({
305
+ instructions: 'Do work',
306
+ model: 'gpt-4.1-mini',
307
+ tier: 'worker',
308
+ handoffNames: ['worker-read'],
309
+ guardrails: [],
310
+ });
311
+ expect(agent).not.toBeNull();
312
+ });
313
+
314
+ it('returns an agent when tier is lead', () => {
315
+ const agent = buildAgentTopology({
316
+ instructions: 'Lead the team',
317
+ model: 'gpt-4.1',
318
+ tier: 'lead',
319
+ handoffNames: ['worker-read', 'worker-write'],
320
+ guardrails: [],
321
+ });
322
+ expect(agent).not.toBeNull();
323
+ });
324
+
325
+ it('handles unknown archetype names gracefully', () => {
326
+ const agent = buildAgentTopology({
327
+ instructions: 'Lead',
328
+ model: 'gpt-4.1',
329
+ tier: 'lead',
330
+ handoffNames: ['worker-read', 'nonexistent-worker'],
331
+ guardrails: [],
332
+ });
333
+ expect(agent).not.toBeNull();
334
+ });
335
+
336
+ it('returns agent when tier is lead but no valid handoffs', () => {
337
+ const agent = buildAgentTopology({
338
+ instructions: 'Lead',
339
+ model: 'gpt-4.1',
340
+ tier: 'lead',
341
+ handoffNames: [],
342
+ guardrails: [],
343
+ });
344
+ expect(agent).not.toBeNull();
345
+ });
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Spawn provider
350
+ // ---------------------------------------------------------------------------
351
+
352
+ describe('OpenAiSdkSpawnProvider', () => {
353
+ let provider: OpenAiSdkSpawnProvider;
354
+
355
+ beforeEach(() => {
356
+ provider = new OpenAiSdkSpawnProvider();
357
+ mockRunState.shouldThrow = false;
358
+ mockRunState.result = { finalOutput: 'completed output' };
359
+ createdAgents.length = 0;
360
+ });
361
+
362
+ afterEach(() => {
363
+ vi.clearAllMocks();
364
+ });
365
+
366
+ describe('canSpawn', () => {
367
+ it('returns false when OPENAI_API_KEY is absent', async () => {
368
+ const original = process.env.OPENAI_API_KEY;
369
+ delete process.env.OPENAI_API_KEY;
370
+ const result = await provider.canSpawn();
371
+ expect(result).toBe(false);
372
+ if (original !== undefined) process.env.OPENAI_API_KEY = original;
373
+ });
374
+
375
+ it('returns true when OPENAI_API_KEY is set', async () => {
376
+ process.env.OPENAI_API_KEY = 'sk-test-key';
377
+ const result = await provider.canSpawn();
378
+ expect(result).toBe(true);
379
+ delete process.env.OPENAI_API_KEY;
380
+ });
381
+ });
382
+
383
+ describe('listRunning', () => {
384
+ it('returns empty array when no spawns are in progress', async () => {
385
+ const running = await provider.listRunning();
386
+ expect(running).toEqual([]);
387
+ });
388
+ });
389
+
390
+ describe('terminate', () => {
391
+ it('handles non-existent instance gracefully', async () => {
392
+ await expect(provider.terminate('nonexistent')).resolves.toBeUndefined();
393
+ });
394
+ });
395
+
396
+ describe('spawn — success path', () => {
397
+ it('returns completed status on success', async () => {
398
+ const result = await provider.spawn({
399
+ taskId: 'T582',
400
+ prompt: 'Do the work',
401
+ options: { tier: 'worker', tracingDisabled: true },
402
+ });
403
+ expect(result.status).toBe('completed');
404
+ expect(result.taskId).toBe('T582');
405
+ expect(result.providerId).toBe('openai-sdk');
406
+ expect(result.output).toBe('completed output');
407
+ expect(result.endTime).toBeDefined();
408
+ });
409
+
410
+ it('has a valid instance ID prefixed with openai-sdk', async () => {
411
+ const result = await provider.spawn({
412
+ taskId: 'T582',
413
+ prompt: 'Test',
414
+ options: { tracingDisabled: true },
415
+ });
416
+ expect(result.instanceId).toMatch(/^openai-sdk-/);
417
+ });
418
+ });
419
+
420
+ describe('spawn — failure path', () => {
421
+ it('returns failed status when SDK throws', async () => {
422
+ mockRunState.shouldThrow = true;
423
+ const result = await provider.spawn({
424
+ taskId: 'T582',
425
+ prompt: 'Failing task',
426
+ options: { tracingDisabled: true },
427
+ });
428
+ expect(result.status).toBe('failed');
429
+ expect(result.error).toBe('mock SDK error');
430
+ expect(result.endTime).toBeDefined();
431
+ });
432
+ });
433
+
434
+ describe('spawn — model override', () => {
435
+ it('accepts explicit model option without error', async () => {
436
+ const result = await provider.spawn({
437
+ taskId: 'T582',
438
+ prompt: 'Custom model',
439
+ options: { model: 'gpt-4o', tier: 'worker', tracingDisabled: true },
440
+ });
441
+ expect(result.status).toBe('completed');
442
+ });
443
+ });
444
+ });
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Adapter
448
+ // ---------------------------------------------------------------------------
449
+
450
+ describe('OpenAiSdkAdapter', () => {
451
+ let adapter: OpenAiSdkAdapter;
452
+
453
+ beforeEach(() => {
454
+ adapter = new OpenAiSdkAdapter();
455
+ });
456
+
457
+ afterEach(async () => {
458
+ if (adapter.isInitialized()) await adapter.dispose();
459
+ vi.clearAllMocks();
460
+ });
461
+
462
+ describe('identity', () => {
463
+ it('has id openai-sdk', () => expect(adapter.id).toBe('openai-sdk'));
464
+ it('has name OpenAI Agents SDK', () => expect(adapter.name).toBe('OpenAI Agents SDK'));
465
+ it('has version 1.0.0', () => expect(adapter.version).toBe('1.0.0'));
466
+ });
467
+
468
+ describe('capabilities', () => {
469
+ it('supports spawn', () => expect(adapter.capabilities.supportsSpawn).toBe(true));
470
+ it('supports install', () => expect(adapter.capabilities.supportsInstall).toBe(true));
471
+ it('does not support hooks', () => expect(adapter.capabilities.supportsHooks).toBe(false));
472
+ it('uses AGENTS.md instruction pattern', () =>
473
+ expect(adapter.capabilities.instructionFilePattern).toBe('AGENTS.md'));
474
+ });
475
+
476
+ describe('sub-providers', () => {
477
+ it('provides a spawn provider', () =>
478
+ expect(adapter.spawn).toBeInstanceOf(OpenAiSdkSpawnProvider));
479
+ it('provides an install provider', () =>
480
+ expect(adapter.install).toBeInstanceOf(OpenAiSdkInstallProvider));
481
+ });
482
+
483
+ describe('initialize', () => {
484
+ it('sets initialized state', async () => {
485
+ expect(adapter.isInitialized()).toBe(false);
486
+ await adapter.initialize('/tmp/project');
487
+ expect(adapter.isInitialized()).toBe(true);
488
+ });
489
+
490
+ it('stores project directory', async () => {
491
+ await adapter.initialize('/tmp/project');
492
+ expect(adapter.getProjectDir()).toBe('/tmp/project');
493
+ });
494
+ });
495
+
496
+ describe('dispose', () => {
497
+ it('resets initialized state', async () => {
498
+ await adapter.initialize('/tmp/project');
499
+ await adapter.dispose();
500
+ expect(adapter.isInitialized()).toBe(false);
501
+ expect(adapter.getProjectDir()).toBeNull();
502
+ });
503
+ });
504
+
505
+ describe('healthCheck', () => {
506
+ it('returns unhealthy when not initialized', async () => {
507
+ const status = await adapter.healthCheck();
508
+ expect(status.healthy).toBe(false);
509
+ expect(status.details?.error).toBe('Adapter not initialized');
510
+ });
511
+
512
+ it('returns unhealthy when API key absent', async () => {
513
+ const original = process.env.OPENAI_API_KEY;
514
+ delete process.env.OPENAI_API_KEY;
515
+ await adapter.initialize('/tmp/project');
516
+ const status = await adapter.healthCheck();
517
+ expect(status.healthy).toBe(false);
518
+ expect(status.details?.apiKeyPresent).toBe(false);
519
+ if (original !== undefined) process.env.OPENAI_API_KEY = original;
520
+ });
521
+
522
+ it('returns healthy when API key present', async () => {
523
+ process.env.OPENAI_API_KEY = 'sk-test';
524
+ await adapter.initialize('/tmp/project');
525
+ const status = await adapter.healthCheck();
526
+ expect(status.healthy).toBe(true);
527
+ delete process.env.OPENAI_API_KEY;
528
+ });
529
+ });
530
+ });
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // Install provider
534
+ // ---------------------------------------------------------------------------
535
+
536
+ describe('OpenAiSdkInstallProvider', () => {
537
+ let installProvider: OpenAiSdkInstallProvider;
538
+
539
+ beforeEach(() => {
540
+ installProvider = new OpenAiSdkInstallProvider();
541
+ mockFsState.exists = false;
542
+ mockFsState.content = '';
543
+ });
544
+
545
+ afterEach(() => vi.clearAllMocks());
546
+
547
+ describe('isInstalled', () => {
548
+ it('returns false (no plugin registry for SDK)', async () => {
549
+ const result = await installProvider.isInstalled();
550
+ expect(result).toBe(false);
551
+ });
552
+ });
553
+
554
+ describe('install', () => {
555
+ it('returns success result', async () => {
556
+ const result = await installProvider.install({ projectDir: '/tmp/project' });
557
+ expect(result.success).toBe(true);
558
+ expect(result.installedAt).toBeTruthy();
559
+ });
560
+
561
+ it('marks instructionFileUpdated when AGENTS.md is created', async () => {
562
+ mockFsState.exists = false;
563
+ const result = await installProvider.install({ projectDir: '/tmp/project' });
564
+ expect(result.instructionFileUpdated).toBe(true);
565
+ });
566
+
567
+ it('does not mark updated when references already present', async () => {
568
+ mockFsState.exists = true;
569
+ mockFsState.content = '@~/.cleo/templates/CLEO-INJECTION.md\n@.cleo/memory-bridge.md\n';
570
+ const result = await installProvider.install({ projectDir: '/tmp/project' });
571
+ expect(result.instructionFileUpdated).toBe(false);
572
+ });
573
+ });
574
+
575
+ describe('uninstall', () => {
576
+ it('resolves without error', async () => {
577
+ await expect(installProvider.uninstall()).resolves.toBeUndefined();
578
+ });
579
+ });
580
+
581
+ describe('ensureInstructionReferences', () => {
582
+ it('resolves without error', async () => {
583
+ await expect(
584
+ installProvider.ensureInstructionReferences('/tmp/project'),
585
+ ).resolves.toBeUndefined();
586
+ });
587
+ });
588
+ });
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // Trace processor
592
+ // ---------------------------------------------------------------------------
593
+
594
+ describe('CleoConduitTraceProcessor', () => {
595
+ let processor: CleoConduitTraceProcessor;
596
+
597
+ beforeEach(() => {
598
+ processor = new CleoConduitTraceProcessor('T582-test');
599
+ });
600
+
601
+ afterEach(() => vi.clearAllMocks());
602
+
603
+ describe('onTraceStart', () => {
604
+ it('resolves without error', async () => {
605
+ await expect(processor.onTraceStart({} as never)).resolves.toBeUndefined();
606
+ });
607
+ });
608
+
609
+ describe('onTraceEnd', () => {
610
+ it('resolves without error', async () => {
611
+ await expect(processor.onTraceEnd({} as never)).resolves.toBeUndefined();
612
+ });
613
+ });
614
+
615
+ describe('onSpanStart', () => {
616
+ it('resolves without error', async () => {
617
+ await expect(processor.onSpanStart({} as never)).resolves.toBeUndefined();
618
+ });
619
+ });
620
+
621
+ describe('onSpanEnd', () => {
622
+ it('does not throw for a well-formed span', async () => {
623
+ const span = makeSpanLike();
624
+ await expect(processor.onSpanEnd(span as never)).resolves.toBeUndefined();
625
+ });
626
+
627
+ it('does not throw for a span with missing fields', async () => {
628
+ await expect(processor.onSpanEnd({} as never)).resolves.toBeUndefined();
629
+ });
630
+ });
631
+
632
+ describe('shutdown', () => {
633
+ it('resolves without error', async () => {
634
+ await expect(processor.shutdown(1000)).resolves.toBeUndefined();
635
+ });
636
+ });
637
+
638
+ describe('forceFlush', () => {
639
+ it('resolves without error', async () => {
640
+ await expect(processor.forceFlush()).resolves.toBeUndefined();
641
+ });
642
+ });
643
+ });
644
+
645
+ // ---------------------------------------------------------------------------
646
+ // Handoff integration: lead + workers with mocked runner
647
+ // ---------------------------------------------------------------------------
648
+
649
+ describe('Handoff integration — lead routes to workers via SDK', () => {
650
+ let provider: OpenAiSdkSpawnProvider;
651
+
652
+ beforeEach(() => {
653
+ provider = new OpenAiSdkSpawnProvider();
654
+ mockRunState.shouldThrow = false;
655
+ mockRunState.result = { finalOutput: 'handoff result' };
656
+ createdAgents.length = 0;
657
+ });
658
+
659
+ afterEach(() => {
660
+ vi.clearAllMocks();
661
+ });
662
+
663
+ it('creates a lead agent when tier is lead with handoffs', async () => {
664
+ const result = await provider.spawn({
665
+ taskId: 'T582-handoff',
666
+ prompt: 'Research and implement feature',
667
+ options: {
668
+ tier: 'lead',
669
+ handoffs: ['worker-read', 'worker-write'],
670
+ tracingDisabled: true,
671
+ },
672
+ });
673
+
674
+ expect(result.status).toBe('completed');
675
+ expect(result.output).toBe('handoff result');
676
+
677
+ // A cleo-lead agent should have been created with 2 handoff workers
678
+ const leadAgent = createdAgents.find((a) => a.name === 'cleo-lead');
679
+ expect(leadAgent).toBeDefined();
680
+ expect(leadAgent?.handoffs).toHaveLength(2);
681
+ });
682
+
683
+ it('result reflects correct providerId and taskId', async () => {
684
+ const result = await provider.spawn({
685
+ taskId: 'T582-verify',
686
+ prompt: 'Verify result',
687
+ options: { tier: 'lead', handoffs: ['worker-read'], tracingDisabled: true },
688
+ });
689
+
690
+ expect(result.providerId).toBe('openai-sdk');
691
+ expect(result.taskId).toBe('T582-verify');
692
+ expect(result.instanceId).toMatch(/^openai-sdk-/);
693
+ });
694
+
695
+ it('handoff workers use worker archetype model (gpt-4.1-mini)', async () => {
696
+ createdAgents.length = 0;
697
+ await provider.spawn({
698
+ taskId: 'T582-model',
699
+ prompt: 'Work',
700
+ options: {
701
+ tier: 'lead',
702
+ handoffs: ['worker-read', 'worker-bash'],
703
+ tracingDisabled: true,
704
+ },
705
+ });
706
+
707
+ // Worker archetypes should use gpt-4.1-mini
708
+ const workerAgents = createdAgents.filter(
709
+ (a) => a.name === 'worker-read' || a.name === 'worker-bash',
710
+ );
711
+ expect(workerAgents.length).toBe(2);
712
+ for (const worker of workerAgents) {
713
+ expect(worker.model).toBe('gpt-4.1-mini');
714
+ }
715
+ });
716
+ });