digital-workers 2.1.1 → 2.3.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.
Files changed (197) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +136 -180
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +34 -21
  5. package/dist/actions.js.map +1 -1
  6. package/dist/agent-comms.d.ts +438 -0
  7. package/dist/agent-comms.d.ts.map +1 -0
  8. package/dist/agent-comms.js +677 -0
  9. package/dist/agent-comms.js.map +1 -0
  10. package/dist/approve.d.ts +40 -8
  11. package/dist/approve.d.ts.map +1 -1
  12. package/dist/approve.js +86 -20
  13. package/dist/approve.js.map +1 -1
  14. package/dist/ask.d.ts +38 -7
  15. package/dist/ask.d.ts.map +1 -1
  16. package/dist/ask.js +85 -25
  17. package/dist/ask.js.map +1 -1
  18. package/dist/browse.d.ts +223 -0
  19. package/dist/browse.d.ts.map +1 -0
  20. package/dist/browse.js +392 -0
  21. package/dist/browse.js.map +1 -0
  22. package/dist/capability-tiers.d.ts +230 -0
  23. package/dist/capability-tiers.d.ts.map +1 -0
  24. package/dist/capability-tiers.js +388 -0
  25. package/dist/capability-tiers.js.map +1 -0
  26. package/dist/cascade-context.d.ts +523 -0
  27. package/dist/cascade-context.d.ts.map +1 -0
  28. package/dist/cascade-context.js +494 -0
  29. package/dist/cascade-context.js.map +1 -0
  30. package/dist/client.d.ts +162 -0
  31. package/dist/client.d.ts.map +1 -0
  32. package/dist/client.js +64 -0
  33. package/dist/client.js.map +1 -0
  34. package/dist/decide.d.ts +42 -6
  35. package/dist/decide.d.ts.map +1 -1
  36. package/dist/decide.js +54 -11
  37. package/dist/decide.js.map +1 -1
  38. package/dist/do.d.ts +36 -7
  39. package/dist/do.d.ts.map +1 -1
  40. package/dist/do.js +82 -39
  41. package/dist/do.js.map +1 -1
  42. package/dist/error-escalation.d.ts +416 -0
  43. package/dist/error-escalation.d.ts.map +1 -0
  44. package/dist/error-escalation.js +656 -0
  45. package/dist/error-escalation.js.map +1 -0
  46. package/dist/generate.d.ts +48 -7
  47. package/dist/generate.d.ts.map +1 -1
  48. package/dist/generate.js +49 -8
  49. package/dist/generate.js.map +1 -1
  50. package/dist/goals.d.ts +10 -9
  51. package/dist/goals.d.ts.map +1 -1
  52. package/dist/goals.js +30 -24
  53. package/dist/goals.js.map +1 -1
  54. package/dist/image.d.ts +189 -0
  55. package/dist/image.d.ts.map +1 -0
  56. package/dist/image.js +528 -0
  57. package/dist/image.js.map +1 -0
  58. package/dist/index.d.ts +59 -2
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +92 -2
  61. package/dist/index.js.map +1 -1
  62. package/dist/is.d.ts +45 -10
  63. package/dist/is.d.ts.map +1 -1
  64. package/dist/is.js +56 -21
  65. package/dist/is.js.map +1 -1
  66. package/dist/kpis.d.ts +24 -15
  67. package/dist/kpis.d.ts.map +1 -1
  68. package/dist/kpis.js +16 -14
  69. package/dist/kpis.js.map +1 -1
  70. package/dist/load-balancing.d.ts +395 -0
  71. package/dist/load-balancing.d.ts.map +1 -0
  72. package/dist/load-balancing.js +991 -0
  73. package/dist/load-balancing.js.map +1 -0
  74. package/dist/logger.d.ts +76 -0
  75. package/dist/logger.d.ts.map +1 -0
  76. package/dist/logger.js +39 -0
  77. package/dist/logger.js.map +1 -0
  78. package/dist/notify.d.ts +38 -9
  79. package/dist/notify.d.ts.map +1 -1
  80. package/dist/notify.js +72 -17
  81. package/dist/notify.js.map +1 -1
  82. package/dist/role.d.ts +5 -4
  83. package/dist/role.d.ts.map +1 -1
  84. package/dist/role.js +13 -10
  85. package/dist/role.js.map +1 -1
  86. package/dist/runtime.d.ts +310 -0
  87. package/dist/runtime.d.ts.map +1 -0
  88. package/dist/runtime.js +510 -0
  89. package/dist/runtime.js.map +1 -0
  90. package/dist/team.d.ts +11 -6
  91. package/dist/team.d.ts.map +1 -1
  92. package/dist/team.js +22 -15
  93. package/dist/team.js.map +1 -1
  94. package/dist/transports/email.d.ts +318 -0
  95. package/dist/transports/email.d.ts.map +1 -0
  96. package/dist/transports/email.js +779 -0
  97. package/dist/transports/email.js.map +1 -0
  98. package/dist/transports/slack.d.ts +515 -0
  99. package/dist/transports/slack.d.ts.map +1 -0
  100. package/dist/transports/slack.js +844 -0
  101. package/dist/transports/slack.js.map +1 -0
  102. package/dist/transports.d.ts.map +1 -1
  103. package/dist/transports.js +44 -25
  104. package/dist/transports.js.map +1 -1
  105. package/dist/types.d.ts +149 -19
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js +6 -0
  108. package/dist/types.js.map +1 -1
  109. package/dist/utils/id.d.ts +19 -0
  110. package/dist/utils/id.d.ts.map +1 -0
  111. package/dist/utils/id.js +21 -0
  112. package/dist/utils/id.js.map +1 -0
  113. package/dist/video.d.ts +203 -0
  114. package/dist/video.d.ts.map +1 -0
  115. package/dist/video.js +528 -0
  116. package/dist/video.js.map +1 -0
  117. package/dist/worker.d.ts +343 -0
  118. package/dist/worker.d.ts.map +1 -0
  119. package/dist/worker.js +698 -0
  120. package/dist/worker.js.map +1 -0
  121. package/package.json +24 -5
  122. package/src/actions.ts +48 -38
  123. package/src/agent-comms.ts +1200 -0
  124. package/src/approve.ts +91 -20
  125. package/src/ask.ts +99 -25
  126. package/src/browse.ts +627 -0
  127. package/src/capability-tiers.ts +545 -0
  128. package/src/cascade-context.ts +648 -0
  129. package/src/client.ts +221 -0
  130. package/src/decide.ts +81 -35
  131. package/src/do.ts +98 -52
  132. package/src/error-escalation.ts +1123 -0
  133. package/src/generate.ts +52 -18
  134. package/src/goals.ts +36 -27
  135. package/src/image.ts +816 -0
  136. package/src/index.ts +410 -2
  137. package/src/is.ts +59 -25
  138. package/src/kpis.ts +41 -36
  139. package/src/load-balancing.ts +1467 -0
  140. package/src/logger.ts +93 -0
  141. package/src/notify.ts +78 -17
  142. package/src/role.ts +30 -20
  143. package/src/runtime.ts +796 -0
  144. package/src/team.ts +24 -19
  145. package/src/transports/email.ts +1160 -0
  146. package/src/transports/slack.ts +1320 -0
  147. package/src/transports.ts +58 -43
  148. package/src/types.ts +182 -46
  149. package/src/utils/id.ts +21 -0
  150. package/src/video.ts +906 -0
  151. package/src/worker.ts +1007 -0
  152. package/test/agent-comms.test.ts +1397 -0
  153. package/test/approve.test.ts +305 -0
  154. package/test/ask.test.ts +274 -0
  155. package/test/browse.test.ts +361 -0
  156. package/test/capability-tiers.test.ts +631 -0
  157. package/test/cascade-context.test.ts +692 -0
  158. package/test/decide.test.ts +252 -0
  159. package/test/do.test.ts +144 -0
  160. package/test/error-escalation.test.ts +1205 -0
  161. package/test/error-logging.test.ts +357 -0
  162. package/test/generate.test.ts +319 -0
  163. package/test/image.test.ts +398 -0
  164. package/test/is.test.ts +287 -0
  165. package/test/load-balancing-safety.test.ts +404 -0
  166. package/test/load-balancing-thread-safety.test.ts +464 -0
  167. package/test/load-balancing.test.ts +1145 -0
  168. package/test/notify.test.ts +434 -0
  169. package/test/primitives.test.ts +320 -0
  170. package/test/runtime-integration.test.ts +892 -0
  171. package/test/transports/crypto.test.ts +230 -0
  172. package/test/transports/email.test.ts +866 -0
  173. package/test/transports/id-generation.test.ts +91 -0
  174. package/test/transports/slack.test.ts +760 -0
  175. package/test/type-safety.test.ts +834 -0
  176. package/test/types.test.ts +95 -2
  177. package/test/video.test.ts +530 -0
  178. package/test/worker.test.ts +1433 -0
  179. package/tsconfig.json +4 -1
  180. package/vitest.config.ts +42 -0
  181. package/wrangler.jsonc +36 -0
  182. package/.turbo/turbo-build.log +0 -5
  183. package/src/actions.js +0 -436
  184. package/src/approve.js +0 -234
  185. package/src/ask.js +0 -226
  186. package/src/decide.js +0 -244
  187. package/src/do.js +0 -227
  188. package/src/generate.js +0 -298
  189. package/src/goals.js +0 -205
  190. package/src/index.js +0 -68
  191. package/src/is.js +0 -317
  192. package/src/kpis.js +0 -270
  193. package/src/notify.js +0 -219
  194. package/src/role.js +0 -110
  195. package/src/team.js +0 -130
  196. package/src/transports.js +0 -357
  197. package/src/types.js +0 -71
