digital-workers 2.0.2 → 2.1.3

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 (69) hide show
  1. package/.turbo/turbo-build.log +4 -5
  2. package/CHANGELOG.md +31 -0
  3. package/LICENSE +21 -0
  4. package/README.md +134 -180
  5. package/dist/actions.d.ts.map +1 -1
  6. package/dist/actions.js +1 -0
  7. package/dist/actions.js.map +1 -1
  8. package/dist/agent-comms.d.ts +438 -0
  9. package/dist/agent-comms.d.ts.map +1 -0
  10. package/dist/agent-comms.js +666 -0
  11. package/dist/agent-comms.js.map +1 -0
  12. package/dist/capability-tiers.d.ts +230 -0
  13. package/dist/capability-tiers.d.ts.map +1 -0
  14. package/dist/capability-tiers.js +388 -0
  15. package/dist/capability-tiers.js.map +1 -0
  16. package/dist/cascade-context.d.ts +523 -0
  17. package/dist/cascade-context.d.ts.map +1 -0
  18. package/dist/cascade-context.js +494 -0
  19. package/dist/cascade-context.js.map +1 -0
  20. package/dist/error-escalation.d.ts +416 -0
  21. package/dist/error-escalation.d.ts.map +1 -0
  22. package/dist/error-escalation.js +656 -0
  23. package/dist/error-escalation.js.map +1 -0
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +34 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/load-balancing.d.ts +395 -0
  29. package/dist/load-balancing.d.ts.map +1 -0
  30. package/dist/load-balancing.js +905 -0
  31. package/dist/load-balancing.js.map +1 -0
  32. package/dist/types.d.ts +8 -0
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/types.js +1 -0
  35. package/dist/types.js.map +1 -1
  36. package/package.json +14 -14
  37. package/src/actions.js +436 -0
  38. package/src/actions.ts +9 -8
  39. package/src/agent-comms.ts +1238 -0
  40. package/src/approve.js +234 -0
  41. package/src/ask.js +226 -0
  42. package/src/capability-tiers.ts +545 -0
  43. package/src/cascade-context.ts +648 -0
  44. package/src/decide.js +244 -0
  45. package/src/do.js +227 -0
  46. package/src/error-escalation.ts +1135 -0
  47. package/src/generate.js +298 -0
  48. package/src/goals.js +205 -0
  49. package/src/index.js +68 -0
  50. package/src/index.ts +223 -0
  51. package/src/is.js +317 -0
  52. package/src/kpis.js +270 -0
  53. package/src/load-balancing.ts +1381 -0
  54. package/src/notify.js +219 -0
  55. package/src/role.js +110 -0
  56. package/src/team.js +130 -0
  57. package/src/transports.js +357 -0
  58. package/src/types.js +71 -0
  59. package/src/types.ts +8 -0
  60. package/test/actions.test.js +401 -0
  61. package/test/agent-comms.test.ts +1397 -0
  62. package/test/capability-tiers.test.ts +631 -0
  63. package/test/cascade-context.test.ts +692 -0
  64. package/test/error-escalation.test.ts +1205 -0
  65. package/test/load-balancing-thread-safety.test.ts +464 -0
  66. package/test/load-balancing.test.ts +1145 -0
  67. package/test/standalone.test.js +250 -0
  68. package/test/types.test.js +371 -0
  69. package/test/types.test.ts +35 -0
