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,1205 @@
1
+ /**
2
+ * Error Escalation Tests
3
+ *
4
+ * TDD tests for multi-level error escalation between agent tiers.
5
+ * Following RED-GREEN-REFACTOR methodology.
6
+ *
7
+ * ## Test Categories
8
+ *
9
+ * 1. Error Classification - Severity levels, categorization, context, chain tracking
10
+ * 2. Escalation Routing - Path determination, tier rules, thresholds, circular prevention
11
+ * 3. Recovery Patterns - Retry backoff, fallback agents, degradation, state management
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
15
+ import type { CapabilityTier } from '../src/capability-tiers.js'
16
+ import {
17
+ // Error Classification
18
+ type ErrorSeverity,
19
+ type ErrorCategory,
20
+ type ClassifiedError,
21
+ type ErrorContext,
22
+ type ErrorChain,
23
+ classifyError,
24
+ createClassifiedError,
25
+ getErrorSeverity,
26
+ getErrorCategory,
27
+ isEscalatable,
28
+ buildErrorChain,
29
+ preserveContext,
30
+
31
+ // Escalation Routing
32
+ type EscalationPath,
33
+ type EscalationPolicy,
34
+ type EscalationThreshold,
35
+ type EscalationResult,
36
+ determineEscalationPath,
37
+ getNextEscalationTier,
38
+ shouldEscalate,
39
+ validateEscalationPath,
40
+ detectCircularEscalation,
41
+ createEscalationPolicy,
42
+
43
+ // Recovery Patterns
44
+ type RetryConfig,
45
+ type RetryState,
46
+ type FallbackConfig,
47
+ type RecoveryState,
48
+ type DegradationLevel,
49
+ calculateBackoff,
50
+ createRetryState,
51
+ shouldRetry,
52
+ selectFallbackAgent,
53
+ getDegradationLevel,
54
+ createRecoveryState,
55
+ updateRecoveryState,
56
+ isRecoverable,
57
+
58
+ // Escalation Engine
59
+ EscalationEngine,
60
+ createEscalationEngine,
61
+ } from '../src/error-escalation.js'
62
+
63
+ // ============================================================================
64
+ // Error Classification Tests
65
+ // ============================================================================
66
+
67
+ describe('Error Classification', () => {
68
+ describe('ErrorSeverity', () => {
69
+ it('should define four severity levels', () => {
70
+ const severities: ErrorSeverity[] = ['low', 'medium', 'high', 'critical']
71
+ expect(severities).toHaveLength(4)
72
+ })
73
+
74
+ it('should classify low severity for minor issues', () => {
75
+ const error = new Error('Minor validation warning')
76
+ const severity = getErrorSeverity(error, { isRetryable: true, impact: 'single-task' })
77
+ expect(severity).toBe('low')
78
+ })
79
+
80
+ it('should classify medium severity for recoverable errors', () => {
81
+ const error = new Error('Temporary service unavailable')
82
+ const severity = getErrorSeverity(error, { isRetryable: true, impact: 'workflow' })
83
+ expect(severity).toBe('medium')
84
+ })
85
+
86
+ it('should classify high severity for significant failures', () => {
87
+ const error = new Error('Authentication failed')
88
+ const severity = getErrorSeverity(error, { isRetryable: false, impact: 'workflow' })
89
+ expect(severity).toBe('high')
90
+ })
91
+
92
+ it('should classify critical severity for system-wide issues', () => {
93
+ const error = new Error('Database connection lost')
94
+ const severity = getErrorSeverity(error, { isRetryable: false, impact: 'system' })
95
+ expect(severity).toBe('critical')
96
+ })
97
+ })
98
+
99
+ describe('ErrorCategory', () => {
100
+ it('should categorize transient errors', () => {
101
+ const error = new Error('Network timeout')
102
+ error.name = 'TimeoutError'
103
+ const category = getErrorCategory(error)
104
+ expect(category).toBe('transient')
105
+ })
106
+
107
+ it('should categorize permanent errors', () => {
108
+ const error = new Error('Invalid configuration')
109
+ error.name = 'ConfigurationError'
110
+ const category = getErrorCategory(error)
111
+ expect(category).toBe('permanent')
112
+ })
113
+
114
+ it('should categorize escalatable errors', () => {
115
+ const error = new Error('Requires human approval')
116
+ error.name = 'ApprovalRequired'
117
+ const category = getErrorCategory(error)
118
+ expect(category).toBe('escalatable')
119
+ })
120
+
121
+ it('should categorize unknown errors as transient by default', () => {
122
+ const error = new Error('Something went wrong')
123
+ const category = getErrorCategory(error)
124
+ expect(category).toBe('transient')
125
+ })
126
+ })
127
+
128
+ describe('ClassifiedError', () => {
129
+ it('should create a classified error with all properties', () => {
130
+ const original = new Error('Test error')
131
+ const classified = createClassifiedError(original, {
132
+ severity: 'medium',
133
+ category: 'transient',
134
+ tier: 'generative',
135
+ agentId: 'agent-1',
136
+ taskId: 'task-123',
137
+ })
138
+
139
+ expect(classified.original).toBe(original)
140
+ expect(classified.severity).toBe('medium')
141
+ expect(classified.category).toBe('transient')
142
+ expect(classified.tier).toBe('generative')
143
+ expect(classified.agentId).toBe('agent-1')
144
+ expect(classified.taskId).toBe('task-123')
145
+ expect(classified.timestamp).toBeInstanceOf(Date)
146
+ expect(classified.id).toBeDefined()
147
+ })
148
+
149
+ it('should auto-classify error when metadata not provided', () => {
150
+ const error = new Error('Auto-classified error')
151
+ const classified = classifyError(error)
152
+
153
+ expect(classified.severity).toBeDefined()
154
+ expect(classified.category).toBeDefined()
155
+ expect(classified.original).toBe(error)
156
+ })
157
+
158
+ it('should preserve stack trace from original error', () => {
159
+ const original = new Error('Original error')
160
+ const classified = createClassifiedError(original, {
161
+ severity: 'low',
162
+ category: 'transient',
163
+ })
164
+
165
+ expect(classified.stack).toBe(original.stack)
166
+ })
167
+ })
168
+
169
+ describe('ErrorContext', () => {
170
+ it('should preserve context through escalation', () => {
171
+ const context: ErrorContext = {
172
+ workflowId: 'wf-123',
173
+ stepId: 'step-1',
174
+ attemptNumber: 1,
175
+ startTime: new Date(),
176
+ metadata: { custom: 'value' },
177
+ }
178
+
179
+ const preserved = preserveContext(context, { attemptNumber: 2 })
180
+
181
+ expect(preserved.workflowId).toBe(context.workflowId)
182
+ expect(preserved.stepId).toBe(context.stepId)
183
+ expect(preserved.attemptNumber).toBe(2)
184
+ expect(preserved.metadata).toEqual(context.metadata)
185
+ })
186
+
187
+ it('should merge metadata when preserving context', () => {
188
+ const context: ErrorContext = {
189
+ workflowId: 'wf-123',
190
+ metadata: { key1: 'value1' },
191
+ }
192
+
193
+ const preserved = preserveContext(context, {
194
+ metadata: { key2: 'value2' },
195
+ })
196
+
197
+ expect(preserved.metadata).toEqual({ key1: 'value1', key2: 'value2' })
198
+ })
199
+ })
200
+
201
+ describe('ErrorChain', () => {
202
+ it('should build error chain from escalation history', () => {
203
+ const error1 = createClassifiedError(new Error('First error'), {
204
+ severity: 'low',
205
+ category: 'transient',
206
+ tier: 'code',
207
+ })
208
+
209
+ const error2 = createClassifiedError(new Error('Second error'), {
210
+ severity: 'medium',
211
+ category: 'escalatable',
212
+ tier: 'generative',
213
+ previousError: error1,
214
+ })
215
+
216
+ const chain = buildErrorChain(error2)
217
+
218
+ expect(chain.length).toBe(2)
219
+ expect(chain[0]!.tier).toBe('code')
220
+ expect(chain[1]!.tier).toBe('generative')
221
+ })
222
+
223
+ it('should track escalation path in chain', () => {
224
+ const error1 = createClassifiedError(new Error('Error at code tier'), {
225
+ severity: 'medium',
226
+ category: 'escalatable',
227
+ tier: 'code',
228
+ })
229
+
230
+ const error2 = createClassifiedError(new Error('Error at generative tier'), {
231
+ severity: 'high',
232
+ category: 'escalatable',
233
+ tier: 'generative',
234
+ previousError: error1,
235
+ })
236
+
237
+ const error3 = createClassifiedError(new Error('Error at agentic tier'), {
238
+ severity: 'high',
239
+ category: 'escalatable',
240
+ tier: 'agentic',
241
+ previousError: error2,
242
+ })
243
+
244
+ const chain = buildErrorChain(error3)
245
+
246
+ expect(chain.map(e => e.tier)).toEqual(['code', 'generative', 'agentic'])
247
+ })
248
+
249
+ it('should limit chain depth to prevent memory issues', () => {
250
+ let current: ClassifiedError | undefined
251
+
252
+ // Create a very long chain
253
+ for (let i = 0; i < 100; i++) {
254
+ current = createClassifiedError(new Error(`Error ${i}`), {
255
+ severity: 'low',
256
+ category: 'transient',
257
+ tier: 'code',
258
+ previousError: current,
259
+ })
260
+ }
261
+
262
+ const chain = buildErrorChain(current!, { maxDepth: 10 })
263
+ expect(chain.length).toBeLessThanOrEqual(10)
264
+ })
265
+ })
266
+
267
+ describe('isEscalatable', () => {
268
+ it('should return true for escalatable category', () => {
269
+ const error = createClassifiedError(new Error('Test'), {
270
+ severity: 'medium',
271
+ category: 'escalatable',
272
+ })
273
+ expect(isEscalatable(error)).toBe(true)
274
+ })
275
+
276
+ it('should return true for high severity transient errors', () => {
277
+ const error = createClassifiedError(new Error('Test'), {
278
+ severity: 'high',
279
+ category: 'transient',
280
+ })
281
+ expect(isEscalatable(error)).toBe(true)
282
+ })
283
+
284
+ it('should return false for permanent low severity errors', () => {
285
+ const error = createClassifiedError(new Error('Test'), {
286
+ severity: 'low',
287
+ category: 'permanent',
288
+ })
289
+ expect(isEscalatable(error)).toBe(false)
290
+ })
291
+
292
+ it('should return true for critical errors regardless of category', () => {
293
+ const error = createClassifiedError(new Error('Test'), {
294
+ severity: 'critical',
295
+ category: 'permanent',
296
+ })
297
+ expect(isEscalatable(error)).toBe(true)
298
+ })
299
+ })
300
+ })
301
+
302
+ // ============================================================================
303
+ // Escalation Routing Tests
304
+ // ============================================================================
305
+
306
+ describe('Escalation Routing', () => {
307
+ describe('EscalationPath', () => {
308
+ it('should determine path from code to generative', () => {
309
+ const error = createClassifiedError(new Error('Needs NLU'), {
310
+ severity: 'medium',
311
+ category: 'escalatable',
312
+ tier: 'code',
313
+ })
314
+
315
+ const path = determineEscalationPath(error)
316
+
317
+ expect(path.fromTier).toBe('code')
318
+ expect(path.toTier).toBe('generative')
319
+ expect(path.reason).toBeDefined()
320
+ })
321
+
322
+ it('should determine path from generative to agentic', () => {
323
+ const error = createClassifiedError(new Error('Needs autonomous action'), {
324
+ severity: 'high',
325
+ category: 'escalatable',
326
+ tier: 'generative',
327
+ })
328
+
329
+ const path = determineEscalationPath(error)
330
+
331
+ expect(path.fromTier).toBe('generative')
332
+ expect(path.toTier).toBe('agentic')
333
+ })
334
+
335
+ it('should determine path from agentic to human', () => {
336
+ const error = createClassifiedError(new Error('Needs human judgment'), {
337
+ severity: 'critical',
338
+ category: 'escalatable',
339
+ tier: 'agentic',
340
+ })
341
+
342
+ const path = determineEscalationPath(error)
343
+
344
+ expect(path.fromTier).toBe('agentic')
345
+ expect(path.toTier).toBe('human')
346
+ })
347
+
348
+ it('should stay at human tier when already at highest', () => {
349
+ const error = createClassifiedError(new Error('Human cannot resolve'), {
350
+ severity: 'critical',
351
+ category: 'escalatable',
352
+ tier: 'human',
353
+ })
354
+
355
+ const path = determineEscalationPath(error)
356
+
357
+ expect(path.fromTier).toBe('human')
358
+ expect(path.toTier).toBe('human')
359
+ expect(path.isTerminal).toBe(true)
360
+ })
361
+ })
362
+
363
+ describe('Tier-Based Routing Rules', () => {
364
+ it('should route code errors to generative for NLU tasks', () => {
365
+ const policy = createEscalationPolicy({
366
+ rules: [
367
+ {
368
+ name: 'nlu-escalation',
369
+ fromTier: 'code',
370
+ toTier: 'generative',
371
+ condition: (error) => error.original.message.includes('NLU'),
372
+ },
373
+ ],
374
+ })
375
+
376
+ const error = createClassifiedError(new Error('NLU processing failed'), {
377
+ severity: 'medium',
378
+ category: 'escalatable',
379
+ tier: 'code',
380
+ })
381
+
382
+ const nextTier = getNextEscalationTier(error, policy)
383
+ expect(nextTier).toBe('generative')
384
+ })
385
+
386
+ it('should route generative errors to agentic for tool usage', () => {
387
+ const policy = createEscalationPolicy({
388
+ rules: [
389
+ {
390
+ name: 'tool-escalation',
391
+ fromTier: 'generative',
392
+ toTier: 'agentic',
393
+ condition: (error) => error.original.message.includes('tool'),
394
+ },
395
+ ],
396
+ })
397
+
398
+ const error = createClassifiedError(new Error('Requires external tool access'), {
399
+ severity: 'high',
400
+ category: 'escalatable',
401
+ tier: 'generative',
402
+ })
403
+
404
+ const nextTier = getNextEscalationTier(error, policy)
405
+ expect(nextTier).toBe('agentic')
406
+ })
407
+
408
+ it('should route agentic errors to human for approval', () => {
409
+ const policy = createEscalationPolicy({
410
+ rules: [
411
+ {
412
+ name: 'approval-escalation',
413
+ fromTier: 'agentic',
414
+ toTier: 'human',
415
+ condition: (error) => error.original.message.includes('approval'),
416
+ },
417
+ ],
418
+ })
419
+
420
+ const error = createClassifiedError(new Error('Requires human approval'), {
421
+ severity: 'critical',
422
+ category: 'escalatable',
423
+ tier: 'agentic',
424
+ })
425
+
426
+ const nextTier = getNextEscalationTier(error, policy)
427
+ expect(nextTier).toBe('human')
428
+ })
429
+
430
+ it('should skip tiers when severity is critical', () => {
431
+ const policy = createEscalationPolicy({
432
+ allowSkipTiers: true,
433
+ skipTierThreshold: 'critical',
434
+ })
435
+
436
+ const error = createClassifiedError(new Error('Critical failure'), {
437
+ severity: 'critical',
438
+ category: 'escalatable',
439
+ tier: 'code',
440
+ })
441
+
442
+ const nextTier = getNextEscalationTier(error, policy)
443
+ expect(nextTier).toBe('human')
444
+ })
445
+ })
446
+
447
+ describe('EscalationThreshold', () => {
448
+ it('should escalate when error count exceeds threshold', () => {
449
+ const threshold: EscalationThreshold = {
450
+ errorCount: 3,
451
+ timeWindow: 60000, // 1 minute
452
+ }
453
+
454
+ const errorHistory = [
455
+ { timestamp: Date.now() - 30000 },
456
+ { timestamp: Date.now() - 20000 },
457
+ { timestamp: Date.now() - 10000 },
458
+ ]
459
+
460
+ const shouldEsc = shouldEscalate(threshold, errorHistory)
461
+ expect(shouldEsc).toBe(true)
462
+ })
463
+
464
+ it('should not escalate when below threshold', () => {
465
+ const threshold: EscalationThreshold = {
466
+ errorCount: 5,
467
+ timeWindow: 60000,
468
+ }
469
+
470
+ const errorHistory = [
471
+ { timestamp: Date.now() - 30000 },
472
+ { timestamp: Date.now() - 20000 },
473
+ ]
474
+
475
+ const shouldEsc = shouldEscalate(threshold, errorHistory)
476
+ expect(shouldEsc).toBe(false)
477
+ })
478
+
479
+ it('should not count errors outside time window', () => {
480
+ const threshold: EscalationThreshold = {
481
+ errorCount: 3,
482
+ timeWindow: 60000,
483
+ }
484
+
485
+ const errorHistory = [
486
+ { timestamp: Date.now() - 120000 }, // Outside window
487
+ { timestamp: Date.now() - 30000 },
488
+ { timestamp: Date.now() - 10000 },
489
+ ]
490
+
491
+ const shouldEsc = shouldEscalate(threshold, errorHistory)
492
+ expect(shouldEsc).toBe(false)
493
+ })
494
+
495
+ it('should support severity-based thresholds', () => {
496
+ const threshold: EscalationThreshold = {
497
+ errorCount: 1,
498
+ severityMultiplier: {
499
+ low: 0,
500
+ medium: 1,
501
+ high: 2,
502
+ critical: 5,
503
+ },
504
+ }
505
+
506
+ // One critical error should exceed threshold of 1
507
+ const errorHistory = [
508
+ { timestamp: Date.now(), severity: 'critical' as ErrorSeverity },
509
+ ]
510
+
511
+ const shouldEsc = shouldEscalate(threshold, errorHistory)
512
+ expect(shouldEsc).toBe(true)
513
+ })
514
+ })
515
+
516
+ describe('Circular Escalation Prevention', () => {
517
+ it('should detect simple circular escalation', () => {
518
+ const path: EscalationPath = {
519
+ fromTier: 'generative',
520
+ toTier: 'code',
521
+ reason: 'De-escalate for simpler handling',
522
+ }
523
+
524
+ const history: CapabilityTier[] = ['code', 'generative']
525
+
526
+ const isCircular = detectCircularEscalation(path, history)
527
+ expect(isCircular).toBe(true)
528
+ })
529
+
530
+ it('should detect complex circular escalation', () => {
531
+ const path: EscalationPath = {
532
+ fromTier: 'agentic',
533
+ toTier: 'generative',
534
+ reason: 'De-escalate',
535
+ }
536
+
537
+ const history: CapabilityTier[] = ['code', 'generative', 'agentic', 'human', 'agentic']
538
+
539
+ const isCircular = detectCircularEscalation(path, history)
540
+ expect(isCircular).toBe(true)
541
+ })
542
+
543
+ it('should allow valid escalation paths', () => {
544
+ const path: EscalationPath = {
545
+ fromTier: 'code',
546
+ toTier: 'generative',
547
+ reason: 'Escalate for NLU',
548
+ }
549
+
550
+ const history: CapabilityTier[] = ['code']
551
+
552
+ const isCircular = detectCircularEscalation(path, history)
553
+ expect(isCircular).toBe(false)
554
+ })
555
+
556
+ it('should track escalation depth', () => {
557
+ const policy = createEscalationPolicy({
558
+ maxEscalationDepth: 3,
559
+ })
560
+
561
+ const history: CapabilityTier[] = ['code', 'generative', 'agentic']
562
+
563
+ const validation = validateEscalationPath(
564
+ { fromTier: 'agentic', toTier: 'human', reason: 'test' },
565
+ history,
566
+ policy
567
+ )
568
+
569
+ expect(validation.valid).toBe(false)
570
+ expect(validation.error).toContain('depth')
571
+ })
572
+ })
573
+
574
+ describe('EscalationPolicy', () => {
575
+ it('should create policy with default values', () => {
576
+ const policy = createEscalationPolicy({})
577
+
578
+ expect(policy.maxEscalationDepth).toBeDefined()
579
+ expect(policy.allowSkipTiers).toBe(false)
580
+ expect(policy.rules).toEqual([])
581
+ })
582
+
583
+ it('should merge custom rules with defaults', () => {
584
+ const policy = createEscalationPolicy({
585
+ rules: [
586
+ {
587
+ name: 'custom-rule',
588
+ fromTier: 'code',
589
+ toTier: 'human',
590
+ condition: () => true,
591
+ },
592
+ ],
593
+ })
594
+
595
+ expect(policy.rules).toHaveLength(1)
596
+ expect(policy.rules[0]!.name).toBe('custom-rule')
597
+ })
598
+
599
+ it('should support tier-specific policies', () => {
600
+ const policy = createEscalationPolicy({
601
+ tierPolicies: {
602
+ code: {
603
+ maxRetries: 5,
604
+ timeout: 1000,
605
+ },
606
+ generative: {
607
+ maxRetries: 3,
608
+ timeout: 5000,
609
+ },
610
+ },
611
+ })
612
+
613
+ expect(policy.tierPolicies?.code?.maxRetries).toBe(5)
614
+ expect(policy.tierPolicies?.generative?.timeout).toBe(5000)
615
+ })
616
+ })
617
+ })
618
+
619
+ // ============================================================================
620
+ // Recovery Pattern Tests
621
+ // ============================================================================
622
+
623
+ describe('Recovery Patterns', () => {
624
+ describe('Retry with Backoff', () => {
625
+ it('should calculate exponential backoff', () => {
626
+ const config: RetryConfig = {
627
+ maxRetries: 5,
628
+ baseDelayMs: 100,
629
+ maxDelayMs: 10000,
630
+ backoffMultiplier: 2,
631
+ }
632
+
633
+ expect(calculateBackoff(config, 0)).toBe(100) // 100 * 2^0
634
+ expect(calculateBackoff(config, 1)).toBe(200) // 100 * 2^1
635
+ expect(calculateBackoff(config, 2)).toBe(400) // 100 * 2^2
636
+ expect(calculateBackoff(config, 3)).toBe(800) // 100 * 2^3
637
+ })
638
+
639
+ it('should cap delay at maxDelayMs', () => {
640
+ const config: RetryConfig = {
641
+ maxRetries: 10,
642
+ baseDelayMs: 1000,
643
+ maxDelayMs: 5000,
644
+ backoffMultiplier: 2,
645
+ }
646
+
647
+ expect(calculateBackoff(config, 5)).toBe(5000) // Would be 32000, capped at 5000
648
+ })
649
+
650
+ it('should add jitter when configured', () => {
651
+ const config: RetryConfig = {
652
+ maxRetries: 5,
653
+ baseDelayMs: 100,
654
+ maxDelayMs: 10000,
655
+ backoffMultiplier: 2,
656
+ jitterPercent: 20,
657
+ }
658
+
659
+ const delays = Array.from({ length: 10 }, () => calculateBackoff(config, 2))
660
+ const baseDelay = 400 // 100 * 2^2
661
+
662
+ // With 20% jitter, delays should be between 320 and 480
663
+ delays.forEach(delay => {
664
+ expect(delay).toBeGreaterThanOrEqual(baseDelay * 0.8)
665
+ expect(delay).toBeLessThanOrEqual(baseDelay * 1.2)
666
+ })
667
+
668
+ // Not all delays should be identical (jitter is working)
669
+ const uniqueDelays = new Set(delays)
670
+ expect(uniqueDelays.size).toBeGreaterThan(1)
671
+ })
672
+
673
+ it('should create retry state', () => {
674
+ const state = createRetryState({
675
+ maxRetries: 3,
676
+ baseDelayMs: 100,
677
+ })
678
+
679
+ expect(state.attemptNumber).toBe(0)
680
+ expect(state.lastAttemptTime).toBeNull()
681
+ expect(state.nextRetryTime).toBeNull()
682
+ expect(state.exhausted).toBe(false)
683
+ })
684
+
685
+ it('should determine if retry should occur', () => {
686
+ const config: RetryConfig = {
687
+ maxRetries: 3,
688
+ baseDelayMs: 100,
689
+ }
690
+
691
+ const state1: RetryState = { attemptNumber: 2, exhausted: false, lastAttemptTime: null, nextRetryTime: null }
692
+ expect(shouldRetry(config, state1)).toBe(true)
693
+
694
+ const state2: RetryState = { attemptNumber: 3, exhausted: true, lastAttemptTime: null, nextRetryTime: null }
695
+ expect(shouldRetry(config, state2)).toBe(false)
696
+ })
697
+
698
+ it('should not retry permanent errors', () => {
699
+ const config: RetryConfig = {
700
+ maxRetries: 3,
701
+ baseDelayMs: 100,
702
+ retryableCategories: ['transient'],
703
+ }
704
+
705
+ const error = createClassifiedError(new Error('Permanent'), {
706
+ severity: 'high',
707
+ category: 'permanent',
708
+ })
709
+
710
+ const state: RetryState = { attemptNumber: 0, exhausted: false, lastAttemptTime: null, nextRetryTime: null }
711
+ expect(shouldRetry(config, state, error)).toBe(false)
712
+ })
713
+ })
714
+
715
+ describe('Fallback to Alternative Agent', () => {
716
+ it('should select fallback agent based on capability', () => {
717
+ const config: FallbackConfig = {
718
+ strategy: 'capability-match',
719
+ requiredSkills: ['data-processing', 'validation'],
720
+ }
721
+
722
+ const agents = [
723
+ { id: 'agent-1', skills: ['data-processing'], tier: 'code' as CapabilityTier },
724
+ { id: 'agent-2', skills: ['data-processing', 'validation'], tier: 'code' as CapabilityTier },
725
+ { id: 'agent-3', skills: ['validation'], tier: 'generative' as CapabilityTier },
726
+ ]
727
+
728
+ const fallback = selectFallbackAgent(config, agents)
729
+ expect(fallback?.id).toBe('agent-2')
730
+ })
731
+
732
+ it('should select fallback agent based on load', () => {
733
+ const config: FallbackConfig = {
734
+ strategy: 'least-loaded',
735
+ }
736
+
737
+ const agents = [
738
+ { id: 'agent-1', currentLoad: 8, maxLoad: 10, tier: 'code' as CapabilityTier },
739
+ { id: 'agent-2', currentLoad: 3, maxLoad: 10, tier: 'code' as CapabilityTier },
740
+ { id: 'agent-3', currentLoad: 5, maxLoad: 10, tier: 'code' as CapabilityTier },
741
+ ]
742
+
743
+ const fallback = selectFallbackAgent(config, agents)
744
+ expect(fallback?.id).toBe('agent-2')
745
+ })
746
+
747
+ it('should prefer same tier when selecting fallback', () => {
748
+ const config: FallbackConfig = {
749
+ strategy: 'same-tier',
750
+ currentTier: 'generative',
751
+ }
752
+
753
+ const agents = [
754
+ { id: 'agent-1', tier: 'code' as CapabilityTier },
755
+ { id: 'agent-2', tier: 'generative' as CapabilityTier },
756
+ { id: 'agent-3', tier: 'agentic' as CapabilityTier },
757
+ ]
758
+
759
+ const fallback = selectFallbackAgent(config, agents)
760
+ expect(fallback?.id).toBe('agent-2')
761
+ })
762
+
763
+ it('should exclude failed agents from fallback selection', () => {
764
+ const config: FallbackConfig = {
765
+ strategy: 'capability-match',
766
+ requiredSkills: ['validation'],
767
+ excludeAgentIds: ['agent-1'],
768
+ }
769
+
770
+ const agents = [
771
+ { id: 'agent-1', skills: ['validation'], tier: 'code' as CapabilityTier },
772
+ { id: 'agent-2', skills: ['validation'], tier: 'code' as CapabilityTier },
773
+ ]
774
+
775
+ const fallback = selectFallbackAgent(config, agents)
776
+ expect(fallback?.id).toBe('agent-2')
777
+ })
778
+
779
+ it('should return null when no suitable fallback exists', () => {
780
+ const config: FallbackConfig = {
781
+ strategy: 'capability-match',
782
+ requiredSkills: ['rare-skill'],
783
+ }
784
+
785
+ const agents = [
786
+ { id: 'agent-1', skills: ['common-skill'], tier: 'code' as CapabilityTier },
787
+ ]
788
+
789
+ const fallback = selectFallbackAgent(config, agents)
790
+ expect(fallback).toBeNull()
791
+ })
792
+ })
793
+
794
+ describe('Graceful Degradation', () => {
795
+ it('should determine degradation level based on severity', () => {
796
+ expect(getDegradationLevel('low')).toBe('none')
797
+ expect(getDegradationLevel('medium')).toBe('partial')
798
+ expect(getDegradationLevel('high')).toBe('significant')
799
+ expect(getDegradationLevel('critical')).toBe('full')
800
+ })
801
+
802
+ it('should determine degradation level based on error count', () => {
803
+ const level = getDegradationLevel('medium', { errorCount: 5, threshold: 3 })
804
+ expect(level).toBe('significant')
805
+ })
806
+
807
+ it('should apply degradation rules', () => {
808
+ const level = getDegradationLevel('low', {
809
+ rules: [
810
+ { condition: (severity) => severity === 'low', level: 'partial' },
811
+ ],
812
+ })
813
+ expect(level).toBe('partial')
814
+ })
815
+ })
816
+
817
+ describe('Recovery State Management', () => {
818
+ it('should create initial recovery state', () => {
819
+ const state = createRecoveryState({
820
+ errorId: 'err-123',
821
+ tier: 'code',
822
+ })
823
+
824
+ expect(state.errorId).toBe('err-123')
825
+ expect(state.tier).toBe('code')
826
+ expect(state.retryState.attemptNumber).toBe(0)
827
+ expect(state.escalated).toBe(false)
828
+ expect(state.resolved).toBe(false)
829
+ })
830
+
831
+ it('should update recovery state after retry', () => {
832
+ const initial = createRecoveryState({
833
+ errorId: 'err-123',
834
+ tier: 'code',
835
+ })
836
+
837
+ const updated = updateRecoveryState(initial, {
838
+ type: 'retry',
839
+ success: false,
840
+ })
841
+
842
+ expect(updated.retryState.attemptNumber).toBe(1)
843
+ expect(updated.lastAction).toBe('retry')
844
+ })
845
+
846
+ it('should update recovery state after escalation', () => {
847
+ const initial = createRecoveryState({
848
+ errorId: 'err-123',
849
+ tier: 'code',
850
+ })
851
+
852
+ const updated = updateRecoveryState(initial, {
853
+ type: 'escalate',
854
+ toTier: 'generative',
855
+ })
856
+
857
+ expect(updated.escalated).toBe(true)
858
+ expect(updated.tier).toBe('generative')
859
+ expect(updated.escalationPath).toContain('code')
860
+ expect(updated.escalationPath).toContain('generative')
861
+ })
862
+
863
+ it('should update recovery state after fallback', () => {
864
+ const initial = createRecoveryState({
865
+ errorId: 'err-123',
866
+ tier: 'code',
867
+ agentId: 'agent-1',
868
+ })
869
+
870
+ const updated = updateRecoveryState(initial, {
871
+ type: 'fallback',
872
+ toAgentId: 'agent-2',
873
+ })
874
+
875
+ expect(updated.agentId).toBe('agent-2')
876
+ expect(updated.lastAction).toBe('fallback')
877
+ expect(updated.fallbackHistory).toContain('agent-1')
878
+ })
879
+
880
+ it('should mark state as resolved', () => {
881
+ const initial = createRecoveryState({
882
+ errorId: 'err-123',
883
+ tier: 'code',
884
+ })
885
+
886
+ const updated = updateRecoveryState(initial, {
887
+ type: 'resolve',
888
+ resolution: 'Manually fixed by human',
889
+ })
890
+
891
+ expect(updated.resolved).toBe(true)
892
+ expect(updated.resolution).toBe('Manually fixed by human')
893
+ })
894
+
895
+ it('should determine if error is recoverable', () => {
896
+ const recoverableState = createRecoveryState({
897
+ errorId: 'err-1',
898
+ tier: 'code',
899
+ })
900
+ expect(isRecoverable(recoverableState)).toBe(true)
901
+
902
+ const exhaustedState = updateRecoveryState(recoverableState, {
903
+ type: 'retry',
904
+ success: false,
905
+ exhausted: true,
906
+ })
907
+ // Still recoverable via escalation
908
+ expect(isRecoverable(exhaustedState)).toBe(true)
909
+
910
+ const terminalState = createRecoveryState({
911
+ errorId: 'err-2',
912
+ tier: 'human',
913
+ })
914
+ const escalatedTerminal = updateRecoveryState(terminalState, {
915
+ type: 'escalate',
916
+ toTier: 'human', // Can't escalate further
917
+ isTerminal: true,
918
+ })
919
+ // If retries exhausted and at human tier, not recoverable
920
+ const fullyExhausted = updateRecoveryState(escalatedTerminal, {
921
+ type: 'retry',
922
+ success: false,
923
+ exhausted: true,
924
+ })
925
+ expect(isRecoverable(fullyExhausted)).toBe(false)
926
+ })
927
+ })
928
+ })
929
+
930
+ // ============================================================================
931
+ // Escalation Engine Tests
932
+ // ============================================================================
933
+
934
+ describe('EscalationEngine', () => {
935
+ let engine: EscalationEngine
936
+
937
+ beforeEach(() => {
938
+ engine = createEscalationEngine({
939
+ policy: createEscalationPolicy({
940
+ maxEscalationDepth: 5,
941
+ }),
942
+ retryConfig: {
943
+ maxRetries: 3,
944
+ baseDelayMs: 100,
945
+ maxDelayMs: 5000,
946
+ backoffMultiplier: 2,
947
+ },
948
+ })
949
+ })
950
+
951
+ afterEach(() => {
952
+ vi.restoreAllMocks()
953
+ })
954
+
955
+ describe('handleError', () => {
956
+ it('should handle error and return escalation result', async () => {
957
+ const error = new Error('Test error')
958
+ const result = await engine.handleError(error, {
959
+ tier: 'code',
960
+ agentId: 'agent-1',
961
+ taskId: 'task-123',
962
+ })
963
+
964
+ expect(result.handled).toBe(true)
965
+ expect(result.classifiedError).toBeDefined()
966
+ expect(result.action).toBeDefined()
967
+ })
968
+
969
+ it('should retry transient errors before escalating', async () => {
970
+ const error = new Error('Transient error')
971
+ error.name = 'TimeoutError'
972
+
973
+ const result = await engine.handleError(error, {
974
+ tier: 'code',
975
+ agentId: 'agent-1',
976
+ })
977
+
978
+ expect(result.action).toBe('retry')
979
+ expect(result.retryDelay).toBeDefined()
980
+ })
981
+
982
+ it('should escalate after retries exhausted', async () => {
983
+ const error = new Error('Persistent error')
984
+
985
+ // Simulate exhausted retries
986
+ const result = await engine.handleError(error, {
987
+ tier: 'code',
988
+ agentId: 'agent-1',
989
+ retryState: {
990
+ attemptNumber: 3,
991
+ exhausted: true,
992
+ lastAttemptTime: new Date(),
993
+ nextRetryTime: null,
994
+ },
995
+ })
996
+
997
+ expect(result.action).toBe('escalate')
998
+ expect(result.escalationPath).toBeDefined()
999
+ })
1000
+
1001
+ it('should fallback when escalation not possible', async () => {
1002
+ const error = new Error('Cannot escalate')
1003
+
1004
+ const result = await engine.handleError(error, {
1005
+ tier: 'human', // Already at highest tier
1006
+ agentId: 'agent-1',
1007
+ retryState: {
1008
+ attemptNumber: 3,
1009
+ exhausted: true,
1010
+ lastAttemptTime: new Date(),
1011
+ nextRetryTime: null,
1012
+ },
1013
+ availableAgents: [
1014
+ { id: 'agent-2', tier: 'human' as CapabilityTier, skills: [] },
1015
+ ],
1016
+ })
1017
+
1018
+ expect(result.action).toBe('fallback')
1019
+ expect(result.fallbackAgent).toBeDefined()
1020
+ })
1021
+ })
1022
+
1023
+ describe('metrics', () => {
1024
+ it('should track error counts by severity', async () => {
1025
+ await engine.handleError(new Error('Low'), {
1026
+ tier: 'code',
1027
+ severity: 'low',
1028
+ })
1029
+ await engine.handleError(new Error('Medium'), {
1030
+ tier: 'code',
1031
+ severity: 'medium',
1032
+ })
1033
+ await engine.handleError(new Error('High'), {
1034
+ tier: 'code',
1035
+ severity: 'high',
1036
+ })
1037
+
1038
+ const metrics = engine.getMetrics()
1039
+
1040
+ expect(metrics.bySeverity.low).toBe(1)
1041
+ expect(metrics.bySeverity.medium).toBe(1)
1042
+ expect(metrics.bySeverity.high).toBe(1)
1043
+ })
1044
+
1045
+ it('should track escalation counts by tier', async () => {
1046
+ await engine.handleError(new Error('Escalate'), {
1047
+ tier: 'code',
1048
+ retryState: { attemptNumber: 3, exhausted: true, lastAttemptTime: new Date(), nextRetryTime: null },
1049
+ })
1050
+
1051
+ const metrics = engine.getMetrics()
1052
+
1053
+ expect(metrics.escalationsByTier['code']).toBeGreaterThan(0)
1054
+ })
1055
+
1056
+ it('should track retry success rate', async () => {
1057
+ // Successful retry
1058
+ await engine.handleError(new Error('Retry success'), {
1059
+ tier: 'code',
1060
+ simulateRetrySuccess: true,
1061
+ })
1062
+
1063
+ // Failed retry
1064
+ await engine.handleError(new Error('Retry fail'), {
1065
+ tier: 'code',
1066
+ simulateRetryFailure: true,
1067
+ })
1068
+
1069
+ const metrics = engine.getMetrics()
1070
+
1071
+ expect(metrics.retrySuccessRate).toBeDefined()
1072
+ })
1073
+ })
1074
+
1075
+ describe('policy enforcement', () => {
1076
+ it('should respect max escalation depth', async () => {
1077
+ const deepEngine = createEscalationEngine({
1078
+ policy: createEscalationPolicy({
1079
+ maxEscalationDepth: 2,
1080
+ }),
1081
+ })
1082
+
1083
+ const result = await deepEngine.handleError(new Error('Deep error'), {
1084
+ tier: 'code',
1085
+ escalationHistory: ['code', 'generative'],
1086
+ retryState: { attemptNumber: 3, exhausted: true, lastAttemptTime: new Date(), nextRetryTime: null },
1087
+ })
1088
+
1089
+ expect(result.action).not.toBe('escalate')
1090
+ })
1091
+
1092
+ it('should apply tier-specific policies', async () => {
1093
+ const tierEngine = createEscalationEngine({
1094
+ policy: createEscalationPolicy({
1095
+ tierPolicies: {
1096
+ code: { maxRetries: 1 },
1097
+ generative: { maxRetries: 5 },
1098
+ },
1099
+ }),
1100
+ })
1101
+
1102
+ const result = await tierEngine.handleError(new Error('Code tier'), {
1103
+ tier: 'code',
1104
+ retryState: { attemptNumber: 1, exhausted: false, lastAttemptTime: new Date(), nextRetryTime: null },
1105
+ })
1106
+
1107
+ // Should escalate after 1 retry at code tier
1108
+ expect(result.action).toBe('escalate')
1109
+ })
1110
+ })
1111
+
1112
+ describe('error context preservation', () => {
1113
+ it('should preserve workflow context through escalation', async () => {
1114
+ const result = await engine.handleError(new Error('Context test'), {
1115
+ tier: 'code',
1116
+ context: {
1117
+ workflowId: 'wf-123',
1118
+ stepId: 'step-1',
1119
+ metadata: { key: 'value' },
1120
+ },
1121
+ retryState: { attemptNumber: 3, exhausted: true, lastAttemptTime: new Date(), nextRetryTime: null },
1122
+ })
1123
+
1124
+ expect(result.preservedContext?.workflowId).toBe('wf-123')
1125
+ expect(result.preservedContext?.stepId).toBe('step-1')
1126
+ expect(result.preservedContext?.metadata?.key).toBe('value')
1127
+ })
1128
+
1129
+ it('should include error chain in result', async () => {
1130
+ const previousError = createClassifiedError(new Error('Previous'), {
1131
+ severity: 'low',
1132
+ category: 'transient',
1133
+ tier: 'code',
1134
+ })
1135
+
1136
+ const result = await engine.handleError(new Error('Current'), {
1137
+ tier: 'generative',
1138
+ previousError,
1139
+ })
1140
+
1141
+ expect(result.errorChain?.length).toBeGreaterThan(1)
1142
+ })
1143
+ })
1144
+ })
1145
+
1146
+ // ============================================================================
1147
+ // Integration Tests
1148
+ // ============================================================================
1149
+
1150
+ describe('Error Escalation Integration', () => {
1151
+ it('should handle full escalation flow from code to human', async () => {
1152
+ const engine = createEscalationEngine({
1153
+ policy: createEscalationPolicy({
1154
+ maxEscalationDepth: 10,
1155
+ }),
1156
+ retryConfig: {
1157
+ maxRetries: 0, // Skip retries for this test
1158
+ baseDelayMs: 100,
1159
+ },
1160
+ })
1161
+
1162
+ const tiers: CapabilityTier[] = ['code', 'generative', 'agentic', 'human']
1163
+ let currentTier: CapabilityTier = 'code'
1164
+ const escalationPath: CapabilityTier[] = [currentTier]
1165
+
1166
+ while (currentTier !== 'human') {
1167
+ const result = await engine.handleError(new Error(`Error at ${currentTier}`), {
1168
+ tier: currentTier,
1169
+ retryState: { attemptNumber: 0, exhausted: true, lastAttemptTime: new Date(), nextRetryTime: null },
1170
+ })
1171
+
1172
+ if (result.action === 'escalate' && result.escalationPath) {
1173
+ currentTier = result.escalationPath.toTier
1174
+ escalationPath.push(currentTier)
1175
+ } else {
1176
+ break
1177
+ }
1178
+ }
1179
+
1180
+ expect(escalationPath).toEqual(tiers)
1181
+ })
1182
+
1183
+ it('should handle concurrent errors without race conditions', async () => {
1184
+ const engine = createEscalationEngine({
1185
+ policy: createEscalationPolicy({}),
1186
+ })
1187
+
1188
+ const errors = Array.from({ length: 10 }, (_, i) =>
1189
+ engine.handleError(new Error(`Concurrent error ${i}`), {
1190
+ tier: 'code',
1191
+ agentId: `agent-${i % 3}`,
1192
+ })
1193
+ )
1194
+
1195
+ const results = await Promise.all(errors)
1196
+
1197
+ results.forEach(result => {
1198
+ expect(result.handled).toBe(true)
1199
+ expect(result.classifiedError).toBeDefined()
1200
+ })
1201
+
1202
+ const metrics = engine.getMetrics()
1203
+ expect(metrics.totalErrors).toBe(10)
1204
+ })
1205
+ })