@jackchen_me/open-multi-agent 0.2.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 (117) hide show
  1. package/README.md +87 -20
  2. package/dist/agent/agent.d.ts +15 -1
  3. package/dist/agent/agent.d.ts.map +1 -1
  4. package/dist/agent/agent.js +144 -10
  5. package/dist/agent/agent.js.map +1 -1
  6. package/dist/agent/loop-detector.d.ts +39 -0
  7. package/dist/agent/loop-detector.d.ts.map +1 -0
  8. package/dist/agent/loop-detector.js +122 -0
  9. package/dist/agent/loop-detector.js.map +1 -0
  10. package/dist/agent/pool.d.ts +2 -1
  11. package/dist/agent/pool.d.ts.map +1 -1
  12. package/dist/agent/pool.js +4 -2
  13. package/dist/agent/pool.js.map +1 -1
  14. package/dist/agent/runner.d.ts +23 -1
  15. package/dist/agent/runner.d.ts.map +1 -1
  16. package/dist/agent/runner.js +113 -12
  17. package/dist/agent/runner.js.map +1 -1
  18. package/dist/index.d.ts +3 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/llm/adapter.d.ts +4 -1
  23. package/dist/llm/adapter.d.ts.map +1 -1
  24. package/dist/llm/adapter.js +11 -0
  25. package/dist/llm/adapter.js.map +1 -1
  26. package/dist/llm/copilot.d.ts.map +1 -1
  27. package/dist/llm/copilot.js +2 -1
  28. package/dist/llm/copilot.js.map +1 -1
  29. package/dist/llm/gemini.d.ts +65 -0
  30. package/dist/llm/gemini.d.ts.map +1 -0
  31. package/dist/llm/gemini.js +317 -0
  32. package/dist/llm/gemini.js.map +1 -0
  33. package/dist/llm/grok.d.ts +21 -0
  34. package/dist/llm/grok.d.ts.map +1 -0
  35. package/dist/llm/grok.js +24 -0
  36. package/dist/llm/grok.js.map +1 -0
  37. package/dist/llm/openai-common.d.ts +8 -1
  38. package/dist/llm/openai-common.d.ts.map +1 -1
  39. package/dist/llm/openai-common.js +35 -2
  40. package/dist/llm/openai-common.js.map +1 -1
  41. package/dist/llm/openai.d.ts +1 -1
  42. package/dist/llm/openai.d.ts.map +1 -1
  43. package/dist/llm/openai.js +20 -2
  44. package/dist/llm/openai.js.map +1 -1
  45. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  46. package/dist/orchestrator/orchestrator.js +89 -9
  47. package/dist/orchestrator/orchestrator.js.map +1 -1
  48. package/dist/task/queue.d.ts +31 -2
  49. package/dist/task/queue.d.ts.map +1 -1
  50. package/dist/task/queue.js +69 -2
  51. package/dist/task/queue.js.map +1 -1
  52. package/dist/tool/text-tool-extractor.d.ts +32 -0
  53. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  54. package/dist/tool/text-tool-extractor.js +187 -0
  55. package/dist/tool/text-tool-extractor.js.map +1 -0
  56. package/dist/types.d.ts +139 -7
  57. package/dist/types.d.ts.map +1 -1
  58. package/dist/utils/trace.d.ts +12 -0
  59. package/dist/utils/trace.d.ts.map +1 -0
  60. package/dist/utils/trace.js +30 -0
  61. package/dist/utils/trace.js.map +1 -0
  62. package/package.json +18 -2
  63. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
  64. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
  65. package/.github/pull_request_template.md +0 -14
  66. package/.github/workflows/ci.yml +0 -23
  67. package/CLAUDE.md +0 -72
  68. package/CODE_OF_CONDUCT.md +0 -48
  69. package/CONTRIBUTING.md +0 -72
  70. package/DECISIONS.md +0 -43
  71. package/README_zh.md +0 -217
  72. package/SECURITY.md +0 -17
  73. package/examples/01-single-agent.ts +0 -131
  74. package/examples/02-team-collaboration.ts +0 -167
  75. package/examples/03-task-pipeline.ts +0 -201
  76. package/examples/04-multi-model-team.ts +0 -261
  77. package/examples/05-copilot-test.ts +0 -49
  78. package/examples/06-local-model.ts +0 -199
  79. package/examples/07-fan-out-aggregate.ts +0 -209
  80. package/examples/08-gemma4-local.ts +0 -203
  81. package/examples/09-gemma4-auto-orchestration.ts +0 -162
  82. package/src/agent/agent.ts +0 -473
  83. package/src/agent/pool.ts +0 -278
  84. package/src/agent/runner.ts +0 -413
  85. package/src/agent/structured-output.ts +0 -126
  86. package/src/index.ts +0 -167
  87. package/src/llm/adapter.ts +0 -87
  88. package/src/llm/anthropic.ts +0 -389
  89. package/src/llm/copilot.ts +0 -551
  90. package/src/llm/openai-common.ts +0 -255
  91. package/src/llm/openai.ts +0 -272
  92. package/src/memory/shared.ts +0 -181
  93. package/src/memory/store.ts +0 -124
  94. package/src/orchestrator/orchestrator.ts +0 -977
  95. package/src/orchestrator/scheduler.ts +0 -352
  96. package/src/task/queue.ts +0 -394
  97. package/src/task/task.ts +0 -239
  98. package/src/team/messaging.ts +0 -232
  99. package/src/team/team.ts +0 -334
  100. package/src/tool/built-in/bash.ts +0 -187
  101. package/src/tool/built-in/file-edit.ts +0 -154
  102. package/src/tool/built-in/file-read.ts +0 -105
  103. package/src/tool/built-in/file-write.ts +0 -81
  104. package/src/tool/built-in/grep.ts +0 -362
  105. package/src/tool/built-in/index.ts +0 -50
  106. package/src/tool/executor.ts +0 -178
  107. package/src/tool/framework.ts +0 -557
  108. package/src/types.ts +0 -391
  109. package/src/utils/semaphore.ts +0 -89
  110. package/tests/semaphore.test.ts +0 -57
  111. package/tests/shared-memory.test.ts +0 -122
  112. package/tests/structured-output.test.ts +0 -331
  113. package/tests/task-queue.test.ts +0 -244
  114. package/tests/task-retry.test.ts +0 -368
  115. package/tests/task-utils.test.ts +0 -155
  116. package/tests/tool-executor.test.ts +0 -193
  117. package/tsconfig.json +0 -25
