2978 lines
104 KiB
TypeScript
2978 lines
104 KiB
TypeScript
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import * as path from "node:path";
|
|
import { scheduler } from "node:timers/promises";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
import {
|
|
type AssistantMessage,
|
|
Effort,
|
|
type Model,
|
|
type ModelUsageHealth,
|
|
type ProviderSessionState,
|
|
} from "@oh-my-pi/pi-ai";
|
|
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
|
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
import { writeModelCache } from "@oh-my-pi/pi-catalog/model-cache";
|
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { parseModelPattern, parseModelString } from "@oh-my-pi/pi-coding-agent/config/model-resolver";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions";
|
|
import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus";
|
|
import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub";
|
|
import { SessionObserverRegistry } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
|
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
type AutoRetryStartEvent = Extract<AgentSessionEvent, { type: "auto_retry_start" }>;
|
|
type AutoRetryEndEvent = Extract<AgentSessionEvent, { type: "auto_retry_end" }>;
|
|
|
|
function trackRetryEvents(session: AgentSession): {
|
|
retryStartEvents: AutoRetryStartEvent[];
|
|
retryEndEvents: AutoRetryEndEvent[];
|
|
} {
|
|
const retryStartEvents: AutoRetryStartEvent[] = [];
|
|
const retryEndEvents: AutoRetryEndEvent[] = [];
|
|
session.subscribe(event => {
|
|
if (event.type === "auto_retry_start") {
|
|
retryStartEvents.push(event);
|
|
}
|
|
if (event.type === "auto_retry_end") {
|
|
retryEndEvents.push(event);
|
|
}
|
|
});
|
|
return { retryStartEvents, retryEndEvents };
|
|
}
|
|
|
|
function getLastAssistantMessage(session: AgentSession): AssistantMessage {
|
|
const lastMessage = session.messages.at(-1);
|
|
if (lastMessage?.role !== "assistant") {
|
|
throw new Error("Expected final assistant message");
|
|
}
|
|
return lastMessage;
|
|
}
|
|
|
|
function createFallbackAgent(primaryModel: Model, requestedModels: string[]): Agent {
|
|
const mock = createMockModel();
|
|
let primaryAttempts = 0;
|
|
return new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
if (model.provider === primaryModel.provider && model.id === primaryModel.id && primaryAttempts === 0) {
|
|
primaryAttempts += 1;
|
|
mock.push({ throw: "rate limit exceeded retry-after-ms=200" });
|
|
} else {
|
|
mock.push({ content: [`ok:${model.provider}/${model.id}`] });
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
}
|
|
|
|
describe("AgentSession retry fallback", () => {
|
|
let tempDir: TempDir;
|
|
let authStorage: AuthStorage;
|
|
let sharedRegistry: ModelRegistry;
|
|
let modelRegistry: ModelRegistry;
|
|
let session: AgentSession | undefined;
|
|
|
|
// The model registry is an immutable fixture whose construction builds a
|
|
// canonical index over ~2.7k bundled models (~100ms). Build it (and the
|
|
// auth DB) once for the whole file instead of per-test; reset only the
|
|
// mutable retry-fallback cooldown state between tests.
|
|
beforeAll(async () => {
|
|
tempDir = TempDir.createSync("@pi-retry-fallback-");
|
|
await initTheme();
|
|
authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
|
|
authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key");
|
|
authStorage.setRuntimeApiKey("openai", "openai-test-key");
|
|
authStorage.setRuntimeApiKey("google", "google-test-key");
|
|
authStorage.setRuntimeApiKey("google-vertex", "google-vertex-test-key");
|
|
authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key");
|
|
authStorage.setRuntimeApiKey("devin", "devin-test-key");
|
|
authStorage.setRuntimeApiKey("openai-codex", "openai-codex-test-key");
|
|
sharedRegistry = new ModelRegistry(authStorage);
|
|
});
|
|
|
|
afterAll(() => {
|
|
authStorage.close();
|
|
tempDir.removeSync();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Reset to the shared registry (a few tests reassign it to a scoped
|
|
// instance) and clear cooldown suppressions left by fallback-path tests
|
|
// (default 5-minute suppression) so state never leaks between tests.
|
|
modelRegistry = sharedRegistry;
|
|
modelRegistry.clearSuppressedSelectors();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (session) {
|
|
await session.dispose();
|
|
session = undefined;
|
|
}
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("advances through a role-keyed fallback chain across retries", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const firstFallback = getBundledModel("openai", "gpt-4o-mini");
|
|
const secondFallback = getBundledModel("openai", "gpt-4o");
|
|
if (!primaryModel || !firstFallback || !secondFallback) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const requestedContexts: string[] = [];
|
|
const retryStartEvents: Array<Extract<AgentSessionEvent, { type: "auto_retry_start" }>> = [];
|
|
const retryEndEvents: Array<Extract<AgentSessionEvent, { type: "auto_retry_end" }>> = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const fallbackSucceededEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_succeeded" }>> = [];
|
|
|
|
const mock = createMockModel();
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
requestedContexts.push(JSON.stringify(context));
|
|
if (model.provider === primaryModel.provider && model.id === primaryModel.id) {
|
|
mock.push({ throw: "overloaded_error: provider returned error 503" });
|
|
} else if (model.provider === firstFallback.provider && model.id === firstFallback.id) {
|
|
mock.push({ throw: "service unavailable: 503 overloaded" });
|
|
} else if (model.provider === secondFallback.provider && model.id === secondFallback.id) {
|
|
mock.push({ content: ["Recovered on second fallback"] });
|
|
} else {
|
|
throw new Error(`Unexpected model requested during retry fallback test: ${model.provider}/${model.id}`);
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
default: [
|
|
`${firstFallback.provider}/${firstFallback.id}`,
|
|
`${secondFallback.provider}/${secondFallback.id}`,
|
|
],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "auto_retry_start") {
|
|
retryStartEvents.push(event);
|
|
}
|
|
if (event.type === "auto_retry_end") {
|
|
retryEndEvents.push(event);
|
|
}
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
if (event.type === "retry_fallback_succeeded") {
|
|
fallbackSucceededEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Recover from rate limits");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${firstFallback.provider}/${firstFallback.id}`,
|
|
`${secondFallback.provider}/${secondFallback.id}`,
|
|
]);
|
|
expect(new Set(requestedContexts).size).toBe(1);
|
|
expect(session.model?.provider).toBe(secondFallback.provider);
|
|
expect(session.model?.id).toBe(secondFallback.id);
|
|
expect(retryStartEvents.map(event => event.delayMs)).toEqual([0, 0]);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${firstFallback.provider}/${firstFallback.id}`,
|
|
role: "default",
|
|
},
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${firstFallback.provider}/${firstFallback.id}`,
|
|
to: `${secondFallback.provider}/${secondFallback.id}`,
|
|
role: "default",
|
|
},
|
|
]);
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 2 });
|
|
expect(fallbackSucceededEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_succeeded",
|
|
model: `${secondFallback.provider}/${secondFallback.id}`,
|
|
role: "default",
|
|
},
|
|
]);
|
|
const registry = new AgentRegistry();
|
|
registry.register({
|
|
id: "fallback-agent",
|
|
displayName: "Fallback Agent",
|
|
kind: "sub",
|
|
session,
|
|
});
|
|
const hub = new AgentHubOverlayComponent({
|
|
observers: new SessionObserverRegistry(),
|
|
hubKeys: [],
|
|
onDone: () => {},
|
|
requestRender: () => {},
|
|
registry,
|
|
irc: new IrcBus(registry),
|
|
});
|
|
try {
|
|
expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain(
|
|
`fallback → ${secondFallback.provider}/${secondFallback.id}`,
|
|
);
|
|
} finally {
|
|
hub.dispose();
|
|
}
|
|
});
|
|
|
|
it("confirms before crossing models when every pooled account is inside reserve", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) throw new Error("Expected bundled reserve fallback models");
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel({ responses: [{ content: ["continued with full context"] }] });
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.usageAwareFallback": true,
|
|
"retry.usageReservePct": 10,
|
|
"retry.usageReservePolicy": "confirm",
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async provider =>
|
|
provider === primaryModel.provider
|
|
? {
|
|
state: "reserve",
|
|
accounts: [
|
|
{
|
|
credentialId: 1,
|
|
credentialType: "oauth",
|
|
selected: true,
|
|
state: "reserve",
|
|
remainingFraction: 0.05,
|
|
},
|
|
],
|
|
}
|
|
: { state: "healthy", accounts: [] },
|
|
);
|
|
const confirmFallback = vi.fn(async () => true);
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
session.setUsageFallbackConfirmer(confirmFallback);
|
|
await session.prompt("Keep working on the same task");
|
|
await session.waitForIdle();
|
|
expect(confirmFallback).toHaveBeenCalledWith({
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
remainingPercent: 5,
|
|
});
|
|
expect(requestedModels).toEqual([`${fallbackModel.provider}/${fallbackModel.id}`]);
|
|
expect(session.messages.some(message => message.role === "user")).toBe(true);
|
|
});
|
|
|
|
it("reselects a healthy same-provider account before considering a model fallback", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) throw new Error("Expected bundled pooled fallback models");
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel({ responses: [{ content: ["same provider continued"] }] });
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.usageAwareFallback": true,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockResolvedValue({
|
|
state: "healthy",
|
|
accounts: [
|
|
{
|
|
credentialId: 1,
|
|
credentialType: "oauth",
|
|
selected: true,
|
|
state: "reserve",
|
|
remainingFraction: 0.05,
|
|
},
|
|
{ credentialId: 2, credentialType: "oauth", state: "healthy", remainingFraction: 0.8 },
|
|
],
|
|
});
|
|
const release = vi
|
|
.spyOn(modelRegistry.authStorage, "releaseSessionCredentialForReselection")
|
|
.mockReturnValue(true);
|
|
const confirmFallback = vi.fn(async () => true);
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
session.setUsageFallbackConfirmer(confirmFallback);
|
|
await session.prompt("Stay on this provider");
|
|
await session.waitForIdle();
|
|
expect(release).toHaveBeenCalledWith(primaryModel.provider, session.sessionId);
|
|
expect(confirmFallback).not.toHaveBeenCalled();
|
|
expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]);
|
|
});
|
|
|
|
it("reselects a healthy sibling before applying a same-provider model fallback", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("anthropic", "claude-haiku-4-5");
|
|
if (!primaryModel || !fallbackModel) throw new Error("Expected bundled same-provider fallback models");
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel({ responses: [{ content: ["same-provider fallback continued"] }] });
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.usageAwareFallback": true,
|
|
"retry.usageReservePolicy": "auto",
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, options) =>
|
|
options.modelId === primaryModel.id
|
|
? {
|
|
state: "reserve",
|
|
accounts: [
|
|
{
|
|
credentialId: 1,
|
|
credentialType: "oauth",
|
|
selected: true,
|
|
state: "reserve",
|
|
remainingFraction: 0.05,
|
|
},
|
|
],
|
|
}
|
|
: {
|
|
state: "healthy",
|
|
accounts: [
|
|
{
|
|
credentialId: 1,
|
|
credentialType: "oauth",
|
|
selected: true,
|
|
state: "reserve",
|
|
remainingFraction: 0.05,
|
|
},
|
|
{
|
|
credentialId: 2,
|
|
credentialType: "oauth",
|
|
state: "healthy",
|
|
remainingFraction: 0.8,
|
|
},
|
|
],
|
|
},
|
|
);
|
|
const release = vi
|
|
.spyOn(modelRegistry.authStorage, "releaseSessionCredentialForReselection")
|
|
.mockReturnValue(true);
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
await session.prompt("Use the healthy sibling for the fallback model");
|
|
await session.waitForIdle();
|
|
|
|
expect(release).toHaveBeenCalledWith(primaryModel.provider, session.sessionId);
|
|
expect(requestedModels).toEqual([`${fallbackModel.provider}/${fallbackModel.id}`]);
|
|
});
|
|
|
|
it("does not dispatch a prompt after its usage preflight is cancelled", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!primaryModel) throw new Error("Expected bundled preflight model");
|
|
const requestedModels: string[] = [];
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
return createMockModel().stream(model, context, options);
|
|
},
|
|
});
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.usageAwareFallback": true,
|
|
});
|
|
const probeStarted = Promise.withResolvers<void>();
|
|
vi.spyOn(modelRegistry.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, options) => {
|
|
probeStarted.resolve();
|
|
const aborted = Promise.withResolvers<ModelUsageHealth>();
|
|
options.signal?.addEventListener(
|
|
"abort",
|
|
() => aborted.reject(options.signal?.reason ?? new DOMException("Aborted", "AbortError")),
|
|
{ once: true },
|
|
);
|
|
return aborted.promise;
|
|
});
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
const prompt = session.prompt("Do not send this after cancellation");
|
|
await probeStarted.promise;
|
|
await session.abort();
|
|
await prompt;
|
|
|
|
expect(requestedModels).toEqual([]);
|
|
});
|
|
|
|
it("continues a startup-owned role fallback chain from the active fallback", async () => {
|
|
const firstFallback = getBundledModel("openai", "gpt-4o-mini");
|
|
const secondFallback = getBundledModel("openai", "gpt-4o");
|
|
if (!firstFallback || !secondFallback) {
|
|
throw new Error("Expected bundled fallback models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const mock = createMockModel();
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: firstFallback,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
if (model.provider === firstFallback.provider && model.id === firstFallback.id) {
|
|
mock.push({ throw: "overloaded_error: provider returned error 503" });
|
|
} else if (model.provider === secondFallback.provider && model.id === secondFallback.id) {
|
|
mock.push({ content: ["Recovered on the remaining fallback"] });
|
|
} else {
|
|
throw new Error(
|
|
`Unexpected model requested during startup fallback test: ${model.provider}/${model.id}`,
|
|
);
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
const primarySelector = "missing-provider/missing-model";
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
slow: [`${firstFallback.provider}/${firstFallback.id}`, `${secondFallback.provider}/${secondFallback.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("slow", primarySelector);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
initialRetryFallback: {
|
|
role: "slow",
|
|
originalSelector: primarySelector,
|
|
originalThinkingLevel: undefined,
|
|
},
|
|
});
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") fallbackAppliedEvents.push(event);
|
|
});
|
|
|
|
await session.prompt("Continue the startup fallback chain");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${firstFallback.provider}/${firstFallback.id}`,
|
|
`${secondFallback.provider}/${secondFallback.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(secondFallback.provider);
|
|
expect(session.model?.id).toBe(secondFallback.id);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${firstFallback.provider}/${firstFallback.id}`,
|
|
to: `${secondFallback.provider}/${secondFallback.id}`,
|
|
role: "slow",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("applies a model-keyed fallback chain to advisor quota failures", async () => {
|
|
const mainModel = getBundledModel("openai", "gpt-4o-mini");
|
|
const advisorPrimary = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const advisorFallback = getBundledModel("openai", "gpt-4o");
|
|
if (!mainModel || !advisorPrimary || !advisorFallback) {
|
|
throw new Error("Expected bundled advisor fallback models to exist");
|
|
}
|
|
|
|
const mainMock = createMockModel({
|
|
responses: [{ content: ["Primary complete"] }, { content: ["Primary complete again"] }],
|
|
});
|
|
const advisorMock = createMockModel();
|
|
let advisorPrimaryAttempts = 0;
|
|
const requestedAdvisorModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const fallbackSucceededEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_succeeded" }>> = [];
|
|
const fallbackSucceeded = Promise.withResolvers<void>();
|
|
const advisorFailures: string[] = [];
|
|
const advisorPrimarySelector = `${advisorPrimary.provider}/${advisorPrimary.id}`;
|
|
const advisorFallbackSelector = `${advisorFallback.provider}/${advisorFallback.id}`;
|
|
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: mainModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: mainMock.stream,
|
|
});
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
[advisorPrimarySelector]: [advisorFallbackSelector],
|
|
},
|
|
"advisor.syncBacklog": "1",
|
|
});
|
|
settings.setModelRole("advisor", advisorPrimarySelector);
|
|
vi.spyOn(modelRegistry.authStorage, "markUsageLimitReached").mockResolvedValue({ switched: false });
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
advisorTools: [],
|
|
advisorConfigs: [{ name: "fallback-test", model: advisorPrimarySelector }],
|
|
advisorStreamFn: (model, context, options) => {
|
|
const selector = `${model.provider}/${model.id}`;
|
|
requestedAdvisorModels.push(selector);
|
|
if (selector === advisorPrimarySelector && advisorPrimaryAttempts++ === 0) {
|
|
advisorMock.push({
|
|
throw: "Devin stream error failed_precondition: Your daily usage quota has been exhausted. Your quota will reset after 1s.",
|
|
});
|
|
} else if (selector === advisorPrimarySelector) {
|
|
advisorMock.push({ content: ["Advisor primary restored"] });
|
|
} else if (selector === advisorFallbackSelector) {
|
|
advisorMock.push({ content: ["Advisor recovered"] });
|
|
} else {
|
|
throw new Error(`Unexpected advisor model requested: ${selector}`);
|
|
}
|
|
return advisorMock.stream(model, context, options);
|
|
},
|
|
});
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") fallbackAppliedEvents.push(event);
|
|
if (event.type === "retry_fallback_succeeded") {
|
|
fallbackSucceededEvents.push(event);
|
|
fallbackSucceeded.resolve();
|
|
}
|
|
if (event.type === "notice" && event.source === "advisor" && event.message.includes("unavailable")) {
|
|
advisorFailures.push(event.message);
|
|
}
|
|
});
|
|
|
|
expect(session.setAdvisorEnabled(true)).toBe(true);
|
|
await session.prompt("Complete one primary turn");
|
|
await session.waitForIdle();
|
|
// The catch-up gate releases immediately while the advisor is mid-failure
|
|
// (a failing advisor must never park the primary), so waitForIdle can
|
|
// return before the fallback retry lands — await the success event.
|
|
await fallbackSucceeded.promise;
|
|
|
|
expect(requestedAdvisorModels).toEqual([advisorPrimarySelector, advisorFallbackSelector]);
|
|
expect(session.getAdvisorAgent()?.state.model).toMatchObject({
|
|
provider: advisorFallback.provider,
|
|
id: advisorFallback.id,
|
|
});
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${advisorPrimarySelector}:medium`,
|
|
to: advisorFallbackSelector,
|
|
role: advisorPrimarySelector,
|
|
},
|
|
]);
|
|
expect(fallbackSucceededEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_succeeded",
|
|
model: advisorFallbackSelector,
|
|
role: advisorPrimarySelector,
|
|
},
|
|
]);
|
|
expect(advisorFailures).toEqual([]);
|
|
|
|
const afterCooldown = Date.now() + 2_000;
|
|
vi.spyOn(Date, "now").mockReturnValue(afterCooldown);
|
|
await session.prompt("Complete another primary turn after the advisor cooldown");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedAdvisorModels).toEqual([advisorPrimarySelector, advisorFallbackSelector, advisorPrimarySelector]);
|
|
expect(session.getAdvisorAgent()?.state.model).toMatchObject({
|
|
provider: advisorPrimary.provider,
|
|
id: advisorPrimary.id,
|
|
});
|
|
});
|
|
|
|
it("activates a model-keyed fallback chain without any role assignment", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
[`${primaryModel.provider}/${primaryModel.id}`]: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Recover via model-keyed chain");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
role: `${primaryModel.provider}/${primaryModel.id}`,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("prefers a model-keyed chain over the matching role chain", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const modelKeyFallback = getBundledModel("openai", "gpt-4o-mini");
|
|
const roleChainFallback = getBundledModel("openai", "gpt-4o");
|
|
if (!primaryModel || !modelKeyFallback || !roleChainFallback) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
default: [`${roleChainFallback.provider}/${roleChainFallback.id}`],
|
|
[`${primaryModel.provider}/${primaryModel.id}`]: [`${modelKeyFallback.provider}/${modelKeyFallback.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Model-keyed chain wins");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${modelKeyFallback.provider}/${modelKeyFallback.id}`,
|
|
]);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${modelKeyFallback.provider}/${modelKeyFallback.id}`,
|
|
role: `${primaryModel.provider}/${primaryModel.id}`,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("falls back to the chain when credential rotation exhausts the retry budget", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel();
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
if (model.provider === primaryModel.provider && model.id === primaryModel.id) {
|
|
mock.push({ throw: "429 usage_limit_reached" });
|
|
} else {
|
|
mock.push({ content: [`ok:${model.provider}/${model.id}`] });
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
// Rotation always claims a sibling credential is available — the shape
|
|
// of a multi-account pool where the sibling check passes but every
|
|
// subsequent request keeps failing on the same capped account.
|
|
vi.spyOn(modelRegistry.authStorage, "markUsageLimitReached").mockResolvedValue({ switched: true });
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 2,
|
|
"retry.fallbackChains": {
|
|
[`${primaryModel.provider}/${primaryModel.id}`]: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Exhaust rotation, then fail over");
|
|
await session.waitForIdle();
|
|
|
|
// Two rotation retries burn the budget on the primary; the exhausted
|
|
// attempt consults the chain instead of giving up.
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
// The fallback model gets a fresh retry budget (attempt resets to 1).
|
|
expect(retryStartEvents.map(event => event.attempt)).toEqual([1, 2, 1]);
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true });
|
|
});
|
|
|
|
it("applies a provider-wildcard chain to any model of that provider", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-opus-4-1");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
// No exact key for this model and no role assignment: only the
|
|
// `anthropic/*` wildcard can match, proving provider-level coverage.
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
"anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Recover via provider wildcard");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
role: "anthropic/*",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("consults the fallback chain on a non-retryable hard error instead of failing the turn", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const mock = createMockModel();
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
if (model.provider === primaryModel.provider) {
|
|
// Classifies as neither transient, usage-limit, nor auth:
|
|
// the generic retry classifier rejects it outright.
|
|
mock.push({ throw: "unrecoverable model quirk" });
|
|
} else {
|
|
mock.push({ content: ["Recovered on fallback"] });
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
"anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Survive a hard error");
|
|
await session.waitForIdle();
|
|
|
|
// Exactly one attempt on the failing model: a hard error switches models
|
|
// immediately, it never backoff-retries the same model.
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
role: "anthropic/*",
|
|
},
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(getLastAssistantMessage(session).stopReason).toBe("stop");
|
|
});
|
|
|
|
it("surfaces a non-retryable error without same-model retries when no fallback candidate has a credential", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const originalGetApiKey = modelRegistry.getApiKey.bind(modelRegistry);
|
|
vi.spyOn(modelRegistry, "getApiKey").mockImplementation((model, sessionId) =>
|
|
model.provider === fallbackModel.provider ? Promise.resolve(undefined) : originalGetApiKey(model, sessionId),
|
|
);
|
|
|
|
const mock = createMockModel();
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
mock.push({ throw: "unrecoverable model quirk" });
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
"anthropic/*": [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Fail hard with no fallback credential");
|
|
await session.waitForIdle();
|
|
|
|
// The switch could not happen and the error is non-retryable: surface it
|
|
// after a single attempt instead of backoff-retrying the failing model.
|
|
expect(requestedModels).toEqual([`${primaryModel.provider}/${primaryModel.id}`]);
|
|
expect(fallbackAppliedEvents).toEqual([]);
|
|
expect(getLastAssistantMessage(session).stopReason).toBe("error");
|
|
});
|
|
|
|
it("substitutes the failing model id into provider-wildcard chain entries", async () => {
|
|
const primaryModel = getBundledModel("google", "gemini-2.5-flash");
|
|
const fallbackModel = getBundledModel("google-vertex", "gemini-2.5-flash");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
// `google-vertex/*` is not a fixed target: it must adopt the failing
|
|
// model's id (google/gemini-2.5-flash -> google-vertex/gemini-2.5-flash).
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
"google/*": ["google-vertex/*"],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Recover via id-preserving wildcard entry");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe("google-vertex");
|
|
expect(session.model?.id).toBe(primaryModel.id);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `google-vertex/${primaryModel.id}`,
|
|
role: "google/*",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("re-prefixes the failing model's bare id for id-prefixed wildcard chain entries", async () => {
|
|
const primaryModel = getBundledModel("google", "gemini-2.5-flash");
|
|
const fallbackModel = getBundledModel("openrouter", "google/gemini-2.5-flash");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
// `openrouter/google/*` splits into provider `openrouter` + id prefix
|
|
// `google`: the failing bare id is re-prefixed into the aggregator's
|
|
// namespace (google/gemini-2.5-flash -> openrouter/google/gemini-2.5-flash).
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
"google/*": ["openrouter/google/*"],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Recover via id-prefixed wildcard entry");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe("openrouter");
|
|
expect(session.model?.id).toBe(`google/${primaryModel.id}`);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `openrouter/google/${primaryModel.id}`,
|
|
role: "google/*",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("matches id-prefixed wildcard keys and strips the vendor prefix for direct-provider targets", async () => {
|
|
const primaryModel = getBundledModel("openrouter", "google/gemini-2.5-flash");
|
|
const fallbackModel = getBundledModel("google-vertex", "gemini-2.5-flash");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
// Key `openrouter/google/*` covers only openrouter's google-namespaced
|
|
// ids; the plain `google-vertex/*` target drops the aggregator's vendor
|
|
// prefix because vertex only knows the bare id.
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
"openrouter/google/*": ["google-vertex/*"],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Recover via id-prefixed wildcard key");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe("google-vertex");
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `google-vertex/${fallbackModel.id}`,
|
|
role: "openrouter/google/*",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses the active initial model as the default fallback primary when other role fallback chains are configured", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
const otherRoleFallbackModel = getBundledModel("openai", "gpt-4o");
|
|
if (!primaryModel || !fallbackModel || !otherRoleFallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
smol: [`${otherRoleFallbackModel.provider}/${otherRoleFallbackModel.id}`],
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Recover using implicit default primary");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
role: "default",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("falls back on structured classifier refusals and pins the fallback", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const fallbackSucceededEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_succeeded" }>> = [];
|
|
const mock = createMockModel();
|
|
let primaryAttempts = 0;
|
|
const refusalDetails = {
|
|
type: "refusal",
|
|
category: "cyber",
|
|
explanation: "Classifier declined this turn.",
|
|
};
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
if (model.provider === primaryModel.provider && model.id === primaryModel.id) {
|
|
primaryAttempts += 1;
|
|
mock.push({
|
|
content: ["Classifier declined this turn."],
|
|
stopReason: "error",
|
|
stopDetails: refusalDetails,
|
|
errorMessage: "Refusal (cyber): Classifier declined this turn.",
|
|
});
|
|
} else if (model.provider === fallbackModel.provider && model.id === fallbackModel.id) {
|
|
mock.push({ content: [`ok:${primaryAttempts}`] });
|
|
} else {
|
|
throw new Error(
|
|
`Unexpected model requested during refusal fallback test: ${model.provider}/${model.id}`,
|
|
);
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
"retry.fallbackRevertPolicy": "cooldown-expiry",
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
if (event.type === "retry_fallback_succeeded") {
|
|
fallbackSucceededEvents.push(event);
|
|
}
|
|
});
|
|
let now = Date.now();
|
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
|
|
await session.prompt("Recover from classifier refusal");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
role: "default",
|
|
},
|
|
]);
|
|
expect(fallbackSucceededEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_succeeded",
|
|
model: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
role: "default",
|
|
},
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
|
|
now += 10 * 60 * 1000;
|
|
await session.prompt("Next turn stays pinned on fallback");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
});
|
|
|
|
it("drops classifier refusal messages before later prompts", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!primaryModel) {
|
|
throw new Error("Expected bundled test model to exist");
|
|
}
|
|
|
|
const mock = createMockModel({
|
|
responses: [
|
|
{
|
|
content: ["Classifier declined this turn."],
|
|
stopReason: "error",
|
|
stopDetails: {
|
|
type: "refusal",
|
|
category: "bio",
|
|
explanation: "Classifier declined this turn.",
|
|
},
|
|
errorMessage: "Refusal (bio): Classifier declined this turn.",
|
|
},
|
|
context => {
|
|
const replayedAssistantText = context.messages
|
|
.filter((message): message is AssistantMessage => message.role === "assistant")
|
|
.flatMap(message => message.content)
|
|
.filter(block => block.type === "text")
|
|
.map(block => block.text)
|
|
.join("\n");
|
|
return {
|
|
content: [replayedAssistantText.includes("Classifier declined this turn.") ? "polluted" : "clean"],
|
|
};
|
|
},
|
|
],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => mock.stream(model, context, options),
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.modelFallback": false,
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
const sessionStopCalls: number[] = [];
|
|
const sessionStopLastAssistantMessages: Array<AssistantMessage | undefined> = [];
|
|
const extensionRunner = {
|
|
emit: vi.fn().mockResolvedValue(undefined),
|
|
emitBeforeAgentStart: vi.fn().mockResolvedValue(undefined),
|
|
hasHandlers: vi.fn((eventType: string) => eventType === "session_stop"),
|
|
emitSessionStop: vi.fn((event: { last_assistant_message?: AssistantMessage }) => {
|
|
sessionStopCalls.push(mock.calls.length);
|
|
sessionStopLastAssistantMessages.push(event.last_assistant_message);
|
|
return Promise.resolve(undefined);
|
|
}),
|
|
} as unknown as ExtensionRunner;
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
extensionRunner,
|
|
});
|
|
|
|
await session.prompt("Trigger classifier refusal");
|
|
await session.waitForIdle();
|
|
await session.prompt("Next prompt should not replay the refusal");
|
|
await session.waitForIdle();
|
|
|
|
expect(mock.calls).toHaveLength(2);
|
|
const replayedAssistantText = mock.calls[1]?.context.messages
|
|
.filter((message): message is AssistantMessage => message.role === "assistant")
|
|
.flatMap(message => message.content)
|
|
.filter(block => block.type === "text")
|
|
.map(block => block.text)
|
|
.join("\n");
|
|
expect(replayedAssistantText).not.toContain("Classifier declined this turn.");
|
|
expect(getLastAssistantMessage(session).content).toEqual([{ type: "text", text: "clean" }]);
|
|
// session_stop hooks must fire after each settled turn — including the
|
|
// refusal turn (regression: prior to PR #3594's review fix, the refusal
|
|
// branch short-circuited before `#emitSessionStopEvent`).
|
|
expect(sessionStopCalls).toEqual([1, 2]);
|
|
expect(sessionStopLastAssistantMessages[0]?.stopReason).toBe("error");
|
|
expect(sessionStopLastAssistantMessages[0]?.stopDetails).toEqual({
|
|
type: "refusal",
|
|
category: "bio",
|
|
explanation: "Classifier declined this turn.",
|
|
});
|
|
});
|
|
|
|
it("keeps the pruned refusal visible to getLastAssistantMessage until the next run", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!primaryModel) {
|
|
throw new Error("Expected bundled test model to exist");
|
|
}
|
|
|
|
const mock = createMockModel({
|
|
responses: [
|
|
{
|
|
stopReason: "error",
|
|
stopDetails: { type: "refusal", category: "cyber", explanation: "Declined." },
|
|
errorMessage: "Refusal (cyber): Declined.",
|
|
},
|
|
{ content: ["recovered"] },
|
|
],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => mock.stream(model, context, options),
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.modelFallback": false,
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
await session.prompt("Trigger classifier refusal");
|
|
await session.waitForIdle();
|
|
|
|
// The refusal turn is pruned from active context (no assistant tail)…
|
|
expect(session.agent.state.messages.at(-1)?.role).toBe("user");
|
|
// …but terminal-outcome consumers (print mode, task executor) must still
|
|
// see the settled error instead of a silently successful-looking state.
|
|
const settled = session.getLastAssistantMessage();
|
|
expect(settled?.stopReason).toBe("error");
|
|
expect(settled?.errorMessage).toBe("Refusal (cyber): Declined.");
|
|
expect(settled?.stopDetails).toEqual({ type: "refusal", category: "cyber", explanation: "Declined." });
|
|
|
|
await session.prompt("Next prompt supersedes the pruned refusal");
|
|
await session.waitForIdle();
|
|
|
|
const recovered = session.getLastAssistantMessage();
|
|
expect(recovered?.stopReason).toBe("stop");
|
|
expect(recovered?.content).toEqual([{ type: "text", text: "recovered" }]);
|
|
});
|
|
|
|
it("does not exceed retry.maxRetries for classifier fallback chains", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const firstFallback = getBundledModel("openai", "gpt-4o-mini");
|
|
const secondFallback = getBundledModel("openai", "gpt-4o");
|
|
if (!primaryModel || !firstFallback || !secondFallback) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const retryEndEvents: Array<Extract<AgentSessionEvent, { type: "auto_retry_end" }>> = [];
|
|
const mock = createMockModel();
|
|
const refusalMessage = "Refusal (cyber): Classifier declined this fallback turn.";
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
if (model.provider === primaryModel.provider && model.id === primaryModel.id) {
|
|
mock.push({ throw: "overloaded_error: provider returned error 503" });
|
|
} else if (model.provider === firstFallback.provider && model.id === firstFallback.id) {
|
|
mock.push({
|
|
stopReason: "error",
|
|
stopDetails: {
|
|
type: "refusal",
|
|
category: "cyber",
|
|
explanation: "Classifier declined this fallback turn.",
|
|
},
|
|
errorMessage: refusalMessage,
|
|
});
|
|
} else {
|
|
throw new Error(
|
|
`Unexpected model requested after retry budget exhaustion: ${model.provider}/${model.id}`,
|
|
);
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
default: [
|
|
`${firstFallback.provider}/${firstFallback.id}`,
|
|
`${secondFallback.provider}/${secondFallback.id}`,
|
|
],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
if (event.type === "auto_retry_end") {
|
|
retryEndEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Stop after the configured retry budget");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${firstFallback.provider}/${firstFallback.id}`,
|
|
]);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${firstFallback.provider}/${firstFallback.id}`,
|
|
role: "default",
|
|
},
|
|
]);
|
|
expect(retryEndEvents).toEqual([
|
|
{
|
|
type: "auto_retry_end",
|
|
success: false,
|
|
attempt: 1,
|
|
finalError: refusalMessage,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("emits auto_retry_end when a mid-saga classifier refusal has no fallback to switch to", async () => {
|
|
// Regression: `#handleRetryableError`'s classifier-refusal branch used to
|
|
// return `false` without emitting `auto_retry_end` whenever no fallback
|
|
// model was available to switch to. A saga that already announced
|
|
// `auto_retry_start` on an earlier (non-refusal) attempt would then never
|
|
// get a matching `auto_retry_end` — leaving any subscriber tracking
|
|
// "retry outstanding" state (e.g. suppressing a duplicate error toast)
|
|
// latched open forever. With `retry.maxRetries: 2` and no fallback chain
|
|
// configured, the second attempt's classifier refusal hits that branch
|
|
// while `retryAttempt (2) <= maxRetries (2)`, so it can't fall through
|
|
// the pre-existing maxRetries-exceeded path (which already emits
|
|
// `auto_retry_end`) — isolating the branch this regression covers.
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!primaryModel) {
|
|
throw new Error("Expected bundled test model to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const refusalMessage = "Refusal (cyber): Classifier declined this retried turn.";
|
|
const mock = createMockModel();
|
|
let calls = 0;
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (model, context, options) => {
|
|
requestedModels.push(`${model.provider}/${model.id}`);
|
|
calls += 1;
|
|
if (calls === 1) {
|
|
mock.push({ throw: "overloaded_error: provider returned error 503" });
|
|
} else if (calls === 2) {
|
|
mock.push({
|
|
stopReason: "error",
|
|
stopDetails: { type: "refusal", category: "cyber", explanation: "Classifier declined." },
|
|
errorMessage: refusalMessage,
|
|
});
|
|
} else {
|
|
throw new Error(`Unexpected model call after the classifier refusal settled: call ${calls}`);
|
|
}
|
|
return mock.stream(model, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 2,
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry once, then hit a classifier refusal with no fallback");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]?.attempt).toBe(1);
|
|
expect(retryEndEvents).toEqual([
|
|
{
|
|
type: "auto_retry_end",
|
|
success: false,
|
|
attempt: 1,
|
|
finalError: refusalMessage,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses Google retry hints in quota errors before quota backoff", async () => {
|
|
const model = getBundledModel("google", "gemini-1.5-flash");
|
|
if (!model) {
|
|
throw new Error("Expected bundled Google test model to exist");
|
|
}
|
|
|
|
const errorMessage =
|
|
"Google API error (429): Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 250000. Please retry in 0.05s.";
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel({
|
|
responses: [{ throw: errorMessage }, { content: ["Recovered after Google quota retry"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const waitSpy = vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry Google token quota");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
maxAttempts: 1,
|
|
delayMs: 50,
|
|
errorMessage,
|
|
});
|
|
expect(waitSpy).toHaveBeenCalledWith(50, { signal: expect.any(AbortSignal) });
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after Google quota retry" });
|
|
});
|
|
|
|
it("keeps retry on the primary model when retry model fallback is disabled", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const fallbackSucceededEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_succeeded" }>> = [];
|
|
const mock = createMockModel({
|
|
responses: [{ throw: "rate limit exceeded retry-after-ms=200" }, { content: ["Recovered on primary retry"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.modelFallback": false,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const waitSpy = vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
if (event.type === "retry_fallback_succeeded") {
|
|
fallbackSucceededEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Retry rate limit without switching models");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
maxAttempts: 1,
|
|
delayMs: 200,
|
|
errorMessage: "rate limit exceeded retry-after-ms=200",
|
|
});
|
|
expect(waitSpy).toHaveBeenCalledWith(200, { signal: expect.any(AbortSignal) });
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
expect(fallbackAppliedEvents).toHaveLength(0);
|
|
expect(fallbackSucceededEvents).toHaveLength(0);
|
|
expect(session.model?.provider).toBe(primaryModel.provider);
|
|
expect(session.model?.id).toBe(primaryModel.id);
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered on primary retry" });
|
|
});
|
|
|
|
it("auto-retries preserved OpenAI first-event timeout errors", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!model) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
|
|
const timeoutMessage = "OpenAI responses stream timed out while waiting for the first event";
|
|
const requestedModels: string[] = [];
|
|
|
|
const mock = createMockModel({
|
|
responses: [{ throw: timeoutMessage }, { content: ["Recovered after OpenAI timeout"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry preserved OpenAI timeout");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
maxAttempts: 1,
|
|
errorMessage: timeoutMessage,
|
|
});
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after OpenAI timeout" });
|
|
});
|
|
|
|
it("auto-retries stream stall errors", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!model) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
|
|
const stallMessage = "Provider stream stalled while waiting for the next event";
|
|
const requestedModels: string[] = [];
|
|
|
|
const mock = createMockModel({
|
|
responses: [{ throw: stallMessage }, { content: ["Recovered after stream stall"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry stream stall");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
maxAttempts: 1,
|
|
errorMessage: stallMessage,
|
|
});
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after stream stall" });
|
|
});
|
|
|
|
it("auto-retries OpenAI processing-request transient errors", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!model) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
|
|
const processingError =
|
|
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID 4a4c6b73-a07c-4de0-aaaf-82560f9f626a in your message.";
|
|
const requestedModels: string[] = [];
|
|
|
|
const mock = createMockModel({
|
|
responses: [{ throw: processingError }, { content: ["Recovered after OpenAI processing error"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry OpenAI processing-request error");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
maxAttempts: 1,
|
|
errorMessage: processingError,
|
|
});
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({
|
|
type: "text",
|
|
text: "Recovered after OpenAI processing error",
|
|
});
|
|
});
|
|
|
|
it("restarts Responses provider state before retrying stale item-id replay errors", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
const fallbackModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!model || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const staleReplayError = "Item with id 'rs_stale' not found.";
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const mock = createMockModel({
|
|
responses: [{ throw: staleReplayError }, { content: ["Recovered after Responses state reset"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
const closeSpy = vi.fn();
|
|
session.providerSessionState.set("openai-responses:openai", {
|
|
close: closeSpy,
|
|
} satisfies ProviderSessionState);
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry stale OpenAI replay");
|
|
await session.waitForIdle();
|
|
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
expect(session.providerSessionState.has("openai-responses:openai")).toBe(false);
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(fallbackAppliedEvents).toHaveLength(0);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
delayMs: 0,
|
|
errorMessage: staleReplayError,
|
|
});
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({
|
|
type: "text",
|
|
text: "Recovered after Responses state reset",
|
|
});
|
|
});
|
|
|
|
it("restarts Responses provider state before retrying Zero Data Retention errors", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
const fallbackModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!model || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
// Mirrors the live wire error from OpenAI ZDR orgs after the in-provider
|
|
// retry has already exhausted itself; the higher-level retry must still
|
|
// classify the failure as a stale-replay event so the session reset and
|
|
// zero-delay backoff fire instead of a model fallback.
|
|
const zdrReplayError = "400 Previous response cannot be used for this organization due to Zero Data Retention.";
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const mock = createMockModel({
|
|
responses: [{ throw: zdrReplayError }, { content: ["Recovered after ZDR reset"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
});
|
|
const closeSpy = vi.fn();
|
|
session.providerSessionState.set("openai-responses:openai", {
|
|
close: closeSpy,
|
|
} satisfies ProviderSessionState);
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry ZDR replay");
|
|
await session.waitForIdle();
|
|
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
expect(session.providerSessionState.has("openai-responses:openai")).toBe(false);
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(fallbackAppliedEvents).toHaveLength(0);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
delayMs: 0,
|
|
errorMessage: zdrReplayError,
|
|
});
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after ZDR reset" });
|
|
});
|
|
|
|
it("auto-retries Anthropic stream-envelope failures before message_start", async () => {
|
|
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
if (!model) {
|
|
throw new Error("Expected bundled Anthropic test model to exist");
|
|
}
|
|
|
|
const envelopeError = "Anthropic stream envelope error: received content_block_start before message_start";
|
|
const requestedModels: string[] = [];
|
|
|
|
const mock = createMockModel({
|
|
responses: [{ throw: envelopeError }, { content: ["Recovered after Anthropic envelope retry"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry Anthropic envelope failure before message_start");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0]).toMatchObject({
|
|
attempt: 1,
|
|
maxAttempts: 1,
|
|
errorMessage: envelopeError,
|
|
});
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({ type: "text", text: "Recovered after Anthropic envelope retry" });
|
|
});
|
|
|
|
it("falls back on mid-stream Anthropic envelope failures without same-model retries", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
// Mid-stream envelope corruption is not auto-retried on the same model
|
|
// (partial content may have been delivered), but a configured fallback
|
|
// chain is still consulted: a different model is a fresh chance.
|
|
const envelopeError = "Anthropic stream envelope error: received content_block_delta before terminal stop signal";
|
|
const requestedModels: string[] = [];
|
|
const fallbackAppliedEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_applied" }>> = [];
|
|
const fallbackSucceededEvents: Array<Extract<AgentSessionEvent, { type: "retry_fallback_succeeded" }>> = [];
|
|
|
|
const mock = createMockModel({ handler: () => ({ throw: envelopeError }) });
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents } = trackRetryEvents(session);
|
|
session.subscribe(event => {
|
|
if (event.type === "retry_fallback_applied") {
|
|
fallbackAppliedEvents.push(event);
|
|
}
|
|
if (event.type === "retry_fallback_succeeded") {
|
|
fallbackSucceededEvents.push(event);
|
|
}
|
|
});
|
|
|
|
await session.prompt("Do not retry Anthropic envelope failure before terminal stop signal");
|
|
await session.waitForIdle();
|
|
|
|
// One attempt per model: chain advances, never a same-model backoff retry.
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(fallbackAppliedEvents).toEqual([
|
|
{
|
|
type: "retry_fallback_applied",
|
|
from: `${primaryModel.provider}/${primaryModel.id}`,
|
|
to: `${fallbackModel.provider}/${fallbackModel.id}`,
|
|
role: "default",
|
|
},
|
|
]);
|
|
// The fallback fails with the same hard error and the chain is exhausted:
|
|
// the failure surfaces instead of looping.
|
|
expect(fallbackSucceededEvents).toHaveLength(0);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("error");
|
|
expect(lastAssistant.errorMessage).toBe(envelopeError);
|
|
});
|
|
|
|
it("closes the retry lifecycle when a retried turn ends with a non-retryable error", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!model) throw new Error("Expected bundled OpenAI test model to exist");
|
|
|
|
const retryableError = "rate limit exceeded retry-after-ms=5";
|
|
const terminalError = "invalid request: schema violation";
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel();
|
|
const agent = new Agent({
|
|
getApiKey: requestedModel => `${requestedModel.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
mock.push({ throw: requestedModels.length === 1 ? retryableError : terminalError });
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
|
|
|
|
await session.prompt("Retry once, then surface a terminal validation failure");
|
|
await session.waitForIdle();
|
|
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryEndEvents).toEqual([
|
|
expect.objectContaining({ success: false, attempt: 1, finalError: terminalError }),
|
|
]);
|
|
expect(session.retryAttempt).toBe(0);
|
|
expect(getLastAssistantMessage(session).stopReason).toBe("error");
|
|
});
|
|
|
|
it("auto-retries a bare Request was aborted error-stop turn (issue #5375)", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!model) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
// A stalled/dropped stream that the provider surfaces as stopReason:"error"
|
|
// carrying the bare abort sentinel, then a clean recovery on the retry.
|
|
const mock = createMockModel({
|
|
responses: [{ throw: "Request was aborted." }, { content: ["recovered after bare abort error"] }],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("Retry the bare abort error");
|
|
await session.waitForIdle();
|
|
|
|
// Same model, retried once (no model fallback for a reason-less abort).
|
|
expect(requestedModels).toEqual([`${model.provider}/${model.id}`, `${model.provider}/${model.id}`]);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
|
const lastAssistant = getLastAssistantMessage(session);
|
|
expect(lastAssistant.stopReason).toBe("stop");
|
|
expect(lastAssistant.content).toContainEqual({
|
|
type: "text",
|
|
text: "recovered after bare abort error",
|
|
});
|
|
});
|
|
|
|
it("matches plain fallback roles for compat-routed primary models", async () => {
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!fallbackModel) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
const routedPrimary = buildModel({
|
|
id: "z-ai/glm-4.7",
|
|
name: "GLM 4.7",
|
|
api: "openai-completions",
|
|
provider: "openrouter",
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 128000,
|
|
maxTokens: 8192,
|
|
compat: { openRouterRouting: { only: ["cerebras"] } },
|
|
});
|
|
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel();
|
|
let primaryAttempts = 0;
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: routedPrimary,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
const route =
|
|
requestedModel.provider === "openrouter" &&
|
|
requestedModel.compat &&
|
|
"openRouterRouting" in requestedModel.compat
|
|
? requestedModel.compat.openRouterRouting?.only?.[0]
|
|
: undefined;
|
|
const requested = `${requestedModel.provider}/${requestedModel.id}${route ? `@${route}` : ""}`;
|
|
requestedModels.push(requested);
|
|
if (requestedModel.provider === "openrouter" && primaryAttempts === 0) {
|
|
primaryAttempts += 1;
|
|
mock.push({ throw: "rate limit exceeded retry-after-ms=200" });
|
|
} else {
|
|
mock.push({ content: [`ok:${requested}`] });
|
|
}
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", "openrouter/z-ai/glm-4.7");
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
await session.prompt("Compat-routed primary should still match plain role");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
"openrouter/z-ai/glm-4.7@cerebras",
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
});
|
|
|
|
it("keeps exact @-suffixed model IDs in fallback selectors", async () => {
|
|
const primaryModel = getBundledModel("openai", "gpt-4o-mini");
|
|
const fallbackModel = getBundledModel("google-vertex", "claude-opus-4-8@default");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled OpenAI and Vertex Anthropic test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel();
|
|
let primaryAttempts = 0;
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: primaryModel,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
if (requestedModel.provider === primaryModel.provider && primaryAttempts === 0) {
|
|
primaryAttempts += 1;
|
|
mock.push({ throw: "rate limit exceeded retry-after-ms=200" });
|
|
} else {
|
|
mock.push({ content: [`ok:${requestedModel.provider}/${requestedModel.id}`] });
|
|
}
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
await session.prompt("Fallback should keep exact @ model id");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
});
|
|
it("suppresses cooled selectors and lazily reverts to the role primary after cooldown expiry", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
"retry.fallbackRevertPolicy": "cooldown-expiry",
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
let now = Date.now();
|
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
|
|
await session.prompt("First prompt triggers fallback");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
|
|
await session.prompt("Immediate second prompt should stay on fallback");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
|
|
now += 240;
|
|
await session.prompt("Third prompt should lazily revert to primary");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(primaryModel.provider);
|
|
expect(session.model?.id).toBe(primaryModel.id);
|
|
});
|
|
|
|
it("restores routed fallback primaries after cooldown expiry", async () => {
|
|
const openRouterModel = getBundledModel("openrouter", "z-ai/glm-4.7");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!openRouterModel || !fallbackModel) {
|
|
throw new Error("Expected bundled OpenRouter and OpenAI test models to exist");
|
|
}
|
|
const routedPrimary = parseModelPattern("openrouter/z-ai/glm-4.7@cerebras", [openRouterModel]).model;
|
|
if (!routedPrimary) {
|
|
throw new Error("Expected routed OpenRouter primary to resolve");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const mock = createMockModel();
|
|
let primaryAttempts = 0;
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model: routedPrimary,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
const route =
|
|
requestedModel.provider === "openrouter"
|
|
? (
|
|
requestedModel.compat as {
|
|
openRouterRouting?: { only?: string[] };
|
|
}
|
|
).openRouterRouting?.only?.[0]
|
|
: undefined;
|
|
const requested = `${requestedModel.provider}/${requestedModel.id}${route ? `@${route}` : ""}`;
|
|
requestedModels.push(requested);
|
|
if (requested === "openrouter/z-ai/glm-4.7@cerebras" && primaryAttempts === 0) {
|
|
primaryAttempts += 1;
|
|
mock.push({ throw: "rate limit exceeded retry-after-ms=200" });
|
|
} else {
|
|
mock.push({ content: [`ok:${requested}`] });
|
|
}
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
"retry.fallbackRevertPolicy": "cooldown-expiry",
|
|
});
|
|
settings.setModelRole("default", "openrouter/z-ai/glm-4.7@cerebras");
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
let now = Date.now();
|
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
|
|
await session.prompt("First prompt triggers routed primary fallback");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
"openrouter/z-ai/glm-4.7@cerebras",
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
|
|
now += 240;
|
|
await session.prompt("Second prompt should restore routed primary");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
"openrouter/z-ai/glm-4.7@cerebras",
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
"openrouter/z-ai/glm-4.7@cerebras",
|
|
]);
|
|
expect(session.model?.provider).toBe("openrouter");
|
|
expect(session.model?.id).toBe("z-ai/glm-4.7");
|
|
expect(
|
|
(session.model?.compat as { openRouterRouting?: { only?: string[] } } | undefined)?.openRouterRouting?.only,
|
|
).toEqual(["cerebras"]);
|
|
});
|
|
it("preserves thinking on bare fallback selectors and does not overwrite user thinking on restore", async () => {
|
|
const primaryModel = getBundledModel("anthropic", "claude-sonnet-4-5");
|
|
const fallbackModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel || !fallbackModel) {
|
|
throw new Error("Expected bundled test models to exist");
|
|
}
|
|
|
|
const requestedModels: string[] = [];
|
|
const agent = createFallbackAgent(primaryModel, requestedModels);
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.fallbackChains": {
|
|
default: [`${fallbackModel.provider}/${fallbackModel.id}`],
|
|
},
|
|
"retry.fallbackRevertPolicy": "cooldown-expiry",
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}:high`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
thinkingLevel: Effort.High,
|
|
});
|
|
let now = Date.now();
|
|
vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
|
|
await session.prompt("First prompt triggers bare-selector fallback");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(fallbackModel.provider);
|
|
expect(session.model?.id).toBe(fallbackModel.id);
|
|
expect(session.thinkingLevel).toBeUndefined();
|
|
|
|
session.setThinkingLevel(Effort.Low);
|
|
now += 240;
|
|
await session.prompt("Second prompt should restore model but preserve user thinking change");
|
|
await session.waitForIdle();
|
|
expect(requestedModels).toEqual([
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
`${fallbackModel.provider}/${fallbackModel.id}`,
|
|
`${primaryModel.provider}/${primaryModel.id}`,
|
|
]);
|
|
expect(session.model?.provider).toBe(primaryModel.provider);
|
|
expect(session.model?.id).toBe(primaryModel.id);
|
|
expect(session.thinkingLevel).toBeUndefined();
|
|
});
|
|
|
|
it("accepts cached Ollama Cloud fallback selectors during startup validation", () => {
|
|
const primaryModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
const cachedModel: Model<"ollama-chat"> = buildModel({
|
|
id: "deepseek-v4-pro",
|
|
name: "DeepSeek V4 Pro",
|
|
api: "ollama-chat",
|
|
provider: "ollama-cloud",
|
|
baseUrl: "https://ollama.com",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 1_000_000,
|
|
maxTokens: 384_000,
|
|
});
|
|
writeModelCache("ollama-cloud", Date.now(), [cachedModel], true, "", path.join(tempDir.path(), "models.db"));
|
|
modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.json"));
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.fallbackChains": { default: ["ollama-cloud/deepseek-v4-pro"] },
|
|
});
|
|
settings.setModelRole("default", `${primaryModel.provider}/${primaryModel.id}`);
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
streamFn: () => {
|
|
throw new Error("Not exercised");
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
expect(session.configWarnings).not.toContain(
|
|
"Fallback chain for role 'default' references unknown model: ollama-cloud/deepseek-v4-pro",
|
|
);
|
|
});
|
|
|
|
it("warns on unknown or malformed model-selector chain keys at startup", () => {
|
|
const primaryModel = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!primaryModel) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.fallbackChains": {
|
|
"nonexistent-provider/nonexistent-model": [`${primaryModel.provider}/${primaryModel.id}`],
|
|
[`${primaryModel.provider}/${primaryModel.id}`]: ["openai/gpt-4o"],
|
|
},
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: { model: primaryModel, systemPrompt: ["Test"], tools: [], messages: [] },
|
|
streamFn: () => {
|
|
throw new Error("Not exercised");
|
|
},
|
|
});
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
|
|
expect(session.configWarnings).toContain(
|
|
"retry.fallbackChains key references unknown model: nonexistent-provider/nonexistent-model",
|
|
);
|
|
expect(session.configWarnings.filter(w => w.includes(`${primaryModel.provider}/${primaryModel.id}`))).toEqual([]);
|
|
});
|
|
|
|
it("normalizes suppression by base selector and clears it on model refresh", async () => {
|
|
const future = Date.now() + 60_000;
|
|
modelRegistry.suppressSelector("openai/gpt-4o:high", future);
|
|
expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(true);
|
|
expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:low")).toBe(true);
|
|
|
|
// `:max` is a real thinking level now, not an xhigh alias — the two parse
|
|
// to distinct selectors...
|
|
expect(parseModelString("openai/gpt-4o:max", { allowMaxSuffix: true })?.thinkingLevel).toBe(Effort.Max);
|
|
expect(parseModelString("openai/gpt-4o:xhigh")?.thinkingLevel).toBe(Effort.XHigh);
|
|
// ...but suppression normalizes every thinking suffix to the base selector,
|
|
// so suppressing either still covers both.
|
|
modelRegistry.suppressSelector("openai/gpt-4o:max", future);
|
|
expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:xhigh")).toBe(true);
|
|
expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o:max")).toBe(true);
|
|
|
|
await modelRegistry.refresh("offline");
|
|
expect(modelRegistry.isSelectorSuppressed("openai/gpt-4o")).toBe(false);
|
|
});
|
|
|
|
it("auto-retries Gemini MALFORMED_FUNCTION_CALL transient errors", async () => {
|
|
const model = getBundledModel("google", "gemini-1.5-flash");
|
|
if (!model) {
|
|
throw new Error("Expected bundled Google test model to exist");
|
|
}
|
|
|
|
const malformedError = "Generation failed with finish reason: MALFORMED_FUNCTION_CALL";
|
|
const requestedModels: string[] = [];
|
|
|
|
const mock = createMockModel({
|
|
responses: [
|
|
{
|
|
content: [
|
|
{ type: "thinking", thinking: "Thinking before malformed function call..." },
|
|
{ type: "text", text: "Text before malformed function call..." },
|
|
],
|
|
stopReason: "error",
|
|
errorMessage: malformedError,
|
|
},
|
|
{ content: ["Recovered after Gemini malformed function call"] },
|
|
],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => {
|
|
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
|
|
return mock.stream(requestedModel, context, options);
|
|
},
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("recover from Gemini malformed error");
|
|
await session.waitForIdle();
|
|
|
|
expect(mock.calls).toHaveLength(2);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(session.agent.state.messages).toHaveLength(2);
|
|
const assistantMsg = session.agent.state.messages[1];
|
|
if (assistantMsg.role !== "assistant") {
|
|
throw new Error(`Expected assistant message, got ${assistantMsg.role}`);
|
|
}
|
|
const contentBlock = assistantMsg.content[0];
|
|
if (contentBlock.type !== "text") {
|
|
throw new Error(`Expected text content block, got ${contentBlock.type}`);
|
|
}
|
|
expect(contentBlock.text).toBe("Recovered after Gemini malformed function call");
|
|
});
|
|
|
|
it("auto-retries provider finish_reason errors after partial text", async () => {
|
|
const model = getBundledModel("openai", "gpt-4o-mini");
|
|
if (!model) {
|
|
throw new Error("Expected bundled OpenAI test model to exist");
|
|
}
|
|
|
|
const errorMessage = "Provider returned error finish_reason";
|
|
const mock = createMockModel({
|
|
responses: [
|
|
{ content: ["partial output before gateway error"], stopReason: "error", errorMessage },
|
|
{ content: ["Recovered after provider finish_reason error"] },
|
|
],
|
|
});
|
|
const agent = new Agent({
|
|
getApiKey: model => `${model.provider}-test-key`,
|
|
initialState: {
|
|
model,
|
|
systemPrompt: ["Test"],
|
|
tools: [],
|
|
messages: [],
|
|
},
|
|
streamFn: (requestedModel, context, options) => mock.stream(requestedModel, context, options),
|
|
});
|
|
|
|
const settings = Settings.isolated({
|
|
"compaction.enabled": false,
|
|
"retry.baseDelayMs": 5,
|
|
"retry.maxRetries": 1,
|
|
"retry.modelFallback": false,
|
|
});
|
|
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
|
|
|
session = new AgentSession({
|
|
agent,
|
|
sessionManager: SessionManager.inMemory(),
|
|
settings,
|
|
modelRegistry,
|
|
});
|
|
const { retryStartEvents, retryEndEvents } = trackRetryEvents(session);
|
|
|
|
await session.prompt("recover from provider finish_reason error");
|
|
await session.waitForIdle();
|
|
|
|
expect(mock.calls).toHaveLength(2);
|
|
expect(retryStartEvents).toHaveLength(1);
|
|
expect(retryStartEvents[0].errorMessage).toBe(errorMessage);
|
|
expect(retryEndEvents).toHaveLength(1);
|
|
expect(session.agent.state.messages).toHaveLength(2);
|
|
const assistantMsg = session.agent.state.messages[1];
|
|
if (assistantMsg.role !== "assistant") {
|
|
throw new Error(`Expected assistant message, got ${assistantMsg.role}`);
|
|
}
|
|
const contentBlock = assistantMsg.content[0];
|
|
if (contentBlock.type !== "text") {
|
|
throw new Error(`Expected text content block, got ${contentBlock.type}`);
|
|
}
|
|
expect(contentBlock.text).toBe("Recovered after provider finish_reason error");
|
|
});
|
|
});
|