diff --git a/src/components/ChatPopup.tsx b/src/components/ChatPopup.tsx index 8a5988a0..5c39c51a 100644 --- a/src/components/ChatPopup.tsx +++ b/src/components/ChatPopup.tsx @@ -18,8 +18,11 @@ import { type ThinkingLevel, type ProviderReasoningConfig, THINKING_LEVEL_LABELS, + SAFE_THINKING_LEVEL, getProviderReasoningConfig, readPersistedThinkingLevel, + resolveWireThinkingLevel, + parseUnsupportedThinkingLevelError, PERSIST_KEY_PREFIX, } from '@/lib/chat-reasoning' @@ -289,11 +292,13 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink const [errorMsg, setErrorMsg] = useState('') const [chatModelState, setChatModelState] = useState(null) const [switchingModel, setSwitchingModel] = useState(false) - // Initialised to a generic placeholder; the real value is snapped to - // the active provider's persisted choice (or that provider's default) - // by the [headerProvider] effect below as soon as chatModelState - // resolves. - const [thinkingLevel, setThinkingLevel] = useState('high') + // Initialised to the always-safe level, not a speculative `high`: the real + // value is snapped to the active provider's persisted choice (or that + // provider's default) by the [headerProvider] effect below as soon as + // chatModelState resolves. Starting at `off` means that if the socket + // connects before the catalog does, the first wire push can't offer a + // reasoning level a local (off-only) model would reject. + const [thinkingLevel, setThinkingLevel] = useState(SAFE_THINKING_LEVEL) const fileInputRef = useRef(null) const [attachments, setAttachments] = useState<{ name: string; path: string; type: string }[]>([]) @@ -665,20 +670,48 @@ function ChatPopup({ isOpen, onClose, onOpenFull, onOpenSettingsSection, onThink if (status !== 'connected') return const key = sessionKeyRef.current if (!key) return - const wireValue: string = effectiveThinkingLevel + // Never push a level the ACTIVE model doesn't support. `resolveWireThinkingLevel` + // clamps to the provider's config (so a stale `high` carried over from a + // reasoning-capable model is folded to the local model's `off`) and returns + // null while the provider is still unknown (catalog loading) so we hold the + // push rather than sending a speculative value the gateway would reject. + const wireLevel = resolveWireThinkingLevel(headerProvider, thinkingLevel) + if (wireLevel === null) return + const wireValue: string = wireLevel if (wireValue === lastSentThinkingRef.current) return lastSentThinkingRef.current = wireValue void wsRequest('sessions.patch', { key, thinkingLevel: wireValue }).catch((err: unknown) => { // Reset so a reconnect or next user change retries. lastSentThinkingRef.current = undefined + // The gateway itself tells us the level to fall back to when a model + // exposes no (or a narrower) reasoning control — e.g. local Gemma: + // thinkingLevel "high" is not supported for llamacpp/... (use off) + // Honour that silently: snap to the suggested level and re-push it, + // surfacing a plain note rather than a red failure banner (a residual + // race, an external model change, or an API client can still reach here). + const message = err instanceof Error ? err.message : 'unknown error' + const suggested = parseUnsupportedThinkingLevelError(message) + if (suggested !== null) { + if (headerProvider) { + try { window.localStorage?.setItem(`${PERSIST_KEY_PREFIX}:${headerProvider}`, suggested) } catch { /* localStorage unavailable */ } + } + setThinkingLevel(prev => (prev === suggested ? prev : suggested)) + setMessages(msgs => [...msgs, { + role: 'system', + text: `Reasoning effort isn't available for this model — using ${THINKING_LEVEL_LABELS[suggested] ?? suggested}.`, + timestamp: Date.now(), + variant: 'success', + }]) + return + } setMessages(msgs => [...msgs, { role: 'system', - text: `Failed to change effort: ${err instanceof Error ? err.message : 'unknown error'}`, + text: `Failed to change effort: ${message}`, timestamp: Date.now(), variant: 'error', }]) }) - }, [status, effectiveThinkingLevel, wsRequest]) + }, [status, headerProvider, thinkingLevel, wsRequest]) // Snap thinkingLevel to the active provider's persisted choice (or its // default) whenever the active provider changes. Without this the diff --git a/src/lib/chat-reasoning.ts b/src/lib/chat-reasoning.ts index 01d09a33..52f711ee 100644 --- a/src/lib/chat-reasoning.ts +++ b/src/lib/chat-reasoning.ts @@ -28,6 +28,10 @@ export const THINKING_LEVEL_LABELS: Record = { adaptive: "Adaptive", }; +export function isThinkingLevel(value: unknown): value is ThinkingLevel { + return typeof value === "string" && value in THINKING_LEVEL_LABELS; +} + // Per-provider effort levels and defaults. // // Product decision (2026-07-23): the reasoning-effort picker is UNIFORM across @@ -82,6 +86,56 @@ export function getProviderReasoningConfig( return REASONING_BY_PROVIDER[provider] ?? FALLBACK_REASONING_CONFIG; } +// The single level that is always safe to send: every provider config includes +// `off`, and the gateway accepts it for models that expose no reasoning control +// at all (local llama.cpp/Gemma). Used as the wire value whenever the active +// provider is still unknown, so the picker can never push a speculative `high` +// at a model that would reject it. +export const SAFE_THINKING_LEVEL: ThinkingLevel = "off"; + +// Clamp a desired level to what the active provider actually supports, so the +// value pushed to the gateway is never one the model will reject. +// +// This is the last line of defence behind the picker's own gating: the picker +// only *offers* supported levels, but a level can still go stale across a model +// switch (a `high` chosen on DeepSeek carried into a local Gemma session before +// the header state has caught up). Routing every wire push through here means +// such a stale value is silently folded to the new provider's default (`off` +// for local Gemma) instead of reaching the gateway and erroring. +// +// When the provider is not yet known (`null` — the model catalog is still +// loading), returns `null` so the caller can hold the push until it knows what +// the active model supports, rather than guessing with the permissive fallback. +export function resolveWireThinkingLevel( + provider: string | null | undefined, + desired: ThinkingLevel, +): ThinkingLevel | null { + if (!provider) return null; + const cfg = getProviderReasoningConfig(provider); + return cfg.levels.includes(desired) ? desired : cfg.default; +} + +// The gateway rejects an unsupported effort with a message that also names the +// level it WILL accept, e.g.: +// +// thinkingLevel "high" is not supported for llamacpp/gemma4-e2b-it-q4_0 (use off) +// +// That parenthetical is the backend telling us the model's real capability. +// Parse it so a client that still races into this error can silently retry with +// the level the backend itself asked for, instead of surfacing a red banner. +// Returns null when the message isn't this specific rejection (any other +// failure should keep its normal error handling). +export function parseUnsupportedThinkingLevelError( + message: string | null | undefined, +): ThinkingLevel | null { + if (typeof message !== "string") return null; + if (!/thinkinglevel/i.test(message) || !/not supported/i.test(message)) return null; + const match = /\(\s*use\s+([a-z-]+)\s*\)/i.exec(message); + if (!match) return null; + const suggested = match[1].toLowerCase(); + return isThinkingLevel(suggested) ? suggested : null; +} + export const PERSIST_KEY_PREFIX = "clawbox:chat:thinkingLevel"; export function readPersistedThinkingLevel( diff --git a/src/lib/openclaw-config.ts b/src/lib/openclaw-config.ts index bda39a1b..8792e6b5 100644 --- a/src/lib/openclaw-config.ts +++ b/src/lib/openclaw-config.ts @@ -5,6 +5,7 @@ import { execFile, spawn } from "child_process"; import { promisify } from "util"; import { getLlamaCppProxyBaseUrl } from "@/lib/llamacpp"; import { readEdition } from "@/lib/edition-source"; +import { getProviderReasoningConfig, isThinkingLevel } from "@/lib/chat-reasoning"; const exec = promisify(execFile); @@ -389,6 +390,20 @@ export async function applyModelOverrideToAllAgentSessions( session.authProfileOverrideSource = source; session.modelProvider = update.provider; session.model = update.modelId; + // Normalise the sticky reasoning-effort override to the new model's + // capability. `thinkingLevel` is a per-session sticky the gateway keeps + // (set via `sessions.patch`); repointing the session to a model that + // can't honour the old level would otherwise leave e.g. a DeepSeek + // `high` on a local llama.cpp Gemma session, and the gateway rejects the + // next turn with `thinkingLevel "high" is not supported for llamacpp/… + // (use off)`. Only rewrite when the existing level is actually + // unsupported, so a compatible level (e.g. cloud→cloud) is left intact. + if (isThinkingLevel(session.thinkingLevel)) { + const reasoning = getProviderReasoningConfig(update.provider); + if (!reasoning.levels.includes(session.thinkingLevel)) { + session.thinkingLevel = reasoning.default; + } + } touchedInFile += 1; } diff --git a/src/tests/unit/apply-model-override.test.ts b/src/tests/unit/apply-model-override.test.ts index b5390242..3a2e10a8 100644 --- a/src/tests/unit/apply-model-override.test.ts +++ b/src/tests/unit/apply-model-override.test.ts @@ -120,6 +120,74 @@ describe("applyModelOverrideToAllAgentSessions — skipUserTagged", () => { expect(after.already_matching.modelOverrideSource).toBe("user"); }); + it("normalises a stale reasoning effort the new model can't honour (deepseek high -> local Gemma off)", async () => { + // The production bug: a session ran DeepSeek with a sticky + // `thinkingLevel: "high"`, then the chat picker repointed it to the + // local llama.cpp Gemma model (which supports `off` only). Without + // normalising the sticky value here, the gateway rejects the next turn + // with `thinkingLevel "high" is not supported for llamacpp/… (use off)`. + await seedSessions("main", { + chat: { + modelOverride: "deepseek-v4-flash", + modelProvider: "deepseek", + providerOverride: "deepseek", + modelOverrideSource: "user", + thinkingLevel: "high", + }, + }); + + await applyModelOverrideToAllAgentSessions( + { provider: "llamacpp", modelId: "gemma4-e2b-it-q4_0" }, + { agentsDir }, + ); + + const after = await readSessions("main"); + expect(after.chat.model).toBe("gemma4-e2b-it-q4_0"); + expect(after.chat.modelProvider).toBe("llamacpp"); + // The stale high is folded down to the local model's only level. + expect(after.chat.thinkingLevel).toBe("off"); + }); + + it("leaves a reasoning effort the new provider still supports untouched (cloud -> cloud)", async () => { + await seedSessions("main", { + chat: { + modelOverride: "deepseek-v4-flash", + modelProvider: "deepseek", + providerOverride: "deepseek", + modelOverrideSource: "user", + thinkingLevel: "high", + }, + }); + + await applyModelOverrideToAllAgentSessions( + { provider: "anthropic", modelId: "claude-sonnet-4-6" }, + { agentsDir }, + ); + + const after = await readSessions("main"); + // anthropic honours `high`, so the sticky effort is preserved. + expect(after.chat.thinkingLevel).toBe("high"); + }); + + it("does not inject a reasoning effort into sessions that never had one", async () => { + await seedSessions("main", { + chat: { + modelOverride: "deepseek-v4-flash", + modelProvider: "deepseek", + modelOverrideSource: "auto", + // no thinkingLevel field + }, + }); + + await applyModelOverrideToAllAgentSessions( + { provider: "llamacpp", modelId: "gemma4-e2b-it-q4_0" }, + { agentsDir }, + ); + + const after = await readSessions("main"); + expect("thinkingLevel" in after.chat).toBe(false); + }); + it("ignores sessions files that fail to parse instead of bailing the sweep", async () => { await seedSessions("main", { ok: { modelOverride: "old", modelOverrideSource: "auto" }, diff --git a/src/tests/unit/chat-reasoning.test.ts b/src/tests/unit/chat-reasoning.test.ts index 7dccc5db..d360d811 100644 --- a/src/tests/unit/chat-reasoning.test.ts +++ b/src/tests/unit/chat-reasoning.test.ts @@ -2,9 +2,14 @@ import { describe, expect, it } from "vitest"; import { getProviderReasoningConfig, readPersistedThinkingLevel, + resolveWireThinkingLevel, + parseUnsupportedThinkingLevelError, + isThinkingLevel, REASONING_BY_PROVIDER, FALLBACK_REASONING_CONFIG, THINKING_LEVEL_LABELS, + SAFE_THINKING_LEVEL, + type ThinkingLevel, } from "@/lib/chat-reasoning"; describe("chat-reasoning", () => { @@ -64,4 +69,108 @@ describe("chat-reasoning", () => { expect(readPersistedThinkingLevel(null, cfg)).toBe(cfg.default); }); }); + + describe("SAFE_THINKING_LEVEL", () => { + it("is `off` and is a member of every provider config's levels", () => { + expect(SAFE_THINKING_LEVEL).toBe("off"); + for (const cfg of Object.values(REASONING_BY_PROVIDER)) { + expect(cfg.levels).toContain(SAFE_THINKING_LEVEL); + } + expect(FALLBACK_REASONING_CONFIG.levels).toContain(SAFE_THINKING_LEVEL); + }); + }); + + describe("resolveWireThinkingLevel", () => { + it("passes a supported level through unchanged", () => { + expect(resolveWireThinkingLevel("anthropic", "high")).toBe("high"); + expect(resolveWireThinkingLevel("clawai", "off")).toBe("off"); + }); + + it("clamps an unsupported level to the provider default", () => { + // Local Gemma (llamacpp) supports `off` only. + expect(resolveWireThinkingLevel("llamacpp", "high")).toBe("off"); + expect(resolveWireThinkingLevel("llamacpp", "medium")).toBe("off"); + }); + + it("holds (returns null) while the active provider is still unknown", () => { + // Catalog still loading: the caller must not push a speculative value. + expect(resolveWireThinkingLevel(null, "high")).toBeNull(); + expect(resolveWireThinkingLevel(undefined, "high")).toBeNull(); + expect(resolveWireThinkingLevel("", "high")).toBeNull(); + }); + + // The exact production bug: a `high` chosen while a reasoning-capable + // remote model (DeepSeek → normalized `clawai`) was active, then the user + // switches the picker to the local llama.cpp Gemma model. The stale `high` + // must never reach the gateway for the local model. + it("folds a stale `high` to `off` across a remote→local model switch", () => { + // Before the switch: clawai honours `high`. + expect(resolveWireThinkingLevel("clawai", "high")).toBe("high"); + + // After the switch to local Gemma, both the state-snap path and the + // wire-clamp path independently yield `off` — so even if the header + // state lags a render behind, the wire value is already safe. + const localCfg = getProviderReasoningConfig("llamacpp"); + const snapped = readPersistedThinkingLevel("llamacpp", localCfg); // no persisted choice + expect(snapped).toBe("off"); + expect(resolveWireThinkingLevel("llamacpp", "high")).toBe("off"); + }); + + it("never returns a level the resolved provider rejects", () => { + const providers = [ + "openai", "codex", "anthropic", "google", + "deepseek", "clawai", "openrouter", "llamacpp", + ]; + const desired: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max", "adaptive"]; + for (const provider of providers) { + const cfg = getProviderReasoningConfig(provider); + for (const level of desired) { + const resolved = resolveWireThinkingLevel(provider, level); + expect(resolved).not.toBeNull(); + expect(cfg.levels).toContain(resolved as ThinkingLevel); + } + } + }); + }); + + describe("parseUnsupportedThinkingLevelError", () => { + it("parses the gateway's `(use off)` hint from the real rejection message", () => { + const msg = 'thinkingLevel "high" is not supported for llamacpp/gemma4-e2b-it-q4_0 (use off)'; + expect(parseUnsupportedThinkingLevelError(msg)).toBe("off"); + }); + + it("honours whatever level the gateway suggests, not just off", () => { + const msg = 'thinkingLevel "xhigh" is not supported for some/model (use low)'; + expect(parseUnsupportedThinkingLevelError(msg)).toBe("low"); + }); + + it("is tolerant of casing and spacing around the hint", () => { + expect( + parseUnsupportedThinkingLevelError('ThinkingLevel "high" is NOT SUPPORTED for x/y ( use off )'), + ).toBe("off"); + }); + + it("returns null for unrelated errors so they keep normal handling", () => { + expect(parseUnsupportedThinkingLevelError("Request timeout")).toBeNull(); + expect(parseUnsupportedThinkingLevelError("session not found")).toBeNull(); + // Right shape but an unknown suggested level → don't invent one. + expect( + parseUnsupportedThinkingLevelError('thinkingLevel "high" is not supported for x/y (use bogus)'), + ).toBeNull(); + expect(parseUnsupportedThinkingLevelError(null)).toBeNull(); + expect(parseUnsupportedThinkingLevelError(undefined)).toBeNull(); + }); + }); + + describe("isThinkingLevel", () => { + it("accepts every labelled level and rejects anything else", () => { + for (const level of Object.keys(THINKING_LEVEL_LABELS)) { + expect(isThinkingLevel(level)).toBe(true); + } + expect(isThinkingLevel("ultra")).toBe(false); + expect(isThinkingLevel("")).toBe(false); + expect(isThinkingLevel(null)).toBe(false); + expect(isThinkingLevel(3)).toBe(false); + }); + }); });