Download src/auto-reply/fallback-state.test.ts from SaylorTwift/openclaw: direct link, hf CLI and curl.
- Browser
- Download file 16.9 kB
-
https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/auto-reply/fallback-state.test.ts
- Command line
-
hf download hf://SaylorTwift/openclaw/src/auto-reply/fallback-state.test.ts
-
curl -L -o fallback-state.test.ts https://huggingface.co/SaylorTwift/openclaw/resolve/main/src/auto-reply/fallback-state.test.ts
16.9 kB
| /** 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<Parameters<typeof resolveFallbackTransition>[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" }); | |
| }); | |
| }); | |
| }); | |