@@ -0,0 +1,1397 @@
1
+ /**
2
+ * Tests for agent-to-agent communication layer
3
+ *
4
+ * TDD RED Phase: Write failing tests first
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
8
+ import type { Worker, WorkerRef } from '../src/types.js'
9
+ import {
10
+ // Types
11
+ type AgentMessage,
12
+ type MessageEnvelope,
13
+ type MessageAck,
14
+ type HandoffRequest,
15
+ type HandoffResult,
16
+ type CoordinationPattern,
17
+ // Message Bus
18
+ AgentMessageBus,
19
+ createMessageBus,
20
+ // Core Functions
21
+ sendToAgent,
22
+ broadcastToGroup,
23
+ requestFromAgent,
24
+ onMessage,
25
+ acknowledge,
26
+ // Coordination Patterns
27
+ requestResponse,
28
+ fanOut,
29
+ fanIn,
30
+ pipeline,
31
+ // Handoff Protocol
32
+ initiateHandoff,
33
+ acceptHandoff,
34
+ rejectHandoff,
35
+ completeHandoff,
36
+ } from '../src/agent-comms.js'
37
+
38
+ // =============================================================================
39
+ // Test Fixtures
40
+ // =============================================================================
41
+
42
+ const agentA: Worker = {
43
+ id: 'agent-a',
44
+ name: 'Agent A',
45
+ type: 'agent',
46
+ status: 'available',
47
+ contacts: {
48
+ api: { endpoint: 'https://api.internal/agent-a', auth: 'bearer' },
49
+ },
50
+ }
51
+
52
+ const agentB: Worker = {
53
+ id: 'agent-b',
54
+ name: 'Agent B',
55
+ type: 'agent',
56
+ status: 'available',
57
+ contacts: {
58
+ api: { endpoint: 'https://api.internal/agent-b', auth: 'bearer' },
59
+ },
60
+ }
61
+
62
+ const agentC: Worker = {
63
+ id: 'agent-c',
64
+ name: 'Agent C',
65
+ type: 'agent',
66
+ status: 'available',
67
+ contacts: {
68
+ api: { endpoint: 'https://api.internal/agent-c', auth: 'bearer' },
69
+ },
70
+ }
71
+
72
+ const agentGroup = {
73
+ id: 'group-processors',
74
+ name: 'Processors',
75
+ members: [agentA, agentB, agentC],
76
+ }
77
+
78
+ // =============================================================================
79
+ // AgentMessage Interface Tests
80
+ // =============================================================================
81
+
82
+ describe('AgentMessage Types', () => {
83
+ describe('AgentMessage interface', () => {
84
+ it('should have required properties', () => {
85
+ const message: AgentMessage = {
86
+ id: 'msg_001',
87
+ type: 'request',
88
+ sender: 'agent-a',
89
+ recipient: 'agent-b',
90
+ payload: { task: 'process-data' },
91
+ timestamp: new Date(),
92
+ }
93
+
94
+ expect(message.id).toBe('msg_001')
95
+ expect(message.type).toBe('request')
96
+ expect(message.sender).toBe('agent-a')
97
+ expect(message.recipient).toBe('agent-b')
98
+ expect(message.payload).toEqual({ task: 'process-data' })
99
+ expect(message.timestamp).toBeInstanceOf(Date)
100
+ })
101
+
102
+ it('should support all message types', () => {
103
+ const types: AgentMessage['type'][] = [
104
+ 'request',
105
+ 'response',
106
+ 'notification',
107
+ 'handoff',
108
+ 'ack',
109
+ 'error',
110
+ ]
111
+ expect(types).toHaveLength(6)
112
+ })
113
+
114
+ it('should support optional properties', () => {
115
+ const message: AgentMessage = {
116
+ id: 'msg_002',
117
+ type: 'request',
118
+ sender: 'agent-a',
119
+ recipient: 'agent-b',
120
+ payload: { data: 'test' },
121
+ timestamp: new Date(),
122
+ correlationId: 'corr_001',
123
+ replyTo: 'agent-a',
124
+ ttl: 30000,
125
+ priority: 'high',
126
+ metadata: { source: 'cascade' },
127
+ }
128
+
129
+ expect(message.correlationId).toBe('corr_001')
130
+ expect(message.replyTo).toBe('agent-a')
131
+ expect(message.ttl).toBe(30000)
132
+ expect(message.priority).toBe('high')
133
+ expect(message.metadata).toEqual({ source: 'cascade' })
134
+ })
135
+ })
136
+
137
+ describe('MessageEnvelope interface', () => {
138
+ it('should wrap message with delivery metadata', () => {
139
+ const envelope: MessageEnvelope = {
140
+ message: {
141
+ id: 'msg_003',
142
+ type: 'notification',
143
+ sender: 'agent-a',
144
+ recipient: 'agent-b',
145
+ payload: { event: 'task-complete' },
146
+ timestamp: new Date(),
147
+ },
148
+ deliveryAttempts: 1,
149
+ firstAttemptAt: new Date(),
150
+ lastAttemptAt: new Date(),
151
+ status: 'pending',
152
+ }
153
+
154
+ expect(envelope.message.id).toBe('msg_003')
155
+ expect(envelope.deliveryAttempts).toBe(1)
156
+ expect(envelope.status).toBe('pending')
157
+ })
158
+
159
+ it('should track delivery status', () => {
160
+ const statuses: MessageEnvelope['status'][] = [
161
+ 'pending',
162
+ 'delivered',
163
+ 'acknowledged',
164
+ 'failed',
165
+ 'expired',
166
+ ]
167
+ expect(statuses).toHaveLength(5)
168
+ })
169
+ })
170
+
171
+ describe('MessageAck interface', () => {
172
+ it('should acknowledge message receipt', () => {
173
+ const ack: MessageAck = {
174
+ messageId: 'msg_001',
175
+ status: 'received',
176
+ timestamp: new Date(),
177
+ agentId: 'agent-b',
178
+ }
179
+
180
+ expect(ack.messageId).toBe('msg_001')
181
+ expect(ack.status).toBe('received')
182
+ expect(ack.agentId).toBe('agent-b')
183
+ })
184
+
185
+ it('should support processing status', () => {
186
+ const ack: MessageAck = {
187
+ messageId: 'msg_001',
188
+ status: 'processed',
189
+ timestamp: new Date(),
190
+ agentId: 'agent-b',
191
+ result: { success: true, output: 'completed' },
192
+ }
193
+
194
+ expect(ack.status).toBe('processed')
195
+ expect(ack.result).toEqual({ success: true, output: 'completed' })
196
+ })
197
+ })
198
+ })
199
+
200
+ // =============================================================================
201
+ // AgentMessageBus Tests
202
+ // =============================================================================
203
+
204
+ describe('AgentMessageBus', () => {
205
+ let bus: AgentMessageBus
206
+
207
+ beforeEach(() => {
208
+ bus = createMessageBus()
209
+ })
210
+
211
+ afterEach(() => {
212
+ bus.dispose()
213
+ })
214
+
215
+ describe('creation and disposal', () => {
216
+ it('should create a message bus', () => {
217
+ expect(bus).toBeDefined()
218
+ expect(bus.send).toBeInstanceOf(Function)
219
+ expect(bus.subscribe).toBeInstanceOf(Function)
220
+ expect(bus.dispose).toBeInstanceOf(Function)
221
+ })
222
+
223
+ it('should clean up on dispose', () => {
224
+ const handler = vi.fn()
225
+ bus.subscribe('agent-a', handler)
226
+ bus.dispose()
227
+
228
+ // Should not call handler after dispose
229
+ bus.send({
230
+ id: 'msg_test',
231
+ type: 'notification',
232
+ sender: 'agent-b',
233
+ recipient: 'agent-a',
234
+ payload: {},
235
+ timestamp: new Date(),
236
+ })
237
+
238
+ expect(handler).not.toHaveBeenCalled()
239
+ })
240
+ })
241
+
242
+ describe('message sending', () => {
243
+ it('should send message to subscribed agent', async () => {
244
+ const handler = vi.fn()
245
+ bus.subscribe('agent-b', handler)
246
+
247
+ const message: AgentMessage = {
248
+ id: 'msg_004',
249
+ type: 'notification',
250
+ sender: 'agent-a',
251
+ recipient: 'agent-b',
252
+ payload: { event: 'test' },
253
+ timestamp: new Date(),
254
+ }
255
+
256
+ await bus.send(message)
257
+
258
+ expect(handler).toHaveBeenCalledWith(message)
259
+ })
260
+
261
+ it('should return delivery envelope', async () => {
262
+ bus.subscribe('agent-b', vi.fn())
263
+
264
+ const message: AgentMessage = {
265
+ id: 'msg_005',
266
+ type: 'request',
267
+ sender: 'agent-a',
268
+ recipient: 'agent-b',
269
+ payload: { task: 'process' },
270
+ timestamp: new Date(),
271
+ }
272
+
273
+ const envelope = await bus.send(message)
274
+
275
+ expect(envelope.message.id).toBe('msg_005')
276
+ expect(envelope.status).toBe('delivered')
277
+ expect(envelope.deliveryAttempts).toBe(1)
278
+ })
279
+
280
+ it('should fail for unsubscribed recipient', async () => {
281
+ const message: AgentMessage = {
282
+ id: 'msg_006',
283
+ type: 'notification',
284
+ sender: 'agent-a',
285
+ recipient: 'unknown-agent',
286
+ payload: {},
287
+ timestamp: new Date(),
288
+ }
289
+
290
+ const envelope = await bus.send(message)
291
+
292
+ expect(envelope.status).toBe('failed')
293
+ expect(envelope.error).toContain('not found')
294
+ })
295
+
296
+ it('should queue messages when recipient is busy', async () => {
297
+ let resolveHandler: () => void
298
+ const handlerPromise = new Promise<void>((resolve) => {
299
+ resolveHandler = resolve
300
+ })
301
+
302
+ // Slow handler that blocks
303
+ const slowHandler = vi.fn(async () => {
304
+ await handlerPromise
305
+ })
306
+
307
+ bus.subscribe('agent-b', slowHandler)
308
+
309
+ const msg1: AgentMessage = {
310
+ id: 'msg_007',
311
+ type: 'request',
312
+ sender: 'agent-a',
313
+ recipient: 'agent-b',
314
+ payload: { order: 1 },
315
+ timestamp: new Date(),
316
+ }
317
+
318
+ const msg2: AgentMessage = {
319
+ id: 'msg_008',
320
+ type: 'request',
321
+ sender: 'agent-a',
322
+ recipient: 'agent-b',
323
+ payload: { order: 2 },
324
+ timestamp: new Date(),
325
+ }
326
+
327
+ // Send both messages
328
+ const p1 = bus.send(msg1)
329
+ const p2 = bus.send(msg2)
330
+
331
+ // First message should be processing
332
+ expect(slowHandler).toHaveBeenCalledTimes(1)
333
+
334
+ // Release the handler
335
+ resolveHandler!()
336
+
337
+ await Promise.all([p1, p2])
338
+
339
+ // Both should have been processed
340
+ expect(slowHandler).toHaveBeenCalledTimes(2)
341
+ })
342
+ })
343
+
344
+ describe('message subscription', () => {
345
+ it('should allow multiple subscribers for same agent', async () => {
346
+ const handler1 = vi.fn()
347
+ const handler2 = vi.fn()
348
+
349
+ bus.subscribe('agent-a', handler1)
350
+ bus.subscribe('agent-a', handler2)
351
+
352
+ const message: AgentMessage = {
353
+ id: 'msg_009',
354
+ type: 'notification',
355
+ sender: 'agent-b',
356
+ recipient: 'agent-a',
357
+ payload: {},
358
+ timestamp: new Date(),
359
+ }
360
+
361
+ await bus.send(message)
362
+
363
+ expect(handler1).toHaveBeenCalledWith(message)
364
+ expect(handler2).toHaveBeenCalledWith(message)
365
+ })
366
+
367
+ it('should return unsubscribe function', async () => {
368
+ const handler = vi.fn()
369
+ const unsubscribe = bus.subscribe('agent-a', handler)
370
+
371
+ unsubscribe()
372
+
373
+ await bus.send({
374
+ id: 'msg_010',
375
+ type: 'notification',
376
+ sender: 'agent-b',
377
+ recipient: 'agent-a',
378
+ payload: {},
379
+ timestamp: new Date(),
380
+ })
381
+
382
+ expect(handler).not.toHaveBeenCalled()
383
+ })
384
+
385
+ it('should filter by message type', async () => {
386
+ const requestHandler = vi.fn()
387
+ const notificationHandler = vi.fn()
388
+
389
+ bus.subscribe('agent-a', requestHandler, { types: ['request'] })
390
+ bus.subscribe('agent-a', notificationHandler, { types: ['notification'] })
391
+
392
+ const request: AgentMessage = {
393
+ id: 'msg_011',
394
+ type: 'request',
395
+ sender: 'agent-b',
396
+ recipient: 'agent-a',
397
+ payload: {},
398
+ timestamp: new Date(),
399
+ }
400
+
401
+ const notification: AgentMessage = {
402
+ id: 'msg_012',
403
+ type: 'notification',
404
+ sender: 'agent-b',
405
+ recipient: 'agent-a',
406
+ payload: {},
407
+ timestamp: new Date(),
408
+ }
409
+
410
+ await bus.send(request)
411
+ await bus.send(notification)
412
+
413
+ expect(requestHandler).toHaveBeenCalledTimes(1)
414
+ expect(notificationHandler).toHaveBeenCalledTimes(1)
415
+ })
416
+ })
417
+
418
+ describe('message acknowledgment', () => {
419
+ it('should track pending acknowledgments', async () => {
420
+ bus.subscribe('agent-b', vi.fn())
421
+
422
+ const message: AgentMessage = {
423
+ id: 'msg_013',
424
+ type: 'request',
425
+ sender: 'agent-a',
426
+ recipient: 'agent-b',
427
+ payload: {},
428
+ timestamp: new Date(),
429
+ }
430
+
431
+ await bus.send(message)
432
+
433
+ const pending = bus.getPendingAcks('agent-a')
434
+ expect(pending).toContain('msg_013')
435
+ })
436
+
437
+ it('should acknowledge received message', async () => {
438
+ bus.subscribe('agent-b', vi.fn())
439
+
440
+ const message: AgentMessage = {
441
+ id: 'msg_014',
442
+ type: 'request',
443
+ sender: 'agent-a',
444
+ recipient: 'agent-b',
445
+ payload: {},
446
+ timestamp: new Date(),
447
+ }
448
+
449
+ await bus.send(message)
450
+ await bus.acknowledge('msg_014', 'agent-b', 'received')
451
+
452
+ const pending = bus.getPendingAcks('agent-a')
453
+ expect(pending).not.toContain('msg_014')
454
+ })
455
+
456
+ it('should timeout unacknowledged messages', async () => {
457
+ vi.useFakeTimers()
458
+
459
+ bus.subscribe('agent-b', vi.fn())
460
+
461
+ const message: AgentMessage = {
462
+ id: 'msg_015',
463
+ type: 'request',
464
+ sender: 'agent-a',
465
+ recipient: 'agent-b',
466
+ payload: {},
467
+ timestamp: new Date(),
468
+ ttl: 1000, // 1 second TTL
469
+ }
470
+
471
+ const envelope = await bus.send(message)
472
+ expect(envelope.status).toBe('delivered')
473
+
474
+ // Advance time past TTL
475
+ vi.advanceTimersByTime(2000)
476
+
477
+ const status = bus.getMessageStatus('msg_015')
478
+ expect(status).toBe('expired')
479
+
480
+ vi.useRealTimers()
481
+ })
482
+ })
483
+ })
484
+
485
+ // =============================================================================
486
+ // Core Communication Functions
487
+ // =============================================================================
488
+
489
+ describe('Core Communication Functions', () => {
490
+ let bus: AgentMessageBus
491
+
492
+ beforeEach(() => {
493
+ bus = createMessageBus()
494
+ })
495
+
496
+ afterEach(() => {
497
+ bus.dispose()
498
+ })
499
+
500
+ describe('sendToAgent', () => {
501
+ it('should send message to specific agent', async () => {
502
+ const handler = vi.fn()
503
+ bus.subscribe('agent-b', handler)
504
+
505
+ const envelope = await sendToAgent(bus, 'agent-a', 'agent-b', {
506
+ task: 'process-data',
507
+ input: { value: 42 },
508
+ })
509
+
510
+ expect(envelope.status).toBe('delivered')
511
+ expect(handler).toHaveBeenCalledWith(
512
+ expect.objectContaining({
513
+ sender: 'agent-a',
514
+ recipient: 'agent-b',
515
+ payload: { task: 'process-data', input: { value: 42 } },
516
+ })
517
+ )
518
+ })
519
+
520
+ it('should generate unique message ID', async () => {
521
+ bus.subscribe('agent-b', vi.fn())
522
+
523
+ const env1 = await sendToAgent(bus, 'agent-a', 'agent-b', { n: 1 })
524
+ const env2 = await sendToAgent(bus, 'agent-a', 'agent-b', { n: 2 })
525
+
526
+ expect(env1.message.id).not.toBe(env2.message.id)
527
+ })
528
+
529
+ it('should support message options', async () => {
530
+ const handler = vi.fn()
531
+ bus.subscribe('agent-b', handler)
532
+
533
+ await sendToAgent(bus, 'agent-a', 'agent-b', { data: 'test' }, {
534
+ priority: 'high',
535
+ ttl: 5000,
536
+ correlationId: 'corr_123',
537
+ })
538
+
539
+ expect(handler).toHaveBeenCalledWith(
540
+ expect.objectContaining({
541
+ priority: 'high',
542
+ ttl: 5000,
543
+ correlationId: 'corr_123',
544
+ })
545
+ )
546
+ })
547
+ })
548
+
549
+ describe('broadcastToGroup', () => {
550
+ it('should send to all agents in group', async () => {
551
+ const handlerA = vi.fn()
552
+ const handlerB = vi.fn()
553
+ const handlerC = vi.fn()
554
+
555
+ bus.subscribe('agent-a', handlerA)
556
+ bus.subscribe('agent-b', handlerB)
557
+ bus.subscribe('agent-c', handlerC)
558
+
559
+ const results = await broadcastToGroup(
560
+ bus,
561
+ 'broadcaster',
562
+ ['agent-a', 'agent-b', 'agent-c'],
563
+ { announcement: 'hello all' }
564
+ )
565
+
566
+ expect(results).toHaveLength(3)
567
+ expect(handlerA).toHaveBeenCalled()
568
+ expect(handlerB).toHaveBeenCalled()
569
+ expect(handlerC).toHaveBeenCalled()
570
+ })
571
+
572
+ it('should continue on partial failure', async () => {
573
+ bus.subscribe('agent-a', vi.fn())
574
+ // agent-b not subscribed - will fail
575
+ bus.subscribe('agent-c', vi.fn())
576
+
577
+ const results = await broadcastToGroup(
578
+ bus,
579
+ 'broadcaster',
580
+ ['agent-a', 'agent-b', 'agent-c'],
581
+ { message: 'test' }
582
+ )
583
+
584
+ expect(results).toHaveLength(3)
585
+ expect(results.filter((r) => r.status === 'delivered')).toHaveLength(2)
586
+ expect(results.filter((r) => r.status === 'failed')).toHaveLength(1)
587
+ })
588
+
589
+ it('should share same correlationId for batch', async () => {
590
+ const receivedMessages: AgentMessage[] = []
591
+
592
+ bus.subscribe('agent-a', (msg) => receivedMessages.push(msg))
593
+ bus.subscribe('agent-b', (msg) => receivedMessages.push(msg))
594
+
595
+ await broadcastToGroup(
596
+ bus,
597
+ 'broadcaster',
598
+ ['agent-a', 'agent-b'],
599
+ { data: 'shared' }
600
+ )
601
+
602
+ expect(receivedMessages[0].correlationId).toBe(
603
+ receivedMessages[1].correlationId
604
+ )
605
+ })
606
+ })
607
+
608
+ describe('requestFromAgent (request/response)', () => {
609
+ it('should send request and await response', async () => {
610
+ // Agent B processes requests
611
+ bus.subscribe('agent-b', async (msg) => {
612
+ if (msg.type === 'request') {
613
+ await bus.send({
614
+ id: `resp_${msg.id}`,
615
+ type: 'response',
616
+ sender: 'agent-b',
617
+ recipient: msg.sender,
618
+ payload: { result: 'processed', input: msg.payload },
619
+ timestamp: new Date(),
620
+ correlationId: msg.id,
621
+ })
622
+ }
623
+ })
624
+
625
+ const response = await requestFromAgent(bus, 'agent-a', 'agent-b', {
626
+ action: 'compute',
627
+ value: 100,
628
+ })
629
+
630
+ expect(response.payload).toEqual({
631
+ result: 'processed',
632
+ input: { action: 'compute', value: 100 },
633
+ })
634
+ })
635
+
636
+ it('should timeout if no response', async () => {
637
+ bus.subscribe('agent-b', vi.fn()) // Handler doesn't respond
638
+
639
+ await expect(
640
+ requestFromAgent(
641
+ bus,
642
+ 'agent-a',
643
+ 'agent-b',
644
+ { data: 'test' },
645
+ { timeout: 100 }
646
+ )
647
+ ).rejects.toThrow('timeout')
648
+ })
649
+
650
+ it('should handle error response', async () => {
651
+ bus.subscribe('agent-b', async (msg) => {
652
+ if (msg.type === 'request') {
653
+ await bus.send({
654
+ id: `err_${msg.id}`,
655
+ type: 'error',
656
+ sender: 'agent-b',
657
+ recipient: msg.sender,
658
+ payload: { error: 'Processing failed', code: 'ERR_PROCESS' },
659
+ timestamp: new Date(),
660
+ correlationId: msg.id,
661
+ })
662
+ }
663
+ })
664
+
665
+ await expect(
666
+ requestFromAgent(bus, 'agent-a', 'agent-b', { data: 'test' })
667
+ ).rejects.toThrow('Processing failed')
668
+ })
669
+ })
670
+
671
+ describe('onMessage', () => {
672
+ it('should register handler for agent', () => {
673
+ const handler = vi.fn()
674
+ const unsubscribe = onMessage(bus, 'agent-a', handler)
675
+
676
+ expect(typeof unsubscribe).toBe('function')
677
+ })
678
+
679
+ it('should invoke handler on message receipt', async () => {
680
+ const handler = vi.fn()
681
+ onMessage(bus, 'agent-a', handler)
682
+
683
+ await bus.send({
684
+ id: 'msg_100',
685
+ type: 'notification',
686
+ sender: 'agent-b',
687
+ recipient: 'agent-a',
688
+ payload: { event: 'test' },
689
+ timestamp: new Date(),
690
+ })
691
+
692
+ expect(handler).toHaveBeenCalledWith(
693
+ expect.objectContaining({ id: 'msg_100' })
694
+ )
695
+ })
696
+
697
+ it('should filter by sender', async () => {
698
+ const handler = vi.fn()
699
+ onMessage(bus, 'agent-a', handler, { from: 'agent-b' })
700
+
701
+ await bus.send({
702
+ id: 'msg_101',
703
+ type: 'notification',
704
+ sender: 'agent-b',
705
+ recipient: 'agent-a',
706
+ payload: {},
707
+ timestamp: new Date(),
708
+ })
709
+
710
+ await bus.send({
711
+ id: 'msg_102',
712
+ type: 'notification',
713
+ sender: 'agent-c',
714
+ recipient: 'agent-a',
715
+ payload: {},
716
+ timestamp: new Date(),
717
+ })
718
+
719
+ expect(handler).toHaveBeenCalledTimes(1)
720
+ expect(handler).toHaveBeenCalledWith(
721
+ expect.objectContaining({ sender: 'agent-b' })
722
+ )
723
+ })
724
+ })
725
+
726
+ describe('acknowledge', () => {
727
+ it('should send acknowledgment message', async () => {
728
+ const ackHandler = vi.fn()
729
+ bus.subscribe('agent-a', ackHandler)
730
+ bus.subscribe('agent-b', vi.fn())
731
+
732
+ const message: AgentMessage = {
733
+ id: 'msg_ack_001',
734
+ type: 'request',
735
+ sender: 'agent-a',
736
+ recipient: 'agent-b',
737
+ payload: {},
738
+ timestamp: new Date(),
739
+ }
740
+
741
+ await bus.send(message)
742
+ await acknowledge(bus, message, 'received')
743
+
744
+ expect(ackHandler).toHaveBeenCalledWith(
745
+ expect.objectContaining({
746
+ type: 'ack',
747
+ correlationId: 'msg_ack_001',
748
+ payload: expect.objectContaining({ status: 'received' }),
749
+ })
750
+ )
751
+ })
752
+
753
+ it('should include result in processed ack', async () => {
754
+ const ackHandler = vi.fn()
755
+ bus.subscribe('agent-a', ackHandler)
756
+ bus.subscribe('agent-b', vi.fn())
757
+
758
+ const message: AgentMessage = {
759
+ id: 'msg_ack_002',
760
+ type: 'request',
761
+ sender: 'agent-a',
762
+ recipient: 'agent-b',
763
+ payload: {},
764
+ timestamp: new Date(),
765
+ }
766
+
767
+ await bus.send(message)
768
+ await acknowledge(bus, message, 'processed', { output: 'done' })
769
+
770
+ expect(ackHandler).toHaveBeenCalledWith(
771
+ expect.objectContaining({
772
+ payload: expect.objectContaining({
773
+ status: 'processed',
774
+ result: { output: 'done' },
775
+ }),
776
+ })
777
+ )
778
+ })
779
+ })
780
+ })
781
+
782
+ // =============================================================================
783
+ // Coordination Patterns
784
+ // =============================================================================
785
+
786
+ describe('Coordination Patterns', () => {
787
+ let bus: AgentMessageBus
788
+
789
+ beforeEach(() => {
790
+ bus = createMessageBus()
791
+ })
792
+
793
+ afterEach(() => {
794
+ bus.dispose()
795
+ })
796
+
797
+ describe('requestResponse pattern', () => {
798
+ it('should implement request/response with correlation', async () => {
799
+ // Setup responder
800
+ bus.subscribe('processor', async (msg) => {
801
+ if (msg.type === 'request') {
802
+ const result = (msg.payload as { value: number }).value * 2
803
+ await bus.send({
804
+ id: `resp_${msg.id}`,
805
+ type: 'response',
806
+ sender: 'processor',
807
+ recipient: msg.sender,
808
+ payload: { result },
809
+ timestamp: new Date(),
810
+ correlationId: msg.id,
811
+ })
812
+ }
813
+ })
814
+
815
+ const response = await requestResponse(bus, {
816
+ from: 'requester',
817
+ to: 'processor',
818
+ payload: { value: 21 },
819
+ })
820
+
821
+ expect(response.payload).toEqual({ result: 42 })
822
+ })
823
+ })
824
+
825
+ describe('fanOut pattern', () => {
826
+ it('should distribute work to multiple agents', async () => {
827
+ const results: unknown[] = []
828
+
829
+ bus.subscribe('worker-1', async (msg) => {
830
+ results.push({ worker: 1, input: msg.payload })
831
+ await bus.send({
832
+ id: `w1_${msg.id}`,
833
+ type: 'response',
834
+ sender: 'worker-1',
835
+ recipient: msg.sender,
836
+ payload: { processed: true, worker: 1 },
837
+ timestamp: new Date(),
838
+ correlationId: msg.correlationId,
839
+ })
840
+ })
841
+
842
+ bus.subscribe('worker-2', async (msg) => {
843
+ results.push({ worker: 2, input: msg.payload })
844
+ await bus.send({
845
+ id: `w2_${msg.id}`,
846
+ type: 'response',
847
+ sender: 'worker-2',
848
+ recipient: msg.sender,
849
+ payload: { processed: true, worker: 2 },
850
+ timestamp: new Date(),
851
+ correlationId: msg.correlationId,
852
+ })
853
+ })
854
+
855
+ const responses = await fanOut(bus, {
856
+ from: 'coordinator',
857
+ to: ['worker-1', 'worker-2'],
858
+ payload: { task: 'process-chunk' },
859
+ })
860
+
861
+ expect(responses).toHaveLength(2)
862
+ expect(results).toHaveLength(2)
863
+ })
864
+
865
+ it('should handle partial failures in fanOut', async () => {
866
+ bus.subscribe('worker-1', async (msg) => {
867
+ await bus.send({
868
+ id: `w1_${msg.id}`,
869
+ type: 'response',
870
+ sender: 'worker-1',
871
+ recipient: msg.sender,
872
+ payload: { success: true },
873
+ timestamp: new Date(),
874
+ correlationId: msg.correlationId,
875
+ })
876
+ })
877
+
878
+ // worker-2 not subscribed
879
+
880
+ const responses = await fanOut(bus, {
881
+ from: 'coordinator',
882
+ to: ['worker-1', 'worker-2'],
883
+ payload: { task: 'process' },
884
+ continueOnError: true,
885
+ })
886
+
887
+ expect(responses.filter((r) => r.success)).toHaveLength(1)
888
+ expect(responses.filter((r) => !r.success)).toHaveLength(1)
889
+ })
890
+ })
891
+
892
+ describe('fanIn pattern', () => {
893
+ it('should collect responses from multiple agents', async () => {
894
+ // Setup workers that send results
895
+ bus.subscribe('collector', vi.fn())
896
+
897
+ const collected = await fanIn(bus, {
898
+ collector: 'collector',
899
+ sources: ['source-1', 'source-2'],
900
+ timeout: 1000,
901
+ onSourceMessage: async (source) => {
902
+ // Simulate sources sending data
903
+ return { from: source, data: `data from ${source}` }
904
+ },
905
+ })
906
+
907
+ expect(collected).toHaveLength(2)
908
+ expect(collected.map((c) => c.from)).toContain('source-1')
909
+ expect(collected.map((c) => c.from)).toContain('source-2')
910
+ })
911
+ })
912
+
913
+ describe('pipeline pattern', () => {
914
+ it('should chain agents in sequence', async () => {
915
+ // Setup pipeline: step1 -> step2 -> step3
916
+ bus.subscribe('step1', async (msg) => {
917
+ const input = msg.payload as { value: number }
918
+ await bus.send({
919
+ id: `s1_${msg.id}`,
920
+ type: 'response',
921
+ sender: 'step1',
922
+ recipient: msg.sender,
923
+ payload: { value: input.value + 10 },
924
+ timestamp: new Date(),
925
+ correlationId: msg.id,
926
+ })
927
+ })
928
+
929
+ bus.subscribe('step2', async (msg) => {
930
+ const input = msg.payload as { value: number }
931
+ await bus.send({
932
+ id: `s2_${msg.id}`,
933
+ type: 'response',
934
+ sender: 'step2',
935
+ recipient: msg.sender,
936
+ payload: { value: input.value * 2 },
937
+ timestamp: new Date(),
938
+ correlationId: msg.id,
939
+ })
940
+ })
941
+
942
+ bus.subscribe('step3', async (msg) => {
943
+ const input = msg.payload as { value: number }
944
+ await bus.send({
945
+ id: `s3_${msg.id}`,
946
+ type: 'response',
947
+ sender: 'step3',
948
+ recipient: msg.sender,
949
+ payload: { value: input.value - 5, final: true },
950
+ timestamp: new Date(),
951
+ correlationId: msg.id,
952
+ })
953
+ })
954
+
955
+ const result = await pipeline(bus, {
956
+ initiator: 'orchestrator',
957
+ stages: ['step1', 'step2', 'step3'],
958
+ input: { value: 5 },
959
+ })
960
+
961
+ // Pipeline: 5 -> +10 = 15 -> *2 = 30 -> -5 = 25
962
+ expect(result.payload).toEqual({ value: 25, final: true })
963
+ })
964
+
965
+ it('should stop pipeline on stage failure', async () => {
966
+ bus.subscribe('step1', async (msg) => {
967
+ await bus.send({
968
+ id: `s1_${msg.id}`,
969
+ type: 'error',
970
+ sender: 'step1',
971
+ recipient: msg.sender,
972
+ payload: { error: 'Stage 1 failed' },
973
+ timestamp: new Date(),
974
+ correlationId: msg.id,
975
+ })
976
+ })
977
+
978
+ bus.subscribe('step2', vi.fn())
979
+
980
+ await expect(
981
+ pipeline(bus, {
982
+ initiator: 'orchestrator',
983
+ stages: ['step1', 'step2'],
984
+ input: { value: 1 },
985
+ })
986
+ ).rejects.toThrow('Stage 1 failed')
987
+ })
988
+ })
989
+ })
990
+
991
+ // =============================================================================
992
+ // Handoff Protocol Tests
993
+ // =============================================================================
994
+
995
+ describe('Handoff Protocol', () => {
996
+ let bus: AgentMessageBus
997
+
998
+ beforeEach(() => {
999
+ bus = createMessageBus()
1000
+ })
1001
+
1002
+ afterEach(() => {
1003
+ bus.dispose()
1004
+ })
1005
+
1006
+ describe('HandoffRequest interface', () => {
1007
+ it('should define handoff request structure', () => {
1008
+ const request: HandoffRequest = {
1009
+ id: 'handoff_001',
1010
+ fromAgent: 'agent-a',
1011
+ toAgent: 'agent-b',
1012
+ context: {
1013
+ task: 'process-order',
1014
+ orderId: 'order_123',
1015
+ progress: 0.5,
1016
+ },
1017
+ reason: 'Escalation to specialist',
1018
+ priority: 'high',
1019
+ timestamp: new Date(),
1020
+ }
1021
+
1022
+ expect(request.fromAgent).toBe('agent-a')
1023
+ expect(request.toAgent).toBe('agent-b')
1024
+ expect(request.context).toBeDefined()
1025
+ expect(request.reason).toBe('Escalation to specialist')
1026
+ })
1027
+ })
1028
+
1029
+ describe('initiateHandoff', () => {
1030
+ it('should send handoff request to target agent', async () => {
1031
+ const handler = vi.fn()
1032
+ bus.subscribe('agent-b', handler)
1033
+
1034
+ const result = await initiateHandoff(bus, {
1035
+ fromAgent: 'agent-a',
1036
+ toAgent: 'agent-b',
1037
+ context: { task: 'complex-analysis' },
1038
+ reason: 'Need specialist',
1039
+ })
1040
+
1041
+ expect(result.status).toBe('pending')
1042
+ expect(handler).toHaveBeenCalledWith(
1043
+ expect.objectContaining({
1044
+ type: 'handoff',
1045
+ payload: expect.objectContaining({
1046
+ action: 'request',
1047
+ context: { task: 'complex-analysis' },
1048
+ }),
1049
+ })
1050
+ )
1051
+ })
1052
+
1053
+ it('should include context in handoff', async () => {
1054
+ const receivedContext: unknown[] = []
1055
+
1056
+ bus.subscribe('agent-b', (msg) => {
1057
+ if (msg.type === 'handoff') {
1058
+ receivedContext.push((msg.payload as { context: unknown }).context)
1059
+ }
1060
+ })
1061
+
1062
+ await initiateHandoff(bus, {
1063
+ fromAgent: 'agent-a',
1064
+ toAgent: 'agent-b',
1065
+ context: {
1066
+ state: { step: 3, data: [1, 2, 3] },
1067
+ history: ['step1', 'step2'],
1068
+ metadata: { priority: 'high' },
1069
+ },
1070
+ })
1071
+
1072
+ expect(receivedContext[0]).toEqual({
1073
+ state: { step: 3, data: [1, 2, 3] },
1074
+ history: ['step1', 'step2'],
1075
+ metadata: { priority: 'high' },
1076
+ })
1077
+ })
1078
+ })
1079
+
1080
+ describe('acceptHandoff', () => {
1081
+ it('should accept pending handoff', async () => {
1082
+ const ackHandler = vi.fn()
1083
+ bus.subscribe('agent-a', ackHandler)
1084
+ bus.subscribe('agent-b', vi.fn())
1085
+
1086
+ const { handoffId } = await initiateHandoff(bus, {
1087
+ fromAgent: 'agent-a',
1088
+ toAgent: 'agent-b',
1089
+ context: {},
1090
+ })
1091
+
1092
+ const result = await acceptHandoff(bus, handoffId, 'agent-b')
1093
+
1094
+ expect(result.status).toBe('accepted')
1095
+ expect(ackHandler).toHaveBeenCalledWith(
1096
+ expect.objectContaining({
1097
+ type: 'handoff',
1098
+ payload: expect.objectContaining({
1099
+ action: 'accepted',
1100
+ handoffId,
1101
+ }),
1102
+ })
1103
+ )
1104
+ })
1105
+
1106
+ it('should fail to accept non-existent handoff', async () => {
1107
+ await expect(
1108
+ acceptHandoff(bus, 'non-existent', 'agent-b')
1109
+ ).rejects.toThrow('Handoff not found')
1110
+ })
1111
+ })
1112
+
1113
+ describe('rejectHandoff', () => {
1114
+ it('should reject handoff with reason', async () => {
1115
+ const ackHandler = vi.fn()
1116
+ bus.subscribe('agent-a', ackHandler)
1117
+ bus.subscribe('agent-b', vi.fn())
1118
+
1119
+ const { handoffId } = await initiateHandoff(bus, {
1120
+ fromAgent: 'agent-a',
1121
+ toAgent: 'agent-b',
1122
+ context: {},
1123
+ })
1124
+
1125
+ const result = await rejectHandoff(bus, handoffId, 'agent-b', {
1126
+ reason: 'Currently at capacity',
1127
+ })
1128
+
1129
+ expect(result.status).toBe('rejected')
1130
+ expect(ackHandler).toHaveBeenCalledWith(
1131
+ expect.objectContaining({
1132
+ payload: expect.objectContaining({
1133
+ action: 'rejected',
1134
+ reason: 'Currently at capacity',
1135
+ }),
1136
+ })
1137
+ )
1138
+ })
1139
+ })
1140
+
1141
+ describe('completeHandoff', () => {
1142
+ it('should complete accepted handoff', async () => {
1143
+ bus.subscribe('agent-a', vi.fn())
1144
+ bus.subscribe('agent-b', vi.fn())
1145
+
1146
+ const { handoffId } = await initiateHandoff(bus, {
1147
+ fromAgent: 'agent-a',
1148
+ toAgent: 'agent-b',
1149
+ context: { task: 'process' },
1150
+ })
1151
+
1152
+ await acceptHandoff(bus, handoffId, 'agent-b')
1153
+
1154
+ const result = await completeHandoff(bus, handoffId, 'agent-b', {
1155
+ result: { success: true, output: 'completed task' },
1156
+ })
1157
+
1158
+ expect(result.status).toBe('completed')
1159
+ expect(result.result).toEqual({ success: true, output: 'completed task' })
1160
+ })
1161
+
1162
+ it('should fail to complete non-accepted handoff', async () => {
1163
+ bus.subscribe('agent-a', vi.fn())
1164
+ bus.subscribe('agent-b', vi.fn())
1165
+
1166
+ const { handoffId } = await initiateHandoff(bus, {
1167
+ fromAgent: 'agent-a',
1168
+ toAgent: 'agent-b',
1169
+ context: {},
1170
+ })
1171
+
1172
+ await expect(
1173
+ completeHandoff(bus, handoffId, 'agent-b', { result: {} })
1174
+ ).rejects.toThrow('Handoff not accepted')
1175
+ })
1176
+ })
1177
+
1178
+ describe('handoff timeout handling', () => {
1179
+ it('should timeout pending handoff', async () => {
1180
+ vi.useFakeTimers()
1181
+
1182
+ bus.subscribe('agent-a', vi.fn())
1183
+ bus.subscribe('agent-b', vi.fn())
1184
+
1185
+ const { handoffId } = await initiateHandoff(bus, {
1186
+ fromAgent: 'agent-a',
1187
+ toAgent: 'agent-b',
1188
+ context: {},
1189
+ timeout: 1000,
1190
+ })
1191
+
1192
+ vi.advanceTimersByTime(2000)
1193
+
1194
+ const status = bus.getHandoffStatus(handoffId)
1195
+ expect(status).toBe('expired')
1196
+
1197
+ vi.useRealTimers()
1198
+ })
1199
+
1200
+ it('should notify initiator on timeout', async () => {
1201
+ vi.useFakeTimers()
1202
+
1203
+ const timeoutHandler = vi.fn()
1204
+ bus.subscribe('agent-a', timeoutHandler)
1205
+ bus.subscribe('agent-b', vi.fn())
1206
+
1207
+ await initiateHandoff(bus, {
1208
+ fromAgent: 'agent-a',
1209
+ toAgent: 'agent-b',
1210
+ context: {},
1211
+ timeout: 1000,
1212
+ onTimeout: timeoutHandler,
1213
+ })
1214
+
1215
+ vi.advanceTimersByTime(2000)
1216
+
1217
+ expect(timeoutHandler).toHaveBeenCalledWith(
1218
+ expect.objectContaining({
1219
+ type: 'handoff',
1220
+ payload: expect.objectContaining({ action: 'timeout' }),
1221
+ })
1222
+ )
1223
+
1224
+ vi.useRealTimers()
1225
+ })
1226
+ })
1227
+
1228
+ describe('handoff failure recovery', () => {
1229
+ it('should allow retry after rejection', async () => {
1230
+ bus.subscribe('agent-a', vi.fn())
1231
+ bus.subscribe('agent-b', vi.fn())
1232
+ bus.subscribe('agent-c', vi.fn())
1233
+
1234
+ // First handoff to agent-b rejected
1235
+ const { handoffId: firstId } = await initiateHandoff(bus, {
1236
+ fromAgent: 'agent-a',
1237
+ toAgent: 'agent-b',
1238
+ context: { task: 'process' },
1239
+ })
1240
+
1241
+ await rejectHandoff(bus, firstId, 'agent-b', { reason: 'Busy' })
1242
+
1243
+ // Retry to agent-c
1244
+ const { handoffId: secondId, status } = await initiateHandoff(bus, {
1245
+ fromAgent: 'agent-a',
1246
+ toAgent: 'agent-c',
1247
+ context: { task: 'process' },
1248
+ previousAttempt: firstId,
1249
+ })
1250
+
1251
+ expect(status).toBe('pending')
1252
+ expect(secondId).not.toBe(firstId)
1253
+ })
1254
+
1255
+ it('should track handoff history', async () => {
1256
+ bus.subscribe('agent-a', vi.fn())
1257
+ bus.subscribe('agent-b', vi.fn())
1258
+ bus.subscribe('agent-c', vi.fn())
1259
+
1260
+ const { handoffId: firstId } = await initiateHandoff(bus, {
1261
+ fromAgent: 'agent-a',
1262
+ toAgent: 'agent-b',
1263
+ context: { task: 'process' },
1264
+ })
1265
+
1266
+ await rejectHandoff(bus, firstId, 'agent-b', { reason: 'Busy' })
1267
+
1268
+ const { handoffId: secondId } = await initiateHandoff(bus, {
1269
+ fromAgent: 'agent-a',
1270
+ toAgent: 'agent-c',
1271
+ context: { task: 'process' },
1272
+ previousAttempt: firstId,
1273
+ })
1274
+
1275
+ await acceptHandoff(bus, secondId, 'agent-c')
1276
+
1277
+ const history = bus.getHandoffHistory(secondId)
1278
+ expect(history).toContain(firstId)
1279
+ })
1280
+ })
1281
+ })
1282
+
1283
+ // =============================================================================
1284
+ // Message Persistence Tests
1285
+ // =============================================================================
1286
+
1287
+ describe('Message Persistence', () => {
1288
+ let bus: AgentMessageBus
1289
+
1290
+ beforeEach(() => {
1291
+ bus = createMessageBus({ persistence: true })
1292
+ })
1293
+
1294
+ afterEach(() => {
1295
+ bus.dispose()
1296
+ })
1297
+
1298
+ it('should persist messages', async () => {
1299
+ bus.subscribe('agent-b', vi.fn())
1300
+
1301
+ await sendToAgent(bus, 'agent-a', 'agent-b', { data: 'test' })
1302
+
1303
+ const messages = bus.getStoredMessages()
1304
+ expect(messages.length).toBeGreaterThan(0)
1305
+ })
1306
+
1307
+ it('should retrieve message history', async () => {
1308
+ bus.subscribe('agent-b', vi.fn())
1309
+
1310
+ await sendToAgent(bus, 'agent-a', 'agent-b', { n: 1 })
1311
+ await sendToAgent(bus, 'agent-a', 'agent-b', { n: 2 })
1312
+ await sendToAgent(bus, 'agent-a', 'agent-b', { n: 3 })
1313
+
1314
+ const history = bus.getMessageHistory('agent-b', { limit: 2 })
1315
+ expect(history).toHaveLength(2)
1316
+ })
1317
+
1318
+ it('should filter messages by time range', async () => {
1319
+ bus.subscribe('agent-b', vi.fn())
1320
+
1321
+ const before = new Date()
1322
+ await sendToAgent(bus, 'agent-a', 'agent-b', { data: 'test' })
1323
+ const after = new Date()
1324
+
1325
+ const messages = bus.getMessageHistory('agent-b', {
1326
+ from: before,
1327
+ to: after,
1328
+ })
1329
+
1330
+ expect(messages.length).toBeGreaterThan(0)
1331
+ })
1332
+
1333
+ it('should clear old messages', async () => {
1334
+ bus.subscribe('agent-b', vi.fn())
1335
+
1336
+ await sendToAgent(bus, 'agent-a', 'agent-b', { data: 'test' })
1337
+
1338
+ const beforeClear = bus.getStoredMessages().length
1339
+ bus.clearMessages({ olderThan: new Date(Date.now() + 1000) })
1340
+ const afterClear = bus.getStoredMessages().length
1341
+
1342
+ expect(afterClear).toBeLessThan(beforeClear)
1343
+ })
1344
+ })
1345
+
1346
+ // =============================================================================
1347
+ // Integration with Worker Types
1348
+ // =============================================================================
1349
+
1350
+ describe('Worker Integration', () => {
1351
+ let bus: AgentMessageBus
1352
+
1353
+ beforeEach(() => {
1354
+ bus = createMessageBus()
1355
+ })
1356
+
1357
+ afterEach(() => {
1358
+ bus.dispose()
1359
+ })
1360
+
1361
+ it('should accept Worker as sender/recipient', async () => {
1362
+ bus.subscribe(agentB.id, vi.fn())
1363
+
1364
+ const envelope = await sendToAgent(bus, agentA, agentB, {
1365
+ task: 'analyze',
1366
+ })
1367
+
1368
+ expect(envelope.message.sender).toBe('agent-a')
1369
+ expect(envelope.message.recipient).toBe('agent-b')
1370
+ })
1371
+
1372
+ it('should accept WorkerRef as sender/recipient', async () => {
1373
+ const refA: WorkerRef = { id: 'agent-a', name: 'Agent A' }
1374
+ const refB: WorkerRef = { id: 'agent-b', name: 'Agent B' }
1375
+
1376
+ bus.subscribe(refB.id, vi.fn())
1377
+
1378
+ const envelope = await sendToAgent(bus, refA, refB, { data: 'test' })
1379
+
1380
+ expect(envelope.message.sender).toBe('agent-a')
1381
+ expect(envelope.message.recipient).toBe('agent-b')
1382
+ })
1383
+
1384
+ it('should work with string agent IDs', async () => {
1385
+ bus.subscribe('target-agent', vi.fn())
1386
+
1387
+ const envelope = await sendToAgent(
1388
+ bus,
1389
+ 'source-agent',
1390
+ 'target-agent',
1391
+ { data: 'test' }
1392
+ )
1393
+
1394
+ expect(envelope.message.sender).toBe('source-agent')
1395
+ expect(envelope.message.recipient).toBe('target-agent')
1396
+ })
1397
+ })