@jackchen_me/open-multi-agent 1.0.0 → 1.0.1
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/package.json +8 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
- package/.github/pull_request_template.md +0 -14
- package/.github/workflows/ci.yml +0 -23
- package/CLAUDE.md +0 -80
- package/CODE_OF_CONDUCT.md +0 -48
- package/CONTRIBUTING.md +0 -72
- package/DECISIONS.md +0 -43
- package/README_zh.md +0 -277
- package/SECURITY.md +0 -17
- package/examples/01-single-agent.ts +0 -131
- package/examples/02-team-collaboration.ts +0 -167
- package/examples/03-task-pipeline.ts +0 -201
- package/examples/04-multi-model-team.ts +0 -261
- package/examples/05-copilot-test.ts +0 -49
- package/examples/06-local-model.ts +0 -200
- package/examples/07-fan-out-aggregate.ts +0 -209
- package/examples/08-gemma4-local.ts +0 -192
- package/examples/09-structured-output.ts +0 -73
- package/examples/10-task-retry.ts +0 -132
- package/examples/11-trace-observability.ts +0 -133
- package/examples/12-grok.ts +0 -154
- package/examples/13-gemini.ts +0 -48
- package/src/agent/agent.ts +0 -622
- package/src/agent/loop-detector.ts +0 -137
- package/src/agent/pool.ts +0 -285
- package/src/agent/runner.ts +0 -542
- package/src/agent/structured-output.ts +0 -126
- package/src/index.ts +0 -182
- package/src/llm/adapter.ts +0 -98
- package/src/llm/anthropic.ts +0 -389
- package/src/llm/copilot.ts +0 -552
- package/src/llm/gemini.ts +0 -378
- package/src/llm/grok.ts +0 -29
- package/src/llm/openai-common.ts +0 -294
- package/src/llm/openai.ts +0 -292
- package/src/memory/shared.ts +0 -181
- package/src/memory/store.ts +0 -124
- package/src/orchestrator/orchestrator.ts +0 -1071
- package/src/orchestrator/scheduler.ts +0 -352
- package/src/task/queue.ts +0 -464
- package/src/task/task.ts +0 -239
- package/src/team/messaging.ts +0 -232
- package/src/team/team.ts +0 -334
- package/src/tool/built-in/bash.ts +0 -187
- package/src/tool/built-in/file-edit.ts +0 -154
- package/src/tool/built-in/file-read.ts +0 -105
- package/src/tool/built-in/file-write.ts +0 -81
- package/src/tool/built-in/grep.ts +0 -362
- package/src/tool/built-in/index.ts +0 -50
- package/src/tool/executor.ts +0 -178
- package/src/tool/framework.ts +0 -557
- package/src/tool/text-tool-extractor.ts +0 -219
- package/src/types.ts +0 -542
- package/src/utils/semaphore.ts +0 -89
- package/src/utils/trace.ts +0 -34
- package/tests/agent-hooks.test.ts +0 -473
- package/tests/agent-pool.test.ts +0 -212
- package/tests/approval.test.ts +0 -464
- package/tests/built-in-tools.test.ts +0 -393
- package/tests/gemini-adapter.test.ts +0 -97
- package/tests/grok-adapter.test.ts +0 -74
- package/tests/llm-adapters.test.ts +0 -357
- package/tests/loop-detection.test.ts +0 -456
- package/tests/openai-fallback.test.ts +0 -159
- package/tests/orchestrator.test.ts +0 -281
- package/tests/scheduler.test.ts +0 -221
- package/tests/semaphore.test.ts +0 -57
- package/tests/shared-memory.test.ts +0 -122
- package/tests/structured-output.test.ts +0 -331
- package/tests/task-queue.test.ts +0 -244
- package/tests/task-retry.test.ts +0 -368
- package/tests/task-utils.test.ts +0 -155
- package/tests/team-messaging.test.ts +0 -329
- package/tests/text-tool-extractor.test.ts +0 -170
- package/tests/tool-executor.test.ts +0 -193
- package/tests/trace.test.ts +0 -453
- package/tsconfig.json +0 -25
- package/vitest.config.ts +0 -9
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { z } from 'zod'
|
|
3
|
-
import {
|
|
4
|
-
buildStructuredOutputInstruction,
|
|
5
|
-
extractJSON,
|
|
6
|
-
validateOutput,
|
|
7
|
-
} from '../src/agent/structured-output.js'
|
|
8
|
-
import { Agent } from '../src/agent/agent.js'
|
|
9
|
-
import { AgentRunner } from '../src/agent/runner.js'
|
|
10
|
-
import { ToolRegistry } from '../src/tool/framework.js'
|
|
11
|
-
import { ToolExecutor } from '../src/tool/executor.js'
|
|
12
|
-
import type { AgentConfig, LLMAdapter, LLMResponse } from '../src/types.js'
|
|
13
|
-
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
// Mock LLM adapter factory
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
function mockAdapter(responses: string[]): LLMAdapter {
|
|
19
|
-
let callIndex = 0
|
|
20
|
-
return {
|
|
21
|
-
name: 'mock',
|
|
22
|
-
async chat() {
|
|
23
|
-
const text = responses[callIndex++] ?? ''
|
|
24
|
-
return {
|
|
25
|
-
id: `mock-${callIndex}`,
|
|
26
|
-
content: [{ type: 'text' as const, text }],
|
|
27
|
-
model: 'mock-model',
|
|
28
|
-
stop_reason: 'end_turn',
|
|
29
|
-
usage: { input_tokens: 10, output_tokens: 20 },
|
|
30
|
-
} satisfies LLMResponse
|
|
31
|
-
},
|
|
32
|
-
async *stream() {
|
|
33
|
-
/* unused in these tests */
|
|
34
|
-
},
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
// extractJSON
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
|
|
42
|
-
describe('extractJSON', () => {
|
|
43
|
-
it('parses clean JSON', () => {
|
|
44
|
-
expect(extractJSON('{"a":1}')).toEqual({ a: 1 })
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('parses JSON wrapped in ```json fence', () => {
|
|
48
|
-
const raw = 'Here is the result:\n```json\n{"a":1}\n```\nDone.'
|
|
49
|
-
expect(extractJSON(raw)).toEqual({ a: 1 })
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('parses JSON wrapped in bare ``` fence', () => {
|
|
53
|
-
const raw = '```\n{"a":1}\n```'
|
|
54
|
-
expect(extractJSON(raw)).toEqual({ a: 1 })
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('extracts embedded JSON object from surrounding text', () => {
|
|
58
|
-
const raw = 'The answer is {"summary":"hello","score":5} as shown above.'
|
|
59
|
-
expect(extractJSON(raw)).toEqual({ summary: 'hello', score: 5 })
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('extracts JSON array', () => {
|
|
63
|
-
expect(extractJSON('[1,2,3]')).toEqual([1, 2, 3])
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('extracts embedded JSON array from surrounding text', () => {
|
|
67
|
-
const raw = 'Here: [{"a":1},{"a":2}] end'
|
|
68
|
-
expect(extractJSON(raw)).toEqual([{ a: 1 }, { a: 2 }])
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('throws on non-JSON text', () => {
|
|
72
|
-
expect(() => extractJSON('just plain text')).toThrow('Failed to extract JSON')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('throws on empty string', () => {
|
|
76
|
-
expect(() => extractJSON('')).toThrow('Failed to extract JSON')
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// validateOutput
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
|
|
84
|
-
describe('validateOutput', () => {
|
|
85
|
-
const schema = z.object({
|
|
86
|
-
summary: z.string(),
|
|
87
|
-
score: z.number().min(0).max(10),
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('returns validated data on success', () => {
|
|
91
|
-
const data = { summary: 'hello', score: 5 }
|
|
92
|
-
expect(validateOutput(schema, data)).toEqual(data)
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
it('throws on missing field', () => {
|
|
96
|
-
expect(() => validateOutput(schema, { summary: 'hello' })).toThrow(
|
|
97
|
-
'Output validation failed',
|
|
98
|
-
)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('throws on wrong type', () => {
|
|
102
|
-
expect(() =>
|
|
103
|
-
validateOutput(schema, { summary: 'hello', score: 'not a number' }),
|
|
104
|
-
).toThrow('Output validation failed')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('throws on value out of range', () => {
|
|
108
|
-
expect(() =>
|
|
109
|
-
validateOutput(schema, { summary: 'hello', score: 99 }),
|
|
110
|
-
).toThrow('Output validation failed')
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('applies Zod transforms', () => {
|
|
114
|
-
const transformSchema = z.object({
|
|
115
|
-
name: z.string().transform(s => s.toUpperCase()),
|
|
116
|
-
})
|
|
117
|
-
const result = validateOutput(transformSchema, { name: 'alice' })
|
|
118
|
-
expect(result).toEqual({ name: 'ALICE' })
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('strips unknown keys with strict schema', () => {
|
|
122
|
-
const strictSchema = z.object({ a: z.number() }).strict()
|
|
123
|
-
expect(() =>
|
|
124
|
-
validateOutput(strictSchema, { a: 1, b: 2 }),
|
|
125
|
-
).toThrow('Output validation failed')
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('shows (root) for root-level errors', () => {
|
|
129
|
-
const stringSchema = z.string()
|
|
130
|
-
expect(() => validateOutput(stringSchema, 42)).toThrow('(root)')
|
|
131
|
-
})
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
// ---------------------------------------------------------------------------
|
|
135
|
-
// buildStructuredOutputInstruction
|
|
136
|
-
// ---------------------------------------------------------------------------
|
|
137
|
-
|
|
138
|
-
describe('buildStructuredOutputInstruction', () => {
|
|
139
|
-
it('includes the JSON Schema representation', () => {
|
|
140
|
-
const schema = z.object({
|
|
141
|
-
summary: z.string(),
|
|
142
|
-
score: z.number(),
|
|
143
|
-
})
|
|
144
|
-
const instruction = buildStructuredOutputInstruction(schema)
|
|
145
|
-
|
|
146
|
-
expect(instruction).toContain('Output Format (REQUIRED)')
|
|
147
|
-
expect(instruction).toContain('"type": "object"')
|
|
148
|
-
expect(instruction).toContain('"summary"')
|
|
149
|
-
expect(instruction).toContain('"score"')
|
|
150
|
-
expect(instruction).toContain('ONLY valid JSON')
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
it('includes description from Zod schema', () => {
|
|
154
|
-
const schema = z.object({
|
|
155
|
-
name: z.string().describe('The person name'),
|
|
156
|
-
})
|
|
157
|
-
const instruction = buildStructuredOutputInstruction(schema)
|
|
158
|
-
expect(instruction).toContain('The person name')
|
|
159
|
-
})
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
// Agent integration (mocked LLM)
|
|
164
|
-
// ---------------------------------------------------------------------------
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Build an Agent with a mocked LLM adapter by injecting an AgentRunner
|
|
168
|
-
* directly into the Agent's private `runner` field, bypassing `createAdapter`.
|
|
169
|
-
*/
|
|
170
|
-
function buildMockAgent(config: AgentConfig, responses: string[]): Agent {
|
|
171
|
-
const adapter = mockAdapter(responses)
|
|
172
|
-
const registry = new ToolRegistry()
|
|
173
|
-
const executor = new ToolExecutor(registry)
|
|
174
|
-
const agent = new Agent(config, registry, executor)
|
|
175
|
-
|
|
176
|
-
// Inject a pre-built runner so `getRunner()` returns it without calling createAdapter.
|
|
177
|
-
const runner = new AgentRunner(adapter, registry, executor, {
|
|
178
|
-
model: config.model,
|
|
179
|
-
systemPrompt: config.systemPrompt,
|
|
180
|
-
maxTurns: config.maxTurns,
|
|
181
|
-
maxTokens: config.maxTokens,
|
|
182
|
-
temperature: config.temperature,
|
|
183
|
-
agentName: config.name,
|
|
184
|
-
})
|
|
185
|
-
;(agent as any).runner = runner
|
|
186
|
-
|
|
187
|
-
return agent
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
describe('Agent structured output (end-to-end)', () => {
|
|
191
|
-
const schema = z.object({
|
|
192
|
-
summary: z.string(),
|
|
193
|
-
sentiment: z.enum(['positive', 'negative', 'neutral']),
|
|
194
|
-
confidence: z.number().min(0).max(1),
|
|
195
|
-
})
|
|
196
|
-
|
|
197
|
-
const baseConfig: AgentConfig = {
|
|
198
|
-
name: 'test-agent',
|
|
199
|
-
model: 'mock-model',
|
|
200
|
-
systemPrompt: 'You are a test agent.',
|
|
201
|
-
outputSchema: schema,
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
it('happy path: valid JSON on first attempt', async () => {
|
|
205
|
-
const validJSON = JSON.stringify({
|
|
206
|
-
summary: 'Great product',
|
|
207
|
-
sentiment: 'positive',
|
|
208
|
-
confidence: 0.95,
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
const agent = buildMockAgent(baseConfig, [validJSON])
|
|
212
|
-
const result = await agent.run('Analyze this review')
|
|
213
|
-
|
|
214
|
-
expect(result.success).toBe(true)
|
|
215
|
-
expect(result.structured).toEqual({
|
|
216
|
-
summary: 'Great product',
|
|
217
|
-
sentiment: 'positive',
|
|
218
|
-
confidence: 0.95,
|
|
219
|
-
})
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
it('retry: invalid first attempt, valid second attempt', async () => {
|
|
223
|
-
const invalidJSON = JSON.stringify({
|
|
224
|
-
summary: 'Great product',
|
|
225
|
-
sentiment: 'INVALID_VALUE',
|
|
226
|
-
confidence: 0.95,
|
|
227
|
-
})
|
|
228
|
-
const validJSON = JSON.stringify({
|
|
229
|
-
summary: 'Great product',
|
|
230
|
-
sentiment: 'positive',
|
|
231
|
-
confidence: 0.95,
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
const agent = buildMockAgent(baseConfig, [invalidJSON, validJSON])
|
|
235
|
-
const result = await agent.run('Analyze this review')
|
|
236
|
-
|
|
237
|
-
expect(result.success).toBe(true)
|
|
238
|
-
expect(result.structured).toEqual({
|
|
239
|
-
summary: 'Great product',
|
|
240
|
-
sentiment: 'positive',
|
|
241
|
-
confidence: 0.95,
|
|
242
|
-
})
|
|
243
|
-
// Token usage should reflect both attempts
|
|
244
|
-
expect(result.tokenUsage.input_tokens).toBe(20) // 10 + 10
|
|
245
|
-
expect(result.tokenUsage.output_tokens).toBe(40) // 20 + 20
|
|
246
|
-
})
|
|
247
|
-
|
|
248
|
-
it('both attempts fail: success=false, structured=undefined', async () => {
|
|
249
|
-
const bad1 = '{"summary": "ok", "sentiment": "WRONG"}'
|
|
250
|
-
const bad2 = '{"summary": "ok", "sentiment": "ALSO_WRONG"}'
|
|
251
|
-
|
|
252
|
-
const agent = buildMockAgent(baseConfig, [bad1, bad2])
|
|
253
|
-
const result = await agent.run('Analyze this review')
|
|
254
|
-
|
|
255
|
-
expect(result.success).toBe(false)
|
|
256
|
-
expect(result.structured).toBeUndefined()
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
it('no outputSchema: original behavior, structured is undefined', async () => {
|
|
260
|
-
const configNoSchema: AgentConfig = {
|
|
261
|
-
name: 'plain-agent',
|
|
262
|
-
model: 'mock-model',
|
|
263
|
-
systemPrompt: 'You are a test agent.',
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const agent = buildMockAgent(configNoSchema, ['Just plain text output'])
|
|
267
|
-
const result = await agent.run('Hello')
|
|
268
|
-
|
|
269
|
-
expect(result.success).toBe(true)
|
|
270
|
-
expect(result.output).toBe('Just plain text output')
|
|
271
|
-
expect(result.structured).toBeUndefined()
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
it('handles JSON wrapped in markdown fence', async () => {
|
|
275
|
-
const fenced = '```json\n{"summary":"ok","sentiment":"neutral","confidence":0.5}\n```'
|
|
276
|
-
|
|
277
|
-
const agent = buildMockAgent(baseConfig, [fenced])
|
|
278
|
-
const result = await agent.run('Analyze')
|
|
279
|
-
|
|
280
|
-
expect(result.success).toBe(true)
|
|
281
|
-
expect(result.structured).toEqual({
|
|
282
|
-
summary: 'ok',
|
|
283
|
-
sentiment: 'neutral',
|
|
284
|
-
confidence: 0.5,
|
|
285
|
-
})
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
it('non-JSON output triggers retry, valid JSON on retry succeeds', async () => {
|
|
289
|
-
const nonJSON = 'I am not sure how to analyze this.'
|
|
290
|
-
const validJSON = JSON.stringify({
|
|
291
|
-
summary: 'Uncertain',
|
|
292
|
-
sentiment: 'neutral',
|
|
293
|
-
confidence: 0.1,
|
|
294
|
-
})
|
|
295
|
-
|
|
296
|
-
const agent = buildMockAgent(baseConfig, [nonJSON, validJSON])
|
|
297
|
-
const result = await agent.run('Analyze this review')
|
|
298
|
-
|
|
299
|
-
expect(result.success).toBe(true)
|
|
300
|
-
expect(result.structured).toEqual({
|
|
301
|
-
summary: 'Uncertain',
|
|
302
|
-
sentiment: 'neutral',
|
|
303
|
-
confidence: 0.1,
|
|
304
|
-
})
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
it('non-JSON output on both attempts: success=false', async () => {
|
|
308
|
-
const agent = buildMockAgent(baseConfig, [
|
|
309
|
-
'Sorry, I cannot do that.',
|
|
310
|
-
'Still cannot do it.',
|
|
311
|
-
])
|
|
312
|
-
const result = await agent.run('Analyze this review')
|
|
313
|
-
|
|
314
|
-
expect(result.success).toBe(false)
|
|
315
|
-
expect(result.structured).toBeUndefined()
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
it('token usage on first-attempt success reflects single call only', async () => {
|
|
319
|
-
const validJSON = JSON.stringify({
|
|
320
|
-
summary: 'Good',
|
|
321
|
-
sentiment: 'positive',
|
|
322
|
-
confidence: 0.9,
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
const agent = buildMockAgent(baseConfig, [validJSON])
|
|
326
|
-
const result = await agent.run('Analyze')
|
|
327
|
-
|
|
328
|
-
expect(result.tokenUsage.input_tokens).toBe(10)
|
|
329
|
-
expect(result.tokenUsage.output_tokens).toBe(20)
|
|
330
|
-
})
|
|
331
|
-
})
|
package/tests/task-queue.test.ts
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
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
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Helpers
|
|
7
|
-
// ---------------------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
/** Create a simple task with a predictable id. */
|
|
10
|
-
function task(id: string, opts: { dependsOn?: string[]; assignee?: string } = {}) {
|
|
11
|
-
const t = createTask({ title: id, description: `task ${id}`, assignee: opts.assignee })
|
|
12
|
-
// Override the random UUID so tests can reference tasks by name.
|
|
13
|
-
return { ...t, id, dependsOn: opts.dependsOn } as ReturnType<typeof createTask>
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Tests
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
describe('TaskQueue', () => {
|
|
21
|
-
// -------------------------------------------------------------------------
|
|
22
|
-
// Basic add & query
|
|
23
|
-
// -------------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
it('adds a task and lists it', () => {
|
|
26
|
-
const q = new TaskQueue()
|
|
27
|
-
q.add(task('a'))
|
|
28
|
-
expect(q.list()).toHaveLength(1)
|
|
29
|
-
expect(q.list()[0].id).toBe('a')
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('fires task:ready for a task with no dependencies', () => {
|
|
33
|
-
const q = new TaskQueue()
|
|
34
|
-
const handler = vi.fn()
|
|
35
|
-
q.on('task:ready', handler)
|
|
36
|
-
|
|
37
|
-
q.add(task('a'))
|
|
38
|
-
expect(handler).toHaveBeenCalledTimes(1)
|
|
39
|
-
expect(handler.mock.calls[0][0].id).toBe('a')
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('blocks a task whose dependency is not yet completed', () => {
|
|
43
|
-
const q = new TaskQueue()
|
|
44
|
-
q.add(task('a'))
|
|
45
|
-
q.add(task('b', { dependsOn: ['a'] }))
|
|
46
|
-
|
|
47
|
-
const b = q.list().find((t) => t.id === 'b')!
|
|
48
|
-
expect(b.status).toBe('blocked')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
// -------------------------------------------------------------------------
|
|
52
|
-
// Dependency resolution
|
|
53
|
-
// -------------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
it('unblocks a dependent task when its dependency completes', () => {
|
|
56
|
-
const q = new TaskQueue()
|
|
57
|
-
const readyHandler = vi.fn()
|
|
58
|
-
q.on('task:ready', readyHandler)
|
|
59
|
-
|
|
60
|
-
q.add(task('a'))
|
|
61
|
-
q.add(task('b', { dependsOn: ['a'] }))
|
|
62
|
-
|
|
63
|
-
// 'a' fires task:ready, 'b' is blocked
|
|
64
|
-
expect(readyHandler).toHaveBeenCalledTimes(1)
|
|
65
|
-
|
|
66
|
-
q.complete('a', 'done')
|
|
67
|
-
|
|
68
|
-
// 'b' should now be unblocked → fires task:ready
|
|
69
|
-
expect(readyHandler).toHaveBeenCalledTimes(2)
|
|
70
|
-
expect(readyHandler.mock.calls[1][0].id).toBe('b')
|
|
71
|
-
expect(q.list().find((t) => t.id === 'b')!.status).toBe('pending')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('keeps a task blocked until ALL dependencies complete', () => {
|
|
75
|
-
const q = new TaskQueue()
|
|
76
|
-
q.add(task('a'))
|
|
77
|
-
q.add(task('b'))
|
|
78
|
-
q.add(task('c', { dependsOn: ['a', 'b'] }))
|
|
79
|
-
|
|
80
|
-
q.complete('a')
|
|
81
|
-
|
|
82
|
-
const cAfterA = q.list().find((t) => t.id === 'c')!
|
|
83
|
-
expect(cAfterA.status).toBe('blocked')
|
|
84
|
-
|
|
85
|
-
q.complete('b')
|
|
86
|
-
|
|
87
|
-
const cAfterB = q.list().find((t) => t.id === 'c')!
|
|
88
|
-
expect(cAfterB.status).toBe('pending')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
// -------------------------------------------------------------------------
|
|
92
|
-
// Cascade failure
|
|
93
|
-
// -------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
it('cascades failure to direct dependents', () => {
|
|
96
|
-
const q = new TaskQueue()
|
|
97
|
-
const failHandler = vi.fn()
|
|
98
|
-
q.on('task:failed', failHandler)
|
|
99
|
-
|
|
100
|
-
q.add(task('a'))
|
|
101
|
-
q.add(task('b', { dependsOn: ['a'] }))
|
|
102
|
-
|
|
103
|
-
q.fail('a', 'boom')
|
|
104
|
-
|
|
105
|
-
expect(failHandler).toHaveBeenCalledTimes(2) // a + b
|
|
106
|
-
expect(q.list().find((t) => t.id === 'b')!.status).toBe('failed')
|
|
107
|
-
expect(q.list().find((t) => t.id === 'b')!.result).toContain('dependency')
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('cascades failure transitively (a → b → c)', () => {
|
|
111
|
-
const q = new TaskQueue()
|
|
112
|
-
q.add(task('a'))
|
|
113
|
-
q.add(task('b', { dependsOn: ['a'] }))
|
|
114
|
-
q.add(task('c', { dependsOn: ['b'] }))
|
|
115
|
-
|
|
116
|
-
q.fail('a', 'boom')
|
|
117
|
-
|
|
118
|
-
expect(q.list().every((t) => t.status === 'failed')).toBe(true)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('does not cascade failure to independent tasks', () => {
|
|
122
|
-
const q = new TaskQueue()
|
|
123
|
-
q.add(task('a'))
|
|
124
|
-
q.add(task('b'))
|
|
125
|
-
q.add(task('c', { dependsOn: ['a'] }))
|
|
126
|
-
|
|
127
|
-
q.fail('a', 'boom')
|
|
128
|
-
|
|
129
|
-
expect(q.list().find((t) => t.id === 'b')!.status).toBe('pending')
|
|
130
|
-
expect(q.list().find((t) => t.id === 'c')!.status).toBe('failed')
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
// -------------------------------------------------------------------------
|
|
134
|
-
// Completion
|
|
135
|
-
// -------------------------------------------------------------------------
|
|
136
|
-
|
|
137
|
-
it('fires all:complete when every task reaches a terminal state', () => {
|
|
138
|
-
const q = new TaskQueue()
|
|
139
|
-
const allComplete = vi.fn()
|
|
140
|
-
q.on('all:complete', allComplete)
|
|
141
|
-
|
|
142
|
-
q.add(task('a'))
|
|
143
|
-
q.add(task('b'))
|
|
144
|
-
|
|
145
|
-
q.complete('a')
|
|
146
|
-
expect(allComplete).not.toHaveBeenCalled()
|
|
147
|
-
|
|
148
|
-
q.complete('b')
|
|
149
|
-
expect(allComplete).toHaveBeenCalledTimes(1)
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('fires all:complete when mix of completed and failed', () => {
|
|
153
|
-
const q = new TaskQueue()
|
|
154
|
-
const allComplete = vi.fn()
|
|
155
|
-
q.on('all:complete', allComplete)
|
|
156
|
-
|
|
157
|
-
q.add(task('a'))
|
|
158
|
-
q.add(task('b', { dependsOn: ['a'] }))
|
|
159
|
-
|
|
160
|
-
q.fail('a', 'err') // cascades to b
|
|
161
|
-
expect(allComplete).toHaveBeenCalledTimes(1)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
it('isComplete returns true for an empty queue', () => {
|
|
165
|
-
const q = new TaskQueue()
|
|
166
|
-
expect(q.isComplete()).toBe(true)
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
// -------------------------------------------------------------------------
|
|
170
|
-
// Query: next / nextAvailable
|
|
171
|
-
// -------------------------------------------------------------------------
|
|
172
|
-
|
|
173
|
-
it('next() returns a pending task for the given assignee', () => {
|
|
174
|
-
const q = new TaskQueue()
|
|
175
|
-
q.add(task('a', { assignee: 'alice' }))
|
|
176
|
-
q.add(task('b', { assignee: 'bob' }))
|
|
177
|
-
|
|
178
|
-
expect(q.next('bob')?.id).toBe('b')
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('next() returns undefined when no pending task matches', () => {
|
|
182
|
-
const q = new TaskQueue()
|
|
183
|
-
q.add(task('a', { assignee: 'alice' }))
|
|
184
|
-
expect(q.next('bob')).toBeUndefined()
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
it('nextAvailable() prefers unassigned tasks', () => {
|
|
188
|
-
const q = new TaskQueue()
|
|
189
|
-
q.add(task('assigned', { assignee: 'alice' }))
|
|
190
|
-
q.add(task('unassigned'))
|
|
191
|
-
|
|
192
|
-
expect(q.nextAvailable()?.id).toBe('unassigned')
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
// -------------------------------------------------------------------------
|
|
196
|
-
// Progress
|
|
197
|
-
// -------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
it('getProgress() returns correct counts', () => {
|
|
200
|
-
const q = new TaskQueue()
|
|
201
|
-
q.add(task('a'))
|
|
202
|
-
q.add(task('b'))
|
|
203
|
-
q.add(task('c', { dependsOn: ['a'] }))
|
|
204
|
-
|
|
205
|
-
q.complete('a')
|
|
206
|
-
|
|
207
|
-
const p = q.getProgress()
|
|
208
|
-
expect(p.total).toBe(3)
|
|
209
|
-
expect(p.completed).toBe(1)
|
|
210
|
-
expect(p.pending).toBe(2) // b + c (unblocked)
|
|
211
|
-
expect(p.blocked).toBe(0)
|
|
212
|
-
})
|
|
213
|
-
|
|
214
|
-
// -------------------------------------------------------------------------
|
|
215
|
-
// Event unsubscribe
|
|
216
|
-
// -------------------------------------------------------------------------
|
|
217
|
-
|
|
218
|
-
it('unsubscribe stops receiving events', () => {
|
|
219
|
-
const q = new TaskQueue()
|
|
220
|
-
const handler = vi.fn()
|
|
221
|
-
const off = q.on('task:ready', handler)
|
|
222
|
-
|
|
223
|
-
q.add(task('a'))
|
|
224
|
-
expect(handler).toHaveBeenCalledTimes(1)
|
|
225
|
-
|
|
226
|
-
off()
|
|
227
|
-
q.add(task('b'))
|
|
228
|
-
expect(handler).toHaveBeenCalledTimes(1) // no new call
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
// -------------------------------------------------------------------------
|
|
232
|
-
// Error cases
|
|
233
|
-
// -------------------------------------------------------------------------
|
|
234
|
-
|
|
235
|
-
it('throws when completing a non-existent task', () => {
|
|
236
|
-
const q = new TaskQueue()
|
|
237
|
-
expect(() => q.complete('ghost')).toThrow('not found')
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
it('throws when failing a non-existent task', () => {
|
|
241
|
-
const q = new TaskQueue()
|
|
242
|
-
expect(() => q.fail('ghost', 'err')).toThrow('not found')
|
|
243
|
-
})
|
|
244
|
-
})
|