Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 25 additions & 25 deletions webview-ui/src/components/settings/ThinkingBudget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
Semantics for Reasoning Effort (ThinkingBudget)

Capability surface:
- modelInfo.supportsReasoningEffort: boolean | Array<"disable" | "none" | "minimal" | "low" | "medium" | "high">
- modelInfo.supportsReasoningEffort: boolean | Array<"disable" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
- true → UI shows ["low","medium","high"]
- array → UI shows exactly the provided values
- array → UI shows exactly the provided values (e.g. GPT-5.5 includes "xhigh")

Selection behavior:
- "disable":
Expand All @@ -17,7 +17,7 @@ Selection behavior:
- set enableReasoningEffort = true
- persist reasoningEffort = "none"
- request builders include reasoning with value "none"
- "minimal" | "low" | "medium" | "high":
- "minimal" | "low" | "medium" | "high" | "xhigh" | "max":
- set enableReasoningEffort = true
- persist the selected value
- request builders include reasoning with the selected effort
Expand All @@ -35,12 +35,7 @@ Notes:
import { useEffect } from "react"
import { Checkbox } from "vscrui"

import {
type ProviderSettings,
type ModelInfo,
type ReasoningEffortWithMinimal,
reasoningEfforts,
} from "@roo-code/types"
import { type ProviderSettings, type ModelInfo, type ReasoningEffortExtended, reasoningEfforts } from "@roo-code/types"

import {
DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS,
Expand Down Expand Up @@ -81,44 +76,49 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
// max-tokens control, so only surface this standalone slider when that branch is inactive.
const isMaxTokensConfigurable = !!modelInfo && modelInfo.supportsMaxTokens && !isReasoningBudgetSupported

// Build available reasoning efforts list from capability
// "disable" turns off reasoning entirely; "none" is a valid reasoning level.
// Both display as "None" in the UI but behave differently.
// Arrays from supportsReasoningEffort may include "disable" (e.g. Z.ai GLM), so type the
// full option set as ReasoningEffortExtended | "disable" from the start to avoid casts.
type ReasoningEffortOption = ReasoningEffortExtended | "disable"
const supports = modelInfo?.supportsReasoningEffort
const baseAvailableOptions: ReadonlyArray<ReasoningEffortWithMinimal> =
const baseAvailableOptions: ReadonlyArray<ReasoningEffortOption> =
supports === true
? (reasoningEfforts as readonly ReasoningEffortWithMinimal[])
? (reasoningEfforts as readonly ReasoningEffortOption[])
: Array.isArray(supports)
? (supports as ReadonlyArray<ReasoningEffortWithMinimal>)
: (reasoningEfforts as readonly ReasoningEffortWithMinimal[])
? (supports as ReadonlyArray<ReasoningEffortOption>)
: (reasoningEfforts as readonly ReasoningEffortOption[])

// "disable" turns off reasoning entirely; "none" is a valid reasoning level.
// Both display as "None" in the UI but behave differently.
// Add "disable" option only when:
// 1. requiredReasoningEffort is not true, AND
// 2. supportsReasoningEffort is boolean true (not an explicit array)
// When the model provides an explicit array, respect those exact values.
type ReasoningEffortOption = ReasoningEffortWithMinimal | "none" | "disable"
const shouldAutoAddDisable =
!modelInfo?.requiredReasoningEffort && supports === true && !baseAvailableOptions.includes("disable" as any)
!modelInfo?.requiredReasoningEffort && supports === true && !baseAvailableOptions.includes("disable")
const availableOptions: ReadonlyArray<ReasoningEffortOption> = shouldAutoAddDisable
? (["disable", ...baseAvailableOptions] as ReasoningEffortOption[])
: (baseAvailableOptions as ReadonlyArray<ReasoningEffortOption>)
? ["disable", ...baseAvailableOptions]
: baseAvailableOptions

// 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 ReasoningEffortWithMinimal | undefined
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined
const defaultReasoningEffort: ReasoningEffortOption = modelInfo?.requiredReasoningEffort
? modelDefaultReasoningEffort || "medium"
: "disable"
// Current reasoning effort from settings, or fall back to default
// 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
const currentReasoningEffort: ReasoningEffortOption = storedReasoningEffort || defaultReasoningEffort
const rawReasoningEffort: ReasoningEffortOption = storedReasoningEffort || defaultReasoningEffort
const currentReasoningEffort: ReasoningEffortOption = availableOptions.includes(rawReasoningEffort)
? rawReasoningEffort
: (availableOptions[0] ?? rawReasoningEffort)

// Set default reasoning effort when model supports it and no value is set
useEffect(() => {
if (isReasoningEffortSupported && !apiConfiguration.reasoningEffort) {
// Only set a default if reasoning is required, otherwise leave as undefined (which maps to "disable")
if (modelInfo?.requiredReasoningEffort && defaultReasoningEffort !== "disable") {
setApiConfigurationField("reasoningEffort", defaultReasoningEffort as ReasoningEffortWithMinimal, false)
setApiConfigurationField("reasoningEffort", defaultReasoningEffort as ReasoningEffortExtended, false)
}
}
}, [
Expand Down Expand Up @@ -282,7 +282,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
} else {
// "none", "minimal", "low", "medium", "high" all enable reasoning
setApiConfigurationField("enableReasoningEffort", true)
setApiConfigurationField("reasoningEffort", value as ReasoningEffortWithMinimal)
setApiConfigurationField("reasoningEffort", value as ReasoningEffortExtended)
}
}}>
<SelectTrigger className="w-full">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// npx vitest src/components/settings/__tests__/ThinkingBudget.spec.tsx

