opencode-conductor-cdd-plugin 1.0.0-beta.20 → 1.0.0-beta.21

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.
@@ -0,0 +1,581 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createDelegationTool } from '../../tools/delegate.js';
3
+ vi.mock('../../utils/configDetection.js', () => ({
4
+ detectCDDConfig: vi.fn(() => ({
5
+ synergyFramework: 'oh-my-opencode',
6
+ synergyActive: true,
7
+ })),
8
+ getAvailableOMOAgents: vi.fn(() => ['sisyphus', 'explore', 'oracle', 'librarian']),
9
+ }));
10
+ vi.mock('../../utils/synergyDelegation.js', () => ({
11
+ resolveAgentForDelegation: vi.fn((agent) => ({
12
+ success: true,
13
+ resolvedAgent: agent,
14
+ shouldFallback: false,
15
+ })),
16
+ }));
17
+ /**
18
+ * Integration tests for OMO 3.0 delegation flow
19
+ *
20
+ * These tests verify the complete delegation cycle:
21
+ * 1. Load plan and extract task
22
+ * 2. Create child session with parentID
23
+ * 3. Send prompt with agent specification and tool restrictions
24
+ * 4. Poll session.status() until idle
25
+ * 5. Extract assistant messages from session
26
+ * 6. Process response (detect PLAN_UPDATED, verify completion)
27
+ */
28
+ function createMockToolContext(sessionID) {
29
+ return {
30
+ sessionID,
31
+ messageID: 'msg-mock-123',
32
+ agent: 'cdd',
33
+ abort: new AbortController().signal,
34
+ metadata: vi.fn(),
35
+ ask: vi.fn().mockResolvedValue(undefined),
36
+ };
37
+ }
38
+ describe('OMO 3.0 Delegation Integration Tests', () => {
39
+ describe('Full Delegation Cycle', () => {
40
+ it('should complete full delegation cycle: session create → prompt → poll → messages', async () => {
41
+ // Mock OpenCode SDK client
42
+ const mockClient = {
43
+ session: {
44
+ create: vi.fn().mockResolvedValue({
45
+ data: { id: 'session-123' },
46
+ error: null,
47
+ }),
48
+ prompt: vi.fn().mockResolvedValue({
49
+ data: {},
50
+ error: null,
51
+ }),
52
+ status: vi.fn()
53
+ // First call: still working
54
+ .mockResolvedValueOnce({
55
+ data: {
56
+ 'session-123': { type: 'working' },
57
+ },
58
+ })
59
+ // Second call: idle (complete)
60
+ .mockResolvedValueOnce({
61
+ data: {
62
+ 'session-123': { type: 'idle' },
63
+ },
64
+ }),
65
+ messages: vi.fn().mockResolvedValue({
66
+ data: [
67
+ {
68
+ info: { role: 'user' },
69
+ parts: [{ type: 'text', text: 'Task prompt' }],
70
+ },
71
+ {
72
+ info: { role: 'assistant' },
73
+ parts: [
74
+ {
75
+ type: 'text',
76
+ text: '✅ Task completed successfully.\n\nImplemented feature X with tests.\n\nCommit: abc123',
77
+ },
78
+ ],
79
+ },
80
+ ],
81
+ error: null,
82
+ }),
83
+ },
84
+ };
85
+ const ctx = { client: mockClient };
86
+ const toolContext = createMockToolContext('parent-session-456');
87
+ // Execute delegation tool
88
+ const delegateTool = createDelegationTool(ctx);
89
+ const result = await delegateTool.execute({
90
+ task_description: 'Implement feature X',
91
+ subagent_type: 'sisyphus',
92
+ prompt: 'Execute this task from plan.md:\n\nTask: Implement feature X\n- Add unit tests\n- Update documentation',
93
+ }, toolContext);
94
+ // Verify session creation with correct parentID
95
+ expect(mockClient.session.create).toHaveBeenCalledWith({
96
+ body: {
97
+ parentID: 'parent-session-456',
98
+ title: 'Implement feature X (@sisyphus)',
99
+ },
100
+ });
101
+ // Verify prompt sent with agent and tool restrictions
102
+ expect(mockClient.session.prompt).toHaveBeenCalledWith({
103
+ path: { id: 'session-123' },
104
+ body: {
105
+ agent: 'sisyphus',
106
+ tools: {
107
+ cdd_delegate: false,
108
+ cdd_bg_task: false,
109
+ },
110
+ parts: [
111
+ {
112
+ type: 'text',
113
+ text: 'Execute this task from plan.md:\n\nTask: Implement feature X\n- Add unit tests\n- Update documentation',
114
+ },
115
+ ],
116
+ },
117
+ });
118
+ // Verify status polling occurred (should be called twice)
119
+ expect(mockClient.session.status).toHaveBeenCalledTimes(2);
120
+ // Verify messages retrieved
121
+ expect(mockClient.session.messages).toHaveBeenCalledWith({
122
+ path: { id: 'session-123' },
123
+ });
124
+ // Verify response contains agent output and metadata
125
+ expect(result).toContain('✅ Task completed successfully');
126
+ expect(result).toContain('Commit: abc123');
127
+ expect(result).toContain('<task_metadata>');
128
+ expect(result).toContain('session_id: session-123');
129
+ expect(result).toContain('agent: sisyphus');
130
+ });
131
+ it('should detect PLAN_UPDATED marker in agent response', async () => {
132
+ const mockClient = {
133
+ session: {
134
+ create: vi.fn().mockResolvedValue({
135
+ data: { id: 'session-999' },
136
+ error: null,
137
+ }),
138
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
139
+ status: vi.fn().mockResolvedValue({
140
+ data: { 'session-999': { type: 'idle' } },
141
+ }),
142
+ messages: vi.fn().mockResolvedValue({
143
+ data: [
144
+ {
145
+ info: { role: 'assistant' },
146
+ parts: [
147
+ {
148
+ type: 'text',
149
+ text: '✅ Task decomposed into 3 subtasks.\n\nPLAN_UPDATED: Added subtasks to plan.md\n\nCommit: def456',
150
+ },
151
+ ],
152
+ },
153
+ ],
154
+ error: null,
155
+ }),
156
+ },
157
+ };
158
+ const ctx = { client: mockClient };
159
+ const delegateTool = createDelegationTool(ctx);
160
+ const result = await delegateTool.execute({
161
+ task_description: 'Decompose large task',
162
+ subagent_type: 'sisyphus',
163
+ prompt: 'Break down this task into smaller subtasks',
164
+ }, createMockToolContext('parent-777'));
165
+ // Result should contain PLAN_UPDATED marker
166
+ expect(result).toContain('PLAN_UPDATED');
167
+ expect(result).toContain('Added subtasks to plan.md');
168
+ // In real usage, the CDD agent would detect this marker and reload plan.md
169
+ const hasPlanUpdate = result.includes('PLAN_UPDATED');
170
+ expect(hasPlanUpdate).toBe(true);
171
+ });
172
+ });
173
+ describe('Error Scenarios', () => {
174
+ it('should handle session creation failure', async () => {
175
+ const mockClient = {
176
+ session: {
177
+ create: vi.fn().mockResolvedValue({
178
+ data: null,
179
+ error: 'Failed to create session: insufficient permissions',
180
+ }),
181
+ prompt: vi.fn(),
182
+ status: vi.fn(),
183
+ messages: vi.fn(),
184
+ },
185
+ };
186
+ const ctx = { client: mockClient };
187
+ const delegateTool = createDelegationTool(ctx);
188
+ const result = await delegateTool.execute({
189
+ task_description: 'Test task',
190
+ subagent_type: 'explore',
191
+ prompt: 'Search for files',
192
+ }, createMockToolContext('parent-123'));
193
+ expect(result).toContain('Error creating session');
194
+ expect(result).toContain('insufficient permissions');
195
+ expect(mockClient.session.prompt).not.toHaveBeenCalled();
196
+ });
197
+ it('should handle agent timeout (exceeds 5 minutes)', async () => {
198
+ // Mock a session that never becomes idle
199
+ const mockClient = {
200
+ session: {
201
+ create: vi.fn().mockResolvedValue({
202
+ data: { id: 'session-timeout' },
203
+ error: null,
204
+ }),
205
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
206
+ status: vi.fn().mockResolvedValue({
207
+ data: { 'session-timeout': { type: 'working' } },
208
+ }),
209
+ messages: vi.fn().mockResolvedValue({
210
+ data: [
211
+ {
212
+ info: { role: 'assistant' },
213
+ parts: [{ type: 'text', text: 'Started working on task...' }],
214
+ },
215
+ ],
216
+ error: null,
217
+ }),
218
+ },
219
+ };
220
+ const ctx = { client: mockClient };
221
+ const delegateTool = createDelegationTool(ctx);
222
+ // Mock setTimeout to avoid actually waiting 5 minutes
223
+ vi.useFakeTimers();
224
+ const resultPromise = delegateTool.execute({
225
+ task_description: 'Long running task',
226
+ subagent_type: 'sisyphus',
227
+ prompt: 'This will take forever',
228
+ }, createMockToolContext('parent-999'));
229
+ // Fast-forward time to simulate timeout
230
+ await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 1000);
231
+ const result = await resultPromise;
232
+ // After timeout, should still retrieve messages (partial response)
233
+ expect(mockClient.session.messages).toHaveBeenCalled();
234
+ expect(result).toContain('Started working on task');
235
+ vi.useRealTimers();
236
+ });
237
+ it('should handle no assistant messages in response', async () => {
238
+ const mockClient = {
239
+ session: {
240
+ create: vi.fn().mockResolvedValue({
241
+ data: { id: 'session-empty' },
242
+ error: null,
243
+ }),
244
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
245
+ status: vi.fn().mockResolvedValue({
246
+ data: { 'session-empty': { type: 'idle' } },
247
+ }),
248
+ messages: vi.fn().mockResolvedValue({
249
+ data: [
250
+ {
251
+ info: { role: 'user' },
252
+ parts: [{ type: 'text', text: 'Prompt' }],
253
+ },
254
+ // No assistant messages
255
+ ],
256
+ error: null,
257
+ }),
258
+ },
259
+ };
260
+ const ctx = { client: mockClient };
261
+ const delegateTool = createDelegationTool(ctx);
262
+ const result = await delegateTool.execute({
263
+ task_description: 'Silent task',
264
+ subagent_type: 'explore',
265
+ prompt: 'Find something',
266
+ }, createMockToolContext('parent-000'));
267
+ expect(result).toContain('No response from agent explore');
268
+ });
269
+ it('should handle messages retrieval failure', async () => {
270
+ const mockClient = {
271
+ session: {
272
+ create: vi.fn().mockResolvedValue({
273
+ data: { id: 'session-456' },
274
+ error: null,
275
+ }),
276
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
277
+ status: vi.fn().mockResolvedValue({
278
+ data: { 'session-456': { type: 'idle' } },
279
+ }),
280
+ messages: vi.fn().mockResolvedValue({
281
+ data: null,
282
+ error: 'Network error: connection timeout',
283
+ }),
284
+ },
285
+ };
286
+ const ctx = { client: mockClient };
287
+ const delegateTool = createDelegationTool(ctx);
288
+ const result = await delegateTool.execute({
289
+ task_description: 'Test task',
290
+ subagent_type: 'oracle',
291
+ prompt: 'Explain something',
292
+ }, createMockToolContext('parent-111'));
293
+ expect(result).toContain('Error fetching messages');
294
+ expect(result).toContain('connection timeout');
295
+ });
296
+ it('should handle status check failures gracefully', async () => {
297
+ let statusCallCount = 0;
298
+ const mockClient = {
299
+ session: {
300
+ create: vi.fn().mockResolvedValue({
301
+ data: { id: 'session-robust' },
302
+ error: null,
303
+ }),
304
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
305
+ status: vi.fn().mockImplementation(() => {
306
+ statusCallCount++;
307
+ if (statusCallCount === 1) {
308
+ // First call fails
309
+ throw new Error('Status API temporarily unavailable');
310
+ }
311
+ // Second call succeeds with idle
312
+ return Promise.resolve({
313
+ data: { 'session-robust': { type: 'idle' } },
314
+ });
315
+ }),
316
+ messages: vi.fn().mockResolvedValue({
317
+ data: [
318
+ {
319
+ info: { role: 'assistant' },
320
+ parts: [{ type: 'text', text: 'Task completed despite status error' }],
321
+ },
322
+ ],
323
+ error: null,
324
+ }),
325
+ },
326
+ };
327
+ const ctx = { client: mockClient };
328
+ const delegateTool = createDelegationTool(ctx);
329
+ const result = await delegateTool.execute({
330
+ task_description: 'Robust task',
331
+ subagent_type: 'librarian',
332
+ prompt: 'Research something',
333
+ }, createMockToolContext('parent-222'));
334
+ // Should still succeed despite status check failure
335
+ expect(result).toContain('Task completed despite status error');
336
+ expect(mockClient.session.status).toHaveBeenCalledTimes(2);
337
+ });
338
+ it('should handle unexpected exceptions during delegation', async () => {
339
+ const mockClient = {
340
+ session: {
341
+ create: vi.fn().mockRejectedValue(new Error('Unexpected SDK error')),
342
+ prompt: vi.fn(),
343
+ status: vi.fn(),
344
+ messages: vi.fn(),
345
+ },
346
+ };
347
+ const ctx = { client: mockClient };
348
+ const delegateTool = createDelegationTool(ctx);
349
+ const result = await delegateTool.execute({
350
+ task_description: 'Failing task',
351
+ subagent_type: 'explore',
352
+ prompt: 'This will fail',
353
+ }, createMockToolContext('parent-333'));
354
+ expect(result).toContain('Error during delegation');
355
+ expect(result).toContain('Unexpected SDK error');
356
+ });
357
+ });
358
+ describe('Task Verification', () => {
359
+ it('should include all required metadata in response', async () => {
360
+ const mockClient = {
361
+ session: {
362
+ create: vi.fn().mockResolvedValue({
363
+ data: { id: 'session-meta' },
364
+ error: null,
365
+ }),
366
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
367
+ status: vi.fn().mockResolvedValue({
368
+ data: { 'session-meta': { type: 'idle' } },
369
+ }),
370
+ messages: vi.fn().mockResolvedValue({
371
+ data: [
372
+ {
373
+ info: { role: 'assistant' },
374
+ parts: [{ type: 'text', text: 'Task completed' }],
375
+ },
376
+ ],
377
+ error: null,
378
+ }),
379
+ },
380
+ };
381
+ const ctx = { client: mockClient };
382
+ const delegateTool = createDelegationTool(ctx);
383
+ const result = await delegateTool.execute({
384
+ task_description: 'Metadata test',
385
+ subagent_type: 'oracle',
386
+ prompt: 'Test prompt',
387
+ }, createMockToolContext('parent-444'));
388
+ // Verify metadata section exists and contains required fields
389
+ expect(result).toContain('<task_metadata>');
390
+ expect(result).toContain('session_id: session-meta');
391
+ expect(result).toContain('agent: oracle');
392
+ expect(result).toContain('</task_metadata>');
393
+ });
394
+ it('should preserve multi-line assistant responses', async () => {
395
+ const mockClient = {
396
+ session: {
397
+ create: vi.fn().mockResolvedValue({
398
+ data: { id: 'session-multiline' },
399
+ error: null,
400
+ }),
401
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
402
+ status: vi.fn().mockResolvedValue({
403
+ data: { 'session-multiline': { type: 'idle' } },
404
+ }),
405
+ messages: vi.fn().mockResolvedValue({
406
+ data: [
407
+ {
408
+ info: { role: 'assistant' },
409
+ parts: [
410
+ { type: 'text', text: 'Line 1: First step completed' },
411
+ { type: 'text', text: 'Line 2: Second step completed' },
412
+ { type: 'text', text: 'Line 3: Final verification passed' },
413
+ ],
414
+ },
415
+ ],
416
+ error: null,
417
+ }),
418
+ },
419
+ };
420
+ const ctx = { client: mockClient };
421
+ const delegateTool = createDelegationTool(ctx);
422
+ const result = await delegateTool.execute({
423
+ task_description: 'Multi-step task',
424
+ subagent_type: 'sisyphus',
425
+ prompt: 'Execute multiple steps',
426
+ }, createMockToolContext('parent-555'));
427
+ // All parts should be joined with newlines
428
+ expect(result).toContain('Line 1: First step completed');
429
+ expect(result).toContain('Line 2: Second step completed');
430
+ expect(result).toContain('Line 3: Final verification passed');
431
+ });
432
+ it('should filter out non-text parts in response', async () => {
433
+ const mockClient = {
434
+ session: {
435
+ create: vi.fn().mockResolvedValue({
436
+ data: { id: 'session-mixed' },
437
+ error: null,
438
+ }),
439
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
440
+ status: vi.fn().mockResolvedValue({
441
+ data: { 'session-mixed': { type: 'idle' } },
442
+ }),
443
+ messages: vi.fn().mockResolvedValue({
444
+ data: [
445
+ {
446
+ info: { role: 'assistant' },
447
+ parts: [
448
+ { type: 'text', text: 'Text response' },
449
+ { type: 'tool_call', toolName: 'some_tool' }, // Should be filtered
450
+ { type: 'text', text: 'More text' },
451
+ ],
452
+ },
453
+ ],
454
+ error: null,
455
+ }),
456
+ },
457
+ };
458
+ const ctx = { client: mockClient };
459
+ const delegateTool = createDelegationTool(ctx);
460
+ const result = await delegateTool.execute({
461
+ task_description: 'Mixed content task',
462
+ subagent_type: 'explore',
463
+ prompt: 'Test mixed content',
464
+ }, createMockToolContext('parent-666'));
465
+ // Only text parts should be in result
466
+ expect(result).toContain('Text response');
467
+ expect(result).toContain('More text');
468
+ expect(result).not.toContain('tool_call');
469
+ expect(result).not.toContain('some_tool');
470
+ });
471
+ });
472
+ describe('Tool Restrictions', () => {
473
+ it('should restrict cdd_delegate and cdd_bg_task tools to prevent recursion', async () => {
474
+ const mockClient = {
475
+ session: {
476
+ create: vi.fn().mockResolvedValue({
477
+ data: { id: 'session-restrict' },
478
+ error: null,
479
+ }),
480
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
481
+ status: vi.fn().mockResolvedValue({
482
+ data: { 'session-restrict': { type: 'idle' } },
483
+ }),
484
+ messages: vi.fn().mockResolvedValue({
485
+ data: [
486
+ {
487
+ info: { role: 'assistant' },
488
+ parts: [{ type: 'text', text: 'Done' }],
489
+ },
490
+ ],
491
+ error: null,
492
+ }),
493
+ },
494
+ };
495
+ const ctx = { client: mockClient };
496
+ const delegateTool = createDelegationTool(ctx);
497
+ await delegateTool.execute({
498
+ task_description: 'Tool restriction test',
499
+ subagent_type: 'sisyphus',
500
+ prompt: 'Test prompt',
501
+ }, createMockToolContext('parent-777'));
502
+ const promptCall = mockClient.session.prompt.mock.calls[0][0];
503
+ expect(promptCall.body.tools).toEqual({
504
+ cdd_delegate: false,
505
+ cdd_bg_task: false,
506
+ });
507
+ });
508
+ });
509
+ describe('Agent Fallback', () => {
510
+ it('should return fallback message when agent is unavailable', async () => {
511
+ const resolveAgentForDelegation = await import('../../utils/synergyDelegation.js');
512
+ vi.spyOn(resolveAgentForDelegation, 'resolveAgentForDelegation').mockReturnValueOnce({
513
+ success: false,
514
+ resolvedAgent: null,
515
+ shouldFallback: true,
516
+ reason: 'Agent sisyphus is disabled or not available',
517
+ });
518
+ const mockClient = {
519
+ session: {
520
+ create: vi.fn(),
521
+ prompt: vi.fn(),
522
+ status: vi.fn(),
523
+ messages: vi.fn(),
524
+ },
525
+ };
526
+ const ctx = { client: mockClient };
527
+ const delegateTool = createDelegationTool(ctx);
528
+ const result = await delegateTool.execute({
529
+ task_description: 'Test with unavailable agent',
530
+ subagent_type: 'sisyphus',
531
+ prompt: 'Test prompt',
532
+ }, createMockToolContext('parent-888'));
533
+ expect(result).toContain('Cannot delegate to');
534
+ expect(result).toContain('sisyphus');
535
+ expect(result).toContain('Falling back to @cdd');
536
+ expect(mockClient.session.create).not.toHaveBeenCalled();
537
+ });
538
+ it('should use resolved agent name when mapping changes agent', async () => {
539
+ const resolveAgentForDelegation = await import('../../utils/synergyDelegation.js');
540
+ vi.spyOn(resolveAgentForDelegation, 'resolveAgentForDelegation').mockReturnValueOnce({
541
+ success: true,
542
+ resolvedAgent: 'explorer',
543
+ shouldFallback: false,
544
+ });
545
+ const mockClient = {
546
+ session: {
547
+ create: vi.fn().mockResolvedValue({
548
+ data: { id: 'session-mapped' },
549
+ error: null,
550
+ }),
551
+ prompt: vi.fn().mockResolvedValue({ data: {}, error: null }),
552
+ status: vi.fn().mockResolvedValue({
553
+ data: { 'session-mapped': { type: 'idle' } },
554
+ }),
555
+ messages: vi.fn().mockResolvedValue({
556
+ data: [
557
+ {
558
+ info: { role: 'assistant' },
559
+ parts: [{ type: 'text', text: 'Search complete' }],
560
+ },
561
+ ],
562
+ error: null,
563
+ }),
564
+ },
565
+ };
566
+ const ctx = { client: mockClient };
567
+ const delegateTool = createDelegationTool(ctx);
568
+ const result = await delegateTool.execute({
569
+ task_description: 'Search codebase',
570
+ subagent_type: 'explore',
571
+ prompt: 'Find all TypeScript files',
572
+ }, createMockToolContext('parent-999'));
573
+ const createCall = mockClient.session.create.mock.calls[0][0];
574
+ expect(createCall.body.title).toContain('@explorer');
575
+ const promptCall = mockClient.session.prompt.mock.calls[0][0];
576
+ expect(promptCall.body.agent).toBe('explorer');
577
+ expect(result).toContain('agent: explorer');
578
+ expect(result).toContain('requested: explore');
579
+ });
580
+ });
581
+ });
@@ -1,3 +1,15 @@
1
1
  import { type PluginInput } from "@opencode-ai/plugin";
2
2
  import { type ToolDefinition } from "@opencode-ai/plugin/tool";
3
+ /**
4
+ * Creates a delegation tool that follows the OMO 3.0 agent invocation pattern.
5
+ *
6
+ * This implementation uses the synchronous prompt() API pattern:
7
+ * 1. Create a child session with parentID
8
+ * 2. Send prompt with agent specification and tool restrictions
9
+ * 3. Poll for completion by checking when session becomes idle
10
+ * 4. Extract and return the final response
11
+ *
12
+ * Based on OMO 3.0 call_omo_agent implementation:
13
+ * https://github.com/code-yeongyu/oh-my-opencode/blob/main/src/tools/call-omo-agent/tools.ts
14
+ */
3
15
  export declare function createDelegationTool(ctx: PluginInput): ToolDefinition;