mirror of
https://github.com/joelhooks/joelclaw.git
synced 2026-09-19 01:24:04 +08:00
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
import type { FallbackSession, TelemetryEmitter } from "../src";
|
|
import { ModelFallbackController } from "../src/controller";
|
|
|
|
type TelemetryEvent = Parameters<TelemetryEmitter["emit"]>;
|
|
|
|
const BASE_CONFIG = {
|
|
fallbackProvider: "anthropic",
|
|
fallbackModel: "claude-sonnet-4-6",
|
|
fallbackTimeoutMs: 1_000,
|
|
fallbackAfterFailures: 3,
|
|
recoveryProbeIntervalMs: 10_000,
|
|
};
|
|
|
|
type SessionState = {
|
|
currentModel: { provider: string; id: string };
|
|
setModel: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function createSession(): SessionState & { session: FallbackSession } {
|
|
const state = {
|
|
currentModel: { provider: "anthropic", id: "claude-opus-4-6" },
|
|
};
|
|
|
|
const setModel = vi.fn(async (model: unknown) => {
|
|
const value = model as { provider: string; id: string };
|
|
state.currentModel = { provider: value.provider, id: value.id };
|
|
});
|
|
|
|
return {
|
|
currentModel: state.currentModel,
|
|
setModel,
|
|
session: {
|
|
setModel,
|
|
get model() {
|
|
return state.currentModel;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("ModelFallbackController", () => {
|
|
test("activates fallback after N consecutive failures", async () => {
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackAfterFailures: 2 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
|
|
expect(await fallbackController.onPromptError(1)).toBe(false);
|
|
expect(await fallbackController.onPromptError(2)).toBe(true);
|
|
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
expect(setModel).toHaveBeenCalledWith({ provider: "anthropic", id: "claude-sonnet-4-6" });
|
|
expect(fallbackController.state.active).toBe(true);
|
|
expect(fallbackController.state.activationCount).toBe(1);
|
|
});
|
|
|
|
test("activates fallback on prompt timeout (mock timers)", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackTimeoutMs: 500, fallbackAfterFailures: 99 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
fallbackController.onPromptDispatched();
|
|
|
|
vi.advanceTimersByTime(400);
|
|
await Promise.resolve();
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(101);
|
|
await Promise.resolve();
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
expect(fallbackController.state.active).toBe(true);
|
|
});
|
|
|
|
test("cancelTimeoutWatch stops pending timeout activation", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackTimeoutMs: 500, fallbackAfterFailures: 99 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
fallbackController.onPromptDispatched();
|
|
|
|
vi.advanceTimersByTime(250);
|
|
fallbackController.cancelTimeoutWatch();
|
|
await Promise.resolve();
|
|
|
|
vi.advanceTimersByTime(1_000);
|
|
await Promise.resolve();
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(fallbackController.state.active).toBe(false);
|
|
});
|
|
|
|
test("onPromptDispatched is idempotent and restarts timeout from latest dispatch", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackTimeoutMs: 500, fallbackAfterFailures: 99 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
fallbackController.onPromptDispatched();
|
|
|
|
vi.advanceTimersByTime(400);
|
|
fallbackController.onPromptDispatched();
|
|
await Promise.resolve();
|
|
|
|
vi.advanceTimersByTime(250);
|
|
await Promise.resolve();
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(251);
|
|
await Promise.resolve();
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
expect(fallbackController.state.active).toBe(true);
|
|
});
|
|
|
|
test("does not activate fallback when primary and fallback are identical", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{
|
|
...BASE_CONFIG,
|
|
fallbackProvider: currentModel.provider,
|
|
fallbackModel: currentModel.id,
|
|
fallbackAfterFailures: 1,
|
|
fallbackTimeoutMs: 250,
|
|
},
|
|
currentModel.provider,
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
|
|
expect(await fallbackController.onPromptError(1)).toBe(false);
|
|
|
|
fallbackController.onPromptDispatched();
|
|
vi.advanceTimersByTime(500);
|
|
await Promise.resolve();
|
|
|
|
expect(setModel).not.toHaveBeenCalled();
|
|
expect(fallbackController.state.active).toBe(false);
|
|
});
|
|
|
|
test("restores primary on successful recovery probe", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackAfterFailures: 1, recoveryProbeIntervalMs: 250 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
|
|
expect(await fallbackController.onPromptError(1)).toBe(true);
|
|
expect(fallbackController.state.active).toBe(true);
|
|
|
|
vi.advanceTimersByTime(2 * 60_000 + 250);
|
|
await Promise.resolve();
|
|
expect(setModel).toHaveBeenCalledTimes(2);
|
|
expect(setModel).toHaveBeenLastCalledWith({ provider: "anthropic", id: "claude-opus-4-6" });
|
|
expect(fallbackController.state.active).toBe(false);
|
|
});
|
|
|
|
test("reports fallback state", async () => {
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackAfterFailures: 1 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
|
|
expect(fallbackController.state).toEqual({
|
|
active: false,
|
|
activeSince: 0,
|
|
activationCount: 0,
|
|
primaryModel: currentModel.id,
|
|
primaryProvider: currentModel.provider,
|
|
fallbackModel: BASE_CONFIG.fallbackModel,
|
|
fallbackProvider: BASE_CONFIG.fallbackProvider,
|
|
lastRecoveryProbe: 0,
|
|
});
|
|
|
|
expect(await fallbackController.onPromptError(1)).toBe(true);
|
|
expect(fallbackController.state.active).toBe(true);
|
|
expect(fallbackController.state.activationCount).toBe(1);
|
|
expect(fallbackController.state.primaryModel).toBe("claude-opus-4-6");
|
|
expect(fallbackController.state.primaryProvider).toBe("anthropic");
|
|
expect(fallbackController.state.fallbackModel).toBe("claude-sonnet-4-6");
|
|
expect(fallbackController.state.fallbackProvider).toBe("anthropic");
|
|
expect(fallbackController.state.lastRecoveryProbe).toBe(0);
|
|
expect(fallbackController.state.activeSince).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("cleans up timers on dispose", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { session, setModel, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackAfterFailures: 1, recoveryProbeIntervalMs: 500 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
|
|
expect(await fallbackController.onPromptError(1)).toBe(true);
|
|
expect(fallbackController.state.active).toBe(true);
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
|
|
fallbackController.dispose();
|
|
vi.advanceTimersByTime(10_000);
|
|
await Promise.resolve();
|
|
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("backs off recovery probes after transient probe failure", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const { currentModel } = createSession();
|
|
let current = { provider: "anthropic", id: currentModel.id };
|
|
const setModel = vi.fn(async (model: unknown) => {
|
|
const value = model as { provider: string; id: string };
|
|
if (value.id === "claude-opus-4-6" && current.id !== "claude-opus-4-6") {
|
|
throw new Error("429 rate_limit from primary probe");
|
|
}
|
|
current = { provider: value.provider, id: value.id };
|
|
});
|
|
const session: FallbackSession = {
|
|
setModel,
|
|
get model() {
|
|
return current;
|
|
},
|
|
};
|
|
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackAfterFailures: 1, recoveryProbeIntervalMs: 250 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
|
|
expect(await fallbackController.onPromptError(1)).toBe(true);
|
|
expect(fallbackController.state.active).toBe(true);
|
|
expect(setModel).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(2 * 60_000 + 250);
|
|
await Promise.resolve();
|
|
expect(setModel).toHaveBeenCalledTimes(2);
|
|
expect(fallbackController.state.active).toBe(true);
|
|
|
|
vi.advanceTimersByTime(250);
|
|
await Promise.resolve();
|
|
expect(setModel).toHaveBeenCalledTimes(2);
|
|
|
|
vi.advanceTimersByTime(2 * 60_000);
|
|
await Promise.resolve();
|
|
expect(setModel).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
test("emits telemetry events", async () => {
|
|
const events: TelemetryEvent[] = [];
|
|
const telemetry: TelemetryEmitter = {
|
|
emit: (action, detail, extra) => {
|
|
events.push([action, detail, extra]);
|
|
},
|
|
};
|
|
vi.useFakeTimers();
|
|
|
|
const { session, currentModel } = createSession();
|
|
const fallbackController = new ModelFallbackController(
|
|
{ ...BASE_CONFIG, fallbackAfterFailures: 1 },
|
|
"anthropic",
|
|
currentModel.id,
|
|
telemetry,
|
|
);
|
|
fallbackController.init(session, () => {});
|
|
|
|
expect(await fallbackController.onPromptError(1)).toBe(true);
|
|
fallbackController.onPromptDispatched();
|
|
vi.advanceTimersByTime(25);
|
|
fallbackController.onFirstToken();
|
|
vi.advanceTimersByTime(75);
|
|
fallbackController.onTurnEnd();
|
|
|
|
await Promise.resolve();
|
|
|
|
const actions = events.map((event) => event[0]);
|
|
expect(actions).toContain("model_fallback.swapped");
|
|
expect(actions).toContain("model_fallback.decision");
|
|
expect(actions).toContain("prompt.latency");
|
|
});
|
|
});
|