Skip to content

Commit fbfbeee

Browse files
committed
fix(thinking): keep explicit session thinkingLevel when runtime downgrades (#87740)
When a session's stored thinkingLevel is unsupported by the active model, the runtime fell back to a supported level for the turn AND wrote that fallback back onto the persisted session override. Because the persistence condition fired exactly when the stored value was the explicit override, the user's explicit choice (e.g. "high") was permanently reset to the supported level (e.g. "off") after every turn — re-setting it just got clobbered again next turn. Downgrade only the level used for the current turn; never persist the support fallback onto the stored override. The explicit override is the user's intent and must survive turns (so it re-applies if a supporting model is used later). Both the reply path (get-reply-run) and the agent-command path carried the same duplicated write-back; both are fixed. Reported by @TitanBob2026.
1 parent 2e042fb commit fbfbeee

3 files changed

Lines changed: 46 additions & 50 deletions

File tree

src/agents/agent-command.live-model-switch.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const state = vi.hoisted(() => ({
4040
persistSessionEntryMock: vi.fn(async (..._args: unknown[]): Promise<unknown> => undefined),
4141
clearSessionAuthProfileOverrideMock: vi.fn(),
4242
isThinkingLevelSupportedMock: vi.fn((_args: unknown) => true),
43+
resolveSupportedThinkingLevelMock: vi.fn((args: { level?: string }) => args.level),
4344
resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"),
4445
loadManifestModelCatalogMock: vi.fn(() => []),
4546
buildWorkspaceSkillSnapshotMock: vi.fn((..._args: unknown[]): unknown => ({
@@ -54,6 +55,7 @@ const state = vi.hoisted(() => ({
5455
sessionEntryMock: undefined as unknown,
5556
sessionStoreMock: undefined as unknown,
5657
storePathMock: undefined as string | undefined,
58+
persistedThinkingMock: undefined as string | undefined,
5759
}));
5860

5961
vi.mock("./model-fallback.js", () => ({
@@ -120,7 +122,7 @@ vi.mock("./command/session.js", () => ({
120122
sessionStore: state.sessionStoreMock,
121123
storePath: state.storePathMock,
122124
isNewSession: false,
123-
persistedThinking: undefined,
125+
persistedThinking: state.persistedThinkingMock,
124126
persistedVerbose: undefined,
125127
}),
126128
}));
@@ -154,7 +156,8 @@ vi.mock("../auto-reply/thinking.js", () => ({
154156
normalizeThinkLevel: (v?: string) => v || undefined,
155157
normalizeVerboseLevel: (v?: string) => v || undefined,
156158
isThinkingLevelSupported: (args: unknown) => state.isThinkingLevelSupportedMock(args),
157-
resolveSupportedThinkingLevel: ({ level }: { level?: string }) => level,
159+
resolveSupportedThinkingLevel: (args: { level?: string }) =>
160+
state.resolveSupportedThinkingLevelMock(args),
158161
supportsXHighThinking: () => false,
159162
}));
160163

@@ -771,6 +774,8 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
771774
state.resolveAcpExplicitTurnPolicyErrorMock.mockReturnValue(null);
772775
state.runtimeConfigMock = undefined;
773776
state.isThinkingLevelSupportedMock.mockReturnValue(true);
777+
state.resolveSupportedThinkingLevelMock.mockImplementation((args) => args.level);
778+
state.persistedThinkingMock = undefined;
774779
state.resolveThinkingDefaultMock.mockReturnValue("low");
775780
state.resolveAgentSkillsFilterMock.mockReturnValue(undefined);
776781
state.loadManifestModelCatalogMock.mockReturnValue([]);
@@ -942,6 +947,37 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
942947
expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1);
943948
});
944949

950+
it("preserves an explicit session thinkingLevel override when the level is unsupported", async () => {
951+
setupSingleAttemptFallback();
952+
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
953+
const sessionEntry: SessionEntry = {
954+
sessionId: "session-1",
955+
updatedAt: 1,
956+
thinkingLevel: "high",
957+
skillsSnapshot: { prompt: "", skills: [], version: 0 },
958+
};
959+
state.sessionEntryMock = sessionEntry;
960+
state.sessionStoreMock = { "agent:main:main": sessionEntry };
961+
state.storePathMock = "/tmp/openclaw-sessions.json";
962+
state.persistedThinkingMock = "high";
963+
// The model rejects the stored level, so the turn downgrades at runtime.
964+
state.isThinkingLevelSupportedMock.mockReturnValue(false);
965+
state.resolveSupportedThinkingLevelMock.mockReturnValue("off");
966+
967+
await agentCommand({
968+
message: "hello",
969+
to: "+1234567890",
970+
});
971+
972+
// Runtime downgrade is per-turn; it must not persist the fallback level back
973+
// onto the user's explicit stored override.
974+
const downgradeWrite = state.persistSessionEntryMock.mock.calls.find((call) => {
975+
const entry = (call[0] as { entry?: { thinkingLevel?: string } } | undefined)?.entry;
976+
return entry?.thinkingLevel === "off";
977+
});
978+
expect(downgradeWrite).toBeUndefined();
979+
});
980+
945981
it("clears stale flag-only pending final delivery when there is no final payload", async () => {
946982
setupSingleAttemptFallback();
947983
state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4"));

src/agents/agent-command.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,27 +1197,10 @@ async function agentCommandInternal(
11971197
level: resolvedThinkLevel,
11981198
catalog: thinkingCatalog,
11991199
});
1200-
if (fallbackThinkLevel !== resolvedThinkLevel) {
1201-
const previousThinkLevel = resolvedThinkLevel;
1202-
resolvedThinkLevel = fallbackThinkLevel;
1203-
if (
1204-
sessionEntry &&
1205-
sessionStore &&
1206-
sessionKey &&
1207-
sessionEntry.thinkingLevel === previousThinkLevel &&
1208-
!suppressVisibleSessionEffects
1209-
) {
1210-
const entry = sessionEntry;
1211-
entry.thinkingLevel = fallbackThinkLevel;
1212-
entry.updatedAt = Date.now();
1213-
await persistSessionEntry({
1214-
sessionStore,
1215-
sessionKey,
1216-
storePath,
1217-
entry,
1218-
});
1219-
}
1220-
}
1200+
// Downgrade only the level used for this turn. The explicit session
1201+
// override is the user's stored intent; persisting the fallback here would
1202+
// permanently reset it to the supported level on every turn (#87740).
1203+
resolvedThinkLevel = fallbackThinkLevel;
12211204
}
12221205
const { resolveSessionTranscriptFile } = await loadTranscriptResolveRuntime();
12231206
let sessionFile: string | undefined;

src/auto-reply/reply/get-reply-run.ts

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,6 @@ const agentRunnerRuntimeLoader = createLazyImportLoader(() => import("./agent-ru
311311
const sessionUpdatesRuntimeLoader = createLazyImportLoader(
312312
() => import("./session-updates.runtime.js"),
313313
);
314-
const sessionStoreRuntimeLoader = createLazyImportLoader(
315-
() => import("../../config/sessions/store.runtime.js"),
316-
);
317314

318315
function loadEmbeddedAgentRuntime() {
319316
return embeddedAgentRuntimeLoader.load();
@@ -327,10 +324,6 @@ function loadSessionUpdatesRuntime() {
327324
return sessionUpdatesRuntimeLoader.load();
328325
}
329326

330-
function loadSessionStoreRuntime() {
331-
return sessionStoreRuntimeLoader.load();
332-
}
333-
334327
function stripPromptThinkingDirectives(body: string): string {
335328
return body
336329
.split("\n")
@@ -880,26 +873,10 @@ export async function runPreparedReply(
880873
level: resolvedThinkLevel,
881874
catalog: thinkingCatalog,
882875
});
883-
if (fallbackThinkLevel !== resolvedThinkLevel) {
884-
const previousThinkLevel = resolvedThinkLevel;
885-
resolvedThinkLevel = fallbackThinkLevel;
886-
if (
887-
sessionEntry &&
888-
sessionStore &&
889-
sessionKey &&
890-
sessionEntry.thinkingLevel === previousThinkLevel
891-
) {
892-
sessionEntry.thinkingLevel = fallbackThinkLevel;
893-
sessionEntry.updatedAt = Date.now();
894-
sessionStore[sessionKey] = sessionEntry;
895-
if (storePath) {
896-
const { updateSessionStore } = await loadSessionStoreRuntime();
897-
await updateSessionStore(storePath, (store) => {
898-
store[sessionKey] = sessionEntry;
899-
});
900-
}
901-
}
902-
}
876+
// Downgrade only the level used for this turn. The explicit session override
877+
// is the user's stored intent; persisting the fallback here would permanently
878+
// reset it to the supported level on every turn (#87740).
879+
resolvedThinkLevel = fallbackThinkLevel;
903880
}
904881
const internalOpts = opts as InternalGetReplyOptions | undefined;
905882
const providedReplyOperation = internalOpts?.replyOperation;

0 commit comments

Comments
 (0)