Files
openclaw/src/auto-reply/fallback-state.test.ts
T
Peter Steinberger 8ee1d046d3 refactor(sessions): consolidate entry state (#115299)
* refactor(sessions): consolidate entry state

* refactor(gateway): unify session kind classification

* refactor(sessions): keep entry state types private

* test(sessions): drop retired pending delivery case

* fix(sessions): clear stale transport-only delivery
2026-07-28 14:31:02 -04:00

302 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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 {
resolveActiveFallbackState,
type FallbackNoticeState,
} from "../status/fallback-notice-state.js";
import { 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" },
},
{
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 },
},
])("$name", ({ state, expected }) => {
const resolved = resolveActiveFallbackState({
selectedModelRef: "demo-primary/model-a",
activeModelRef: "demo-fallback/model-b",
state,
});
expect(resolved).toEqual(expected);
});
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("normalizes fallback reason whitespace for summaries", () => {
const resolved = resolveDemoFallbackTransition({
attempts: [{ ...baseAttempt, reason: "rate_limit\n\tburst" }],
});
expect(resolved.reasonSummary).toBe("rate limit burst");
});
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 fixtureprovenance 可追溯:
// 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");
});
});