@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.
Files changed (80) hide show
  1. package/package.json +8 -2
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
  4. package/.github/pull_request_template.md +0 -14
  5. package/.github/workflows/ci.yml +0 -23
  6. package/CLAUDE.md +0 -80
  7. package/CODE_OF_CONDUCT.md +0 -48
  8. package/CONTRIBUTING.md +0 -72
  9. package/DECISIONS.md +0 -43
  10. package/README_zh.md +0 -277
  11. package/SECURITY.md +0 -17
  12. package/examples/01-single-agent.ts +0 -131
  13. package/examples/02-team-collaboration.ts +0 -167
  14. package/examples/03-task-pipeline.ts +0 -201
  15. package/examples/04-multi-model-team.ts +0 -261
  16. package/examples/05-copilot-test.ts +0 -49
  17. package/examples/06-local-model.ts +0 -200
  18. package/examples/07-fan-out-aggregate.ts +0 -209
  19. package/examples/08-gemma4-local.ts +0 -192
  20. package/examples/09-structured-output.ts +0 -73
  21. package/examples/10-task-retry.ts +0 -132
  22. package/examples/11-trace-observability.ts +0 -133
  23. package/examples/12-grok.ts +0 -154
  24. package/examples/13-gemini.ts +0 -48
  25. package/src/agent/agent.ts +0 -622
  26. package/src/agent/loop-detector.ts +0 -137
  27. package/src/agent/pool.ts +0 -285
  28. package/src/agent/runner.ts +0 -542
  29. package/src/agent/structured-output.ts +0 -126
  30. package/src/index.ts +0 -182
  31. package/src/llm/adapter.ts +0 -98
  32. package/src/llm/anthropic.ts +0 -389
  33. package/src/llm/copilot.ts +0 -552
  34. package/src/llm/gemini.ts +0 -378
  35. package/src/llm/grok.ts +0 -29
  36. package/src/llm/openai-common.ts +0 -294
  37. package/src/llm/openai.ts +0 -292
  38. package/src/memory/shared.ts +0 -181
  39. package/src/memory/store.ts +0 -124
  40. package/src/orchestrator/orchestrator.ts +0 -1071
  41. package/src/orchestrator/scheduler.ts +0 -352
  42. package/src/task/queue.ts +0 -464
  43. package/src/task/task.ts +0 -239
  44. package/src/team/messaging.ts +0 -232
  45. package/src/team/team.ts +0 -334
  46. package/src/tool/built-in/bash.ts +0 -187
  47. package/src/tool/built-in/file-edit.ts +0 -154
  48. package/src/tool/built-in/file-read.ts +0 -105
  49. package/src/tool/built-in/file-write.ts +0 -81
  50. package/src/tool/built-in/grep.ts +0 -362
  51. package/src/tool/built-in/index.ts +0 -50
  52. package/src/tool/executor.ts +0 -178
  53. package/src/tool/framework.ts +0 -557
  54. package/src/tool/text-tool-extractor.ts +0 -219
  55. package/src/types.ts +0 -542
  56. package/src/utils/semaphore.ts +0 -89
  57. package/src/utils/trace.ts +0 -34
  58. package/tests/agent-hooks.test.ts +0 -473
  59. package/tests/agent-pool.test.ts +0 -212
  60. package/tests/approval.test.ts +0 -464
  61. package/tests/built-in-tools.test.ts +0 -393
  62. package/tests/gemini-adapter.test.ts +0 -97
  63. package/tests/grok-adapter.test.ts +0 -74
  64. package/tests/llm-adapters.test.ts +0 -357
  65. package/tests/loop-detection.test.ts +0 -456
  66. package/tests/openai-fallback.test.ts +0 -159
  67. package/tests/orchestrator.test.ts +0 -281
  68. package/tests/scheduler.test.ts +0 -221
  69. package/tests/semaphore.test.ts +0 -57
  70. package/tests/shared-memory.test.ts +0 -122
  71. package/tests/structured-output.test.ts +0 -331
  72. package/tests/task-queue.test.ts +0 -244
  73. package/tests/task-retry.test.ts +0 -368
  74. package/tests/task-utils.test.ts +0 -155
  75. package/tests/team-messaging.test.ts +0 -329
  76. package/tests/text-tool-extractor.test.ts +0 -170
  77. package/tests/tool-executor.test.ts +0 -193
  78. package/tests/trace.test.ts +0 -453
  79. package/tsconfig.json +0 -25
  80. 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
- })
@@ -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
- })