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.
- package/.turbo/turbo-build.log +4 -5
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +134 -180
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +1 -0
- package/dist/actions.js.map +1 -1
- package/dist/agent-comms.d.ts +438 -0
- package/dist/agent-comms.d.ts.map +1 -0
- package/dist/agent-comms.js +666 -0
- package/dist/agent-comms.js.map +1 -0
- package/dist/capability-tiers.d.ts +230 -0
- package/dist/capability-tiers.d.ts.map +1 -0
- package/dist/capability-tiers.js +388 -0
- package/dist/capability-tiers.js.map +1 -0
- package/dist/cascade-context.d.ts +523 -0
- package/dist/cascade-context.d.ts.map +1 -0
- package/dist/cascade-context.js +494 -0
- package/dist/cascade-context.js.map +1 -0
- package/dist/error-escalation.d.ts +416 -0
- package/dist/error-escalation.d.ts.map +1 -0
- package/dist/error-escalation.js +656 -0
- package/dist/error-escalation.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -1
- package/dist/load-balancing.d.ts +395 -0
- package/dist/load-balancing.d.ts.map +1 -0
- package/dist/load-balancing.js +905 -0
- package/dist/load-balancing.js.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -1
- package/package.json +14 -14
- package/src/actions.js +436 -0
- package/src/actions.ts +9 -8
- package/src/agent-comms.ts +1238 -0
- package/src/approve.js +234 -0
- package/src/ask.js +226 -0
- package/src/capability-tiers.ts +545 -0
- package/src/cascade-context.ts +648 -0
- package/src/decide.js +244 -0
- package/src/do.js +227 -0
- package/src/error-escalation.ts +1135 -0
- package/src/generate.js +298 -0
- package/src/goals.js +205 -0
- package/src/index.js +68 -0
- package/src/index.ts +223 -0
- package/src/is.js +317 -0
- package/src/kpis.js +270 -0
- package/src/load-balancing.ts +1381 -0
- package/src/notify.js +219 -0
- package/src/role.js +110 -0
- package/src/team.js +130 -0
- package/src/transports.js +357 -0
- package/src/types.js +71 -0
- package/src/types.ts +8 -0
- package/test/actions.test.js +401 -0
- package/test/agent-comms.test.ts +1397 -0
- package/test/capability-tiers.test.ts +631 -0
- package/test/cascade-context.test.ts +692 -0
- package/test/error-escalation.test.ts +1205 -0
- package/test/load-balancing-thread-safety.test.ts +464 -0
- package/test/load-balancing.test.ts +1145 -0
- package/test/standalone.test.js +250 -0
- package/test/types.test.js +371 -0
- 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
|
+
})
|