369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { createTask } from '../src/task/task.js'
|
|
import { executeWithRetry, computeRetryDelay } from '../src/orchestrator/orchestrator.js'
|
|
import type { AgentRunResult } from '../src/types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SUCCESS_RESULT: AgentRunResult = {
|
|
success: true,
|
|
output: 'done',
|
|
messages: [],
|
|
tokenUsage: { input_tokens: 10, output_tokens: 20 },
|
|
toolCalls: [],
|
|
}
|
|
|
|
const FAILURE_RESULT: AgentRunResult = {
|
|
success: false,
|
|
output: 'agent failed',
|
|
messages: [],
|
|
tokenUsage: { input_tokens: 10, output_tokens: 20 },
|
|
toolCalls: [],
|
|
}
|
|
|
|
/** No-op delay for tests. */
|
|
const noDelay = () => Promise.resolve()
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// computeRetryDelay
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('computeRetryDelay', () => {
|
|
it('computes exponential backoff', () => {
|
|
expect(computeRetryDelay(1000, 2, 1)).toBe(1000) // 1000 * 2^0
|
|
expect(computeRetryDelay(1000, 2, 2)).toBe(2000) // 1000 * 2^1
|
|
expect(computeRetryDelay(1000, 2, 3)).toBe(4000) // 1000 * 2^2
|
|
})
|
|
|
|
it('caps at 30 seconds', () => {
|
|
// 1000 * 2^20 = 1,048,576,000 — way over cap
|
|
expect(computeRetryDelay(1000, 2, 21)).toBe(30_000)
|
|
})
|
|
|
|
it('handles backoff of 1 (constant delay)', () => {
|
|
expect(computeRetryDelay(500, 1, 1)).toBe(500)
|
|
expect(computeRetryDelay(500, 1, 5)).toBe(500)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createTask: retry fields
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('createTask with retry fields', () => {
|
|
it('passes through retry config', () => {
|
|
const t = createTask({
|
|
title: 'Retry task',
|
|
description: 'test',
|
|
maxRetries: 3,
|
|
retryDelayMs: 500,
|
|
retryBackoff: 1.5,
|
|
})
|
|
expect(t.maxRetries).toBe(3)
|
|
expect(t.retryDelayMs).toBe(500)
|
|
expect(t.retryBackoff).toBe(1.5)
|
|
})
|
|
|
|
it('defaults retry fields to undefined', () => {
|
|
const t = createTask({ title: 'No retry', description: 'test' })
|
|
expect(t.maxRetries).toBeUndefined()
|
|
expect(t.retryDelayMs).toBeUndefined()
|
|
expect(t.retryBackoff).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// executeWithRetry — tests the real exported function
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('executeWithRetry', () => {
|
|
it('succeeds on first attempt with no retry config', async () => {
|
|
const run = vi.fn().mockResolvedValue(SUCCESS_RESULT)
|
|
const task = createTask({ title: 'Simple', description: 'test' })
|
|
|
|
const result = await executeWithRetry(run, task, undefined, noDelay)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(result.output).toBe('done')
|
|
expect(run).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('succeeds on first attempt even when maxRetries > 0', async () => {
|
|
const run = vi.fn().mockResolvedValue(SUCCESS_RESULT)
|
|
const task = createTask({
|
|
title: 'Has retries',
|
|
description: 'test',
|
|
maxRetries: 3,
|
|
})
|
|
|
|
const result = await executeWithRetry(run, task, undefined, noDelay)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(run).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('retries on exception and succeeds on second attempt', async () => {
|
|
const run = vi.fn()
|
|
.mockRejectedValueOnce(new Error('transient error'))
|
|
.mockResolvedValueOnce(SUCCESS_RESULT)
|
|
|
|
const task = createTask({
|
|
title: 'Retry task',
|
|
description: 'test',
|
|
maxRetries: 2,
|
|
retryDelayMs: 100,
|
|
retryBackoff: 2,
|
|
})
|
|
|
|
const retryEvents: unknown[] = []
|
|
const result = await executeWithRetry(
|
|
run,
|
|
task,
|
|
(data) => retryEvents.push(data),
|
|
noDelay,
|
|
)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(run).toHaveBeenCalledTimes(2)
|
|
expect(retryEvents).toHaveLength(1)
|
|
expect(retryEvents[0]).toEqual({
|
|
attempt: 1,
|
|
maxAttempts: 3,
|
|
error: 'transient error',
|
|
nextDelayMs: 100, // 100 * 2^0
|
|
})
|
|
})
|
|
|
|
it('retries on success:false and succeeds on second attempt', async () => {
|
|
const run = vi.fn()
|
|
.mockResolvedValueOnce(FAILURE_RESULT)
|
|
.mockResolvedValueOnce(SUCCESS_RESULT)
|
|
|
|
const task = createTask({
|
|
title: 'Retry task',
|
|
description: 'test',
|
|
maxRetries: 1,
|
|
retryDelayMs: 50,
|
|
})
|
|
|
|
const result = await executeWithRetry(run, task, undefined, noDelay)
|
|
|
|
expect(result.success).toBe(true)
|
|
expect(run).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('exhausts all retries on persistent exception', async () => {
|
|
const run = vi.fn().mockRejectedValue(new Error('persistent error'))
|
|
|
|
const task = createTask({
|
|
title: 'Always fails',
|
|
description: 'test',
|
|
maxRetries: 2,
|
|
retryDelayMs: 10,
|
|
retryBackoff: 1,
|
|
})
|
|
|
|
const retryEvents: unknown[] = []
|
|
const result = await executeWithRetry(
|
|
run,
|
|
task,
|
|
(data) => retryEvents.push(data),
|
|
noDelay,
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.output).toBe('persistent error')
|
|
expect(run).toHaveBeenCalledTimes(3) // 1 initial + 2 retries
|
|
expect(retryEvents).toHaveLength(2)
|
|
})
|
|
|
|
it('exhausts all retries on persistent success:false', async () => {
|
|
const run = vi.fn().mockResolvedValue(FAILURE_RESULT)
|
|
|
|
const task = createTask({
|
|
title: 'Always fails',
|
|
description: 'test',
|
|
maxRetries: 1,
|
|
})
|
|
|
|
const result = await executeWithRetry(run, task, undefined, noDelay)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.output).toBe('agent failed')
|
|
expect(run).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('emits correct exponential backoff delays', async () => {
|
|
const run = vi.fn().mockRejectedValue(new Error('error'))
|
|
|
|
const task = createTask({
|
|
title: 'Backoff test',
|
|
description: 'test',
|
|
maxRetries: 3,
|
|
retryDelayMs: 100,
|
|
retryBackoff: 2,
|
|
})
|
|
|
|
const retryEvents: Array<{ nextDelayMs: number }> = []
|
|
await executeWithRetry(
|
|
run,
|
|
task,
|
|
(data) => retryEvents.push(data),
|
|
noDelay,
|
|
)
|
|
|
|
expect(retryEvents).toHaveLength(3)
|
|
expect(retryEvents[0]!.nextDelayMs).toBe(100) // 100 * 2^0
|
|
expect(retryEvents[1]!.nextDelayMs).toBe(200) // 100 * 2^1
|
|
expect(retryEvents[2]!.nextDelayMs).toBe(400) // 100 * 2^2
|
|
})
|
|
|
|
it('no retry events when maxRetries is 0 (default)', async () => {
|
|
const run = vi.fn().mockRejectedValue(new Error('fail'))
|
|
const task = createTask({ title: 'No retry', description: 'test' })
|
|
|
|
const retryEvents: unknown[] = []
|
|
const result = await executeWithRetry(
|
|
run,
|
|
task,
|
|
(data) => retryEvents.push(data),
|
|
noDelay,
|
|
)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(run).toHaveBeenCalledTimes(1)
|
|
expect(retryEvents).toHaveLength(0)
|
|
})
|
|
|
|
it('calls the delay function with computed delay', async () => {
|
|
const run = vi.fn()
|
|
.mockRejectedValueOnce(new Error('error'))
|
|
.mockResolvedValueOnce(SUCCESS_RESULT)
|
|
|
|
const task = createTask({
|
|
title: 'Delay test',
|
|
description: 'test',
|
|
maxRetries: 1,
|
|
retryDelayMs: 250,
|
|
retryBackoff: 3,
|
|
})
|
|
|
|
const mockDelay = vi.fn().mockResolvedValue(undefined)
|
|
await executeWithRetry(run, task, undefined, mockDelay)
|
|
|
|
expect(mockDelay).toHaveBeenCalledTimes(1)
|
|
expect(mockDelay).toHaveBeenCalledWith(250) // 250 * 3^0
|
|
})
|
|
|
|
it('caps delay at 30 seconds', async () => {
|
|
const run = vi.fn()
|
|
.mockRejectedValueOnce(new Error('error'))
|
|
.mockResolvedValueOnce(SUCCESS_RESULT)
|
|
|
|
const task = createTask({
|
|
title: 'Cap test',
|
|
description: 'test',
|
|
maxRetries: 1,
|
|
retryDelayMs: 50_000,
|
|
retryBackoff: 2,
|
|
})
|
|
|
|
const mockDelay = vi.fn().mockResolvedValue(undefined)
|
|
await executeWithRetry(run, task, undefined, mockDelay)
|
|
|
|
expect(mockDelay).toHaveBeenCalledWith(30_000) // capped
|
|
})
|
|
|
|
it('accumulates token usage across retry attempts', async () => {
|
|
const failResult: AgentRunResult = {
|
|
...FAILURE_RESULT,
|
|
tokenUsage: { input_tokens: 100, output_tokens: 50 },
|
|
}
|
|
const successResult: AgentRunResult = {
|
|
...SUCCESS_RESULT,
|
|
tokenUsage: { input_tokens: 200, output_tokens: 80 },
|
|
}
|
|
|
|
const run = vi.fn()
|
|
.mockResolvedValueOnce(failResult)
|
|
.mockResolvedValueOnce(failResult)
|
|
.mockResolvedValueOnce(successResult)
|
|
|
|
const task = createTask({
|
|
title: 'Token test',
|
|
description: 'test',
|
|
maxRetries: 2,
|
|
retryDelayMs: 10,
|
|
})
|
|
|
|
const result = await executeWithRetry(run, task, undefined, noDelay)
|
|
|
|
expect(result.success).toBe(true)
|
|
// 100+100+200 input, 50+50+80 output
|
|
expect(result.tokenUsage.input_tokens).toBe(400)
|
|
expect(result.tokenUsage.output_tokens).toBe(180)
|
|
})
|
|
|
|
it('accumulates token usage even when all retries fail', async () => {
|
|
const failResult: AgentRunResult = {
|
|
...FAILURE_RESULT,
|
|
tokenUsage: { input_tokens: 50, output_tokens: 30 },
|
|
}
|
|
|
|
const run = vi.fn().mockResolvedValue(failResult)
|
|
|
|
const task = createTask({
|
|
title: 'Token fail test',
|
|
description: 'test',
|
|
maxRetries: 1,
|
|
})
|
|
|
|
const result = await executeWithRetry(run, task, undefined, noDelay)
|
|
|
|
expect(result.success).toBe(false)
|
|
// 50+50 input, 30+30 output (2 attempts)
|
|
expect(result.tokenUsage.input_tokens).toBe(100)
|
|
expect(result.tokenUsage.output_tokens).toBe(60)
|
|
})
|
|
|
|
it('clamps negative maxRetries to 0 (single attempt)', async () => {
|
|
const run = vi.fn().mockRejectedValue(new Error('fail'))
|
|
|
|
const task = createTask({
|
|
title: 'Negative retry',
|
|
description: 'test',
|
|
maxRetries: -5,
|
|
})
|
|
// Manually set negative value since createTask doesn't validate
|
|
;(task as any).maxRetries = -5
|
|
|
|
const result = await executeWithRetry(run, task, undefined, noDelay)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(run).toHaveBeenCalledTimes(1) // exactly 1 attempt, no retries
|
|
})
|
|
|
|
it('clamps backoff below 1 to 1 (constant delay)', async () => {
|
|
const run = vi.fn()
|
|
.mockRejectedValueOnce(new Error('error'))
|
|
.mockResolvedValueOnce(SUCCESS_RESULT)
|
|
|
|
const task = createTask({
|
|
title: 'Bad backoff',
|
|
description: 'test',
|
|
maxRetries: 1,
|
|
retryDelayMs: 100,
|
|
retryBackoff: -2,
|
|
})
|
|
;(task as any).retryBackoff = -2
|
|
|
|
const mockDelay = vi.fn().mockResolvedValue(undefined)
|
|
await executeWithRetry(run, task, undefined, mockDelay)
|
|
|
|
// backoff clamped to 1, so delay = 100 * 1^0 = 100
|
|
expect(mockDelay).toHaveBeenCalledWith(100)
|
|
})
|
|
})
|