@@ -1,368 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { createTask } from '../src/task/task.js'
3
- import { executeWithRetry, computeRetryDelay } from '../src/orchestrator/orchestrator.js'
4
- import type { AgentRunResult } from '../src/types.js'
5
-
6
- // ---------------------------------------------------------------------------
7
- // Helpers
8
- // ---------------------------------------------------------------------------
9
-
10
- const SUCCESS_RESULT: AgentRunResult = {
11
- success: true,
12
- output: 'done',
13
- messages: [],
14
- tokenUsage: { input_tokens: 10, output_tokens: 20 },
15
- toolCalls: [],
16
- }
17
-
18
- const FAILURE_RESULT: AgentRunResult = {
19
- success: false,
20
- output: 'agent failed',
21
- messages: [],
22
- tokenUsage: { input_tokens: 10, output_tokens: 20 },
23
- toolCalls: [],
24
- }
25
-
26
- /** No-op delay for tests. */
27
- const noDelay = () => Promise.resolve()
28
-
29
- // ---------------------------------------------------------------------------
30
- // computeRetryDelay
31
- // ---------------------------------------------------------------------------
32
-
33
- describe('computeRetryDelay', () => {
34
- it('computes exponential backoff', () => {
35
- expect(computeRetryDelay(1000, 2, 1)).toBe(1000) // 1000 * 2^0
36
- expect(computeRetryDelay(1000, 2, 2)).toBe(2000) // 1000 * 2^1
37
- expect(computeRetryDelay(1000, 2, 3)).toBe(4000) // 1000 * 2^2
38
- })
39
-
40
- it('caps at 30 seconds', () => {
41
- // 1000 * 2^20 = 1,048,576,000 — way over cap
42
- expect(computeRetryDelay(1000, 2, 21)).toBe(30_000)
43
- })
44
-
45
- it('handles backoff of 1 (constant delay)', () => {
46
- expect(computeRetryDelay(500, 1, 1)).toBe(500)
47
- expect(computeRetryDelay(500, 1, 5)).toBe(500)
48
- })
49
- })
50
-
51
- // ---------------------------------------------------------------------------
52
- // createTask: retry fields
53
- // ---------------------------------------------------------------------------
54
-
55
- describe('createTask with retry fields', () => {
56
- it('passes through retry config', () => {
57
- const t = createTask({
58
- title: 'Retry task',
59
- description: 'test',
60
- maxRetries: 3,
61
- retryDelayMs: 500,
62
- retryBackoff: 1.5,
63
- })
64
- expect(t.maxRetries).toBe(3)
65
- expect(t.retryDelayMs).toBe(500)
66
- expect(t.retryBackoff).toBe(1.5)
67
- })
68
-
69
- it('defaults retry fields to undefined', () => {
70
- const t = createTask({ title: 'No retry', description: 'test' })
71
- expect(t.maxRetries).toBeUndefined()
72
- expect(t.retryDelayMs).toBeUndefined()
73
- expect(t.retryBackoff).toBeUndefined()
74
- })
75
- })
76
-
77
- // ---------------------------------------------------------------------------
78
- // executeWithRetry — tests the real exported function
79
- // ---------------------------------------------------------------------------
80
-
81
- describe('executeWithRetry', () => {
82
- it('succeeds on first attempt with no retry config', async () => {
83
- const run = vi.fn().mockResolvedValue(SUCCESS_RESULT)
84
- const task = createTask({ title: 'Simple', description: 'test' })
85
-
86
- const result = await executeWithRetry(run, task, undefined, noDelay)
87
-
88
- expect(result.success).toBe(true)
89
- expect(result.output).toBe('done')
90
- expect(run).toHaveBeenCalledTimes(1)
91
- })
92
-
93
- it('succeeds on first attempt even when maxRetries > 0', async () => {
94
- const run = vi.fn().mockResolvedValue(SUCCESS_RESULT)
95
- const task = createTask({
96
- title: 'Has retries',
97
- description: 'test',
98
- maxRetries: 3,
99
- })
100
-
101
- const result = await executeWithRetry(run, task, undefined, noDelay)
102
-
103
- expect(result.success).toBe(true)
104
- expect(run).toHaveBeenCalledTimes(1)
105
- })
106
-
107
- it('retries on exception and succeeds on second attempt', async () => {
108
- const run = vi.fn()
109
- .mockRejectedValueOnce(new Error('transient error'))
110
- .mockResolvedValueOnce(SUCCESS_RESULT)
111
-
112
- const task = createTask({
113
- title: 'Retry task',
114
- description: 'test',
115
- maxRetries: 2,
116
- retryDelayMs: 100,
117
- retryBackoff: 2,
118
- })
119
-
120
- const retryEvents: unknown[] = []
121
- const result = await executeWithRetry(
122
- run,
123
- task,
124
- (data) => retryEvents.push(data),
125
- noDelay,
126
- )
127
-
128
- expect(result.success).toBe(true)
129
- expect(run).toHaveBeenCalledTimes(2)
130
- expect(retryEvents).toHaveLength(1)
131
- expect(retryEvents[0]).toEqual({
132
- attempt: 1,
133
- maxAttempts: 3,
134
- error: 'transient error',
135
- nextDelayMs: 100, // 100 * 2^0
136
- })
137
- })
138
-
139
- it('retries on success:false and succeeds on second attempt', async () => {
140
- const run = vi.fn()
141
- .mockResolvedValueOnce(FAILURE_RESULT)
142
- .mockResolvedValueOnce(SUCCESS_RESULT)
143
-
144
- const task = createTask({
145
- title: 'Retry task',
146
- description: 'test',
147
- maxRetries: 1,
148
- retryDelayMs: 50,
149
- })
150
-
151
- const result = await executeWithRetry(run, task, undefined, noDelay)
152
-
153
- expect(result.success).toBe(true)
154
- expect(run).toHaveBeenCalledTimes(2)
155
- })
156
-
157
- it('exhausts all retries on persistent exception', async () => {
158
- const run = vi.fn().mockRejectedValue(new Error('persistent error'))
159
-
160
- const task = createTask({
161
- title: 'Always fails',
162
- description: 'test',
163
- maxRetries: 2,
164
- retryDelayMs: 10,
165
- retryBackoff: 1,
166
- })
167
-
168
- const retryEvents: unknown[] = []
169
- const result = await executeWithRetry(
170
- run,
171
- task,
172
- (data) => retryEvents.push(data),
173
- noDelay,
174
- )
175
-
176
- expect(result.success).toBe(false)
177
- expect(result.output).toBe('persistent error')
178
- expect(run).toHaveBeenCalledTimes(3) // 1 initial + 2 retries
179
- expect(retryEvents).toHaveLength(2)
180
- })
181
-
182
- it('exhausts all retries on persistent success:false', async () => {
183
- const run = vi.fn().mockResolvedValue(FAILURE_RESULT)
184
-
185
- const task = createTask({
186
- title: 'Always fails',
187
- description: 'test',
188
- maxRetries: 1,
189
- })
190
-
191
- const result = await executeWithRetry(run, task, undefined, noDelay)
192
-
193
- expect(result.success).toBe(false)
194
- expect(result.output).toBe('agent failed')
195
- expect(run).toHaveBeenCalledTimes(2)
196
- })
197
-
198
- it('emits correct exponential backoff delays', async () => {
199
- const run = vi.fn().mockRejectedValue(new Error('error'))
200
-
201
- const task = createTask({
202
- title: 'Backoff test',
203
- description: 'test',
204
- maxRetries: 3,
205
- retryDelayMs: 100,
206
- retryBackoff: 2,
207
- })
208
-
209
- const retryEvents: Array<{ nextDelayMs: number }> = []
210
- await executeWithRetry(
211
- run,
212
- task,
213
- (data) => retryEvents.push(data),
214
- noDelay,
215
- )
216
-
217
- expect(retryEvents).toHaveLength(3)
218
- expect(retryEvents[0]!.nextDelayMs).toBe(100) // 100 * 2^0
219
- expect(retryEvents[1]!.nextDelayMs).toBe(200) // 100 * 2^1
220
- expect(retryEvents[2]!.nextDelayMs).toBe(400) // 100 * 2^2
221
- })
222
-
223
- it('no retry events when maxRetries is 0 (default)', async () => {
224
- const run = vi.fn().mockRejectedValue(new Error('fail'))
225
- const task = createTask({ title: 'No retry', description: 'test' })
226
-
227
- const retryEvents: unknown[] = []
228
- const result = await executeWithRetry(
229
- run,
230
- task,
231
- (data) => retryEvents.push(data),
232
- noDelay,
233
- )
234
-
235
- expect(result.success).toBe(false)
236
- expect(run).toHaveBeenCalledTimes(1)
237
- expect(retryEvents).toHaveLength(0)
238
- })
239
-
240
- it('calls the delay function with computed delay', async () => {
241
- const run = vi.fn()
242
- .mockRejectedValueOnce(new Error('error'))
243
- .mockResolvedValueOnce(SUCCESS_RESULT)
244
-
245
- const task = createTask({
246
- title: 'Delay test',
247
- description: 'test',
248
- maxRetries: 1,
249
- retryDelayMs: 250,
250
- retryBackoff: 3,
251
- })
252
-
253
- const mockDelay = vi.fn().mockResolvedValue(undefined)
254
- await executeWithRetry(run, task, undefined, mockDelay)
255
-
256
- expect(mockDelay).toHaveBeenCalledTimes(1)
257
- expect(mockDelay).toHaveBeenCalledWith(250) // 250 * 3^0
258
- })
259
-
260
- it('caps delay at 30 seconds', async () => {
261
- const run = vi.fn()
262
- .mockRejectedValueOnce(new Error('error'))
263
- .mockResolvedValueOnce(SUCCESS_RESULT)
264
-
265
- const task = createTask({
266
- title: 'Cap test',
267
- description: 'test',
268
- maxRetries: 1,
269
- retryDelayMs: 50_000,
270
- retryBackoff: 2,
271
- })
272
-
273
- const mockDelay = vi.fn().mockResolvedValue(undefined)
274
- await executeWithRetry(run, task, undefined, mockDelay)
275
-
276
- expect(mockDelay).toHaveBeenCalledWith(30_000) // capped
277
- })
278
-
279
- it('accumulates token usage across retry attempts', async () => {
280
- const failResult: AgentRunResult = {
281
- ...FAILURE_RESULT,
282
- tokenUsage: { input_tokens: 100, output_tokens: 50 },
283
- }
284
- const successResult: AgentRunResult = {
285
- ...SUCCESS_RESULT,
286
- tokenUsage: { input_tokens: 200, output_tokens: 80 },
287
- }
288
-
289
- const run = vi.fn()
290
- .mockResolvedValueOnce(failResult)
291
- .mockResolvedValueOnce(failResult)
292
- .mockResolvedValueOnce(successResult)
293
-
294
- const task = createTask({
295
- title: 'Token test',
296
- description: 'test',
297
- maxRetries: 2,
298
- retryDelayMs: 10,
299
- })
300
-
301
- const result = await executeWithRetry(run, task, undefined, noDelay)
302
-
303
- expect(result.success).toBe(true)
304
- // 100+100+200 input, 50+50+80 output
305
- expect(result.tokenUsage.input_tokens).toBe(400)
306
- expect(result.tokenUsage.output_tokens).toBe(180)
307
- })
308
-
309
- it('accumulates token usage even when all retries fail', async () => {
310
- const failResult: AgentRunResult = {
311
- ...FAILURE_RESULT,
312
- tokenUsage: { input_tokens: 50, output_tokens: 30 },
313
- }
314
-
315
- const run = vi.fn().mockResolvedValue(failResult)
316
-
317
- const task = createTask({
318
- title: 'Token fail test',
319
- description: 'test',
320
- maxRetries: 1,
321
- })
322
-
323
- const result = await executeWithRetry(run, task, undefined, noDelay)
324
-
325
- expect(result.success).toBe(false)
326
- // 50+50 input, 30+30 output (2 attempts)
327
- expect(result.tokenUsage.input_tokens).toBe(100)
328
- expect(result.tokenUsage.output_tokens).toBe(60)
329
- })
330
-
331
- it('clamps negative maxRetries to 0 (single attempt)', async () => {
332
- const run = vi.fn().mockRejectedValue(new Error('fail'))
333
-
334
- const task = createTask({
335
- title: 'Negative retry',
336
- description: 'test',
337
- maxRetries: -5,
338
- })
339
- // Manually set negative value since createTask doesn't validate
340
- ;(task as any).maxRetries = -5
341
-
342
- const result = await executeWithRetry(run, task, undefined, noDelay)
343
-
344
- expect(result.success).toBe(false)
345
- expect(run).toHaveBeenCalledTimes(1) // exactly 1 attempt, no retries
346
- })
347
-
348
- it('clamps backoff below 1 to 1 (constant delay)', async () => {
349
- const run = vi.fn()
350
- .mockRejectedValueOnce(new Error('error'))
351
- .mockResolvedValueOnce(SUCCESS_RESULT)
352
-
353
- const task = createTask({
354
- title: 'Bad backoff',
355
- description: 'test',
356
- maxRetries: 1,
357
- retryDelayMs: 100,
358
- retryBackoff: -2,
359
- })
360
- ;(task as any).retryBackoff = -2
361
-
362
- const mockDelay = vi.fn().mockResolvedValue(undefined)
363
- await executeWithRetry(run, task, undefined, mockDelay)
364
-
365
- // backoff clamped to 1, so delay = 100 * 1^0 = 100
366
- expect(mockDelay).toHaveBeenCalledWith(100)
367
- })
368
- })
@@ -1,155 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import {
3
- createTask,
4
- isTaskReady,
5
- getTaskDependencyOrder,
6
- validateTaskDependencies,
7
- } from '../src/task/task.js'
8
- import type { Task } from '../src/types.js'
9
-
10
- // ---------------------------------------------------------------------------
11
- // Helpers
12
- // ---------------------------------------------------------------------------
13
-
14
- function task(id: string, opts: { dependsOn?: string[]; status?: Task['status'] } = {}): Task {
15
- const t = createTask({ title: id, description: `task ${id}` })
16
- return { ...t, id, dependsOn: opts.dependsOn, status: opts.status ?? 'pending' }
17
- }
18
-
19
- // ---------------------------------------------------------------------------
20
- // createTask
21
- // ---------------------------------------------------------------------------
22
-
23
- describe('createTask', () => {
24
- it('creates a task with pending status and timestamps', () => {
25
- const t = createTask({ title: 'Test', description: 'A test task' })
26
- expect(t.id).toBeDefined()
27
- expect(t.status).toBe('pending')
28
- expect(t.createdAt).toBeInstanceOf(Date)
29
- expect(t.updatedAt).toBeInstanceOf(Date)
30
- })
31
-
32
- it('copies dependsOn array (no shared reference)', () => {
33
- const deps = ['a']
34
- const t = createTask({ title: 'T', description: 'D', dependsOn: deps })
35
- deps.push('b')
36
- expect(t.dependsOn).toEqual(['a'])
37
- })
38
- })
39
-
40
- // ---------------------------------------------------------------------------
41
- // isTaskReady
42
- // ---------------------------------------------------------------------------
43
-
44
- describe('isTaskReady', () => {
45
- it('returns true for a pending task with no dependencies', () => {
46
- const t = task('a')
47
- expect(isTaskReady(t, [t])).toBe(true)
48
- })
49
-
50
- it('returns false for a non-pending task', () => {
51
- const t = task('a', { status: 'blocked' })
52
- expect(isTaskReady(t, [t])).toBe(false)
53
- })
54
-
55
- it('returns true when all dependencies are completed', () => {
56
- const dep = task('dep', { status: 'completed' })
57
- const t = task('a', { dependsOn: ['dep'] })
58
- expect(isTaskReady(t, [dep, t])).toBe(true)
59
- })
60
-
61
- it('returns false when a dependency is not yet completed', () => {
62
- const dep = task('dep', { status: 'in_progress' })
63
- const t = task('a', { dependsOn: ['dep'] })
64
- expect(isTaskReady(t, [dep, t])).toBe(false)
65
- })
66
-
67
- it('returns false when a dependency is missing from the task set', () => {
68
- const t = task('a', { dependsOn: ['ghost'] })
69
- expect(isTaskReady(t, [t])).toBe(false)
70
- })
71
- })
72
-
73
- // ---------------------------------------------------------------------------
74
- // getTaskDependencyOrder
75
- // ---------------------------------------------------------------------------
76
-
77
- describe('getTaskDependencyOrder', () => {
78
- it('returns empty array for empty input', () => {
79
- expect(getTaskDependencyOrder([])).toEqual([])
80
- })
81
-
82
- it('returns tasks with no deps first', () => {
83
- const a = task('a')
84
- const b = task('b', { dependsOn: ['a'] })
85
- const ordered = getTaskDependencyOrder([b, a])
86
- expect(ordered[0].id).toBe('a')
87
- expect(ordered[1].id).toBe('b')
88
- })
89
-
90
- it('handles a diamond dependency (a → b,c → d)', () => {
91
- const a = task('a')
92
- const b = task('b', { dependsOn: ['a'] })
93
- const c = task('c', { dependsOn: ['a'] })
94
- const d = task('d', { dependsOn: ['b', 'c'] })
95
-
96
- const ordered = getTaskDependencyOrder([d, c, b, a])
97
- const ids = ordered.map((t) => t.id)
98
-
99
- // a must come before b and c; b and c must come before d
100
- expect(ids.indexOf('a')).toBeLessThan(ids.indexOf('b'))
101
- expect(ids.indexOf('a')).toBeLessThan(ids.indexOf('c'))
102
- expect(ids.indexOf('b')).toBeLessThan(ids.indexOf('d'))
103
- expect(ids.indexOf('c')).toBeLessThan(ids.indexOf('d'))
104
- })
105
-
106
- it('returns partial result when a cycle exists', () => {
107
- const a = task('a', { dependsOn: ['b'] })
108
- const b = task('b', { dependsOn: ['a'] })
109
- const ordered = getTaskDependencyOrder([a, b])
110
- // Neither can be ordered — result should be empty (or partial)
111
- expect(ordered.length).toBeLessThan(2)
112
- })
113
- })
114
-
115
- // ---------------------------------------------------------------------------
116
- // validateTaskDependencies
117
- // ---------------------------------------------------------------------------
118
-
119
- describe('validateTaskDependencies', () => {
120
- it('returns valid for tasks with no deps', () => {
121
- const result = validateTaskDependencies([task('a'), task('b')])
122
- expect(result.valid).toBe(true)
123
- expect(result.errors).toHaveLength(0)
124
- })
125
-
126
- it('detects self-dependency', () => {
127
- const t = task('a', { dependsOn: ['a'] })
128
- const result = validateTaskDependencies([t])
129
- expect(result.valid).toBe(false)
130
- expect(result.errors[0]).toContain('depends on itself')
131
- })
132
-
133
- it('detects unknown dependency', () => {
134
- const t = task('a', { dependsOn: ['ghost'] })
135
- const result = validateTaskDependencies([t])
136
- expect(result.valid).toBe(false)
137
- expect(result.errors[0]).toContain('unknown dependency')
138
- })
139
-
140
- it('detects a cycle (a → b → a)', () => {
141
- const a = task('a', { dependsOn: ['b'] })
142
- const b = task('b', { dependsOn: ['a'] })
143
- const result = validateTaskDependencies([a, b])
144
- expect(result.valid).toBe(false)
145
- expect(result.errors.some((e) => e.toLowerCase().includes('cyclic'))).toBe(true)
146
- })
147
-
148
- it('detects a longer cycle (a → b → c → a)', () => {
149
- const a = task('a', { dependsOn: ['c'] })
150
- const b = task('b', { dependsOn: ['a'] })
151
- const c = task('c', { dependsOn: ['b'] })
152
- const result = validateTaskDependencies([a, b, c])
153
- expect(result.valid).toBe(false)
154
- })
155
- })