Files
hermes-agent/ui-opentui/src/test/gatewayRecovery.test.ts
T
alt-glitch a38152cd91 feat(tui): run on Node 26 (one runtime), finalize copy UX, rename to ui-opentui
Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the
repo ships a single JavaScript runtime: child_process for the gateway, vitest
for tests, an esbuild + Solid build step. Mouse selection copies the rendered
text you highlight, and the clipboard path is crash-proofed (a broken copy
pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ ->
ui-opentui/ and updates the launcher/installer/Docker references.
2026-06-09 16:16:48 +00:00

77 lines
2.9 KiB
TypeScript

/**
* Recovery-budget policy test (LOGIC side, pure). The crash-loop bound: attempts
* are capped within a sliding window, stale attempts are pruned, and recovery is
* refused with no session. Plus opencode-style exponential backoff (1s→30s cap).
*/
import { describe, expect, test } from 'vitest'
import {
backoffMs,
GATEWAY_RECOVERY_LIMIT,
GATEWAY_RECOVERY_WINDOW_MS,
planGatewayRecovery
} from '../logic/gatewayRecovery.ts'
describe('planGatewayRecovery — crash-loop budget', () => {
test('allows GATEWAY_RECOVERY_LIMIT attempts within the window, refuses the next', () => {
const sid = 'sess-1'
let attempts: number[] = []
const now = 1_000_000
// The first LIMIT exits all recover, each recording its timestamp.
for (let i = 0; i < GATEWAY_RECOVERY_LIMIT; i++) {
const plan = planGatewayRecovery(sid, null, attempts, now + i)
expect(plan.recover).toBe(true)
expect(plan.sid).toBe(sid)
attempts = plan.attempts
}
expect(attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
// The (LIMIT+1)th within the window is refused; attempts are NOT extended.
const refused = planGatewayRecovery(sid, null, attempts, now + GATEWAY_RECOVERY_LIMIT)
expect(refused.recover).toBe(false)
expect(refused.attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
})
test('prunes attempts older than GATEWAY_RECOVERY_WINDOW_MS, freeing the budget', () => {
const sid = 'sess-1'
const now = 1_000_000
// Three stale attempts (all outside the window) + one fresh.
const stale = [now - GATEWAY_RECOVERY_WINDOW_MS - 5, now - GATEWAY_RECOVERY_WINDOW_MS - 4, now - 30_000]
const plan = planGatewayRecovery(sid, null, stale, now)
// The two truly-stale ones are pruned; the in-window one survives + `now` added.
expect(plan.recover).toBe(true)
expect(plan.attempts).toEqual([now - 30_000, now])
})
test('refuses recovery when there is no session id (live nor recover)', () => {
const plan = planGatewayRecovery(null, null, [], 1_000_000)
expect(plan.recover).toBe(false)
expect(plan.sid).toBeNull()
expect(plan.attempts).toEqual([])
})
test('falls back to the recoverSid when the live sid was already cleared', () => {
const plan = planGatewayRecovery(null, 'pending-sess', [], 1_000_000)
expect(plan.recover).toBe(true)
expect(plan.sid).toBe('pending-sess')
})
})
describe('backoffMs — exponential delay (1s→30s cap)', () => {
test('doubles per attempt (1-based) and caps at 30000ms', () => {
expect(backoffMs(1)).toBe(1000)
expect(backoffMs(2)).toBe(2000)
expect(backoffMs(3)).toBe(4000)
expect(backoffMs(4)).toBe(8000)
expect(backoffMs(5)).toBe(16000)
expect(backoffMs(6)).toBe(30000) // 32000 clamped to the cap
expect(backoffMs(10)).toBe(30000) // stays at the cap
})
test('clamps a non-positive attempt to the first delay', () => {
expect(backoffMs(0)).toBe(1000)
expect(backoffMs(-3)).toBe(1000)
})
})