@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.
- package/.github/workflows/ci.yml +1 -1
- package/CLAUDE.md +11 -3
- package/README.md +87 -20
- package/README_zh.md +85 -25
- package/dist/agent/agent.d.ts +15 -1
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +144 -10
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/loop-detector.d.ts +39 -0
- package/dist/agent/loop-detector.d.ts.map +1 -0
- package/dist/agent/loop-detector.js +122 -0
- package/dist/agent/loop-detector.js.map +1 -0
- package/dist/agent/pool.d.ts +2 -1
- package/dist/agent/pool.d.ts.map +1 -1
- package/dist/agent/pool.js +4 -2
- package/dist/agent/pool.js.map +1 -1
- package/dist/agent/runner.d.ts +23 -1
- package/dist/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +113 -12
- package/dist/agent/runner.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +4 -1
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +11 -0
- package/dist/llm/adapter.js.map +1 -1
- package/dist/llm/copilot.d.ts.map +1 -1
- package/dist/llm/copilot.js +2 -1
- package/dist/llm/copilot.js.map +1 -1
- package/dist/llm/gemini.d.ts +65 -0
- package/dist/llm/gemini.d.ts.map +1 -0
- package/dist/llm/gemini.js +317 -0
- package/dist/llm/gemini.js.map +1 -0
- package/dist/llm/grok.d.ts +21 -0
- package/dist/llm/grok.d.ts.map +1 -0
- package/dist/llm/grok.js +24 -0
- package/dist/llm/grok.js.map +1 -0
- package/dist/llm/openai-common.d.ts +8 -1
- package/dist/llm/openai-common.d.ts.map +1 -1
- package/dist/llm/openai-common.js +35 -2
- package/dist/llm/openai-common.js.map +1 -1
- package/dist/llm/openai.d.ts +1 -1
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +20 -2
- package/dist/llm/openai.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +89 -9
- package/dist/orchestrator/orchestrator.js.map +1 -1
- package/dist/task/queue.d.ts +31 -2
- package/dist/task/queue.d.ts.map +1 -1
- package/dist/task/queue.js +69 -2
- package/dist/task/queue.js.map +1 -1
- package/dist/tool/text-tool-extractor.d.ts +32 -0
- package/dist/tool/text-tool-extractor.d.ts.map +1 -0
- package/dist/tool/text-tool-extractor.js +187 -0
- package/dist/tool/text-tool-extractor.js.map +1 -0
- package/dist/types.d.ts +139 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/trace.d.ts +12 -0
- package/dist/utils/trace.d.ts.map +1 -0
- package/dist/utils/trace.js +30 -0
- package/dist/utils/trace.js.map +1 -0
- package/examples/06-local-model.ts +1 -0
- package/examples/08-gemma4-local.ts +76 -87
- package/examples/09-structured-output.ts +73 -0
- package/examples/10-task-retry.ts +132 -0
- package/examples/11-trace-observability.ts +133 -0
- package/examples/12-grok.ts +154 -0
- package/examples/13-gemini.ts +48 -0
- package/package.json +11 -1
- package/src/agent/agent.ts +159 -10
- package/src/agent/loop-detector.ts +137 -0
- package/src/agent/pool.ts +9 -2
- package/src/agent/runner.ts +148 -19
- package/src/index.ts +15 -0
- package/src/llm/adapter.ts +12 -1
- package/src/llm/copilot.ts +2 -1
- package/src/llm/gemini.ts +378 -0
- package/src/llm/grok.ts +29 -0
- package/src/llm/openai-common.ts +41 -2
- package/src/llm/openai.ts +23 -3
- package/src/orchestrator/orchestrator.ts +105 -11
- package/src/task/queue.ts +73 -3
- package/src/tool/text-tool-extractor.ts +219 -0
- package/src/types.ts +157 -6
- package/src/utils/trace.ts +34 -0
- package/tests/agent-hooks.test.ts +473 -0
- package/tests/agent-pool.test.ts +212 -0
- package/tests/approval.test.ts +464 -0
- package/tests/built-in-tools.test.ts +393 -0
- package/tests/gemini-adapter.test.ts +97 -0
- package/tests/grok-adapter.test.ts +74 -0
- package/tests/llm-adapters.test.ts +357 -0
- package/tests/loop-detection.test.ts +456 -0
- package/tests/openai-fallback.test.ts +159 -0
- package/tests/orchestrator.test.ts +281 -0
- package/tests/scheduler.test.ts +221 -0
- package/tests/team-messaging.test.ts +329 -0
- package/tests/text-tool-extractor.test.ts +170 -0
- package/tests/trace.test.ts +453 -0
- package/vitest.config.ts +9 -0
- 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
|
+
})
|