From 27a4b5ac0ed32f24482af62ea927d1666a227f58 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 22 Aug 2026 20:53:19 +0000 Subject: [PATCH 1/6] fix(reasoning): apply model defaults when settings unset --- src/api/providers/__tests__/nanogpt.spec.ts | 42 ++++++++++++++++++ src/api/providers/nanogpt.ts | 21 ++++++++- .../components/settings/ThinkingBudget.tsx | 14 ++---- .../__tests__/ThinkingBudget.spec.tsx | 43 +++++++++++++++++++ 4 files changed, 107 insertions(+), 13 deletions(-) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 4de0998afb..9f9bd474e1 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -204,6 +204,40 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("temperature") }) + it("uses the model's advertised reasoning effort when settings are unset", async () => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", + }, + }) + + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "high" }) + }) + + it("uses the first supported effort when the model cannot disable reasoning", async () => { + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) + }) + + it("omits reasoning effort when reasoning is explicitly disabled", async () => { + await collectStream( + new NanoGptHandler({ + nanoGptModelId: "model:thinking", + enableReasoningEffort: false, + reasoningEffort: "high", + }).createMessage("sys", messages), + ) + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") + }) + it("keeps Muse Spark tool-result history contiguous across turns", async () => { const modelId = "meta/muse-spark-1.2-contributor" vi.mocked(getModels).mockResolvedValue({ @@ -370,6 +404,14 @@ describe("NanoGptHandler", () => { }) describe("completePrompt", () => { + it("uses the same default reasoning effort as streaming requests", async () => { + mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) + + await new NanoGptHandler({ nanoGptModelId: "model:thinking" }).completePrompt("prompt") + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) + }) + it("requests cache-capable routing without changing the completion model ID", async () => { mockCreate.mockResolvedValue({ choices: [{ message: { content: "response" } }] }) const handler = new NanoGptHandler({ diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 43d4641251..691a6a2490 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -39,8 +39,25 @@ function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): Reason configured === "disable" || configured === "none" || options.enableReasoningEffort === false const supported = info.supportsReasoningEffort - if (!reasoningDisabled && configured && configured !== "minimal") { - if (supported === true || (Array.isArray(supported) && supported.includes(configured))) return configured + if (reasoningDisabled && (supported === true || (Array.isArray(supported) && supported.includes("disable")))) { + return undefined + } + + const candidates = [reasoningDisabled ? undefined : configured, info.reasoningEffort] + if (Array.isArray(supported) && !supported.includes("disable")) { + candidates.push(supported.find((effort) => effort !== "none" && effort !== "minimal")) + } + + for (const effort of candidates) { + if ( + effort && + effort !== "disable" && + effort !== "none" && + effort !== "minimal" && + (supported === true || (Array.isArray(supported) && supported.includes(effort))) + ) { + return effort + } } const fallback = info.reasoningEffort diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index d8ee0cd448..47883171aa 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -102,9 +102,8 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod // Default reasoning effort - use model's default if available // GPT-5 models have "medium" as their default in the model configuration const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined - const defaultReasoningEffort: ReasoningEffortOption = modelInfo?.requiredReasoningEffort - ? modelDefaultReasoningEffort || "medium" - : "disable" + const defaultReasoningEffort: ReasoningEffortOption = + modelDefaultReasoningEffort ?? (modelInfo?.requiredReasoningEffort ? "medium" : "disable") // Current reasoning effort from settings, or fall back to default. // Clamp to availableOptions so the Select trigger always renders a valid option. const storedReasoningEffort = apiConfiguration.reasoningEffort as ReasoningEffortOption | undefined @@ -120,19 +119,12 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod useEffect(() => { if ( isReasoningEffortSupported && - modelInfo?.requiredReasoningEffort && storedReasoningEffort !== currentReasoningEffort && currentReasoningEffort !== "disable" ) { setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended, false) } - }, [ - isReasoningEffortSupported, - storedReasoningEffort, - currentReasoningEffort, - modelInfo?.requiredReasoningEffort, - setApiConfigurationField, - ]) + }, [isReasoningEffortSupported, storedReasoningEffort, currentReasoningEffort, setApiConfigurationField]) // Sync enableReasoningEffort based on selection // "disable" turns off reasoning; "none" is a valid level (reasoning enabled) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index 8cb6a6fe99..9092458529 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -274,11 +274,13 @@ describe("ThinkingBudget", () => { }) it("should fall back to first available option when stored value is not in the explicit array", () => { + const setApiConfigurationField = vi.fn() // Covers the clamp branch: defaultReasoningEffort="disable" but array omits "disable" render( { // The select value should be "low" (first item), not "disable" expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) + }) + + it("should use and persist an optional model's advertised reasoning default", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "high") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) + }) + + it("should preserve an explicit disable selection for optional reasoning", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "disable") + expect(setApiConfigurationField).not.toHaveBeenCalled() }) it("should normalize an invalid disabled value to the default for required reasoning", () => { From 002937e207dd5ebb7a9a48399eda9dc5094047ae Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 22 Aug 2026 22:04:38 +0000 Subject: [PATCH 2/6] test(reasoning): cover default state matrix --- .../__tests__/ThinkingBudget.spec.tsx | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index 9092458529..2586f50722 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -5,7 +5,7 @@ import React from "react" import { render, screen, fireEvent } from "@/utils/test-utils" -import type { ModelInfo } from "@roo-code/types" +import type { ModelInfo, ProviderSettings } from "@roo-code/types" import { ThinkingBudget } from "../ThinkingBudget" @@ -353,6 +353,81 @@ describe("ThinkingBudget", () => { expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "max", false) }) + it("should use the first supported effort when required reasoning has no advertised default", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) + }) + + it.each<{ + name: string + apiConfiguration: ProviderSettings + modelInfo: ModelInfo + expected: string + expectedWrite?: string + }>([ + { + name: "keeps a supported stored effort over the model default", + apiConfiguration: { reasoningEffort: "low", enableReasoningEffort: true }, + modelInfo: { + ...reasoningEffortModelInfo, + supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", + }, + expected: "low", + expectedWrite: undefined, + }, + { + name: "normalizes an unsupported stored effort to the model default", + apiConfiguration: { reasoningEffort: "max", enableReasoningEffort: true }, + modelInfo: { + ...reasoningEffortModelInfo, + supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", + }, + expected: "high", + expectedWrite: "high", + }, + { + name: "defaults optional boolean reasoning support to disabled", + apiConfiguration: {}, + modelInfo: reasoningEffortModelInfo, + expected: "disable", + expectedWrite: undefined, + }, + ])("$name", ({ apiConfiguration, modelInfo, expected, expectedWrite }) => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", expected) + if (expectedWrite) { + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", expectedWrite, false) + } else { + expect(setApiConfigurationField).not.toHaveBeenCalledWith("reasoningEffort", expect.anything(), false) + } + }) + it("should fall back to rawReasoningEffort when availableOptions is empty", () => { // Covers the ?? rawReasoningEffort branch when availableOptions[0] is undefined render( From dc1be12d6c0784f4538b21b5d00980afb53f4dfa Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Thu, 27 Aug 2026 01:46:36 +0000 Subject: [PATCH 3/6] test(reasoning): strengthen reasoning effort edge-case coverage --- src/api/providers/__tests__/nanogpt.spec.ts | 36 +++++++++++++++++++ .../components/settings/ThinkingBudget.tsx | 3 +- .../__tests__/ThinkingBudget.spec.tsx | 5 +++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index 9f9bd474e1..c96dd6a2bb 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -221,11 +221,47 @@ describe("NanoGptHandler", () => { }) it("uses the first supported effort when the model cannot disable reasoning", async () => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: ["high", "medium", "low"], + }, + }) + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) }) + it.each([undefined, true] as const)( + "omits reasoning effort when the disable option is selected and enableReasoningEffort is %s", + async (enableReasoningEffort) => { + await collectStream( + new NanoGptHandler({ + nanoGptModelId: "model:thinking", + enableReasoningEffort, + reasoningEffort: "disable", + }).createMessage("sys", messages), + ) + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") + }, + ) + + it("resolves none to the canonical lowest supported effort when reasoning is enabled", async () => { + await collectStream( + new NanoGptHandler({ + nanoGptModelId: "model:thinking", + enableReasoningEffort: true, + reasoningEffort: "none", + }).createMessage("sys", messages), + ) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) + }) + it("omits reasoning effort when reasoning is explicitly disabled", async () => { await collectStream( new NanoGptHandler({ diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index 47883171aa..9c8f89094a 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -99,8 +99,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod ? ["disable", ...baseAvailableOptions] : baseAvailableOptions - // Default reasoning effort - use model's default if available - // GPT-5 models have "medium" as their default in the model configuration + // Use the model's declared default when present; otherwise fall back based on requiredReasoningEffort. const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined const defaultReasoningEffort: ReasoningEffortOption = modelDefaultReasoningEffort ?? (modelInfo?.requiredReasoningEffort ? "medium" : "disable") diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index 2586f50722..dca0768fd2 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -425,6 +425,11 @@ describe("ThinkingBudget", () => { expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", expectedWrite, false) } else { expect(setApiConfigurationField).not.toHaveBeenCalledWith("reasoningEffort", expect.anything(), false) + expect(setApiConfigurationField).not.toHaveBeenCalledWith( + "enableReasoningEffort", + expect.anything(), + false, + ) } }) From 80899b4e819aed09af96f6bf83acce3cab689832 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 00:31:30 +0000 Subject: [PATCH 4/6] fix(reasoning): preserve required model defaults --- package.json | 2 +- scripts/check-reasoning-defaults.ts | 104 ++++++++++++++++++ src/api/providers/__tests__/nanogpt.spec.ts | 36 ++++++ src/api/providers/nanogpt.ts | 4 +- .../components/settings/ThinkingBudget.tsx | 4 +- .../SettingsView.change-detection.spec.tsx | 20 ++++ .../__tests__/ThinkingBudget.spec.tsx | 12 +- 7 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 scripts/check-reasoning-defaults.ts diff --git a/package.json b/package.json index 1a44a12680..e16c89e21c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && pnpm cleanup-protocol:model-check", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-reasoning-defaults.ts && pnpm cleanup-protocol:model-check", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "test:coverage": "turbo test:coverage --log-order grouped --output-logs new-only", "format": "turbo format --log-order grouped --output-logs new-only", diff --git a/scripts/check-reasoning-defaults.ts b/scripts/check-reasoning-defaults.ts new file mode 100644 index 0000000000..7b2e04b45a --- /dev/null +++ b/scripts/check-reasoning-defaults.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict" + +type Effort = "disable" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" +type Supported = true | readonly Effort[] + +interface ModelState { + supported: Supported + required: boolean + modelDefault?: Effort + stored?: Effort + enabled?: boolean +} + +const canonicalEfforts = ["low", "medium", "high", "xhigh", "max"] as const +const defaultEfforts = ["low", "medium", "high"] as const +const supportedSets: Supported[] = [ + true, + ["disable", "low", "high"], + ["low", "high"], + ["none", "low", "high"], + ["low", "medium", "high", "xhigh", "max"], +] +const values = [undefined, "disable", "none", "low", "high", "max"] as const +const enabledValues = [undefined, false, true] as const + +function availableOptions(state: ModelState): readonly Effort[] { + return state.supported === true + ? state.required + ? defaultEfforts + : ["disable", ...defaultEfforts] + : state.supported +} + +function supportsEffort(supported: Supported, effort: Effort): boolean { + return supported === true || supported.includes(effort) +} + +function resolveSelection(state: ModelState): Effort { + const available = availableOptions(state) + const defaultEffort = state.modelDefault ?? (state.required ? "medium" : "disable") + const raw = state.stored ?? defaultEffort + const fallback = available.includes(defaultEffort) ? defaultEffort : (available[0] ?? raw) + return available.includes(raw) ? raw : fallback +} + +function resolveRequest(state: ModelState): Effort | undefined { + const disabled = state.stored === "disable" || state.stored === "none" || state.enabled === false + const canDisable = supportsEffort(state.supported, "disable") + if (disabled && canDisable) return undefined + + const candidates = [disabled ? undefined : state.stored, state.modelDefault] + const supported = state.supported + if (supported !== true && !supported.includes("disable")) { + candidates.push(canonicalEfforts.find((effort) => supported.includes(effort))) + } + + for (const effort of candidates) { + if ( + effort && + effort !== "disable" && + effort !== "none" && + effort !== "minimal" && + supportsEffort(state.supported, effort) + ) { + return effort + } + } + + return state.required && state.modelDefault && state.modelDefault !== "none" ? state.modelDefault : undefined +} + +let checked = 0 +for (const supported of supportedSets) { + for (const required of [false, true]) { + for (const modelDefault of values) { + for (const stored of values) { + for (const enabled of enabledValues) { + const state: ModelState = { supported, required, modelDefault, stored, enabled } + const selection = resolveSelection(state) + const available = availableOptions(state) + assert.ok(available.length === 0 || available.includes(selection), "selection must be supported") + if (!available.includes("disable")) + assert.notEqual(selection, "disable", "required reasoning cannot disable") + + const normalized: ModelState = { + ...state, + stored: selection, + enabled: required || selection !== "disable", + } + const request = resolveRequest(normalized) + if (selection === "disable") { + assert.equal(request, undefined, "an explicit supported disable must omit reasoning") + } else if (canonicalEfforts.includes(selection as (typeof canonicalEfforts)[number])) { + assert.equal(request, selection, "a normalized effort must reach the request") + } + + checked++ + } + } + } + } +} + +console.log(`Reasoning defaults model check passed (${checked} states)`) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index c96dd6a2bb..f648ae0775 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -235,9 +235,37 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) }) + it.each([ + ["a stale disable effort", { reasoningEffort: "disable" as const }], + ["a stale disabled toggle", { enableReasoningEffort: false }], + ])("uses a supported fallback for %s when the model cannot disable reasoning", async (_name, settings) => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "high"], + }, + }) + + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking", ...settings }).createMessage("sys", messages), + ) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) + }) + it.each([undefined, true] as const)( "omits reasoning effort when the disable option is selected and enableReasoningEffort is %s", async (enableReasoningEffort) => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable", "low", "high"], + }, + }) await collectStream( new NanoGptHandler({ nanoGptModelId: "model:thinking", @@ -263,6 +291,14 @@ describe("NanoGptHandler", () => { }) it("omits reasoning effort when reasoning is explicitly disabled", async () => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable", "low", "high"], + }, + }) await collectStream( new NanoGptHandler({ nanoGptModelId: "model:thinking", diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 691a6a2490..180aa9cfa1 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -32,6 +32,7 @@ type NanoGptCachingRequest = { caching?: true } const NANO_GPT_MERGED_TOOL_RESULT_MODELS = new Set(["meta/muse-spark-1.2-contributor"]) const NANO_GPT_ASTRA_MODEL_IDS = new Set(["openai/gpt-6-astra", "openai/gpt-6-astra-pro"]) +const NANO_GPT_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): ReasoningEffortExtended | undefined { const configured = options.reasoningEffort @@ -45,13 +46,12 @@ function getReasoningEffort(options: ApiHandlerOptions, info: ModelInfo): Reason const candidates = [reasoningDisabled ? undefined : configured, info.reasoningEffort] if (Array.isArray(supported) && !supported.includes("disable")) { - candidates.push(supported.find((effort) => effort !== "none" && effort !== "minimal")) + candidates.push(NANO_GPT_REASONING_EFFORTS.find((effort) => supported.includes(effort))) } for (const effort of candidates) { if ( effort && - effort !== "disable" && effort !== "none" && effort !== "minimal" && (supported === true || (Array.isArray(supported) && supported.includes(effort))) diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index 9c8f89094a..eb1cbbb86a 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -114,14 +114,14 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod ? rawReasoningEffort : fallbackReasoningEffort - // Set default reasoning effort when model supports it and no value is set + // Keep normalized defaults pending so Save persists them to the provider profile. useEffect(() => { if ( isReasoningEffortSupported && storedReasoningEffort !== currentReasoningEffort && currentReasoningEffort !== "disable" ) { - setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended, false) + setApiConfigurationField("reasoningEffort", currentReasoningEffort as ReasoningEffortExtended) } }, [isReasoningEffortSupported, storedReasoningEffort, currentReasoningEffort, setApiConfigurationField]) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 034038e27e..07a4c3a018 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -277,6 +277,9 @@ const mockApiOptions = ({ apiConfiguration, setApiConfigurationField }: any) => {provider} ))} + ) @@ -491,6 +494,23 @@ describe("SettingsView - Change Detection Fix", () => { expect(onDone).toHaveBeenCalled() }, 10000) + it("persists a normalized reasoning default through Save", async () => { + ;(useExtensionState as any).mockReturnValue(createExtensionState()) + + renderWithExtensionState(, { queryClient }) + await waitFor(() => expect(screen.getByTestId("save-button")).toBeDisabled()) + + fireEvent.click(screen.getByTestId("set-reasoning-default")) + expect(screen.getByTestId("save-button")).toBeEnabled() + + fireEvent.click(screen.getByTestId("save-button")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "upsertApiConfiguration", + text: "default", + apiConfiguration: expect.objectContaining({ reasoningEffort: "high" }), + }) + }, 10000) + // These tests are passing for the basic case but failing due to vi.doMock limitations // The core fix has been verified - when no actual changes are made, no unsaved changes dialog appears diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index dca0768fd2..dae935fe0a 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -290,7 +290,7 @@ describe("ThinkingBudget", () => { // The select value should be "low" (first item), not "disable" expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low") - expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low") expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) }) @@ -310,7 +310,7 @@ describe("ThinkingBudget", () => { ) expect(screen.getByTestId("select")).toHaveAttribute("data-value", "high") - expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high") expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) }) @@ -350,7 +350,7 @@ describe("ThinkingBudget", () => { ) expect(screen.getByTestId("select")).toHaveAttribute("data-value", "max") - expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "max", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "max") }) it("should use the first supported effort when required reasoning has no advertised default", () => { @@ -369,7 +369,7 @@ describe("ThinkingBudget", () => { ) expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low") - expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low", false) + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "low") expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) }) @@ -422,9 +422,9 @@ describe("ThinkingBudget", () => { expect(screen.getByTestId("select")).toHaveAttribute("data-value", expected) if (expectedWrite) { - expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", expectedWrite, false) + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", expectedWrite) } else { - expect(setApiConfigurationField).not.toHaveBeenCalledWith("reasoningEffort", expect.anything(), false) + expect(setApiConfigurationField).not.toHaveBeenCalledWith("reasoningEffort", expect.anything()) expect(setApiConfigurationField).not.toHaveBeenCalledWith( "enableReasoningEffort", expect.anything(), From c3da7a6e1202c2f3fe4de77f8716fa6ef8875a30 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:22:23 +0000 Subject: [PATCH 5/6] test(reasoning): kill changed-code mutants --- src/api/providers/__tests__/nanogpt.spec.ts | 59 ++++++++++++++++ .../__tests__/ThinkingBudget.spec.tsx | 67 +++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index f648ae0775..aa5ed955b1 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -235,6 +235,63 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) }) + it.each([ + ["an unsupported configured effort", { reasoningEffort: "max" as const }, ["low", "high"] as const, undefined], + ["a none model default", {}, ["none", "low"] as const, "none" as const], + ["a minimal model default", {}, ["minimal", "low"] as const, "minimal" as const], + ])("uses a canonical fallback for %s", async (_name, settings, supportsReasoningEffort, reasoningEffort) => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: [...supportsReasoningEffort], + reasoningEffort, + }, + }) + + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking", ...settings }).createMessage("sys", messages), + ) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "low" }) + }) + + it("uses a configured effort when reasoning support is boolean", async () => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: true, + }, + }) + + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking", reasoningEffort: "high" }).createMessage( + "sys", + messages, + ), + ) + + expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "high" }) + }) + + it("omits an unset optional effort when disable is supported and no default is advertised", async () => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable", "low", "high"], + }, + }) + + await collectStream(new NanoGptHandler({ nanoGptModelId: "model:thinking" }).createMessage("sys", messages)) + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") + }) + it.each([ ["a stale disable effort", { reasoningEffort: "disable" as const }], ["a stale disabled toggle", { enableReasoningEffort: false }], @@ -264,6 +321,7 @@ describe("NanoGptHandler", () => { contextWindow: 1050000, supportsPromptCache: false, supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", }, }) await collectStream( @@ -297,6 +355,7 @@ describe("NanoGptHandler", () => { contextWindow: 1050000, supportsPromptCache: false, supportsReasoningEffort: ["disable", "low", "high"], + reasoningEffort: "high", }, }) await collectStream( diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index dae935fe0a..0814db85a9 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -79,6 +79,12 @@ describe("ThinkingBudget", () => { vi.clearAllMocks() }) + it("should render nothing when model information is unavailable", () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + it("should render nothing when model doesn't support thinking", () => { const { container } = render( { expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true, false) }) + it("should use medium when boolean reasoning support is required without an advertised default", () => { + const setApiConfigurationField = vi.fn() + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "medium") + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "medium") + }) + + it("should synchronize a default when model reasoning metadata changes", () => { + const setApiConfigurationField = vi.fn() + const { rerender } = render( + , + ) + + setApiConfigurationField.mockClear() + rerender( + , + ) + + expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "high") + }) + it.each<{ name: string apiConfiguration: ProviderSettings @@ -450,6 +502,21 @@ describe("ThinkingBudget", () => { expect(screen.getByTestId("select")).toHaveAttribute("data-value", "medium") }) + it("should retain the disabled fallback when availableOptions is empty and settings are unset", () => { + render( + , + ) + + expect(screen.getByTestId("select")).toHaveAttribute("data-value", "disable") + }) + it("should show 'disable' option when supportsReasoningEffort array explicitly includes disable", () => { render( Date: Sat, 5 Sep 2026 01:26:31 +0000 Subject: [PATCH 6/6] test(reasoning): cover boolean disable capability --- src/api/providers/__tests__/nanogpt.spec.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/api/providers/__tests__/nanogpt.spec.ts b/src/api/providers/__tests__/nanogpt.spec.ts index aa5ed955b1..c17239f890 100644 --- a/src/api/providers/__tests__/nanogpt.spec.ts +++ b/src/api/providers/__tests__/nanogpt.spec.ts @@ -277,6 +277,27 @@ describe("NanoGptHandler", () => { expect(mockCreate.mock.calls[0][0]).toMatchObject({ reasoning_effort: "high" }) }) + it("honors disable when optional reasoning support is boolean", async () => { + vi.mocked(getModels).mockResolvedValue({ + "model:thinking": { + maxTokens: 128000, + contextWindow: 1050000, + supportsPromptCache: false, + supportsReasoningEffort: true, + reasoningEffort: "high", + }, + }) + + await collectStream( + new NanoGptHandler({ nanoGptModelId: "model:thinking", reasoningEffort: "disable" }).createMessage( + "sys", + messages, + ), + ) + + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("reasoning_effort") + }) + it("omits an unset optional effort when disable is supported and no default is advertised", async () => { vi.mocked(getModels).mockResolvedValue({ "model:thinking": {