digital-workers 2.1.3 → 2.4.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 (183) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +2 -0
  4. package/dist/actions.d.ts.map +1 -1
  5. package/dist/actions.js +33 -21
  6. package/dist/actions.js.map +1 -1
  7. package/dist/agent-comms.d.ts.map +1 -1
  8. package/dist/agent-comms.js +36 -25
  9. package/dist/agent-comms.js.map +1 -1
  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.js +3 -3
  23. package/dist/capability-tiers.js.map +1 -1
  24. package/dist/cascade-context.d.ts +28 -28
  25. package/dist/client.d.ts +162 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +64 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/decide.d.ts +42 -6
  30. package/dist/decide.d.ts.map +1 -1
  31. package/dist/decide.js +54 -11
  32. package/dist/decide.js.map +1 -1
  33. package/dist/do.d.ts +36 -7
  34. package/dist/do.d.ts.map +1 -1
  35. package/dist/do.js +82 -39
  36. package/dist/do.js.map +1 -1
  37. package/dist/error-escalation.d.ts.map +1 -1
  38. package/dist/error-escalation.js +38 -38
  39. package/dist/error-escalation.js.map +1 -1
  40. package/dist/generate.d.ts +48 -7
  41. package/dist/generate.d.ts.map +1 -1
  42. package/dist/generate.js +49 -8
  43. package/dist/generate.js.map +1 -1
  44. package/dist/goals.d.ts +10 -9
  45. package/dist/goals.d.ts.map +1 -1
  46. package/dist/goals.js +30 -24
  47. package/dist/goals.js.map +1 -1
  48. package/dist/image.d.ts +189 -0
  49. package/dist/image.d.ts.map +1 -0
  50. package/dist/image.js +528 -0
  51. package/dist/image.js.map +1 -0
  52. package/dist/index.d.ts +49 -2
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +58 -2
  55. package/dist/index.js.map +1 -1
  56. package/dist/is.d.ts +45 -10
  57. package/dist/is.d.ts.map +1 -1
  58. package/dist/is.js +56 -21
  59. package/dist/is.js.map +1 -1
  60. package/dist/kpis.d.ts +24 -15
  61. package/dist/kpis.d.ts.map +1 -1
  62. package/dist/kpis.js +16 -14
  63. package/dist/kpis.js.map +1 -1
  64. package/dist/load-balancing.d.ts.map +1 -1
  65. package/dist/load-balancing.js +124 -38
  66. package/dist/load-balancing.js.map +1 -1
  67. package/dist/logger.d.ts +76 -0
  68. package/dist/logger.d.ts.map +1 -0
  69. package/dist/logger.js +39 -0
  70. package/dist/logger.js.map +1 -0
  71. package/dist/notify.d.ts +38 -9
  72. package/dist/notify.d.ts.map +1 -1
  73. package/dist/notify.js +72 -17
  74. package/dist/notify.js.map +1 -1
  75. package/dist/role.d.ts +5 -4
  76. package/dist/role.d.ts.map +1 -1
  77. package/dist/role.js +13 -10
  78. package/dist/role.js.map +1 -1
  79. package/dist/runtime.d.ts +310 -0
  80. package/dist/runtime.d.ts.map +1 -0
  81. package/dist/runtime.js +510 -0
  82. package/dist/runtime.js.map +1 -0
  83. package/dist/team.d.ts +11 -6
  84. package/dist/team.d.ts.map +1 -1
  85. package/dist/team.js +22 -15
  86. package/dist/team.js.map +1 -1
  87. package/dist/transports/email.d.ts +318 -0
  88. package/dist/transports/email.d.ts.map +1 -0
  89. package/dist/transports/email.js +779 -0
  90. package/dist/transports/email.js.map +1 -0
  91. package/dist/transports/slack.d.ts +515 -0
  92. package/dist/transports/slack.d.ts.map +1 -0
  93. package/dist/transports/slack.js +844 -0
  94. package/dist/transports/slack.js.map +1 -0
  95. package/dist/transports.d.ts.map +1 -1
  96. package/dist/transports.js +44 -25
  97. package/dist/transports.js.map +1 -1
  98. package/dist/types.d.ts +141 -19
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/types.js +5 -0
  101. package/dist/types.js.map +1 -1
  102. package/dist/utils/id.d.ts +19 -0
  103. package/dist/utils/id.d.ts.map +1 -0
  104. package/dist/utils/id.js +21 -0
  105. package/dist/utils/id.js.map +1 -0
  106. package/dist/video.d.ts +203 -0
  107. package/dist/video.d.ts.map +1 -0
  108. package/dist/video.js +528 -0
  109. package/dist/video.js.map +1 -0
  110. package/dist/worker.d.ts +343 -0
  111. package/dist/worker.d.ts.map +1 -0
  112. package/dist/worker.js +698 -0
  113. package/dist/worker.js.map +1 -0
  114. package/package.json +32 -14
  115. package/src/actions.ts +39 -30
  116. package/src/agent-comms.ts +54 -92
  117. package/src/approve.ts +91 -20
  118. package/src/ask.ts +99 -25
  119. package/src/browse.ts +627 -0
  120. package/src/capability-tiers.ts +5 -5
  121. package/src/client.ts +221 -0
  122. package/src/decide.ts +81 -35
  123. package/src/do.ts +98 -52
  124. package/src/error-escalation.ts +55 -67
  125. package/src/generate.ts +52 -18
  126. package/src/goals.ts +36 -27
  127. package/src/image.ts +816 -0
  128. package/src/index.ts +187 -2
  129. package/src/is.ts +59 -25
  130. package/src/kpis.ts +41 -36
  131. package/src/load-balancing.ts +132 -46
  132. package/src/logger.ts +93 -0
  133. package/src/notify.ts +78 -17
  134. package/src/role.ts +30 -20
  135. package/src/runtime.ts +796 -0
  136. package/src/team.ts +24 -19
  137. package/src/transports/email.ts +1160 -0
  138. package/src/transports/slack.ts +1320 -0
  139. package/src/transports.ts +58 -43
  140. package/src/types.ts +174 -46
  141. package/src/utils/id.ts +21 -0
  142. package/src/video.ts +906 -0
  143. package/src/worker.ts +1007 -0
  144. package/test/approve.test.ts +305 -0
  145. package/test/ask.test.ts +274 -0
  146. package/test/browse.test.ts +361 -0
  147. package/test/decide.test.ts +252 -0
  148. package/test/do.test.ts +144 -0
  149. package/test/error-logging.test.ts +357 -0
  150. package/test/generate.test.ts +319 -0
  151. package/test/image.test.ts +398 -0
  152. package/test/is.test.ts +287 -0
  153. package/test/load-balancing-safety.test.ts +404 -0
  154. package/test/notify.test.ts +434 -0
  155. package/test/primitives.test.ts +320 -0
  156. package/test/runtime-integration.test.ts +892 -0
  157. package/test/transports/crypto.test.ts +230 -0
  158. package/test/transports/email.test.ts +866 -0
  159. package/test/transports/id-generation.test.ts +91 -0
  160. package/test/transports/slack.test.ts +760 -0
  161. package/test/type-safety.test.ts +834 -0
  162. package/test/types.test.ts +60 -2
  163. package/test/video.test.ts +530 -0
  164. package/test/worker.test.ts +1433 -0
  165. package/tsconfig.json +4 -1
  166. package/vitest.config.ts +42 -0
  167. package/wrangler.jsonc +36 -0
  168. package/LICENSE +0 -21
  169. package/src/actions.js +0 -436
  170. package/src/approve.js +0 -234
  171. package/src/ask.js +0 -226
  172. package/src/decide.js +0 -244
  173. package/src/do.js +0 -227
  174. package/src/generate.js +0 -298
  175. package/src/goals.js +0 -205
  176. package/src/index.js +0 -68
  177. package/src/is.js +0 -317
  178. package/src/kpis.js +0 -270
  179. package/src/notify.js +0 -219
  180. package/src/role.js +0 -110
  181. package/src/team.js +0 -130
  182. package/src/transports.js +0 -357
  183. package/src/types.js +0 -71