import React from "react"

import { render, screen, fireEvent } from "@/utils/test-utils"

import type { ModelInfo } from "@roo-code/types"
Expand All @@ -18,16 +20,20 @@ vi.mock("@/components/ui", () => ({
onChange={(e) => onValueChange([parseInt(e.target.value)])}
/>
),
Select: ({ children, value, onValueChange: _onValueChange }: any) => (
<div data-testid="select" data-value={value}>
{children}
Select: ({ children, value, onValueChange }: any) => (
<div data-testid="select" data-value={value} data-onvaluechange={onValueChange}>
{React.Children.map(children, (child) => React.cloneElement(child, { onValueChange }))}
</div>
),
SelectTrigger: ({ children }: any) => <button data-testid="select-trigger">{children}</button>,
SelectValue: ({ placeholder }: any) => <span data-testid="select-value">{placeholder}</span>,
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid={`select-item-${value}`} data-value={value}>
SelectContent: ({ children, onValueChange }: any) => (
<div data-testid="select-content">
{React.Children.map(children, (child) => React.cloneElement(child, { onValueChange }))}
</div>
),
SelectItem: ({ children, value, onValueChange }: any) => (
<div data-testid={`select-item-${value}`} data-value={value} onClick={() => onValueChange?.(value)}>
{children}
</div>
),
Expand Down Expand Up @@ -266,6 +272,40 @@ describe("ThinkingBudget", () => {
expect(screen.getByTestId("select-item-high")).toBeInTheDocument()
})

it("should fall back to first available option when stored value is not in the explicit array", () => {
// Covers the clamp branch: defaultReasoningEffort="disable" but array omits "disable"
render(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{}}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: ["low", "high"],
}}
/>,
)

// The select value should be "low" (first item), not "disable"
expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low")
})

it("should fall back to rawReasoningEffort when availableOptions is empty", () => {
// Covers the ?? rawReasoningEffort branch when availableOptions[0] is undefined
render(
<ThinkingBudget
{...defaultProps}
apiConfiguration={{ reasoningEffort: "medium" }}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: [] as any,
}}
/>,
)

// With an empty options array, falls back to the stored value "medium"
expect(screen.getByTestId("select")).toHaveAttribute("data-value", "medium")
})

it("should show 'disable' option when supportsReasoningEffort array explicitly includes disable", () => {
render(
<ThinkingBudget
Expand Down Expand Up @@ -304,6 +344,48 @@ describe("ThinkingBudget", () => {
expect(screen.getByTestId("select-item-medium")).toBeInTheDocument()
expect(screen.getByTestId("select-item-high")).toBeInTheDocument()
})

it("should show 'xhigh' option when supportsReasoningEffort array includes xhigh (e.g. gpt-5.5)", () => {
render(
<ThinkingBudget
{...defaultProps}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"],
}}
/>,
)

expect(screen.getByTestId("reasoning-effort")).toBeInTheDocument()
// Exactly the declared options — no unsupported tiers or auto-added "disable"
expect(screen.getByTestId("select-item-none")).toBeInTheDocument()
expect(screen.getByTestId("select-item-low")).toBeInTheDocument()
expect(screen.getByTestId("select-item-medium")).toBeInTheDocument()
expect(screen.getByTestId("select-item-high")).toBeInTheDocument()
expect(screen.getByTestId("select-item-xhigh")).toBeInTheDocument()
expect(screen.queryByTestId("select-item-disable")).not.toBeInTheDocument()
expect(screen.queryByTestId("select-item-max")).not.toBeInTheDocument()
})

it("should enable reasoning and persist 'xhigh' when xhigh is selected", () => {
const setApiConfigurationField = vi.fn()

render(
<ThinkingBudget
{...defaultProps}
setApiConfigurationField={setApiConfigurationField}
modelInfo={{
...reasoningEffortModelInfo,
supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"],
}}
/>,
)

fireEvent.click(screen.getByTestId("select-item-xhigh"))

expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true)
expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "xhigh")
})
})

describe("configurable max output tokens (supportsMaxTokens)", () => {
Expand Down
Loading