- Move all co-located *.test.* files into sibling __tests__/ directories so the
layout is consistent across packages (159 renames + content-rewrite moves).
Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
accidentally committed in a merge and the stale pi.js was masking a real
test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
createFnAgent / promptWithFallback / describeModel from @fusion/engine
instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
the two plugin package.jsons that were missing it; exports describeModel
from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
.test.ts; switch three boundary-result toEqual assertions to toMatchObject
so the new content/isError fields don't trip exact-match comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
||
import {
|
||
computeRecoveryDecision,
|
||
formatDelay,
|
||
MAX_RECOVERY_RETRIES,
|
||
BASE_DELAY_MS,
|
||
MAX_DELAY_MS,
|
||
BACKOFF_MULTIPLIER,
|
||
} from "../recovery-policy.js";
|
||
|
||
describe("computeRecoveryDecision", () => {
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it("returns shouldRetry=true on first failure (count=0)", () => {
|
||
const decision = computeRecoveryDecision({});
|
||
expect(decision.shouldRetry).toBe(true);
|
||
expect(decision.exhausted).toBe(false);
|
||
expect(decision.nextState.recoveryRetryCount).toBe(1);
|
||
expect(decision.nextState.nextRecoveryAt).toBeDefined();
|
||
expect(decision.delayMs).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("increments recovery count on each attempt", () => {
|
||
const d1 = computeRecoveryDecision({ recoveryRetryCount: 0 });
|
||
expect(d1.nextState.recoveryRetryCount).toBe(1);
|
||
|
||
const d2 = computeRecoveryDecision({ recoveryRetryCount: 1 });
|
||
expect(d2.nextState.recoveryRetryCount).toBe(2);
|
||
|
||
const d3 = computeRecoveryDecision({ recoveryRetryCount: 2 });
|
||
expect(d3.nextState.recoveryRetryCount).toBe(3);
|
||
});
|
||
|
||
it("exhausts after MAX_RECOVERY_RETRIES attempts", () => {
|
||
const decision = computeRecoveryDecision({
|
||
recoveryRetryCount: MAX_RECOVERY_RETRIES,
|
||
});
|
||
expect(decision.shouldRetry).toBe(false);
|
||
expect(decision.exhausted).toBe(true);
|
||
expect(decision.nextState.recoveryRetryCount).toBeUndefined();
|
||
expect(decision.nextState.nextRecoveryAt).toBeUndefined();
|
||
expect(decision.delayMs).toBe(0);
|
||
});
|
||
|
||
it("also exhausts when count exceeds max (overflow safety)", () => {
|
||
const decision = computeRecoveryDecision({
|
||
recoveryRetryCount: 999,
|
||
});
|
||
expect(decision.shouldRetry).toBe(false);
|
||
expect(decision.exhausted).toBe(true);
|
||
});
|
||
|
||
it("uses exponential backoff with increasing delays", () => {
|
||
// Use fixed random for deterministic test
|
||
vi.spyOn(Math, "random").mockReturnValue(0.5); // No jitter when random=0.5
|
||
|
||
const d1 = computeRecoveryDecision({});
|
||
const d2 = computeRecoveryDecision({ recoveryRetryCount: 1 });
|
||
const d3 = computeRecoveryDecision({ recoveryRetryCount: 2 });
|
||
|
||
// Base: 60s, then 120s, then 240s (capped at 300s)
|
||
expect(d1.delayMs).toBe(BASE_DELAY_MS); // 60s × 2^0 = 60s
|
||
expect(d2.delayMs).toBe(BASE_DELAY_MS * BACKOFF_MULTIPLIER); // 60s × 2^1 = 120s
|
||
expect(d3.delayMs).toBe(BASE_DELAY_MS * BACKOFF_MULTIPLIER ** 2); // 60s × 2^2 = 240s
|
||
});
|
||
|
||
it("caps delay at MAX_DELAY_MS", () => {
|
||
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||
|
||
// With high retry count, delay should be capped
|
||
const decision = computeRecoveryDecision({ recoveryRetryCount: 2 });
|
||
expect(decision.delayMs).toBeLessThanOrEqual(MAX_DELAY_MS * 1.1); // Allow for jitter
|
||
});
|
||
|
||
it("applies jitter (±10%) to delays", () => {
|
||
// Zero jitter
|
||
vi.spyOn(Math, "random").mockReturnValue(0.5);
|
||
const noJitter = computeRecoveryDecision({});
|
||
|
||
// Max positive jitter
|
||
vi.spyOn(Math, "random").mockReturnValue(1.0);
|
||
const maxJitter = computeRecoveryDecision({});
|
||
|
||
// Max negative jitter
|
||
vi.spyOn(Math, "random").mockReturnValue(0.0);
|
||
const minJitter = computeRecoveryDecision({});
|
||
|
||
// All should be within ±10% of base delay
|
||
const base = BASE_DELAY_MS;
|
||
expect(noJitter.delayMs).toBe(base);
|
||
expect(maxJitter.delayMs).toBeGreaterThan(base);
|
||
expect(maxJitter.delayMs).toBeLessThanOrEqual(base * 1.1);
|
||
expect(minJitter.delayMs).toBeLessThan(base);
|
||
expect(minJitter.delayMs).toBeGreaterThanOrEqual(base * 0.9);
|
||
});
|
||
|
||
it("sets nextRecoveryAt to a future ISO timestamp", () => {
|
||
const before = Date.now();
|
||
const decision = computeRecoveryDecision({});
|
||
const after = Date.now();
|
||
|
||
const recoveryTime = new Date(decision.nextState.nextRecoveryAt!).getTime();
|
||
expect(recoveryTime).toBeGreaterThanOrEqual(before + decision.delayMs - 1);
|
||
expect(recoveryTime).toBeLessThanOrEqual(after + decision.delayMs + 1);
|
||
});
|
||
|
||
it("treats undefined recoveryRetryCount as 0", () => {
|
||
const decision = computeRecoveryDecision({ recoveryRetryCount: undefined });
|
||
expect(decision.shouldRetry).toBe(true);
|
||
expect(decision.nextState.recoveryRetryCount).toBe(1);
|
||
});
|
||
|
||
it("clears recovery metadata when exhausted", () => {
|
||
const decision = computeRecoveryDecision({
|
||
recoveryRetryCount: MAX_RECOVERY_RETRIES,
|
||
nextRecoveryAt: new Date().toISOString(),
|
||
});
|
||
expect(decision.nextState.recoveryRetryCount).toBeUndefined();
|
||
expect(decision.nextState.nextRecoveryAt).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("formatDelay", () => {
|
||
it("formats seconds under 60 as Ns", () => {
|
||
expect(formatDelay(5000)).toBe("5s");
|
||
expect(formatDelay(30000)).toBe("30s");
|
||
expect(formatDelay(59000)).toBe("59s");
|
||
});
|
||
|
||
it("formats exact minutes as Nm", () => {
|
||
expect(formatDelay(60000)).toBe("1m");
|
||
expect(formatDelay(120000)).toBe("2m");
|
||
expect(formatDelay(300000)).toBe("5m");
|
||
});
|
||
|
||
it("formats non-exact minutes as seconds", () => {
|
||
expect(formatDelay(90000)).toBe("90s");
|
||
expect(formatDelay(150000)).toBe("150s");
|
||
});
|
||
|
||
it("handles zero", () => {
|
||
expect(formatDelay(0)).toBe("0s");
|
||
});
|
||
});
|
||
|
||
describe("constants", () => {
|
||
it("MAX_RECOVERY_RETRIES is 3", () => {
|
||
expect(MAX_RECOVERY_RETRIES).toBe(3);
|
||
});
|
||
|
||
it("BASE_DELAY_MS is 60 seconds", () => {
|
||
expect(BASE_DELAY_MS).toBe(60_000);
|
||
});
|
||
|
||
it("MAX_DELAY_MS is 300 seconds (5 minutes)", () => {
|
||
expect(MAX_DELAY_MS).toBe(300_000);
|
||
});
|
||
});
|