
























@@ -1,4 +1,5 @@
11import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2+import type { SessionEntry } from "../config/sessions.js";
23import { INTERNAL_RUNTIME_CONTEXT_BEGIN, INTERNAL_RUNTIME_CONTEXT_END } from "./internal-events.js";
34import { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
45@@ -33,6 +34,7 @@ const state = vi.hoisted(() => ({
3334deliverAgentCommandResultMock: vi.fn(),
3435trajectoryRecordEventMock: vi.fn(),
3536trajectoryFlushMock: vi.fn(async () => undefined),
37+persistSessionEntryMock: vi.fn(async (..._args: unknown[]): Promise<unknown> => undefined),
3638clearSessionAuthProfileOverrideMock: vi.fn(),
3739isThinkingLevelSupportedMock: vi.fn((_args: unknown) => true),
3840resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"),
@@ -46,6 +48,7 @@ const state = vi.hoisted(() => ({
4648authProfileStoreMock: { profiles: {} } as { profiles: Record<string, unknown> },
4749sessionEntryMock: undefined as unknown,
4850sessionStoreMock: undefined as unknown,
51+storePathMock: undefined as string | undefined,
4952}));
50535154vi.mock("./model-fallback.js", () => ({
@@ -66,6 +69,16 @@ vi.mock("./command/attempt-execution.runtime.js", () => ({
6669sessionFileHasContent: vi.fn(async () => false),
6770}));
687172+vi.mock("./command/attempt-execution.shared.js", async () => {
73+const actual = await vi.importActual<typeof import("./command/attempt-execution.shared.js")>(
74+"./command/attempt-execution.shared.js",
75+);
76+return {
77+ ...actual,
78+persistSessionEntry: (...args: unknown[]) => state.persistSessionEntryMock(...args),
79+};
80+});
81+6982vi.mock("./command/delivery.runtime.js", () => ({
7083deliverAgentCommandResult: (...args: unknown[]) => state.deliverAgentCommandResultMock(...args),
7184}));
@@ -99,7 +112,7 @@ vi.mock("./command/session.js", () => ({
99112skillsSnapshot: { prompt: "", skills: [], version: 0 },
100113},
101114sessionStore: state.sessionStoreMock,
102-storePath: undefined,
115+storePath: state.storePathMock,
103116isNewSession: false,
104117persistedThinking: undefined,
105118persistedVerbose: undefined,
@@ -108,6 +121,10 @@ vi.mock("./command/session.js", () => ({
108121109122vi.mock("./command/types.js", () => ({}));
110123124+vi.mock("./harness/runtime-plugin.js", () => ({
125+ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
126+}));
127+111128vi.mock("../acp/policy.js", () => ({
112129resolveAcpAgentPolicyError: (...args: unknown[]) => state.resolveAcpAgentPolicyErrorMock(...args),
113130resolveAcpDispatchPolicyError: (...args: unknown[]) =>
@@ -266,9 +283,13 @@ vi.mock("../utils/message-channel.js", () => ({
266283}));
267284268285vi.mock("./agent-scope.js", () => ({
286+clearAutoFallbackPrimaryProbeSelection: vi.fn(),
287+entryMatchesAutoFallbackPrimaryProbe: () => true,
269288hasSessionAutoModelFallbackProvenance: () => false,
270289listAgentEntries: () => [],
271290listAgentIds: () => ["default"],
291+markAutoFallbackPrimaryProbe: vi.fn(),
292+resolveAutoFallbackPrimaryProbe: () => undefined,
272293resolveAgentConfig: () => undefined,
273294resolveAgentDir: () => "/tmp/agent",
274295resolveEffectiveModelFallbacks: state.resolveEffectiveModelFallbacksMock,
@@ -735,6 +756,30 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
735756state.authProfileStoreMock = { profiles: {} };
736757state.sessionEntryMock = undefined;
737758state.sessionStoreMock = undefined;
759+state.storePathMock = undefined;
760+state.persistSessionEntryMock.mockImplementation(async (...args: unknown[]) => {
761+const params = args[0] as {
762+sessionStore?: Record<string, unknown>;
763+sessionKey?: string;
764+entry?: unknown;
765+shouldPersist?: (entry: unknown) => boolean;
766+};
767+const current =
768+params.sessionStore && params.sessionKey
769+ ? params.sessionStore[params.sessionKey]
770+ : undefined;
771+if (params.shouldPersist && !params.shouldPersist(current)) {
772+if (current === undefined && params.sessionStore && params.sessionKey) {
773+delete params.sessionStore[params.sessionKey];
774+}
775+return current;
776+}
777+if (params.sessionStore && params.sessionKey && params.entry) {
778+params.sessionStore[params.sessionKey] = params.entry;
779+return params.entry;
780+}
781+return current;
782+});
738783state.buildWorkspaceSkillSnapshotMock.mockReturnValue({
739784prompt: "",
740785skills: [],
@@ -1033,6 +1078,90 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
10331078expect(state.runWithModelFallbackMock).toHaveBeenCalledTimes(2);
10341079});
103510801081+it("does not persist a user live switch as an auto fallback probe result", async () => {
1082+const sessionEntry: SessionEntry = {
1083+sessionId: "session-1",
1084+updatedAt: Date.now(),
1085+providerOverride: "openai",
1086+modelOverride: "claude",
1087+modelOverrideSource: "auto",
1088+modelOverrideFallbackOriginProvider: "anthropic",
1089+modelOverrideFallbackOriginModel: "claude",
1090+skillsSnapshot: { prompt: "", skills: [], version: 0 },
1091+};
1092+state.sessionEntryMock = sessionEntry;
1093+const sessionStore: Record<string, SessionEntry> = { "agent:main": sessionEntry };
1094+state.sessionStoreMock = sessionStore;
1095+state.storePathMock = "/tmp/openclaw-session-store.json";
1096+setupModelSwitchRetry({
1097+provider: "openai",
1098+model: "gpt-5.4",
1099+authProfileId: "openai:primary",
1100+authProfileIdSource: "user",
1101+});
1102+state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
1103+1104+await runBasicAgentCommand();
1105+1106+const autoPinnedSwitchWrites = state.persistSessionEntryMock.mock.calls.filter((call) => {
1107+const entry = (call[0] as { entry?: Record<string, unknown> } | undefined)?.entry;
1108+return (
1109+entry?.providerOverride === "openai" &&
1110+entry?.modelOverride === "gpt-5.4" &&
1111+entry?.modelOverrideSource === "auto" &&
1112+entry?.modelOverrideFallbackOriginProvider === "anthropic"
1113+);
1114+});
1115+expect(autoPinnedSwitchWrites).toHaveLength(0);
1116+expectRecordFields(mockCallArg(state.updateSessionStoreAfterAgentRunMock), {
1117+fallbackProvider: "openai",
1118+fallbackModel: "gpt-5.4",
1119+});
1120+});
1121+1122+it("does not overwrite a concurrent user model switch after a primary probe", async () => {
1123+const sessionEntry: SessionEntry = {
1124+sessionId: "session-1",
1125+updatedAt: Date.now(),
1126+providerOverride: "openai",
1127+modelOverride: "claude",
1128+modelOverrideSource: "auto",
1129+modelOverrideFallbackOriginProvider: "anthropic",
1130+modelOverrideFallbackOriginModel: "claude",
1131+skillsSnapshot: { prompt: "", skills: [], version: 0 },
1132+};
1133+state.sessionEntryMock = sessionEntry;
1134+const sessionStore: Record<string, SessionEntry> = { "agent:main": sessionEntry };
1135+state.sessionStoreMock = sessionStore;
1136+state.storePathMock = "/tmp/openclaw-session-store.json";
1137+state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
1138+const result = await params.run(params.provider, params.model);
1139+sessionStore["agent:main"] = {
1140+sessionId: "session-1",
1141+updatedAt: Date.now(),
1142+providerOverride: "google",
1143+modelOverride: "gemini-3-pro",
1144+modelOverrideSource: "user",
1145+skillsSnapshot: { prompt: "", skills: [], version: 0 },
1146+};
1147+return {
1148+ result,
1149+provider: params.provider,
1150+model: params.model,
1151+attempts: [],
1152+};
1153+});
1154+state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude"));
1155+1156+await runBasicAgentCommand();
1157+1158+expectRecordFields(sessionStore["agent:main"], {
1159+providerOverride: "google",
1160+modelOverride: "gemini-3-pro",
1161+modelOverrideSource: "user",
1162+});
1163+});
1164+10361165it("keeps aliased session auth profiles for codex-cli runs", async () => {
10371166let capturedAuthProfileProvider: string | undefined;
10381167const sessionEntry = {
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。