/** Tests model fallback notice formatting and transition state tracking. */ import { afterEach, describe, expect, it } from "vitest"; import { testing as cliBackendsTesting } from "../agents/cli-backends.test-support.js"; import { canonicalizeProviderModelId } from "../agents/provider-model-route.js"; import { resolveActiveFallbackState, type FallbackNoticeState, } from "../status/fallback-notice-state.js"; import { buildFallbackClearedNotice, buildFallbackNotice, resolveFallbackTransition, } from "./fallback-state.js"; const baseAttempt = { provider: "demo-primary", model: "demo-primary/model-a", error: "Provider demo-primary is in cooldown (all profiles unavailable)", reason: "rate_limit" as const, }; const activeFallbackState: FallbackNoticeState = { fallbackNotice: { kind: "active", selectedModel: "demo-primary/model-a", activeModel: "demo-fallback/model-b", reason: "rate limit", }, }; function registerAnthropicCliBackendForTest(): void { cliBackendsTesting.setDepsForTest({ resolveRuntimeCliBackends: () => [ { id: "claude-cli", modelProvider: "anthropic", pluginId: "anthropic", config: { command: "claude" }, bundleMcp: false, }, ], }); } function resolveDemoFallbackTransition( overrides: Partial[0]> = {}, ) { return resolveFallbackTransition({ selectedProvider: "demo-primary", selectedModel: "model-a", activeProvider: "demo-fallback", activeModel: "model-b", attempts: [baseAttempt], state: {}, ...overrides, }); } describe("fallback-state", () => { afterEach(() => { cliBackendsTesting.resetDepsForTest(); }); it.each([ { name: "treats fallback as active only when state matches selected and active refs", state: activeFallbackState, expected: { active: true, reason: "rate limit" }, expectedSetupLookups: 2, }, { name: "does not discover runtime aliases without persisted fallback state", state: undefined, expected: { active: false, reason: undefined }, expectedSetupLookups: 0, }, { name: "does not treat runtime drift as fallback when persisted state does not match", state: { fallbackNotice: { kind: "active", selectedModel: "other-provider/other-model", activeModel: "demo-fallback/model-b", reason: "rate limit", }, } satisfies FallbackNoticeState, expected: { active: false, reason: undefined }, expectedSetupLookups: 0, }, { name: "does not discover runtime aliases when the recorded active ref differs", state: { fallbackNotice: { kind: "active", selectedModel: "demo-primary/model-a", activeModel: "other-provider/other-model", reason: "rate limit", }, } satisfies FallbackNoticeState, expected: { active: false, reason: undefined }, expectedSetupLookups: 0, }, { name: "does not report a matching persisted CLI runtime alias as fallback", selectedModelRef: "anthropic/claude-opus-4-7", activeModelRef: "claude-cli/claude-opus-4-7", state: { fallbackNotice: { kind: "active", selectedModel: "anthropic/claude-opus-4-7", activeModel: "claude-cli/claude-opus-4-7", reason: "selected model unavailable", }, } satisfies FallbackNoticeState, expected: { active: false, reason: undefined }, expectedSetupLookups: 2, }, ])("$name", ({ state, expected, expectedSetupLookups, selectedModelRef, activeModelRef }) => { let setupLookups = 0; cliBackendsTesting.setDepsForTest({ resolveRuntimeCliBackends: () => [], resolvePluginSetupCliBackend: ({ backend }) => { setupLookups += 1; return backend === "claude-cli" ? { pluginId: "anthropic", backend: { id: "claude-cli", modelProvider: "anthropic", config: { command: "claude" }, bundleMcp: false, }, } : undefined; }, }); const resolved = resolveActiveFallbackState({ selectedModelRef: selectedModelRef ?? "demo-primary/model-a", activeModelRef: activeModelRef ?? "demo-fallback/model-b", config: {}, state, }); expect(resolved).toEqual(expected); expect(setupLookups).toBe(expectedSetupLookups); }); it("marks fallback transition when selected->active pair changes", () => { const resolved = resolveDemoFallbackTransition(); expect(resolved.fallbackActive).toBe(true); expect(resolved.fallbackTransitioned).toBe(true); expect(resolved.fallbackCleared).toBe(false); expect(resolved.stateChanged).toBe(true); expect(resolved.reasonSummary).toBe("rate limit"); expect(resolved.nextState.selectedModel).toBe("demo-primary/model-a"); expect(resolved.nextState.activeModel).toBe("demo-fallback/model-b"); }); it("preserves provider-local model prefixes through fallback and recovery", () => { const refs = { selectedProvider: "custom", selectedModel: "custom/model", activeProvider: "custom", activeModel: "model", attempts: [{ ...baseAttempt, provider: "custom", model: "custom/model" }], }; const activated = resolveDemoFallbackTransition(refs); expect(activated).toMatchObject({ fallbackActive: true, fallbackTransitioned: true, nextState: { selectedModel: "custom/custom/model", activeModel: "custom/model" }, attemptSummaries: ["custom/custom/model rate limit"], }); const state: FallbackNoticeState = { fallbackNotice: { kind: "active", selectedModel: activated.selectedModelRef, activeModel: activated.activeModelRef, reason: activated.reasonSummary, }, }; expect(resolveDemoFallbackTransition({ ...refs, state })).toMatchObject({ fallbackTransitioned: false, stateChanged: false, }); expect( resolveDemoFallbackTransition({ ...refs, activeModel: refs.selectedModel, state }), ).toMatchObject({ fallbackCleared: true, nextState: { selectedModel: undefined, activeModel: undefined, reason: undefined }, }); expect(buildFallbackNotice(refs)).toContain("selected custom/custom/model"); expect( buildFallbackClearedNotice({ ...refs, previousActiveModel: activated.activeModelRef }), ).toBe("↪️ Model Fallback cleared: custom/custom/model (was custom/model)"); }); it("prefers formatted transient error details over generic rate-limit labels", () => { const resolved = resolveDemoFallbackTransition({ attempts: [ { ...baseAttempt, error: "429 Too Many Requests: Claude Max usage limit reached, try again in 6 minutes.", }, ], }); expect(resolved.reasonSummary).toContain("HTTP 429: Too Many Requests"); expect(resolved.reasonSummary).toContain("Claude Max usage limit reached"); }); it.each([ // 真实 AWS Bedrock fixture,provenance 可追溯: // src/agents/failover-error.test.ts:54(引用 AWS troubleshooting 文档) // src/agents/failover-error.test.ts:688 / provider-error-patterns.test.ts:153 "ThrottlingException: Your request was denied due to exceeding the account quotas for Amazon Bedrock.", "ThrottlingException: Too many concurrent requests", ])( "preserves throttle-flavored transient details over the generic rate-limit label (%j)", (error) => { const resolved = resolveDemoFallbackTransition({ attempts: [{ ...baseAttempt, error }], }); // 回归: TRANSIENT_ERROR_DETAIL_HINT_RE 必须命中 throttle 词族 // (throttle/throttling/throttled/ThrottlingException)。原先裸 `throttl\b` // 仅匹配不存在的词干 "throttl",真实 Bedrock 消息全部失配,详细预览被 // 塌缩成通用 "rate limit" 标签。修复后预览得以保留。 expect(resolved.reasonSummary).toContain("ThrottlingException"); expect(resolved.reasonSummary).not.toBe("rate limit"); }, ); it("still collapses to the reason label when a transient reason lacks any transient-detail hint", () => { // 防止过度匹配: 修复不得让门控对无 transient 提示的文本也放行。 const resolved = resolveDemoFallbackTransition({ attempts: [{ ...baseAttempt, error: "Unauthorized: invalid API key" }], }); expect(resolved.reasonSummary).toBe("rate limit"); }); it("keeps truncated transient error details UTF-16 safe", () => { const detail = "x".repeat(68); const resolved = resolveDemoFallbackTransition({ attempts: [{ ...baseAttempt, error: `429 ${detail}😀tail` }], }); expect(resolved.reasonSummary).toBe(`HTTP 429: ${detail}…`); }); it("refreshes reason when fallback remains active with same model pair", () => { const resolved = resolveDemoFallbackTransition({ attempts: [{ ...baseAttempt, reason: "timeout" }], state: activeFallbackState, }); expect(resolved.fallbackTransitioned).toBe(false); expect(resolved.stateChanged).toBe(true); expect(resolved.nextState.reason).toBe("timeout"); }); it("marks fallback as cleared when runtime returns to selected model", () => { const resolved = resolveDemoFallbackTransition({ activeProvider: "demo-primary", selectedModel: "model-a", activeModel: "model-a", attempts: [], state: activeFallbackState, }); expect(resolved.fallbackActive).toBe(false); expect(resolved.fallbackCleared).toBe(true); expect(resolved.fallbackTransitioned).toBe(false); expect(resolved.stateChanged).toBe(true); expect(resolved.nextState.selectedModel).toBeUndefined(); expect(resolved.nextState.activeModel).toBeUndefined(); expect(resolved.nextState.reason).toBeUndefined(); }); it("does not treat a CLI runtime alias as a model fallback", () => { registerAnthropicCliBackendForTest(); const resolved = resolveFallbackTransition({ selectedProvider: "anthropic", selectedModel: "claude-opus-4-7", activeProvider: "claude-cli", activeModel: "claude-opus-4-7", attempts: [], state: { fallbackNotice: { kind: "active", selectedModel: "anthropic/claude-opus-4-7", activeModel: "claude-cli/claude-opus-4-7", reason: "selected model unavailable", }, }, cfg: {}, }); expect(resolved.fallbackActive).toBe(false); expect(resolved.fallbackCleared).toBe(false); expect(resolved.stateChanged).toBe(true); expect(resolved.nextState.selectedModel).toBeUndefined(); expect(resolved.nextState.activeModel).toBeUndefined(); }); it("does not repeat runtime alias comparison when persisted fallback refs match", () => { let setupBackendLookups = 0; cliBackendsTesting.setDepsForTest({ resolvePluginSetupCliBackend: ({ backend }) => { setupBackendLookups += 1; return backend === "claude-cli" ? { pluginId: "anthropic", backend: { id: "claude-cli", modelProvider: "anthropic", config: { command: "claude" }, bundleMcp: false, }, } : undefined; }, resolvePluginSetupRegistry: () => { throw new Error("full setup registry should not load for a single runtime alias"); }, resolveRuntimeCliBackends: () => [], }); const resolved = resolveFallbackTransition({ selectedProvider: "anthropic", selectedModel: "claude-opus-4-7", activeProvider: "claude-cli", activeModel: "claude-opus-4-7", attempts: [], state: { fallbackNotice: { kind: "active", selectedModel: "anthropic/claude-opus-4-7", activeModel: "claude-cli/claude-opus-4-7", reason: "selected model unavailable", }, }, cfg: {}, }); expect(resolved.fallbackActive).toBe(false); expect(setupBackendLookups).toBe(2); }); it("does not build a fallback notice for equivalent CLI runtime aliases", () => { registerAnthropicCliBackendForTest(); expect( buildFallbackNotice({ selectedProvider: "anthropic", selectedModel: "claude-opus-4-7", activeProvider: "claude-cli", activeModel: "claude-opus-4-7", attempts: [], }), ).toBeNull(); }); it.each(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "o3"])( "does not build a fallback notice for the OpenAI Codex runtime provider alias with %s", (model) => { expect( buildFallbackNotice({ selectedProvider: "openai", selectedModel: model, activeProvider: "openai", activeModel: model, attempts: [], }), ).toBeNull(); }, ); it("still reports fallback when the OpenAI Codex runtime switches model ids", () => { expect( buildFallbackNotice({ selectedProvider: "openai", selectedModel: "gpt-5.5", activeProvider: "openai", activeModel: "gpt-5.4", attempts: [], }), ).toContain("selected openai/gpt-5.5"); }); describe("Arcee wire identity", () => { it.each([ { name: "fresh state", state: {} satisfies FallbackNoticeState, expectedStateChanged: false, }, { name: "captured alias-only state", state: { fallbackNotice: { kind: "active", selectedModel: "arcee/trinity-large-preview", activeModel: "arcee/arcee-ai/trinity-large-preview", reason: "selected model unavailable", }, } satisfies FallbackNoticeState, expectedStateChanged: true, }, ])("keeps $name out of fallback state", ({ state, expectedStateChanged }) => { const params = { selectedProvider: "arcee", selectedModel: "trinity-large-preview", activeProvider: "arcee", activeModel: "arcee-ai/trinity-large-preview", attempts: [], cfg: {}, state, }; expect(canonicalizeProviderModelId("arcee", "arcee-ai/trinity-large-preview")).toBe( "trinity-large-preview", ); const resolved = resolveFallbackTransition(params); expect(resolved).toMatchObject({ fallbackActive: false, fallbackTransitioned: false, fallbackCleared: false, stateChanged: expectedStateChanged, }); expect(resolved.nextState).toEqual({ selectedModel: undefined, activeModel: undefined, reason: undefined, }); expect(buildFallbackNotice(params)).toBeNull(); expect( resolveActiveFallbackState({ selectedModelRef: "arcee/trinity-large-preview", activeModelRef: "arcee/arcee-ai/trinity-large-preview", config: {}, state, }), ).toEqual({ active: false, reason: undefined }); }); it.each([ { name: "different model", activeProvider: "arcee", activeModel: "arcee-ai/trinity-large-thinking", activeRef: "arcee/arcee-ai/trinity-large-thinking", }, { name: "different provider", activeProvider: "openrouter", activeModel: "arcee-ai/trinity-large-preview", activeRef: "openrouter/arcee-ai/trinity-large-preview", }, ])("keeps a $name as a real fallback", ({ activeProvider, activeModel, activeRef }) => { const params = { selectedProvider: "arcee", selectedModel: "trinity-large-preview", activeProvider, activeModel, attempts: [], cfg: {}, state: {}, }; const resolved = resolveFallbackTransition(params); expect(resolved).toMatchObject({ fallbackActive: true, fallbackTransitioned: true, fallbackCleared: false, stateChanged: true, nextState: { selectedModel: "arcee/trinity-large-preview", activeModel: activeRef, }, }); expect(buildFallbackNotice(params)).toContain(activeRef); expect( resolveActiveFallbackState({ selectedModelRef: "arcee/trinity-large-preview", activeModelRef: activeRef, config: {}, state: { fallbackNotice: { kind: "active", selectedModel: "arcee/trinity-large-preview", activeModel: activeRef, reason: "selected model unavailable", }, }, }), ).toEqual({ active: true, reason: "selected model unavailable" }); }); }); });