@jackchen_me/open-multi-agent 0.2.0 → 1.0.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 (104) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CLAUDE.md +11 -3
  3. package/README.md +87 -20
  4. package/README_zh.md +85 -25
  5. package/dist/agent/agent.d.ts +15 -1
  6. package/dist/agent/agent.d.ts.map +1 -1
  7. package/dist/agent/agent.js +144 -10
  8. package/dist/agent/agent.js.map +1 -1
  9. package/dist/agent/loop-detector.d.ts +39 -0
  10. package/dist/agent/loop-detector.d.ts.map +1 -0
  11. package/dist/agent/loop-detector.js +122 -0
  12. package/dist/agent/loop-detector.js.map +1 -0
  13. package/dist/agent/pool.d.ts +2 -1
  14. package/dist/agent/pool.d.ts.map +1 -1
  15. package/dist/agent/pool.js +4 -2
  16. package/dist/agent/pool.js.map +1 -1
  17. package/dist/agent/runner.d.ts +23 -1
  18. package/dist/agent/runner.d.ts.map +1 -1
  19. package/dist/agent/runner.js +113 -12
  20. package/dist/agent/runner.js.map +1 -1
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/llm/adapter.d.ts +4 -1
  26. package/dist/llm/adapter.d.ts.map +1 -1
  27. package/dist/llm/adapter.js +11 -0
  28. package/dist/llm/adapter.js.map +1 -1
  29. package/dist/llm/copilot.d.ts.map +1 -1
  30. package/dist/llm/copilot.js +2 -1
  31. package/dist/llm/copilot.js.map +1 -1
  32. package/dist/llm/gemini.d.ts +65 -0
  33. package/dist/llm/gemini.d.ts.map +1 -0
  34. package/dist/llm/gemini.js +317 -0
  35. package/dist/llm/gemini.js.map +1 -0
  36. package/dist/llm/grok.d.ts +21 -0
  37. package/dist/llm/grok.d.ts.map +1 -0
  38. package/dist/llm/grok.js +24 -0
  39. package/dist/llm/grok.js.map +1 -0
  40. package/dist/llm/openai-common.d.ts +8 -1
  41. package/dist/llm/openai-common.d.ts.map +1 -1
  42. package/dist/llm/openai-common.js +35 -2
  43. package/dist/llm/openai-common.js.map +1 -1
  44. package/dist/llm/openai.d.ts +1 -1
  45. package/dist/llm/openai.d.ts.map +1 -1
  46. package/dist/llm/openai.js +20 -2
  47. package/dist/llm/openai.js.map +1 -1
  48. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  49. package/dist/orchestrator/orchestrator.js +89 -9
  50. package/dist/orchestrator/orchestrator.js.map +1 -1
  51. package/dist/task/queue.d.ts +31 -2
  52. package/dist/task/queue.d.ts.map +1 -1
  53. package/dist/task/queue.js +69 -2
  54. package/dist/task/queue.js.map +1 -1
  55. package/dist/tool/text-tool-extractor.d.ts +32 -0
  56. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  57. package/dist/tool/text-tool-extractor.js +187 -0
  58. package/dist/tool/text-tool-extractor.js.map +1 -0
  59. package/dist/types.d.ts +139 -7
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/utils/trace.d.ts +12 -0
  62. package/dist/utils/trace.d.ts.map +1 -0
  63. package/dist/utils/trace.js +30 -0
  64. package/dist/utils/trace.js.map +1 -0
  65. package/examples/06-local-model.ts +1 -0
  66. package/examples/08-gemma4-local.ts +76 -87
  67. package/examples/09-structured-output.ts +73 -0
  68. package/examples/10-task-retry.ts +132 -0
  69. package/examples/11-trace-observability.ts +133 -0
  70. package/examples/12-grok.ts +154 -0
  71. package/examples/13-gemini.ts +48 -0
  72. package/package.json +11 -1
  73. package/src/agent/agent.ts +159 -10
  74. package/src/agent/loop-detector.ts +137 -0
  75. package/src/agent/pool.ts +9 -2
  76. package/src/agent/runner.ts +148 -19
  77. package/src/index.ts +15 -0
  78. package/src/llm/adapter.ts +12 -1
  79. package/src/llm/copilot.ts +2 -1
  80. package/src/llm/gemini.ts +378 -0
  81. package/src/llm/grok.ts +29 -0
  82. package/src/llm/openai-common.ts +41 -2
  83. package/src/llm/openai.ts +23 -3
  84. package/src/orchestrator/orchestrator.ts +105 -11
  85. package/src/task/queue.ts +73 -3
  86. package/src/tool/text-tool-extractor.ts +219 -0
  87. package/src/types.ts +157 -6
  88. package/src/utils/trace.ts +34 -0
  89. package/tests/agent-hooks.test.ts +473 -0
  90. package/tests/agent-pool.test.ts +212 -0
  91. package/tests/approval.test.ts +464 -0
  92. package/tests/built-in-tools.test.ts +393 -0
  93. package/tests/gemini-adapter.test.ts +97 -0
  94. package/tests/grok-adapter.test.ts +74 -0
  95. package/tests/llm-adapters.test.ts +357 -0
  96. package/tests/loop-detection.test.ts +456 -0
  97. package/tests/openai-fallback.test.ts +159 -0
  98. package/tests/orchestrator.test.ts +281 -0
  99. package/tests/scheduler.test.ts +221 -0
  100. package/tests/team-messaging.test.ts +329 -0
  101. package/tests/text-tool-extractor.test.ts +170 -0
  102. package/tests/trace.test.ts +453 -0
  103. package/vitest.config.ts +9 -0
  104. package/examples/09-gemma4-auto-orchestration.ts +0 -162