@@ -0,0 +1,892 @@
1
+ /**
2
+ * Tests for Runtime Integration
3
+ *
4
+ * Tests the HumanRequestProcessor that connects transport adapters
5
+ * to request handling, store management, and callback notification.
6
+ */
7
+
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
+ import {
10
+ HumanRequestProcessor,
11
+ createHumanRequestProcessor,
12
+ InMemoryRequestStore,
13
+ type HumanRequest,
14
+ type HumanRequestStore,
15
+ type RequestStatus,
16
+ type ProcessorConfig,
17
+ type RequestResult,
18
+ } from '../src/runtime.js'
19
+ import { SlackTransport, createSlackTransport } from '../src/transports/slack.js'
20
+ import {
21
+ EmailTransport,
22
+ createEmailTransportWithProvider,
23
+ type EmailProvider,
24
+ } from '../src/transports/email.js'
25
+ import type { DeliveryResult } from '../src/transports.js'
26
+
27
+ // =============================================================================
28
+ // Mock Setup
29
+ // =============================================================================
30
+
31
+ // Mock fetch for Slack API
32
+ const mockFetch = vi.fn()
33
+ global.fetch = mockFetch
34
+
35
+ // Mock Slack API responses
36
+ const mockSlackPostMessageResponse = {
37
+ ok: true,
38
+ ts: '1234567890.123456',
39
+ channel: 'C123456',
40
+ message: {
41
+ type: 'message',
42
+ text: 'Test message',
43
+ ts: '1234567890.123456',
44
+ bot_id: 'B123456',
45
+ },
46
+ }
47
+
48
+ // Mock email provider
49
+ function createMockEmailProvider(): EmailProvider {
50
+ return {
51
+ name: 'mock',
52
+ send: vi.fn().mockResolvedValue({
53
+ success: true,
54
+ messageId: 'msg_mock_123',
55
+ }),
56
+ }
57
+ }
58
+
59
+ // =============================================================================
60
+ // InMemoryRequestStore Tests
61
+ // =============================================================================
62
+
63
+ describe('InMemoryRequestStore', () => {
64
+ let store: InMemoryRequestStore
65
+
66
+ beforeEach(() => {
67
+ store = new InMemoryRequestStore()
68
+ })
69
+
70
+ describe('create', () => {
71
+ it('should create a new request', async () => {
72
+ const request = await store.create({
73
+ type: 'approval',
74
+ target: 'manager@example.com',
75
+ content: 'Approve expense $500',
76
+ requestedBy: { id: 'user_1', name: 'Alice' },
77
+ transport: 'email',
78
+ })
79
+
80
+ expect(request.id).toBeDefined()
81
+ expect(request.status).toBe('pending')
82
+ expect(request.createdAt).toBeInstanceOf(Date)
83
+ expect(request.content).toBe('Approve expense $500')
84
+ })
85
+
86
+ it('should generate unique IDs', async () => {
87
+ const req1 = await store.create({
88
+ type: 'approval',
89
+ target: 'a@example.com',
90
+ content: 'Test 1',
91
+ transport: 'email',
92
+ })
93
+ const req2 = await store.create({
94
+ type: 'approval',
95
+ target: 'b@example.com',
96
+ content: 'Test 2',
97
+ transport: 'email',
98
+ })
99
+
100
+ expect(req1.id).not.toBe(req2.id)
101
+ })
102
+ })
103
+
104
+ describe('get', () => {
105
+ it('should retrieve a request by ID', async () => {
106
+ const created = await store.create({
107
+ type: 'approval',
108
+ target: 'manager@example.com',
109
+ content: 'Test',
110
+ transport: 'slack',
111
+ })
112
+
113
+ const retrieved = await store.get(created.id)
114
+
115
+ expect(retrieved).toBeDefined()
116
+ expect(retrieved?.id).toBe(created.id)
117
+ expect(retrieved?.content).toBe('Test')
118
+ })
119
+
120
+ it('should return undefined for non-existent request', async () => {
121
+ const retrieved = await store.get('non_existent_id')
122
+ expect(retrieved).toBeUndefined()
123
+ })
124
+ })
125
+
126
+ describe('update', () => {
127
+ it('should update request status', async () => {
128
+ const request = await store.create({
129
+ type: 'approval',
130
+ target: 'manager@example.com',
131
+ content: 'Test',
132
+ transport: 'email',
133
+ })
134
+
135
+ const updated = await store.update(request.id, {
136
+ status: 'completed',
137
+ result: { approved: true },
138
+ })
139
+
140
+ expect(updated?.status).toBe('completed')
141
+ expect(updated?.result).toEqual({ approved: true })
142
+ expect(updated?.completedAt).toBeInstanceOf(Date)
143
+ })
144
+
145
+ it('should return undefined when updating non-existent request', async () => {
146
+ const updated = await store.update('non_existent', { status: 'completed' })
147
+ expect(updated).toBeUndefined()
148
+ })
149
+ })
150
+
151
+ describe('findByExternalId', () => {
152
+ it('should find request by external message ID', async () => {
153
+ const request = await store.create({
154
+ type: 'approval',
155
+ target: 'manager@example.com',
156
+ content: 'Test',
157
+ transport: 'slack',
158
+ })
159
+
160
+ await store.update(request.id, {
161
+ externalId: 'slack_msg_123',
162
+ })
163
+
164
+ const found = await store.findByExternalId('slack_msg_123')
165
+
166
+ expect(found).toBeDefined()
167
+ expect(found?.id).toBe(request.id)
168
+ })
169
+
170
+ it('should return undefined for non-existent external ID', async () => {
171
+ const found = await store.findByExternalId('non_existent')
172
+ expect(found).toBeUndefined()
173
+ })
174
+ })
175
+
176
+ describe('listPending', () => {
177
+ it('should list pending requests', async () => {
178
+ await store.create({
179
+ type: 'approval',
180
+ target: 'a@example.com',
181
+ content: 'Test 1',
182
+ transport: 'email',
183
+ })
184
+ const req2 = await store.create({
185
+ type: 'approval',
186
+ target: 'b@example.com',
187
+ content: 'Test 2',
188
+ transport: 'email',
189
+ })
190
+ await store.update(req2.id, { status: 'completed' })
191
+
192
+ const pending = await store.listPending()
193
+
194
+ expect(pending).toHaveLength(1)
195
+ expect(pending[0].content).toBe('Test 1')
196
+ })
197
+ })
198
+
199
+ describe('listExpired', () => {
200
+ it('should list expired requests', async () => {
201
+ const pastDate = new Date(Date.now() - 1000)
202
+
203
+ const request = await store.create({
204
+ type: 'approval',
205
+ target: 'manager@example.com',
206
+ content: 'Test',
207
+ transport: 'email',
208
+ expiresAt: pastDate,
209
+ })
210
+
211
+ const expired = await store.listExpired()
212
+
213
+ expect(expired).toHaveLength(1)
214
+ expect(expired[0].id).toBe(request.id)
215
+ })
216
+
217
+ it('should not include non-expired requests', async () => {
218
+ const futureDate = new Date(Date.now() + 60000)
219
+
220
+ await store.create({
221
+ type: 'approval',
222
+ target: 'manager@example.com',
223
+ content: 'Test',
224
+ transport: 'email',
225
+ expiresAt: futureDate,
226
+ })
227
+
228
+ const expired = await store.listExpired()
229
+
230
+ expect(expired).toHaveLength(0)
231
+ })
232
+ })
233
+ })
234
+
235
+ // =============================================================================
236
+ // HumanRequestProcessor Tests
237
+ // =============================================================================
238
+
239
+ describe('HumanRequestProcessor', () => {
240
+ let processor: HumanRequestProcessor
241
+ let store: InMemoryRequestStore
242
+ let slackTransport: SlackTransport
243
+ let emailTransport: EmailTransport
244
+ let mockEmailProvider: EmailProvider
245
+
246
+ beforeEach(() => {
247
+ vi.clearAllMocks()
248
+
249
+ // Setup mock fetch for Slack
250
+ mockFetch.mockResolvedValue({
251
+ ok: true,
252
+ json: () => Promise.resolve(mockSlackPostMessageResponse),
253
+ })
254
+
255
+ store = new InMemoryRequestStore()
256
+
257
+ slackTransport = createSlackTransport({
258
+ botToken: 'xoxb-test-token',
259
+ signingSecret: 'test-secret',
260
+ apiUrl: 'https://slack.test/api',
261
+ })
262
+
263
+ mockEmailProvider = createMockEmailProvider()
264
+ emailTransport = createEmailTransportWithProvider(mockEmailProvider, {
265
+ from: 'approvals@example.com',
266
+ approvalBaseUrl: 'https://app.example.com/approvals',
267
+ })
268
+
269
+ processor = createHumanRequestProcessor({
270
+ store,
271
+ transports: {
272
+ slack: slackTransport,
273
+ email: emailTransport,
274
+ },
275
+ })
276
+ })
277
+
278
+ afterEach(() => {
279
+ vi.restoreAllMocks()
280
+ })
281
+
282
+ describe('submitRequest', () => {
283
+ it('should submit approval request via Slack transport', async () => {
284
+ const result = await processor.submitRequest({
285
+ type: 'approval',
286
+ target: '#approvals',
287
+ content: 'Approve deployment to production?',
288
+ transport: 'slack',
289
+ context: { version: '2.1.0' },
290
+ requestedBy: { id: 'user_1', name: 'Alice' },
291
+ })
292
+
293
+ expect(result.success).toBe(true)
294
+ expect(result.requestId).toBeDefined()
295
+ expect(result.externalId).toBe('1234567890.123456')
296
+
297
+ // Verify request was stored
298
+ const stored = await store.get(result.requestId!)
299
+ expect(stored).toBeDefined()
300
+ expect(stored?.status).toBe('pending')
301
+ expect(stored?.transport).toBe('slack')
302
+ })
303
+
304
+ it('should submit approval request via Email transport', async () => {
305
+ const result = await processor.submitRequest({
306
+ type: 'approval',
307
+ target: 'manager@example.com',
308
+ content: 'Approve expense $500?',
309
+ transport: 'email',
310
+ context: { amount: 500 },
311
+ requestedBy: { id: 'user_2', name: 'Bob' },
312
+ })
313
+
314
+ expect(result.success).toBe(true)
315
+ expect(result.requestId).toBeDefined()
316
+
317
+ expect(mockEmailProvider.send).toHaveBeenCalled()
318
+ })
319
+
320
+ it('should store request with timeout', async () => {
321
+ const timeout = 30000 // 30 seconds
322
+
323
+ const result = await processor.submitRequest({
324
+ type: 'approval',
325
+ target: '#approvals',
326
+ content: 'Time-sensitive approval',
327
+ transport: 'slack',
328
+ timeout,
329
+ })
330
+
331
+ const stored = await store.get(result.requestId!)
332
+ expect(stored?.expiresAt).toBeDefined()
333
+
334
+ // Should expire roughly at timeout time
335
+ const expectedExpiry = Date.now() + timeout
336
+ const actualExpiry = stored!.expiresAt!.getTime()
337
+ expect(Math.abs(actualExpiry - expectedExpiry)).toBeLessThan(1000)
338
+ })
339
+
340
+ it('should handle transport failure', async () => {
341
+ mockFetch.mockResolvedValueOnce({
342
+ ok: true,
343
+ json: () => Promise.resolve({ ok: false, error: 'channel_not_found' }),
344
+ })
345
+
346
+ const result = await processor.submitRequest({
347
+ type: 'approval',
348
+ target: '#nonexistent',
349
+ content: 'Test',
350
+ transport: 'slack',
351
+ })
352
+
353
+ expect(result.success).toBe(false)
354
+ expect(result.error).toContain('channel_not_found')
355
+ })
356
+
357
+ it('should reject unsupported transport', async () => {
358
+ const result = await processor.submitRequest({
359
+ type: 'approval',
360
+ target: '+1234567890',
361
+ content: 'Test',
362
+ transport: 'sms' as any, // Not configured
363
+ })
364
+
365
+ expect(result.success).toBe(false)
366
+ expect(result.error).toContain('not configured')
367
+ })
368
+ })
369
+
370
+ describe('handleWebhook (Slack)', () => {
371
+ it('should process approval response from Slack', async () => {
372
+ // First submit a request
373
+ const submitResult = await processor.submitRequest({
374
+ type: 'approval',
375
+ target: '#approvals',
376
+ content: 'Approve deployment?',
377
+ transport: 'slack',
378
+ requestedBy: { id: 'user_1' },
379
+ })
380
+
381
+ const requestId = submitResult.requestId!
382
+
383
+ // Mock the webhook payload for approval
384
+ const webhookResult = await processor.handleWebhook('slack', {
385
+ type: 'approval_response',
386
+ requestId,
387
+ approved: true,
388
+ respondedBy: { id: 'U123456', name: 'Manager' },
389
+ notes: 'Looks good!',
390
+ })
391
+
392
+ expect(webhookResult.success).toBe(true)
393
+ expect(webhookResult.requestId).toBe(requestId)
394
+
395
+ // Verify request was updated
396
+ const stored = await store.get(requestId)
397
+ expect(stored?.status).toBe('completed')
398
+ expect(stored?.result?.approved).toBe(true)
399
+ expect(stored?.result?.respondedBy?.id).toBe('U123456')
400
+ })
401
+
402
+ it('should process rejection response', async () => {
403
+ const submitResult = await processor.submitRequest({
404
+ type: 'approval',
405
+ target: '#approvals',
406
+ content: 'Approve deployment?',
407
+ transport: 'slack',
408
+ })
409
+
410
+ const webhookResult = await processor.handleWebhook('slack', {
411
+ type: 'approval_response',
412
+ requestId: submitResult.requestId!,
413
+ approved: false,
414
+ respondedBy: { id: 'U123456' },
415
+ notes: 'Not ready yet',
416
+ })
417
+
418
+ expect(webhookResult.success).toBe(true)
419
+
420
+ const stored = await store.get(submitResult.requestId!)
421
+ expect(stored?.result?.approved).toBe(false)
422
+ })
423
+
424
+ it('should reject webhook for non-existent request', async () => {
425
+ const webhookResult = await processor.handleWebhook('slack', {
426
+ type: 'approval_response',
427
+ requestId: 'non_existent',
428
+ approved: true,
429
+ })
430
+
431
+ expect(webhookResult.success).toBe(false)
432
+ expect(webhookResult.error).toContain('not found')
433
+ })
434
+
435
+ it('should reject webhook for already completed request', async () => {
436
+ const submitResult = await processor.submitRequest({
437
+ type: 'approval',
438
+ target: '#approvals',
439
+ content: 'Test',
440
+ transport: 'slack',
441
+ })
442
+
443
+ // Complete the request
444
+ await processor.handleWebhook('slack', {
445
+ type: 'approval_response',
446
+ requestId: submitResult.requestId!,
447
+ approved: true,
448
+ })
449
+
450
+ // Try to respond again
451
+ const duplicateResult = await processor.handleWebhook('slack', {
452
+ type: 'approval_response',
453
+ requestId: submitResult.requestId!,
454
+ approved: false,
455
+ })
456
+
457
+ expect(duplicateResult.success).toBe(false)
458
+ expect(duplicateResult.error).toContain('already completed')
459
+ })
460
+ })
461
+
462
+ describe('handleWebhook (Email)', () => {
463
+ it('should process approval response from email reply', async () => {
464
+ const submitResult = await processor.submitRequest({
465
+ type: 'approval',
466
+ target: 'manager@example.com',
467
+ content: 'Approve expense?',
468
+ transport: 'email',
469
+ })
470
+
471
+ const webhookResult = await processor.handleWebhook('email', {
472
+ type: 'email_reply',
473
+ requestId: submitResult.requestId!,
474
+ from: 'manager@example.com',
475
+ content: 'APPROVED\n\nGo ahead.',
476
+ })
477
+
478
+ expect(webhookResult.success).toBe(true)
479
+
480
+ const stored = await store.get(submitResult.requestId!)
481
+ expect(stored?.result?.approved).toBe(true)
482
+ })
483
+ })
484
+
485
+ describe('onComplete callback', () => {
486
+ it('should call onComplete when request is completed', async () => {
487
+ const onComplete = vi.fn()
488
+
489
+ processor = createHumanRequestProcessor({
490
+ store,
491
+ transports: { slack: slackTransport },
492
+ onComplete,
493
+ })
494
+
495
+ const submitResult = await processor.submitRequest({
496
+ type: 'approval',
497
+ target: '#approvals',
498
+ content: 'Test',
499
+ transport: 'slack',
500
+ })
501
+
502
+ await processor.handleWebhook('slack', {
503
+ type: 'approval_response',
504
+ requestId: submitResult.requestId!,
505
+ approved: true,
506
+ respondedBy: { id: 'U123456' },
507
+ })
508
+
509
+ expect(onComplete).toHaveBeenCalledTimes(1)
510
+ expect(onComplete).toHaveBeenCalledWith(
511
+ expect.objectContaining({
512
+ requestId: submitResult.requestId,
513
+ result: expect.objectContaining({
514
+ approved: true,
515
+ }),
516
+ })
517
+ )
518
+ })
519
+
520
+ it('should call onComplete with rejection result', async () => {
521
+ const onComplete = vi.fn()
522
+
523
+ processor = createHumanRequestProcessor({
524
+ store,
525
+ transports: { slack: slackTransport },
526
+ onComplete,
527
+ })
528
+
529
+ const submitResult = await processor.submitRequest({
530
+ type: 'approval',
531
+ target: '#approvals',
532
+ content: 'Test',
533
+ transport: 'slack',
534
+ })
535
+
536
+ await processor.handleWebhook('slack', {
537
+ type: 'approval_response',
538
+ requestId: submitResult.requestId!,
539
+ approved: false,
540
+ })
541
+
542
+ expect(onComplete).toHaveBeenCalledWith(
543
+ expect.objectContaining({
544
+ result: expect.objectContaining({
545
+ approved: false,
546
+ }),
547
+ })
548
+ )
549
+ })
550
+ })
551
+
552
+ describe('requestor notification', () => {
553
+ it('should notify requestor when request is approved', async () => {
554
+ processor = createHumanRequestProcessor({
555
+ store,
556
+ transports: { slack: slackTransport },
557
+ notifyRequestor: true,
558
+ })
559
+
560
+ // Clear the initial submit call
561
+ mockFetch.mockClear()
562
+ mockFetch.mockResolvedValue({
563
+ ok: true,
564
+ json: () => Promise.resolve(mockSlackPostMessageResponse),
565
+ })
566
+
567
+ const submitResult = await processor.submitRequest({
568
+ type: 'approval',
569
+ target: '#approvals',
570
+ content: 'Approve deployment?',
571
+ transport: 'slack',
572
+ requestedBy: { id: 'U_REQUESTOR' },
573
+ notifyChannel: '#notifications',
574
+ })
575
+
576
+ // Clear submit calls, track webhook completion
577
+ mockFetch.mockClear()
578
+ mockFetch.mockResolvedValue({
579
+ ok: true,
580
+ json: () => Promise.resolve(mockSlackPostMessageResponse),
581
+ })
582
+
583
+ await processor.handleWebhook('slack', {
584
+ type: 'approval_response',
585
+ requestId: submitResult.requestId!,
586
+ approved: true,
587
+ respondedBy: { id: 'U123456', name: 'Manager' },
588
+ })
589
+
590
+ // Should have sent notification to requestor
591
+ expect(mockFetch).toHaveBeenCalled()
592
+ const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]
593
+ const body = JSON.parse(lastCall[1].body)
594
+ expect(body.channel).toBe('notifications')
595
+ expect(body.text).toContain('approved')
596
+ })
597
+ })
598
+
599
+ describe('timeout handling', () => {
600
+ it('should expire timed out requests when checked manually', async () => {
601
+ const onTimeout = vi.fn()
602
+
603
+ // Create a request with an already-expired timestamp
604
+ const expiredRequest = await store.create({
605
+ type: 'approval',
606
+ target: '#approvals',
607
+ content: 'Already expired',
608
+ transport: 'slack',
609
+ expiresAt: new Date(Date.now() - 1000), // Already expired
610
+ })
611
+
612
+ processor = createHumanRequestProcessor({
613
+ store,
614
+ transports: { slack: slackTransport },
615
+ onTimeout,
616
+ checkIntervalMs: 100000, // Very long interval - we'll check manually
617
+ })
618
+
619
+ // Manually trigger the timeout check by destroying and checking state
620
+ // The processor checks on interval, but we can verify the store state
621
+ const expired = await store.listExpired()
622
+ expect(expired.length).toBe(1)
623
+ expect(expired[0].id).toBe(expiredRequest.id)
624
+
625
+ processor.destroy()
626
+ })
627
+
628
+ it('should track expiration time correctly', async () => {
629
+ processor = createHumanRequestProcessor({
630
+ store,
631
+ transports: { slack: slackTransport },
632
+ checkIntervalMs: 100000, // Disable auto-check effectively
633
+ })
634
+
635
+ const timeout = 30000
636
+
637
+ const submitResult = await processor.submitRequest({
638
+ type: 'approval',
639
+ target: '#approvals',
640
+ content: 'Time-sensitive',
641
+ transport: 'slack',
642
+ timeout,
643
+ })
644
+
645
+ const stored = await store.get(submitResult.requestId!)
646
+ expect(stored?.expiresAt).toBeDefined()
647
+
648
+ // Should expire roughly at timeout time
649
+ const expectedExpiry = Date.now() + timeout
650
+ const actualExpiry = stored!.expiresAt!.getTime()
651
+ expect(Math.abs(actualExpiry - expectedExpiry)).toBeLessThan(1000)
652
+
653
+ processor.destroy()
654
+ })
655
+
656
+ it('should not list non-expired requests as expired', async () => {
657
+ processor = createHumanRequestProcessor({
658
+ store,
659
+ transports: { slack: slackTransport },
660
+ checkIntervalMs: 100000,
661
+ })
662
+
663
+ await processor.submitRequest({
664
+ type: 'approval',
665
+ target: '#approvals',
666
+ content: 'Not expired yet',
667
+ transport: 'slack',
668
+ timeout: 60000, // 60 seconds from now
669
+ })
670
+
671
+ const expired = await store.listExpired()
672
+ expect(expired.length).toBe(0)
673
+
674
+ processor.destroy()
675
+ })
676
+
677
+ it('should cleanup interval on destroy', () => {
678
+ const onTimeout = vi.fn()
679
+
680
+ processor = createHumanRequestProcessor({
681
+ store,
682
+ transports: { slack: slackTransport },
683
+ onTimeout,
684
+ checkIntervalMs: 100,
685
+ })
686
+
687
+ // Should not throw when destroyed
688
+ expect(() => processor.destroy()).not.toThrow()
689
+
690
+ // Multiple destroys should be safe
691
+ expect(() => processor.destroy()).not.toThrow()
692
+ })
693
+ })
694
+
695
+ describe('getRequest', () => {
696
+ it('should retrieve request by ID', async () => {
697
+ const submitResult = await processor.submitRequest({
698
+ type: 'approval',
699
+ target: '#approvals',
700
+ content: 'Test content',
701
+ transport: 'slack',
702
+ })
703
+
704
+ const request = await processor.getRequest(submitResult.requestId!)
705
+
706
+ expect(request).toBeDefined()
707
+ expect(request?.content).toBe('Test content')
708
+ })
709
+ })
710
+
711
+ describe('cancelRequest', () => {
712
+ it('should cancel a pending request', async () => {
713
+ const submitResult = await processor.submitRequest({
714
+ type: 'approval',
715
+ target: '#approvals',
716
+ content: 'Test',
717
+ transport: 'slack',
718
+ })
719
+
720
+ const cancelResult = await processor.cancelRequest(submitResult.requestId!)
721
+
722
+ expect(cancelResult.success).toBe(true)
723
+
724
+ const stored = await store.get(submitResult.requestId!)
725
+ expect(stored?.status).toBe('cancelled')
726
+ })
727
+
728
+ it('should not cancel an already completed request', async () => {
729
+ const submitResult = await processor.submitRequest({
730
+ type: 'approval',
731
+ target: '#approvals',
732
+ content: 'Test',
733
+ transport: 'slack',
734
+ })
735
+
736
+ // Complete the request
737
+ await processor.handleWebhook('slack', {
738
+ type: 'approval_response',
739
+ requestId: submitResult.requestId!,
740
+ approved: true,
741
+ })
742
+
743
+ // Try to cancel
744
+ const cancelResult = await processor.cancelRequest(submitResult.requestId!)
745
+
746
+ expect(cancelResult.success).toBe(false)
747
+ expect(cancelResult.error).toContain('already completed')
748
+ })
749
+ })
750
+ })
751
+
752
+ // =============================================================================
753
+ // Integration Tests
754
+ // =============================================================================
755
+
756
+ describe('Runtime Integration', () => {
757
+ let processor: HumanRequestProcessor
758
+ let store: InMemoryRequestStore
759
+
760
+ beforeEach(() => {
761
+ vi.clearAllMocks()
762
+
763
+ mockFetch.mockResolvedValue({
764
+ ok: true,
765
+ json: () => Promise.resolve(mockSlackPostMessageResponse),
766
+ })
767
+
768
+ store = new InMemoryRequestStore()
769
+
770
+ const slackTransport = createSlackTransport({
771
+ botToken: 'xoxb-test-token',
772
+ signingSecret: 'test-secret',
773
+ apiUrl: 'https://slack.test/api',
774
+ })
775
+
776
+ processor = createHumanRequestProcessor({
777
+ store,
778
+ transports: { slack: slackTransport },
779
+ })
780
+ })
781
+
782
+ it('should handle complete approval workflow', async () => {
783
+ const onComplete = vi.fn()
784
+
785
+ processor = createHumanRequestProcessor({
786
+ store,
787
+ transports: {
788
+ slack: createSlackTransport({
789
+ botToken: 'xoxb-test-token',
790
+ signingSecret: 'test-secret',
791
+ apiUrl: 'https://slack.test/api',
792
+ }),
793
+ },
794
+ onComplete,
795
+ })
796
+
797
+ // 1. Submit approval request
798
+ const submitResult = await processor.submitRequest({
799
+ type: 'approval',
800
+ target: '#deployments',
801
+ content: 'Deploy v2.1.0 to production?',
802
+ transport: 'slack',
803
+ context: {
804
+ version: '2.1.0',
805
+ environment: 'production',
806
+ requestedBy: 'alice@company.com',
807
+ },
808
+ requestedBy: { id: 'U_ALICE', name: 'Alice' },
809
+ })
810
+
811
+ expect(submitResult.success).toBe(true)
812
+
813
+ // 2. Verify request is pending
814
+ let request = await processor.getRequest(submitResult.requestId!)
815
+ expect(request?.status).toBe('pending')
816
+
817
+ // 3. Simulate webhook callback (approval)
818
+ const webhookResult = await processor.handleWebhook('slack', {
819
+ type: 'approval_response',
820
+ requestId: submitResult.requestId!,
821
+ approved: true,
822
+ respondedBy: { id: 'U_BOB', name: 'Bob' },
823
+ notes: 'Looks good, proceed!',
824
+ })
825
+
826
+ expect(webhookResult.success).toBe(true)
827
+
828
+ // 4. Verify request is completed
829
+ request = await processor.getRequest(submitResult.requestId!)
830
+ expect(request?.status).toBe('completed')
831
+ expect(request?.result?.approved).toBe(true)
832
+ expect(request?.result?.respondedBy?.name).toBe('Bob')
833
+ expect(request?.result?.notes).toBe('Looks good, proceed!')
834
+
835
+ // 5. Verify callback was invoked
836
+ expect(onComplete).toHaveBeenCalledWith(
837
+ expect.objectContaining({
838
+ requestId: submitResult.requestId,
839
+ request: expect.objectContaining({
840
+ content: 'Deploy v2.1.0 to production?',
841
+ }),
842
+ result: expect.objectContaining({
843
+ approved: true,
844
+ respondedBy: expect.objectContaining({ name: 'Bob' }),
845
+ }),
846
+ })
847
+ )
848
+ })
849
+
850
+ it('should handle rejection workflow', async () => {
851
+ const onComplete = vi.fn()
852
+
853
+ processor = createHumanRequestProcessor({
854
+ store,
855
+ transports: {
856
+ slack: createSlackTransport({
857
+ botToken: 'xoxb-test-token',
858
+ signingSecret: 'test-secret',
859
+ apiUrl: 'https://slack.test/api',
860
+ }),
861
+ },
862
+ onComplete,
863
+ })
864
+
865
+ const submitResult = await processor.submitRequest({
866
+ type: 'approval',
867
+ target: '#expenses',
868
+ content: 'Approve $10,000 expense?',
869
+ transport: 'slack',
870
+ })
871
+
872
+ await processor.handleWebhook('slack', {
873
+ type: 'approval_response',
874
+ requestId: submitResult.requestId!,
875
+ approved: false,
876
+ respondedBy: { id: 'U_CFO' },
877
+ notes: 'Over budget limit',
878
+ })
879
+
880
+ const request = await processor.getRequest(submitResult.requestId!)
881
+ expect(request?.result?.approved).toBe(false)
882
+ expect(request?.result?.notes).toBe('Over budget limit')
883
+
884
+ expect(onComplete).toHaveBeenCalledWith(
885
+ expect.objectContaining({
886
+ result: expect.objectContaining({
887
+ approved: false,
888
+ }),
889
+ })
890
+ )
891
+ })
892
+ })