@@ -0,0 +1,1145 @@
1
+ /**
2
+ * Load Balancing and Routing Tests
3
+ *
4
+ * TDD tests for agent coordination with load balancing and routing capabilities.
5
+ * Following Red-Green-Refactor methodology.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi, expectTypeOf } from 'vitest'
9
+ import type {
10
+ LoadBalancer,
11
+ BalancerStrategy,
12
+ AgentInfo,
13
+ RouteResult,
14
+ TaskRequest,
15
+ AgentAvailability,
16
+ RoutingRule,
17
+ RoutingRuleCondition,
18
+ RoutingMetrics,
19
+ CompositeBalancerConfig,
20
+ } from '../src/load-balancing.js'
21
+ import {
22
+ createRoundRobinBalancer,
23
+ createLeastBusyBalancer,
24
+ createCapabilityRouter,
25
+ createPriorityQueueBalancer,
26
+ createAgentAvailabilityTracker,
27
+ createCompositeBalancer,
28
+ createRoutingRuleEngine,
29
+ // Metrics
30
+ collectRoutingMetrics,
31
+ resetRoutingMetrics,
32
+ } from '../src/load-balancing.js'
33
+ import type { Worker, WorkerStatus } from '../src/types.js'
34
+
35
+ // ============================================================================
36
+ // Test Fixtures
37
+ // ============================================================================
38
+
39
+ const createAgent = (
40
+ id: string,
41
+ skills: string[] = [],
42
+ status: WorkerStatus = 'available',
43
+ currentLoad = 0
44
+ ): AgentInfo => ({
45
+ id,
46
+ name: `Agent ${id}`,
47
+ type: 'agent',
48
+ status,
49
+ skills,
50
+ currentLoad,
51
+ maxLoad: 10,
52
+ contacts: { api: `https://agent-${id}.example.com` },
53
+ metadata: {},
54
+ })
55
+
56
+ const createTask = (
57
+ id: string,
58
+ requiredSkills: string[] = [],
59
+ priority: number = 5
60
+ ): TaskRequest => ({
61
+ id,
62
+ name: `Task ${id}`,
63
+ requiredSkills,
64
+ priority,
65
+ metadata: {},
66
+ })
67
+
68
+ // ============================================================================
69
+ // Round-Robin Balancer Tests
70
+ // ============================================================================
71
+
72
+ describe('RoundRobinBalancer', () => {
73
+ let agents: AgentInfo[]
74
+
75
+ beforeEach(() => {
76
+ agents = [
77
+ createAgent('agent-1', ['code', 'review']),
78
+ createAgent('agent-2', ['code', 'test']),
79
+ createAgent('agent-3', ['code', 'deploy']),
80
+ ]
81
+ })
82
+
83
+ describe('route()', () => {
84
+ it('should distribute tasks in round-robin order', () => {
85
+ const balancer = createRoundRobinBalancer(agents)
86
+
87
+ const task1 = createTask('task-1')
88
+ const task2 = createTask('task-2')
89
+ const task3 = createTask('task-3')
90
+ const task4 = createTask('task-4')
91
+
92
+ const result1 = balancer.route(task1)
93
+ const result2 = balancer.route(task2)
94
+ const result3 = balancer.route(task3)
95
+ const result4 = balancer.route(task4)
96
+
97
+ expect(result1.agent.id).toBe('agent-1')
98
+ expect(result2.agent.id).toBe('agent-2')
99
+ expect(result3.agent.id).toBe('agent-3')
100
+ expect(result4.agent.id).toBe('agent-1') // Wraps around
101
+ })
102
+
103
+ it('should skip unavailable agents', () => {
104
+ agents[1].status = 'offline'
105
+ const balancer = createRoundRobinBalancer(agents)
106
+
107
+ const task1 = createTask('task-1')
108
+ const task2 = createTask('task-2')
109
+ const task3 = createTask('task-3')
110
+
111
+ const result1 = balancer.route(task1)
112
+ const result2 = balancer.route(task2)
113
+ const result3 = balancer.route(task3)
114
+
115
+ expect(result1.agent.id).toBe('agent-1')
116
+ expect(result2.agent.id).toBe('agent-3')
117
+ expect(result3.agent.id).toBe('agent-1')
118
+ })
119
+
120
+ it('should return null route if no agents available', () => {
121
+ agents.forEach(a => a.status = 'offline')
122
+ const balancer = createRoundRobinBalancer(agents)
123
+
124
+ const result = balancer.route(createTask('task-1'))
125
+
126
+ expect(result.agent).toBeNull()
127
+ expect(result.reason).toBe('no-available-agents')
128
+ })
129
+
130
+ it('should include routing metadata in result', () => {
131
+ const balancer = createRoundRobinBalancer(agents)
132
+ const task = createTask('task-1')
133
+
134
+ const result = balancer.route(task)
135
+
136
+ expect(result.strategy).toBe('round-robin')
137
+ expect(result.timestamp).toBeInstanceOf(Date)
138
+ expect(result.task).toBe(task)
139
+ })
140
+ })
141
+
142
+ describe('addAgent() / removeAgent()', () => {
143
+ it('should add new agents to the pool', () => {
144
+ const balancer = createRoundRobinBalancer(agents)
145
+ const newAgent = createAgent('agent-4', ['security'])
146
+
147
+ balancer.addAgent(newAgent)
148
+
149
+ // Route through all agents
150
+ balancer.route(createTask('t1'))
151
+ balancer.route(createTask('t2'))
152
+ balancer.route(createTask('t3'))
153
+ const result = balancer.route(createTask('t4'))
154
+
155
+ expect(result.agent.id).toBe('agent-4')
156
+ })
157
+
158
+ it('should remove agents from the pool', () => {
159
+ const balancer = createRoundRobinBalancer(agents)
160
+
161
+ balancer.removeAgent('agent-2')
162
+
163
+ const result1 = balancer.route(createTask('t1'))
164
+ const result2 = balancer.route(createTask('t2'))
165
+ const result3 = balancer.route(createTask('t3'))
166
+
167
+ expect(result1.agent.id).toBe('agent-1')
168
+ expect(result2.agent.id).toBe('agent-3')
169
+ expect(result3.agent.id).toBe('agent-1')
170
+ })
171
+ })
172
+
173
+ describe('getAgents()', () => {
174
+ it('should return current agent list', () => {
175
+ const balancer = createRoundRobinBalancer(agents)
176
+
177
+ expect(balancer.getAgents()).toHaveLength(3)
178
+ expect(balancer.getAgents().map(a => a.id)).toEqual([
179
+ 'agent-1',
180
+ 'agent-2',
181
+ 'agent-3',
182
+ ])
183
+ })
184
+ })
185
+ })
186
+
187
+ // ============================================================================
188
+ // Least-Busy Balancer Tests
189
+ // ============================================================================
190
+
191
+ describe('LeastBusyBalancer', () => {
192
+ let agents: AgentInfo[]
193
+
194
+ beforeEach(() => {
195
+ agents = [
196
+ createAgent('agent-1', ['code'], 'available', 5), // 50% load
197
+ createAgent('agent-2', ['code'], 'available', 2), // 20% load
198
+ createAgent('agent-3', ['code'], 'available', 8), // 80% load
199
+ ]
200
+ })
201
+
202
+ describe('route()', () => {
203
+ it('should route to agent with lowest load', () => {
204
+ const balancer = createLeastBusyBalancer(agents)
205
+
206
+ const result = balancer.route(createTask('task-1'))
207
+
208
+ expect(result.agent.id).toBe('agent-2') // Lowest load (20%)
209
+ })
210
+
211
+ it('should update load tracking after routing', () => {
212
+ const balancer = createLeastBusyBalancer(agents)
213
+
214
+ const result1 = balancer.route(createTask('task-1'))
215
+ expect(result1.agent.id).toBe('agent-2')
216
+
217
+ // After routing, agent-2's load increases
218
+ const result2 = balancer.route(createTask('task-2'))
219
+ // Now agent-2 has load 3, agent-1 has load 5
220
+ expect(result2.agent.id).toBe('agent-2') // Still lowest
221
+ })
222
+
223
+ it('should skip agents at max capacity', () => {
224
+ agents[1].currentLoad = 10 // Max capacity
225
+ const balancer = createLeastBusyBalancer(agents)
226
+
227
+ const result = balancer.route(createTask('task-1'))
228
+
229
+ expect(result.agent.id).toBe('agent-1') // Next lowest
230
+ })
231
+
232
+ it('should calculate load percentage correctly', () => {
233
+ const balancer = createLeastBusyBalancer(agents)
234
+
235
+ const metrics = balancer.getLoadMetrics()
236
+
237
+ expect(metrics['agent-1']).toBe(0.5) // 5/10 = 50%
238
+ expect(metrics['agent-2']).toBe(0.2) // 2/10 = 20%
239
+ expect(metrics['agent-3']).toBe(0.8) // 8/10 = 80%
240
+ })
241
+
242
+ it('should handle tie-breaking with round-robin', () => {
243
+ agents[0].currentLoad = 2
244
+ agents[1].currentLoad = 2
245
+ const balancer = createLeastBusyBalancer(agents)
246
+
247
+ const result1 = balancer.route(createTask('task-1'))
248
+ const result2 = balancer.route(createTask('task-2'))
249
+
250
+ // Both have same load, should alternate
251
+ expect(result1.agent.id).not.toBe(result2.agent.id)
252
+ })
253
+ })
254
+
255
+ describe('releaseLoad()', () => {
256
+ it('should decrease agent load when task completes', () => {
257
+ const balancer = createLeastBusyBalancer(agents)
258
+
259
+ balancer.route(createTask('task-1')) // Increases agent-2 load
260
+ balancer.releaseLoad('agent-2')
261
+
262
+ const metrics = balancer.getLoadMetrics()
263
+ expect(metrics['agent-2']).toBe(0.2) // Back to original
264
+ })
265
+ })
266
+
267
+ describe('setLoad()', () => {
268
+ it('should allow manual load adjustment', () => {
269
+ const balancer = createLeastBusyBalancer(agents)
270
+
271
+ balancer.setLoad('agent-1', 1)
272
+
273
+ const metrics = balancer.getLoadMetrics()
274
+ expect(metrics['agent-1']).toBe(0.1)
275
+ })
276
+ })
277
+ })
278
+
279
+ // ============================================================================
280
+ // Capability-Based Router Tests
281
+ // ============================================================================
282
+
283
+ describe('CapabilityRouter', () => {
284
+ let agents: AgentInfo[]
285
+
286
+ beforeEach(() => {
287
+ agents = [
288
+ createAgent('code-agent', ['code', 'review', 'test']),
289
+ createAgent('deploy-agent', ['deploy', 'monitor', 'rollback']),
290
+ createAgent('ml-agent', ['ml', 'data', 'train']),
291
+ createAgent('full-stack', ['code', 'deploy', 'ml']),
292
+ ]
293
+ })
294
+
295
+ describe('route()', () => {
296
+ it('should route to agent with matching skills', () => {
297
+ const router = createCapabilityRouter(agents)
298
+
299
+ const task = createTask('code-task', ['code'])
300
+ const result = router.route(task)
301
+
302
+ expect(result.agent).not.toBeNull()
303
+ expect(result.agent.skills).toContain('code')
304
+ })
305
+
306
+ it('should route to agent with all required skills', () => {
307
+ const router = createCapabilityRouter(agents)
308
+
309
+ const task = createTask('full-stack-task', ['code', 'deploy'])
310
+ const result = router.route(task)
311
+
312
+ expect(result.agent.id).toBe('full-stack')
313
+ })
314
+
315
+ it('should return null if no agent has required skills', () => {
316
+ const router = createCapabilityRouter(agents)
317
+
318
+ const task = createTask('impossible', ['quantum', 'teleport'])
319
+ const result = router.route(task)
320
+
321
+ expect(result.agent).toBeNull()
322
+ expect(result.reason).toBe('no-matching-capability')
323
+ })
324
+
325
+ it('should prefer agent with closest skill match (avoid over-qualification)', () => {
326
+ const router = createCapabilityRouter(agents, { preferExactMatch: true })
327
+
328
+ const task = createTask('code-only', ['code'])
329
+ const result = router.route(task)
330
+
331
+ // Should prefer code-agent over full-stack (closer match)
332
+ expect(result.agent.id).toBe('code-agent')
333
+ })
334
+
335
+ it('should include skill match score in result', () => {
336
+ const router = createCapabilityRouter(agents)
337
+
338
+ const task = createTask('code-task', ['code', 'review'])
339
+ const result = router.route(task)
340
+
341
+ expect(result.matchScore).toBeDefined()
342
+ expect(result.matchScore).toBeGreaterThan(0)
343
+ expect(result.matchScore).toBeLessThanOrEqual(1)
344
+ })
345
+ })
346
+
347
+ describe('findAgentsWithSkills()', () => {
348
+ it('should return all agents with specified skills', () => {
349
+ const router = createCapabilityRouter(agents)
350
+
351
+ const matches = router.findAgentsWithSkills(['code'])
352
+
353
+ expect(matches).toHaveLength(2)
354
+ expect(matches.map(a => a.id)).toContain('code-agent')
355
+ expect(matches.map(a => a.id)).toContain('full-stack')
356
+ })
357
+
358
+ it('should return empty array if no matches', () => {
359
+ const router = createCapabilityRouter(agents)
360
+
361
+ const matches = router.findAgentsWithSkills(['nonexistent'])
362
+
363
+ expect(matches).toHaveLength(0)
364
+ })
365
+ })
366
+
367
+ describe('getSkillCoverage()', () => {
368
+ it('should return skill coverage across all agents', () => {
369
+ const router = createCapabilityRouter(agents)
370
+
371
+ const coverage = router.getSkillCoverage()
372
+
373
+ expect(coverage.code).toBe(2) // 2 agents have 'code'
374
+ expect(coverage.deploy).toBe(2)
375
+ expect(coverage.ml).toBe(2)
376
+ expect(coverage.review).toBe(1)
377
+ })
378
+ })
379
+ })
380
+
381
+ // ============================================================================
382
+ // Priority Queue Balancer Tests
383
+ // ============================================================================
384
+
385
+ describe('PriorityQueueBalancer', () => {
386
+ let agents: AgentInfo[]
387
+
388
+ beforeEach(() => {
389
+ agents = [
390
+ createAgent('agent-1', ['general']),
391
+ createAgent('agent-2', ['general']),
392
+ ]
393
+ })
394
+
395
+ describe('enqueue() / route()', () => {
396
+ it('should process higher priority tasks first', async () => {
397
+ const balancer = createPriorityQueueBalancer(agents)
398
+
399
+ balancer.enqueue(createTask('low', [], 1))
400
+ balancer.enqueue(createTask('high', [], 10))
401
+ balancer.enqueue(createTask('medium', [], 5))
402
+
403
+ const result1 = await balancer.routeNext()
404
+ const result2 = await balancer.routeNext()
405
+ const result3 = await balancer.routeNext()
406
+
407
+ expect(result1.task.id).toBe('high')
408
+ expect(result2.task.id).toBe('medium')
409
+ expect(result3.task.id).toBe('low')
410
+ })
411
+
412
+ it('should handle equal priority with FIFO', async () => {
413
+ const balancer = createPriorityQueueBalancer(agents)
414
+
415
+ balancer.enqueue(createTask('first', [], 5))
416
+ balancer.enqueue(createTask('second', [], 5))
417
+ balancer.enqueue(createTask('third', [], 5))
418
+
419
+ const result1 = await balancer.routeNext()
420
+ const result2 = await balancer.routeNext()
421
+ const result3 = await balancer.routeNext()
422
+
423
+ expect(result1.task.id).toBe('first')
424
+ expect(result2.task.id).toBe('second')
425
+ expect(result3.task.id).toBe('third')
426
+ })
427
+
428
+ it('should return null if queue is empty', async () => {
429
+ const balancer = createPriorityQueueBalancer(agents)
430
+
431
+ const result = await balancer.routeNext()
432
+
433
+ expect(result).toBeNull()
434
+ })
435
+ })
436
+
437
+ describe('priority preemption', () => {
438
+ it('should support priority levels (1-10)', () => {
439
+ const balancer = createPriorityQueueBalancer(agents)
440
+
441
+ expect(() => balancer.enqueue(createTask('valid', [], 1))).not.toThrow()
442
+ expect(() => balancer.enqueue(createTask('valid', [], 10))).not.toThrow()
443
+ expect(() => balancer.enqueue(createTask('invalid', [], 0))).toThrow()
444
+ expect(() => balancer.enqueue(createTask('invalid', [], 11))).toThrow()
445
+ })
446
+
447
+ it('should support priority boost for aging tasks', async () => {
448
+ vi.useFakeTimers()
449
+ try {
450
+ const balancer = createPriorityQueueBalancer(agents, {
451
+ enableAging: true,
452
+ agingBoostPerSecond: 1,
453
+ })
454
+
455
+ balancer.enqueue(createTask('old', [], 1))
456
+
457
+ // Simulate time passing
458
+ vi.advanceTimersByTime(5000)
459
+
460
+ // Priority should have increased
461
+ const effectivePriority = balancer.getEffectivePriority('old')
462
+ expect(effectivePriority).toBeGreaterThan(1)
463
+ } finally {
464
+ vi.useRealTimers()
465
+ }
466
+ })
467
+ })
468
+
469
+ describe('starvation prevention', () => {
470
+ it('should prevent task starvation with max wait time', async () => {
471
+ vi.useFakeTimers({ now: new Date('2026-01-01T00:00:00Z') })
472
+ try {
473
+ const balancer = createPriorityQueueBalancer(agents, {
474
+ maxWaitTime: 10000, // 10 seconds max wait
475
+ })
476
+
477
+ const oldTask = createTask('starving', [], 1)
478
+ balancer.enqueue(oldTask)
479
+
480
+ // Advance time before adding high priority tasks
481
+ vi.advanceTimersByTime(11000)
482
+
483
+ // Keep adding higher priority tasks (these are added AFTER the wait time)
484
+ for (let i = 0; i < 10; i++) {
485
+ balancer.enqueue(createTask(`high-${i}`, [], 10))
486
+ }
487
+
488
+ // Starving task should be promoted due to max wait time exceeded
489
+ const result = await balancer.routeNext()
490
+ expect(result.task.id).toBe('starving')
491
+ } finally {
492
+ vi.useRealTimers()
493
+ }
494
+ })
495
+ })
496
+
497
+ describe('queue management', () => {
498
+ it('should return queue size', () => {
499
+ const balancer = createPriorityQueueBalancer(agents)
500
+
501
+ balancer.enqueue(createTask('t1'))
502
+ balancer.enqueue(createTask('t2'))
503
+ balancer.enqueue(createTask('t3'))
504
+
505
+ expect(balancer.queueSize()).toBe(3)
506
+ })
507
+
508
+ it('should clear the queue', () => {
509
+ const balancer = createPriorityQueueBalancer(agents)
510
+
511
+ balancer.enqueue(createTask('t1'))
512
+ balancer.enqueue(createTask('t2'))
513
+
514
+ balancer.clear()
515
+
516
+ expect(balancer.queueSize()).toBe(0)
517
+ })
518
+
519
+ it('should peek at next task without removing', () => {
520
+ const balancer = createPriorityQueueBalancer(agents)
521
+
522
+ balancer.enqueue(createTask('t1', [], 5))
523
+ balancer.enqueue(createTask('t2', [], 10))
524
+
525
+ const peeked = balancer.peek()
526
+ expect(peeked.id).toBe('t2')
527
+ expect(balancer.queueSize()).toBe(2) // Still 2
528
+ })
529
+ })
530
+ })
531
+
532
+ // ============================================================================
533
+ // Agent Availability Tracker Tests
534
+ // ============================================================================
535
+
536
+ describe('AgentAvailabilityTracker', () => {
537
+ let agents: AgentInfo[]
538
+
539
+ beforeEach(() => {
540
+ agents = [
541
+ createAgent('agent-1'),
542
+ createAgent('agent-2'),
543
+ createAgent('agent-3', [], 'offline'),
544
+ ]
545
+ })
546
+
547
+ describe('tracking availability', () => {
548
+ it('should track agent availability status', () => {
549
+ const tracker = createAgentAvailabilityTracker(agents)
550
+
551
+ const availability = tracker.getAvailability('agent-1')
552
+
553
+ expect(availability.status).toBe('available')
554
+ expect(availability.lastSeen).toBeInstanceOf(Date)
555
+ })
556
+
557
+ it('should update agent status', () => {
558
+ const tracker = createAgentAvailabilityTracker(agents)
559
+
560
+ tracker.updateStatus('agent-1', 'busy')
561
+
562
+ const availability = tracker.getAvailability('agent-1')
563
+ expect(availability.status).toBe('busy')
564
+ })
565
+
566
+ it('should return all available agents', () => {
567
+ const tracker = createAgentAvailabilityTracker(agents)
568
+
569
+ const available = tracker.getAvailableAgents()
570
+
571
+ expect(available).toHaveLength(2)
572
+ expect(available.map(a => a.id)).toContain('agent-1')
573
+ expect(available.map(a => a.id)).toContain('agent-2')
574
+ })
575
+ })
576
+
577
+ describe('heartbeat tracking', () => {
578
+ it('should update lastSeen on heartbeat', () => {
579
+ vi.useFakeTimers()
580
+ try {
581
+ const tracker = createAgentAvailabilityTracker(agents)
582
+
583
+ const before = tracker.getAvailability('agent-1').lastSeen
584
+
585
+ vi.advanceTimersByTime(1000)
586
+ tracker.heartbeat('agent-1')
587
+
588
+ const after = tracker.getAvailability('agent-1').lastSeen
589
+ expect(after.getTime()).toBeGreaterThan(before.getTime())
590
+ } finally {
591
+ vi.useRealTimers()
592
+ }
593
+ })
594
+
595
+ it('should mark agent offline after timeout', () => {
596
+ vi.useFakeTimers()
597
+ try {
598
+ const tracker = createAgentAvailabilityTracker(agents, {
599
+ heartbeatTimeout: 5000,
600
+ })
601
+
602
+ vi.advanceTimersByTime(6000)
603
+ tracker.checkTimeouts()
604
+
605
+ const availability = tracker.getAvailability('agent-1')
606
+ expect(availability.status).toBe('offline')
607
+ } finally {
608
+ vi.useRealTimers()
609
+ }
610
+ })
611
+ })
612
+
613
+ describe('availability events', () => {
614
+ it('should emit events on status change', () => {
615
+ const tracker = createAgentAvailabilityTracker(agents)
616
+ const handler = vi.fn()
617
+
618
+ tracker.onStatusChange(handler)
619
+ tracker.updateStatus('agent-1', 'busy')
620
+
621
+ expect(handler).toHaveBeenCalledWith({
622
+ agentId: 'agent-1',
623
+ previousStatus: 'available',
624
+ currentStatus: 'busy',
625
+ timestamp: expect.any(Date),
626
+ })
627
+ })
628
+ })
629
+
630
+ describe('capacity tracking', () => {
631
+ it('should track current capacity utilization', () => {
632
+ const tracker = createAgentAvailabilityTracker(agents)
633
+
634
+ tracker.updateLoad('agent-1', 5, 10) // 50% capacity
635
+ tracker.updateLoad('agent-2', 8, 10) // 80% capacity
636
+
637
+ const utilization = tracker.getCapacityUtilization()
638
+
639
+ expect(utilization['agent-1']).toBe(0.5)
640
+ expect(utilization['agent-2']).toBe(0.8)
641
+ })
642
+
643
+ it('should calculate overall capacity', () => {
644
+ const tracker = createAgentAvailabilityTracker(agents)
645
+
646
+ tracker.updateLoad('agent-1', 5, 10)
647
+ tracker.updateLoad('agent-2', 8, 10)
648
+
649
+ const overall = tracker.getOverallCapacity()
650
+
651
+ expect(overall.total).toBe(20) // 2 available agents * 10 max each
652
+ expect(overall.used).toBe(13) // 5 + 8
653
+ expect(overall.available).toBe(7) // 20 - 13
654
+ expect(overall.utilization).toBeCloseTo(0.65) // 13/20
655
+ })
656
+ })
657
+ })
658
+
659
+ // ============================================================================
660
+ // Routing Rule Engine Tests
661
+ // ============================================================================
662
+
663
+ describe('RoutingRuleEngine', () => {
664
+ let agents: AgentInfo[]
665
+
666
+ beforeEach(() => {
667
+ agents = [
668
+ createAgent('fast-agent', ['code'], 'available', 0),
669
+ createAgent('slow-agent', ['code'], 'available', 5),
670
+ createAgent('secure-agent', ['security'], 'available', 0),
671
+ ]
672
+ })
673
+
674
+ describe('rule definition', () => {
675
+ it('should create routing rules', () => {
676
+ const engine = createRoutingRuleEngine(agents)
677
+
678
+ engine.addRule({
679
+ name: 'security-tasks',
680
+ priority: 10,
681
+ condition: (task) => task.requiredSkills.includes('security'),
682
+ action: (task, agents) => agents.find(a => a.id === 'secure-agent'),
683
+ })
684
+
685
+ const rules = engine.getRules()
686
+ expect(rules).toHaveLength(1)
687
+ expect(rules[0].name).toBe('security-tasks')
688
+ })
689
+
690
+ it('should validate rule structure', () => {
691
+ const engine = createRoutingRuleEngine(agents)
692
+
693
+ expect(() => engine.addRule({
694
+ name: '', // Invalid: empty name
695
+ priority: 1,
696
+ condition: () => true,
697
+ action: () => null,
698
+ })).toThrow()
699
+
700
+ expect(() => engine.addRule({
701
+ name: 'valid',
702
+ priority: -1, // Invalid: negative priority
703
+ condition: () => true,
704
+ action: () => null,
705
+ })).toThrow()
706
+ })
707
+ })
708
+
709
+ describe('rule evaluation', () => {
710
+ it('should evaluate rules in priority order', () => {
711
+ const engine = createRoutingRuleEngine(agents)
712
+
713
+ engine.addRule({
714
+ name: 'low-priority',
715
+ priority: 1,
716
+ condition: () => true,
717
+ action: () => agents[1], // slow-agent
718
+ })
719
+
720
+ engine.addRule({
721
+ name: 'high-priority',
722
+ priority: 10,
723
+ condition: () => true,
724
+ action: () => agents[0], // fast-agent
725
+ })
726
+
727
+ const result = engine.route(createTask('test'))
728
+
729
+ expect(result.agent.id).toBe('fast-agent')
730
+ expect(result.matchedRule).toBe('high-priority')
731
+ })
732
+
733
+ it('should skip rules with false conditions', () => {
734
+ const engine = createRoutingRuleEngine(agents)
735
+
736
+ engine.addRule({
737
+ name: 'never-match',
738
+ priority: 10,
739
+ condition: () => false,
740
+ action: () => agents[0],
741
+ })
742
+
743
+ engine.addRule({
744
+ name: 'always-match',
745
+ priority: 1,
746
+ condition: () => true,
747
+ action: () => agents[1],
748
+ })
749
+
750
+ const result = engine.route(createTask('test'))
751
+
752
+ expect(result.agent.id).toBe('slow-agent')
753
+ expect(result.matchedRule).toBe('always-match')
754
+ })
755
+
756
+ it('should use default routing if no rules match', () => {
757
+ const engine = createRoutingRuleEngine(agents, {
758
+ defaultStrategy: 'least-busy',
759
+ })
760
+
761
+ engine.addRule({
762
+ name: 'never-match',
763
+ priority: 10,
764
+ condition: () => false,
765
+ action: () => agents[0],
766
+ })
767
+
768
+ const result = engine.route(createTask('test'))
769
+
770
+ // Should use default strategy when no rules match
771
+ // fast-agent and secure-agent both have load 0, so either could be selected
772
+ expect(['fast-agent', 'secure-agent']).toContain(result.agent.id)
773
+ expect(result.matchedRule).toBeNull()
774
+ expect(result.usedDefault).toBe(true)
775
+ })
776
+ })
777
+
778
+ describe('condition types', () => {
779
+ it('should support skill-based conditions', () => {
780
+ const engine = createRoutingRuleEngine(agents)
781
+
782
+ engine.addRule({
783
+ name: 'security-route',
784
+ priority: 5,
785
+ condition: { requiredSkills: { contains: 'security' } },
786
+ action: () => agents.find(a => a.id === 'secure-agent'),
787
+ })
788
+
789
+ const secTask = createTask('sec-task', ['security'])
790
+ const codeTask = createTask('code-task', ['code'])
791
+
792
+ const secResult = engine.route(secTask)
793
+ expect(secResult.agent.id).toBe('secure-agent')
794
+
795
+ // Code task shouldn't match security rule
796
+ const codeResult = engine.route(codeTask)
797
+ expect(codeResult.matchedRule).not.toBe('security-route')
798
+ })
799
+
800
+ it('should support priority-based conditions', () => {
801
+ const engine = createRoutingRuleEngine(agents)
802
+
803
+ engine.addRule({
804
+ name: 'high-priority-route',
805
+ priority: 10,
806
+ condition: { priority: { gte: 8 } },
807
+ action: () => agents[0], // Fast agent for high priority
808
+ })
809
+
810
+ const highPriority = createTask('urgent', [], 9)
811
+ const lowPriority = createTask('normal', [], 3)
812
+
813
+ expect(engine.route(highPriority).matchedRule).toBe('high-priority-route')
814
+ expect(engine.route(lowPriority).matchedRule).not.toBe('high-priority-route')
815
+ })
816
+
817
+ it('should support metadata-based conditions', () => {
818
+ const engine = createRoutingRuleEngine(agents)
819
+
820
+ engine.addRule({
821
+ name: 'internal-route',
822
+ priority: 5,
823
+ condition: { metadata: { source: 'internal' } },
824
+ action: () => agents[0],
825
+ })
826
+
827
+ const internalTask = createTask('internal-task')
828
+ internalTask.metadata = { source: 'internal' }
829
+
830
+ const externalTask = createTask('external-task')
831
+ externalTask.metadata = { source: 'external' }
832
+
833
+ expect(engine.route(internalTask).matchedRule).toBe('internal-route')
834
+ expect(engine.route(externalTask).matchedRule).not.toBe('internal-route')
835
+ })
836
+ })
837
+
838
+ describe('rule management', () => {
839
+ it('should remove rules by name', () => {
840
+ const engine = createRoutingRuleEngine(agents)
841
+
842
+ engine.addRule({
843
+ name: 'temp-rule',
844
+ priority: 1,
845
+ condition: () => true,
846
+ action: () => agents[0],
847
+ })
848
+
849
+ expect(engine.getRules()).toHaveLength(1)
850
+
851
+ engine.removeRule('temp-rule')
852
+
853
+ expect(engine.getRules()).toHaveLength(0)
854
+ })
855
+
856
+ it('should update existing rules', () => {
857
+ const engine = createRoutingRuleEngine(agents)
858
+
859
+ engine.addRule({
860
+ name: 'my-rule',
861
+ priority: 1,
862
+ condition: () => true,
863
+ action: () => agents[0],
864
+ })
865
+
866
+ engine.updateRule('my-rule', { priority: 10 })
867
+
868
+ const rules = engine.getRules()
869
+ expect(rules[0].priority).toBe(10)
870
+ })
871
+
872
+ it('should enable/disable rules', () => {
873
+ const engine = createRoutingRuleEngine(agents)
874
+
875
+ engine.addRule({
876
+ name: 'toggleable',
877
+ priority: 10,
878
+ condition: () => true,
879
+ action: () => agents[0],
880
+ })
881
+
882
+ engine.disableRule('toggleable')
883
+ expect(engine.route(createTask('test')).matchedRule).not.toBe('toggleable')
884
+
885
+ engine.enableRule('toggleable')
886
+ expect(engine.route(createTask('test')).matchedRule).toBe('toggleable')
887
+ })
888
+ })
889
+ })
890
+
891
+ // ============================================================================
892
+ // Composite Balancer Tests
893
+ // ============================================================================
894
+
895
+ describe('CompositeBalancer', () => {
896
+ let agents: AgentInfo[]
897
+
898
+ beforeEach(() => {
899
+ agents = [
900
+ createAgent('agent-1', ['code'], 'available', 2),
901
+ createAgent('agent-2', ['code', 'review'], 'available', 5),
902
+ createAgent('agent-3', ['deploy'], 'available', 1),
903
+ ]
904
+ })
905
+
906
+ describe('strategy composition', () => {
907
+ it('should use first successful strategy', () => {
908
+ const balancer = createCompositeBalancer(agents, {
909
+ strategies: ['capability', 'least-busy'],
910
+ })
911
+
912
+ const task = createTask('code-task', ['code'])
913
+ const result = balancer.route(task)
914
+
915
+ // Capability router finds agent-1 (has 'code' skill)
916
+ expect(result.agent.id).toBe('agent-1')
917
+ // Only includes strategies attempted before success
918
+ expect(result.strategies).toContain('capability')
919
+ })
920
+
921
+ it('should fallback through strategies', () => {
922
+ const balancer = createCompositeBalancer(agents, {
923
+ strategies: ['capability', 'round-robin'],
924
+ fallbackBehavior: 'next-strategy',
925
+ })
926
+
927
+ // Task with no matching skills
928
+ const task = createTask('unknown-task', ['quantum'])
929
+
930
+ // Should fallback to round-robin since no capability match
931
+ const result = balancer.route(task)
932
+ expect(result.agent).not.toBeNull()
933
+ expect(result.usedFallback).toBe(true)
934
+ })
935
+
936
+ it('should support weighted strategy combination', () => {
937
+ const balancer = createCompositeBalancer(agents, {
938
+ strategies: [
939
+ { strategy: 'capability', weight: 0.7 },
940
+ { strategy: 'least-busy', weight: 0.3 },
941
+ ],
942
+ })
943
+
944
+ const task = createTask('weighted-task', ['code'])
945
+ const result = balancer.route(task)
946
+
947
+ // When capability succeeds, it records the weight
948
+ expect(result.strategyScores).toBeDefined()
949
+ expect(result.strategyScores.capability).toBe(0.7)
950
+ })
951
+ })
952
+
953
+ describe('custom strategy plugins', () => {
954
+ it('should support custom routing strategies', () => {
955
+ const balancer = createCompositeBalancer(agents, {
956
+ strategies: ['custom'],
957
+ customStrategies: {
958
+ custom: (task, agents) => {
959
+ // Always pick the last agent
960
+ return agents[agents.length - 1]
961
+ },
962
+ },
963
+ })
964
+
965
+ const result = balancer.route(createTask('test'))
966
+
967
+ expect(result.agent.id).toBe('agent-3')
968
+ })
969
+ })
970
+ })
971
+
972
+ // ============================================================================
973
+ // Routing Metrics Tests
974
+ // ============================================================================
975
+
976
+ describe('RoutingMetrics', () => {
977
+ beforeEach(() => {
978
+ resetRoutingMetrics()
979
+ })
980
+
981
+ describe('collectRoutingMetrics()', () => {
982
+ it('should track routing decisions', () => {
983
+ const agents = [createAgent('agent-1')]
984
+ const balancer = createRoundRobinBalancer(agents)
985
+
986
+ balancer.route(createTask('t1'))
987
+ balancer.route(createTask('t2'))
988
+ balancer.route(createTask('t3'))
989
+
990
+ const metrics = collectRoutingMetrics()
991
+
992
+ expect(metrics.totalRouted).toBe(3)
993
+ })
994
+
995
+ it('should track routing latency', () => {
996
+ const agents = [createAgent('agent-1')]
997
+ const balancer = createRoundRobinBalancer(agents)
998
+
999
+ balancer.route(createTask('t1'))
1000
+
1001
+ const metrics = collectRoutingMetrics()
1002
+
1003
+ expect(metrics.averageLatencyMs).toBeGreaterThanOrEqual(0)
1004
+ })
1005
+
1006
+ it('should track per-agent distribution', () => {
1007
+ const agents = [
1008
+ createAgent('agent-1'),
1009
+ createAgent('agent-2'),
1010
+ ]
1011
+ const balancer = createRoundRobinBalancer(agents)
1012
+
1013
+ balancer.route(createTask('t1'))
1014
+ balancer.route(createTask('t2'))
1015
+ balancer.route(createTask('t3'))
1016
+ balancer.route(createTask('t4'))
1017
+
1018
+ const metrics = collectRoutingMetrics()
1019
+
1020
+ expect(metrics.perAgent['agent-1'].routedCount).toBe(2)
1021
+ expect(metrics.perAgent['agent-2'].routedCount).toBe(2)
1022
+ })
1023
+
1024
+ it('should track failed routing attempts', () => {
1025
+ const agents = [createAgent('agent-1', [], 'offline')]
1026
+ const balancer = createRoundRobinBalancer(agents)
1027
+
1028
+ balancer.route(createTask('t1'))
1029
+
1030
+ const metrics = collectRoutingMetrics()
1031
+
1032
+ expect(metrics.failedRoutes).toBe(1)
1033
+ })
1034
+
1035
+ it('should track strategy usage', () => {
1036
+ const agents = [createAgent('agent-1')]
1037
+ const rrBalancer = createRoundRobinBalancer(agents)
1038
+ const lbBalancer = createLeastBusyBalancer(agents)
1039
+
1040
+ rrBalancer.route(createTask('t1'))
1041
+ rrBalancer.route(createTask('t2'))
1042
+ lbBalancer.route(createTask('t3'))
1043
+
1044
+ const metrics = collectRoutingMetrics()
1045
+
1046
+ expect(metrics.strategyUsage['round-robin']).toBe(2)
1047
+ expect(metrics.strategyUsage['least-busy']).toBe(1)
1048
+ })
1049
+ })
1050
+
1051
+ describe('resetRoutingMetrics()', () => {
1052
+ it('should reset all metrics to zero', () => {
1053
+ const agents = [createAgent('agent-1')]
1054
+ const balancer = createRoundRobinBalancer(agents)
1055
+
1056
+ balancer.route(createTask('t1'))
1057
+
1058
+ resetRoutingMetrics()
1059
+
1060
+ const metrics = collectRoutingMetrics()
1061
+ expect(metrics.totalRouted).toBe(0)
1062
+ })
1063
+ })
1064
+ })
1065
+
1066
+ // ============================================================================
1067
+ // Type Definition Tests
1068
+ // ============================================================================
1069
+
1070
+ describe('Type Definitions', () => {
1071
+ it('should have correct LoadBalancer interface', () => {
1072
+ const balancer: LoadBalancer = {
1073
+ route: vi.fn(),
1074
+ addAgent: vi.fn(),
1075
+ removeAgent: vi.fn(),
1076
+ getAgents: vi.fn().mockReturnValue([]),
1077
+ }
1078
+
1079
+ expectTypeOf(balancer.route).toBeFunction()
1080
+ expectTypeOf(balancer.addAgent).toBeFunction()
1081
+ expectTypeOf(balancer.removeAgent).toBeFunction()
1082
+ expectTypeOf(balancer.getAgents).toBeFunction()
1083
+ })
1084
+
1085
+ it('should have correct AgentInfo type', () => {
1086
+ const agent: AgentInfo = {
1087
+ id: 'test',
1088
+ name: 'Test Agent',
1089
+ type: 'agent',
1090
+ status: 'available',
1091
+ skills: [],
1092
+ currentLoad: 0,
1093
+ maxLoad: 10,
1094
+ contacts: {},
1095
+ metadata: {},
1096
+ }
1097
+
1098
+ expectTypeOf(agent).toHaveProperty('id')
1099
+ expectTypeOf(agent).toHaveProperty('skills')
1100
+ expectTypeOf(agent).toHaveProperty('currentLoad')
1101
+ expectTypeOf(agent).toHaveProperty('maxLoad')
1102
+ })
1103
+
1104
+ it('should have correct RouteResult type', () => {
1105
+ const result: RouteResult = {
1106
+ agent: null as any,
1107
+ task: null as any,
1108
+ strategy: 'round-robin',
1109
+ timestamp: new Date(),
1110
+ }
1111
+
1112
+ expectTypeOf(result).toHaveProperty('agent')
1113
+ expectTypeOf(result).toHaveProperty('task')
1114
+ expectTypeOf(result).toHaveProperty('strategy')
1115
+ expectTypeOf(result).toHaveProperty('timestamp')
1116
+ })
1117
+
1118
+ it('should have correct TaskRequest type', () => {
1119
+ const task: TaskRequest = {
1120
+ id: 'test',
1121
+ name: 'Test Task',
1122
+ requiredSkills: [],
1123
+ priority: 5,
1124
+ metadata: {},
1125
+ }
1126
+
1127
+ expectTypeOf(task).toHaveProperty('id')
1128
+ expectTypeOf(task).toHaveProperty('requiredSkills')
1129
+ expectTypeOf(task).toHaveProperty('priority')
1130
+ })
1131
+
1132
+ it('should have correct RoutingRule type', () => {
1133
+ const rule: RoutingRule = {
1134
+ name: 'test-rule',
1135
+ priority: 1,
1136
+ condition: () => true,
1137
+ action: () => null as any,
1138
+ }
1139
+
1140
+ expectTypeOf(rule).toHaveProperty('name')
1141
+ expectTypeOf(rule).toHaveProperty('priority')
1142
+ expectTypeOf(rule).toHaveProperty('condition')
1143
+ expectTypeOf(rule).toHaveProperty('action')
1144
+ })
1145
+ })