@@ -0,0 +1,464 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { TaskQueue } from '../src/task/queue.js'
3
+ import { createTask } from '../src/task/task.js'
4
+ import { OpenMultiAgent } from '../src/orchestrator/orchestrator.js'
5
+ import { Agent } from '../src/agent/agent.js'
6
+ import { AgentRunner } from '../src/agent/runner.js'
7
+ import { ToolRegistry } from '../src/tool/framework.js'
8
+ import { ToolExecutor } from '../src/tool/executor.js'
9
+ import { AgentPool } from '../src/agent/pool.js'
10
+ import type { AgentConfig, LLMAdapter, LLMResponse, Task } from '../src/types.js'
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function task(id: string, opts: { dependsOn?: string[]; assignee?: string } = {}) {
17
+ const t = createTask({ title: id, description: `task ${id}`, assignee: opts.assignee })
18
+ return { ...t, id, dependsOn: opts.dependsOn } as ReturnType<typeof createTask>
19
+ }
20
+
21
+ function mockAdapter(responseText: string): LLMAdapter {
22
+ return {
23
+ name: 'mock',
24
+ async chat() {
25
+ return {
26
+ id: 'mock-1',
27
+ content: [{ type: 'text' as const, text: responseText }],
28
+ model: 'mock-model',
29
+ stop_reason: 'end_turn',
30
+ usage: { input_tokens: 10, output_tokens: 20 },
31
+ } satisfies LLMResponse
32
+ },
33
+ async *stream() {
34
+ /* unused */
35
+ },
36
+ }
37
+ }
38
+
39
+ function buildMockAgent(config: AgentConfig, responseText: string): Agent {
40
+ const registry = new ToolRegistry()
41
+ const executor = new ToolExecutor(registry)
42
+ const agent = new Agent(config, registry, executor)
43
+ const runner = new AgentRunner(mockAdapter(responseText), registry, executor, {
44
+ model: config.model,
45
+ systemPrompt: config.systemPrompt,
46
+ maxTurns: config.maxTurns,
47
+ maxTokens: config.maxTokens,
48
+ temperature: config.temperature,
49
+ agentName: config.name,
50
+ })
51
+ ;(agent as any).runner = runner
52
+ return agent
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // TaskQueue: skip / skipRemaining
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('TaskQueue — skip', () => {
60
+ it('marks a task as skipped', () => {
61
+ const q = new TaskQueue()
62
+ q.add(task('a'))
63
+ q.skip('a', 'user rejected')
64
+ expect(q.list()[0].status).toBe('skipped')
65
+ expect(q.list()[0].result).toBe('user rejected')
66
+ })
67
+
68
+ it('fires task:skipped event with updated task object', () => {
69
+ const q = new TaskQueue()
70
+ const handler = vi.fn()
71
+ q.on('task:skipped', handler)
72
+
73
+ q.add(task('a'))
74
+ q.skip('a', 'rejected')
75
+
76
+ expect(handler).toHaveBeenCalledTimes(1)
77
+ const emitted = handler.mock.calls[0][0]
78
+ expect(emitted.id).toBe('a')
79
+ expect(emitted.status).toBe('skipped')
80
+ expect(emitted.result).toBe('rejected')
81
+ })
82
+
83
+ it('cascades skip to dependent tasks', () => {
84
+ const q = new TaskQueue()
85
+ q.add(task('a'))
86
+ q.add(task('b', { dependsOn: ['a'] }))
87
+ q.add(task('c', { dependsOn: ['b'] }))
88
+
89
+ q.skip('a', 'rejected')
90
+
91
+ expect(q.list().find((t) => t.id === 'a')!.status).toBe('skipped')
92
+ expect(q.list().find((t) => t.id === 'b')!.status).toBe('skipped')
93
+ expect(q.list().find((t) => t.id === 'c')!.status).toBe('skipped')
94
+ })
95
+
96
+ it('does not cascade to independent tasks', () => {
97
+ const q = new TaskQueue()
98
+ q.add(task('a'))
99
+ q.add(task('b'))
100
+ q.add(task('c', { dependsOn: ['a'] }))
101
+
102
+ q.skip('a', 'rejected')
103
+
104
+ expect(q.list().find((t) => t.id === 'b')!.status).toBe('pending')
105
+ expect(q.list().find((t) => t.id === 'c')!.status).toBe('skipped')
106
+ })
107
+
108
+ it('throws when skipping a non-existent task', () => {
109
+ const q = new TaskQueue()
110
+ expect(() => q.skip('nope', 'reason')).toThrow('not found')
111
+ })
112
+
113
+ it('isComplete() treats skipped as terminal', () => {
114
+ const q = new TaskQueue()
115
+ q.add(task('a'))
116
+ q.add(task('b'))
117
+
118
+ q.complete('a', 'done')
119
+ expect(q.isComplete()).toBe(false)
120
+
121
+ q.skip('b', 'rejected')
122
+ expect(q.isComplete()).toBe(true)
123
+ })
124
+
125
+ it('getProgress() counts skipped tasks', () => {
126
+ const q = new TaskQueue()
127
+ q.add(task('a'))
128
+ q.add(task('b'))
129
+ q.add(task('c'))
130
+
131
+ q.complete('a', 'done')
132
+ q.skip('b', 'rejected')
133
+
134
+ const progress = q.getProgress()
135
+ expect(progress.completed).toBe(1)
136
+ expect(progress.skipped).toBe(1)
137
+ expect(progress.pending).toBe(1)
138
+ })
139
+ })
140
+
141
+ describe('TaskQueue — skipRemaining', () => {
142
+ it('marks all non-terminal tasks as skipped', () => {
143
+ const q = new TaskQueue()
144
+ q.add(task('a'))
145
+ q.add(task('b'))
146
+ q.add(task('c', { dependsOn: ['a'] }))
147
+
148
+ q.complete('a', 'done')
149
+ q.skipRemaining('approval rejected')
150
+
151
+ expect(q.list().find((t) => t.id === 'a')!.status).toBe('completed')
152
+ expect(q.list().find((t) => t.id === 'b')!.status).toBe('skipped')
153
+ expect(q.list().find((t) => t.id === 'c')!.status).toBe('skipped')
154
+ })
155
+
156
+ it('leaves failed tasks untouched', () => {
157
+ const q = new TaskQueue()
158
+ q.add(task('a'))
159
+ q.add(task('b'))
160
+
161
+ q.fail('a', 'error')
162
+ q.skipRemaining()
163
+
164
+ expect(q.list().find((t) => t.id === 'a')!.status).toBe('failed')
165
+ expect(q.list().find((t) => t.id === 'b')!.status).toBe('skipped')
166
+ })
167
+
168
+ it('emits task:skipped with the updated task object (not stale)', () => {
169
+ const q = new TaskQueue()
170
+ const handler = vi.fn()
171
+ q.on('task:skipped', handler)
172
+
173
+ q.add(task('a'))
174
+ q.add(task('b'))
175
+
176
+ q.skipRemaining('reason')
177
+
178
+ expect(handler).toHaveBeenCalledTimes(2)
179
+ // Every emitted task must have status 'skipped'
180
+ for (const call of handler.mock.calls) {
181
+ expect(call[0].status).toBe('skipped')
182
+ expect(call[0].result).toBe('reason')
183
+ }
184
+ })
185
+
186
+ it('fires all:complete after skipRemaining', () => {
187
+ const q = new TaskQueue()
188
+ const handler = vi.fn()
189
+ q.on('all:complete', handler)
190
+
191
+ q.add(task('a'))
192
+ q.add(task('b'))
193
+
194
+ q.complete('a', 'done')
195
+ expect(handler).not.toHaveBeenCalled()
196
+
197
+ q.skipRemaining()
198
+ expect(handler).toHaveBeenCalledTimes(1)
199
+ })
200
+ })
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Orchestrator: onApproval integration
204
+ // ---------------------------------------------------------------------------
205
+
206
+ describe('onApproval integration', () => {
207
+ function patchPool(orchestrator: OpenMultiAgent, agents: Map<string, Agent>) {
208
+ ;(orchestrator as any).buildPool = () => {
209
+ const pool = new AgentPool(5)
210
+ for (const [, agent] of agents) {
211
+ pool.add(agent)
212
+ }
213
+ return pool
214
+ }
215
+ }
216
+
217
+ function setup(onApproval?: (tasks: readonly Task[], next: readonly Task[]) => Promise<boolean>) {
218
+ const agentA: AgentConfig = { name: 'agent-a', model: 'mock', systemPrompt: 'You are agent A.' }
219
+ const agentB: AgentConfig = { name: 'agent-b', model: 'mock', systemPrompt: 'You are agent B.' }
220
+
221
+ const orchestrator = new OpenMultiAgent({
222
+ defaultModel: 'mock',
223
+ ...(onApproval ? { onApproval } : {}),
224
+ })
225
+
226
+ const team = orchestrator.createTeam('test', {
227
+ name: 'test',
228
+ agents: [agentA, agentB],
229
+ })
230
+
231
+ const mockAgents = new Map<string, Agent>()
232
+ mockAgents.set('agent-a', buildMockAgent(agentA, 'result from A'))
233
+ mockAgents.set('agent-b', buildMockAgent(agentB, 'result from B'))
234
+ patchPool(orchestrator, mockAgents)
235
+
236
+ return { orchestrator, team }
237
+ }
238
+
239
+ it('approve all — all tasks complete normally', async () => {
240
+ const approvalSpy = vi.fn().mockResolvedValue(true)
241
+ const { orchestrator, team } = setup(approvalSpy)
242
+
243
+ const result = await orchestrator.runTasks(team, [
244
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
245
+ { title: 'task-2', description: 'second', assignee: 'agent-b', dependsOn: ['task-1'] },
246
+ ])
247
+
248
+ expect(result.success).toBe(true)
249
+ expect(result.agentResults.has('agent-a')).toBe(true)
250
+ expect(result.agentResults.has('agent-b')).toBe(true)
251
+ // onApproval called once (between round 1 and round 2)
252
+ expect(approvalSpy).toHaveBeenCalledTimes(1)
253
+ })
254
+
255
+ it('reject mid-pipeline — remaining tasks skipped', async () => {
256
+ const approvalSpy = vi.fn().mockResolvedValue(false)
257
+ const { orchestrator, team } = setup(approvalSpy)
258
+
259
+ const result = await orchestrator.runTasks(team, [
260
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
261
+ { title: 'task-2', description: 'second', assignee: 'agent-b', dependsOn: ['task-1'] },
262
+ ])
263
+
264
+ expect(approvalSpy).toHaveBeenCalledTimes(1)
265
+ // Only agent-a's output present (task-2 was skipped, never ran)
266
+ expect(result.agentResults.has('agent-a')).toBe(true)
267
+ expect(result.agentResults.has('agent-b')).toBe(false)
268
+ })
269
+
270
+ it('no callback — tasks flow without interruption', async () => {
271
+ const { orchestrator, team } = setup(/* no onApproval */)
272
+
273
+ const result = await orchestrator.runTasks(team, [
274
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
275
+ { title: 'task-2', description: 'second', assignee: 'agent-b', dependsOn: ['task-1'] },
276
+ ])
277
+
278
+ expect(result.success).toBe(true)
279
+ expect(result.agentResults.has('agent-a')).toBe(true)
280
+ expect(result.agentResults.has('agent-b')).toBe(true)
281
+ })
282
+
283
+ it('callback receives correct arguments — completedTasks array and nextTasks', async () => {
284
+ const approvalSpy = vi.fn().mockResolvedValue(true)
285
+ const { orchestrator, team } = setup(approvalSpy)
286
+
287
+ await orchestrator.runTasks(team, [
288
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
289
+ { title: 'task-2', description: 'second', assignee: 'agent-b', dependsOn: ['task-1'] },
290
+ ])
291
+
292
+ // First arg: array of completed tasks from this round
293
+ const completedTasks = approvalSpy.mock.calls[0][0]
294
+ expect(completedTasks).toHaveLength(1)
295
+ expect(completedTasks[0].title).toBe('task-1')
296
+ expect(completedTasks[0].status).toBe('completed')
297
+
298
+ // Second arg: the next tasks about to run
299
+ const nextTasks = approvalSpy.mock.calls[0][1]
300
+ expect(nextTasks).toHaveLength(1)
301
+ expect(nextTasks[0].title).toBe('task-2')
302
+ })
303
+
304
+ it('callback throwing an error skips remaining tasks gracefully', async () => {
305
+ const approvalSpy = vi.fn().mockRejectedValue(new Error('network timeout'))
306
+ const { orchestrator, team } = setup(approvalSpy)
307
+
308
+ // Should not throw — error is caught and remaining tasks are skipped
309
+ const result = await orchestrator.runTasks(team, [
310
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
311
+ { title: 'task-2', description: 'second', assignee: 'agent-b', dependsOn: ['task-1'] },
312
+ ])
313
+
314
+ expect(approvalSpy).toHaveBeenCalledTimes(1)
315
+ expect(result.agentResults.has('agent-a')).toBe(true)
316
+ expect(result.agentResults.has('agent-b')).toBe(false)
317
+ })
318
+
319
+ it('parallel batch — completedTasks contains all tasks from the round', async () => {
320
+ const approvalSpy = vi.fn().mockResolvedValue(true)
321
+ const agentA: AgentConfig = { name: 'agent-a', model: 'mock', systemPrompt: 'A' }
322
+ const agentB: AgentConfig = { name: 'agent-b', model: 'mock', systemPrompt: 'B' }
323
+ const agentC: AgentConfig = { name: 'agent-c', model: 'mock', systemPrompt: 'C' }
324
+
325
+ const orchestrator = new OpenMultiAgent({
326
+ defaultModel: 'mock',
327
+ onApproval: approvalSpy,
328
+ })
329
+
330
+ const team = orchestrator.createTeam('test', {
331
+ name: 'test',
332
+ agents: [agentA, agentB, agentC],
333
+ })
334
+
335
+ const mockAgents = new Map<string, Agent>()
336
+ mockAgents.set('agent-a', buildMockAgent(agentA, 'A done'))
337
+ mockAgents.set('agent-b', buildMockAgent(agentB, 'B done'))
338
+ mockAgents.set('agent-c', buildMockAgent(agentC, 'C done'))
339
+ patchPool(orchestrator, mockAgents)
340
+
341
+ // task-1 and task-2 are independent (run in parallel), task-3 depends on both
342
+ await orchestrator.runTasks(team, [
343
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
344
+ { title: 'task-2', description: 'second', assignee: 'agent-b' },
345
+ { title: 'task-3', description: 'third', assignee: 'agent-c', dependsOn: ['task-1', 'task-2'] },
346
+ ])
347
+
348
+ // Approval called once between the parallel batch and task-3
349
+ expect(approvalSpy).toHaveBeenCalledTimes(1)
350
+ const completedTasks = approvalSpy.mock.calls[0][0] as Task[]
351
+ // Both task-1 and task-2 completed in the same round
352
+ expect(completedTasks).toHaveLength(2)
353
+ const titles = completedTasks.map((t: Task) => t.title).sort()
354
+ expect(titles).toEqual(['task-1', 'task-2'])
355
+ })
356
+
357
+ it('single batch with no second round — callback never fires', async () => {
358
+ const approvalSpy = vi.fn().mockResolvedValue(true)
359
+ const { orchestrator, team } = setup(approvalSpy)
360
+
361
+ const result = await orchestrator.runTasks(team, [
362
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
363
+ { title: 'task-2', description: 'second', assignee: 'agent-b' },
364
+ ])
365
+
366
+ expect(result.success).toBe(true)
367
+ // No second round → callback never called
368
+ expect(approvalSpy).not.toHaveBeenCalled()
369
+ })
370
+
371
+ it('mixed success/failure in batch — completedTasks only contains succeeded tasks', async () => {
372
+ const approvalSpy = vi.fn().mockResolvedValue(true)
373
+ const agentA: AgentConfig = { name: 'agent-a', model: 'mock', systemPrompt: 'A' }
374
+ const agentB: AgentConfig = { name: 'agent-b', model: 'mock', systemPrompt: 'B' }
375
+ const agentC: AgentConfig = { name: 'agent-c', model: 'mock', systemPrompt: 'C' }
376
+
377
+ const orchestrator = new OpenMultiAgent({
378
+ defaultModel: 'mock',
379
+ onApproval: approvalSpy,
380
+ })
381
+
382
+ const team = orchestrator.createTeam('test', {
383
+ name: 'test',
384
+ agents: [agentA, agentB, agentC],
385
+ })
386
+
387
+ const mockAgents = new Map<string, Agent>()
388
+ mockAgents.set('agent-a', buildMockAgent(agentA, 'A done'))
389
+ mockAgents.set('agent-b', buildMockAgent(agentB, 'B done'))
390
+ mockAgents.set('agent-c', buildMockAgent(agentC, 'C done'))
391
+
392
+ // Patch buildPool so that pool.run for agent-b returns a failure result
393
+ ;(orchestrator as any).buildPool = () => {
394
+ const pool = new AgentPool(5)
395
+ for (const [, agent] of mockAgents) pool.add(agent)
396
+ const originalRun = pool.run.bind(pool)
397
+ pool.run = async (agentName: string, prompt: string, opts?: any) => {
398
+ if (agentName === 'agent-b') {
399
+ return {
400
+ success: false,
401
+ output: 'simulated failure',
402
+ messages: [],
403
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
404
+ toolCalls: [],
405
+ }
406
+ }
407
+ return originalRun(agentName, prompt, opts)
408
+ }
409
+ return pool
410
+ }
411
+
412
+ // task-1 (success) and task-2 (fail) run in parallel, task-3 depends on task-1
413
+ await orchestrator.runTasks(team, [
414
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
415
+ { title: 'task-2', description: 'second', assignee: 'agent-b' },
416
+ { title: 'task-3', description: 'third', assignee: 'agent-c', dependsOn: ['task-1'] },
417
+ ])
418
+
419
+ expect(approvalSpy).toHaveBeenCalledTimes(1)
420
+ const completedTasks = approvalSpy.mock.calls[0][0] as Task[]
421
+ // Only task-1 succeeded — task-2 failed, so it should not appear
422
+ expect(completedTasks).toHaveLength(1)
423
+ expect(completedTasks[0].title).toBe('task-1')
424
+ expect(completedTasks[0].status).toBe('completed')
425
+ })
426
+
427
+ it('onProgress receives task_skipped events when approval is rejected', async () => {
428
+ const progressSpy = vi.fn()
429
+ const agentA: AgentConfig = { name: 'agent-a', model: 'mock', systemPrompt: 'A' }
430
+ const agentB: AgentConfig = { name: 'agent-b', model: 'mock', systemPrompt: 'B' }
431
+
432
+ const orchestrator = new OpenMultiAgent({
433
+ defaultModel: 'mock',
434
+ onApproval: vi.fn().mockResolvedValue(false),
435
+ onProgress: progressSpy,
436
+ })
437
+
438
+ const team = orchestrator.createTeam('test', {
439
+ name: 'test',
440
+ agents: [agentA, agentB],
441
+ })
442
+
443
+ const mockAgents = new Map<string, Agent>()
444
+ mockAgents.set('agent-a', buildMockAgent(agentA, 'A done'))
445
+ mockAgents.set('agent-b', buildMockAgent(agentB, 'B done'))
446
+ ;(orchestrator as any).buildPool = () => {
447
+ const pool = new AgentPool(5)
448
+ for (const [, agent] of mockAgents) pool.add(agent)
449
+ return pool
450
+ }
451
+
452
+ await orchestrator.runTasks(team, [
453
+ { title: 'task-1', description: 'first', assignee: 'agent-a' },
454
+ { title: 'task-2', description: 'second', assignee: 'agent-b', dependsOn: ['task-1'] },
455
+ ])
456
+
457
+ const skippedEvents = progressSpy.mock.calls
458
+ .map((c: any) => c[0])
459
+ .filter((e: any) => e.type === 'task_skipped')
460
+
461
+ expect(skippedEvents).toHaveLength(1)
462
+ expect(skippedEvents[0].data.status).toBe('skipped')
463
+ })
464
+ })