



























@@ -6,6 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
66import type { OpenClawConfig } from "../config/config.js";
77import { createDiagnosticLogRecordCapture } from "../logging/test-helpers/diagnostic-log-capture.js";
88import type { AuthProfileStore } from "./auth-profiles.js";
9+import type { SessionSuspensionParams } from "./session-suspension.js";
910import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
10111112// Mock auth-profile submodules before importing model-fallback so the module
@@ -29,6 +30,34 @@ vi.mock("./provider-model-normalization.runtime.js", () => ({
2930normalizeProviderModelIdWithRuntime: () => undefined,
3031}));
313233+const sessionSuspensionMocks = vi.hoisted(() => ({
34+suspendSession: vi.fn().mockResolvedValue(undefined),
35+runWithDeferredSessionSuspension: vi.fn(
36+(run: () => Promise<unknown>, onDeferred?: (params: SessionSuspensionParams) => void) => {
37+onDeferred?.({
38+cfg: {},
39+sessionId: "test-session",
40+laneId: "main",
41+reason: "quota_exhausted",
42+failedProvider: "openai",
43+failedModel: "gpt-4.1-mini",
44+});
45+return run();
46+},
47+),
48+resolveSessionSuspensionReason: vi.fn((reason: string) => {
49+if (reason === "billing") {
50+return "manual";
51+}
52+if (reason === "rate_limit") {
53+return "quota_exhausted";
54+}
55+return "circuit_open";
56+}),
57+}));
58+59+vi.mock("./session-suspension.js", () => sessionSuspensionMocks);
60+3261const emptyPluginMetadataSnapshot = vi.hoisted(() => ({
3362policyHash: "model-fallback-probe-test-empty-plugin-policy",
3463configFingerprint: "model-fallback-probe-test-empty-plugin-metadata",
@@ -341,6 +370,8 @@ describe("runWithModelFallback – probe logic", () => {
341370cleanupLogCapture = undefined;
342371setLoggerOverride(null);
343372resetLogger();
373+sessionSuspensionMocks.suspendSession.mockClear();
374+sessionSuspensionMocks.runWithDeferredSessionSuspension.mockClear();
344375vi.restoreAllMocks();
345376});
346377@@ -793,4 +824,238 @@ describe("runWithModelFallback – probe logic", () => {
793824"billing",
794825);
795826});
827+828+it("does not lock lane when fallback candidates remain after suspend_lanes decision", async () => {
829+const cfg = makeCfg({
830+agents: {
831+defaults: {
832+model: {
833+primary: "openai/gpt-4.1-mini",
834+fallbacks: ["anthropic/claude-haiku-3-5"],
835+},
836+},
837+},
838+} as Partial<OpenClawConfig>);
839+840+// Put only OpenAI into cooldown; Anthropic is available
841+mockedIsProfileInCooldown.mockImplementation((_store: AuthProfileStore, profileId: string) =>
842+profileId.startsWith("openai"),
843+);
844+mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 60 * 1000);
845+mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
846+847+const run = vi.fn().mockResolvedValue("fallback-ok");
848+849+await runWithModelFallback({
850+ cfg,
851+provider: "openai",
852+model: "gpt-4.1-mini",
853+ run,
854+sessionId: "test-session",
855+lane: "main",
856+});
857+858+expect(sessionSuspensionMocks.suspendSession).not.toHaveBeenCalled();
859+});
860+861+it("defers embedded lane suspension only while another candidate remains", async () => {
862+const cfg = makeCfg({
863+agents: {
864+defaults: {
865+model: {
866+primary: "openai/gpt-4.1-mini",
867+fallbacks: ["anthropic/claude-haiku-3-5"],
868+},
869+},
870+},
871+} as Partial<OpenClawConfig>);
872+mockedIsProfileInCooldown.mockReturnValue(false);
873+const run = vi
874+.fn()
875+.mockRejectedValueOnce(new Error("primary failed"))
876+.mockResolvedValueOnce("fallback-ok");
877+878+const result = await runWithModelFallback({
879+ cfg,
880+provider: "openai",
881+model: "gpt-4.1-mini",
882+ run,
883+sessionId: "test-session",
884+lane: "main",
885+});
886+887+expect(result.result).toBe("fallback-ok");
888+expect(run).toHaveBeenCalledTimes(2);
889+expect(sessionSuspensionMocks.runWithDeferredSessionSuspension).toHaveBeenCalledOnce();
890+});
891+892+it("discards deferred suspension when the outer run is aborted", async () => {
893+const cfg = makeCfg({
894+agents: {
895+defaults: {
896+model: {
897+primary: "openai/gpt-4.1-mini",
898+fallbacks: ["anthropic/claude-haiku-3-5"],
899+},
900+},
901+},
902+} as Partial<OpenClawConfig>);
903+mockedIsProfileInCooldown.mockReturnValue(false);
904+const controller = new AbortController();
905+const disconnect = new Error("client disconnected");
906+disconnect.name = "ClientDisconnectError";
907+const run = vi.fn().mockImplementation(async () => {
908+controller.abort(disconnect);
909+throw disconnect;
910+});
911+912+await expect(
913+runWithModelFallback({
914+ cfg,
915+provider: "openai",
916+model: "gpt-4.1-mini",
917+ run,
918+sessionId: "test-session",
919+lane: "main",
920+abortSignal: controller.signal,
921+}),
922+).rejects.toBe(disconnect);
923+924+expect(run).toHaveBeenCalledOnce();
925+expect(sessionSuspensionMocks.runWithDeferredSessionSuspension).toHaveBeenCalledOnce();
926+expect(sessionSuspensionMocks.suspendSession).not.toHaveBeenCalled();
927+});
928+929+it("keeps generic no-lane terminal suspension unbound", async () => {
930+const cfg = makeCfg({
931+agents: {
932+defaults: {
933+model: {
934+primary: "openai/gpt-4.1-mini",
935+fallbacks: ["anthropic/claude-haiku-3-5"],
936+},
937+},
938+},
939+} as Partial<OpenClawConfig>);
940+941+// Both providers in cooldown
942+mockedIsProfileInCooldown.mockReturnValue(true);
943+mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 60 * 1000);
944+mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
945+mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => {
946+if (provider === "openai") {
947+return ["openai-profile-1"];
948+}
949+if (provider === "anthropic") {
950+return ["anthropic-profile-1"];
951+}
952+return [];
953+});
954+955+// Throttle primary probe so billing goes to suspend_lanes
956+probeThrottleInternals.lastProbeAttempt.set("openai", NOW - 10_000);
957+958+const run = vi.fn().mockResolvedValue("should-not-run");
959+960+await expect(
961+runWithModelFallback({
962+ cfg,
963+provider: "openai",
964+model: "gpt-4.1-mini",
965+ run,
966+sessionId: "test-session",
967+}),
968+).rejects.toThrow();
969+970+expect(sessionSuspensionMocks.suspendSession).toHaveBeenCalledWith(
971+expect.objectContaining({
972+laneId: undefined,
973+failedProvider: "anthropic",
974+}),
975+);
976+expect(sessionSuspensionMocks.suspendSession).not.toHaveBeenCalledWith(
977+expect.objectContaining({ failedProvider: "openai" }),
978+);
979+expect(
980+sessionSuspensionMocks.suspendSession.mock.calls.every(
981+([params]) => params.laneId === undefined,
982+),
983+).toBe(true);
984+});
985+986+it("restores a deferred embedded lane when later candidates cannot run", async () => {
987+const cfg = makeCfg({
988+agents: {
989+defaults: {
990+model: {
991+primary: "openai/gpt-4.1-mini",
992+fallbacks: ["anthropic/claude-haiku-3-5"],
993+},
994+},
995+},
996+} as Partial<OpenClawConfig>);
997+mockedIsProfileInCooldown.mockImplementation((_store: AuthProfileStore, profileId: string) =>
998+profileId.startsWith("anthropic"),
999+);
1000+mockedGetSoonestCooldownExpiry.mockReturnValue(NOW + 30 * 60 * 1000);
1001+mockedResolveProfilesUnavailableReason.mockReturnValue("billing");
1002+mockedResolveAuthProfileOrder.mockImplementation(({ provider }: { provider: string }) => [
1003+`${provider}-profile-1`,
1004+]);
1005+const run = vi.fn().mockRejectedValueOnce(new Error("primary failed"));
1006+1007+await expect(
1008+runWithModelFallback({
1009+ cfg,
1010+provider: "openai",
1011+model: "gpt-4.1-mini",
1012+ run,
1013+sessionId: "test-session",
1014+}),
1015+).rejects.toThrow();
1016+1017+expect(run).toHaveBeenCalledOnce();
1018+expect(sessionSuspensionMocks.suspendSession).toHaveBeenCalledWith(
1019+expect.objectContaining({
1020+laneId: "main",
1021+failedProvider: "anthropic",
1022+}),
1023+);
1024+});
1025+1026+it("restores deferred suspension when a later harness precheck fails", async () => {
1027+const cfg = makeCfg({
1028+agents: {
1029+defaults: {
1030+model: {
1031+primary: "openai/gpt-4.1-mini",
1032+fallbacks: ["anthropic/claude-haiku-3-5"],
1033+},
1034+},
1035+},
1036+} as Partial<OpenClawConfig>);
1037+mockedIsProfileInCooldown.mockReturnValue(false);
1038+const run = vi.fn().mockRejectedValueOnce(new Error("primary failed"));
1039+1040+await expect(
1041+runWithModelFallback({
1042+ cfg,
1043+provider: "openai",
1044+model: "gpt-4.1-mini",
1045+sessionId: "test-session",
1046+resolveAgentHarnessRuntimeOverride: (provider) =>
1047+provider === "anthropic" ? "missing-strict-harness" : undefined,
1048+prepareAgentHarnessRuntime: () => undefined,
1049+ run,
1050+}),
1051+).rejects.toThrow('Requested agent harness "missing-strict-harness" is not registered.');
1052+1053+expect(run).toHaveBeenCalledOnce();
1054+expect(sessionSuspensionMocks.suspendSession).toHaveBeenCalledWith(
1055+expect.objectContaining({
1056+laneId: "main",
1057+failedProvider: "openai",
1058+}),
1059+);
1060+});
7961061});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。