@jackchen_me/open-multi-agent 0.1.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/ISSUE_TEMPLATE/bug_report.md +40 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
- package/.github/pull_request_template.md +14 -0
- package/.github/workflows/ci.yml +23 -0
- package/CLAUDE.md +80 -0
- package/CODE_OF_CONDUCT.md +48 -0
- package/CONTRIBUTING.md +72 -0
- package/DECISIONS.md +43 -0
- package/README.md +144 -144
- package/README_zh.md +277 -0
- package/SECURITY.md +17 -0
- package/dist/agent/agent.d.ts +20 -1
- package/dist/agent/agent.d.ts.map +1 -1
- package/dist/agent/agent.js +233 -12
- 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/agent/structured-output.d.ts +33 -0
- package/dist/agent/structured-output.d.ts.map +1 -0
- package/dist/agent/structured-output.js +116 -0
- package/dist/agent/structured-output.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/adapter.d.ts +12 -4
- package/dist/llm/adapter.d.ts.map +1 -1
- package/dist/llm/adapter.js +28 -5
- package/dist/llm/adapter.js.map +1 -1
- package/dist/llm/anthropic.d.ts +1 -1
- package/dist/llm/anthropic.d.ts.map +1 -1
- package/dist/llm/anthropic.js +2 -1
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/copilot.d.ts +92 -0
- package/dist/llm/copilot.d.ts.map +1 -0
- package/dist/llm/copilot.js +427 -0
- package/dist/llm/copilot.js.map +1 -0
- 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 +54 -0
- package/dist/llm/openai-common.d.ts.map +1 -0
- package/dist/llm/openai-common.js +242 -0
- package/dist/llm/openai-common.js.map +1 -0
- package/dist/llm/openai.d.ts +2 -2
- package/dist/llm/openai.d.ts.map +1 -1
- package/dist/llm/openai.js +23 -226
- package/dist/llm/openai.js.map +1 -1
- package/dist/orchestrator/orchestrator.d.ts +25 -1
- package/dist/orchestrator/orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.js +214 -41
- 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 +70 -3
- package/dist/task/queue.js.map +1 -1
- package/dist/task/task.d.ts +3 -0
- package/dist/task/task.d.ts.map +1 -1
- package/dist/task/task.js +5 -1
- package/dist/task/task.js.map +1 -1
- package/dist/team/messaging.d.ts.map +1 -1
- package/dist/team/messaging.js +2 -1
- package/dist/team/messaging.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 +167 -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/05-copilot-test.ts +49 -0
- package/examples/06-local-model.ts +200 -0
- package/examples/07-fan-out-aggregate.ts +209 -0
- package/examples/08-gemma4-local.ts +192 -0
- 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 +14 -3
- package/src/agent/agent.ts +273 -15
- package/src/agent/loop-detector.ts +137 -0
- package/src/agent/pool.ts +9 -2
- package/src/agent/runner.ts +148 -19
- package/src/agent/structured-output.ts +126 -0
- package/src/index.ts +17 -1
- package/src/llm/adapter.ts +29 -5
- package/src/llm/anthropic.ts +2 -1
- package/src/llm/copilot.ts +552 -0
- package/src/llm/gemini.ts +378 -0
- package/src/llm/grok.ts +29 -0
- package/src/llm/openai-common.ts +294 -0
- package/src/llm/openai.ts +31 -261
- package/src/orchestrator/orchestrator.ts +260 -40
- package/src/task/queue.ts +74 -4
- package/src/task/task.ts +8 -1
- package/src/team/messaging.ts +3 -1
- package/src/tool/text-tool-extractor.ts +219 -0
- package/src/types.ts +186 -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/semaphore.test.ts +57 -0
- package/tests/shared-memory.test.ts +122 -0
- package/tests/structured-output.test.ts +331 -0
- package/tests/task-queue.test.ts +244 -0
- package/tests/task-retry.test.ts +368 -0
- package/tests/task-utils.test.ts +155 -0
- package/tests/team-messaging.test.ts +329 -0
- package/tests/text-tool-extractor.test.ts +170 -0
- package/tests/tool-executor.test.ts +193 -0
- package/tests/trace.test.ts +453 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,368 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
})
|