Skip to content

Commit 5ef31ba

Browse files
author
CodeKing
committed
fix(ThinkingBudget): support extended reasoning effort (Zoo-Code-Org#774) [imported from upstream 211d360]
1 parent cd0906b commit 5ef31ba

2 files changed

Lines changed: 113 additions & 31 deletions

File tree

webview-ui/src/components/settings/ThinkingBudget.tsx

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
Semantics for Reasoning Effort (ThinkingBudget)
33
44
Capability surface:
5-
- modelInfo.supportsReasoningEffort: boolean | Array<"disable" | "none" | "minimal" | "low" | "medium" | "high">
5+
- modelInfo.supportsReasoningEffort: boolean | Array<"disable" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
66
- true → UI shows ["low","medium","high"]
7-
- array → UI shows exactly the provided values
7+
- array → UI shows exactly the provided values (e.g. GPT-5.5 includes "xhigh")
88
99
Selection behavior:
1010
- "disable":
@@ -17,7 +17,7 @@ Selection behavior:
1717
- set enableReasoningEffort = true
1818
- persist reasoningEffort = "none"
1919
- request builders include reasoning with value "none"
20-
- "minimal" | "low" | "medium" | "high":
20+
- "minimal" | "low" | "medium" | "high" | "xhigh" | "max":
2121
- set enableReasoningEffort = true
2222
- persist the selected value
2323
- request builders include reasoning with the selected effort
@@ -35,12 +35,7 @@ Notes:
3535
import { useEffect } from "react"
3636
import { Checkbox } from "vscrui"
3737

38-
import {
39-
type ProviderSettings,
40-
type ModelInfo,
41-
type ReasoningEffortWithMinimal,
42-
reasoningEfforts,
43-
} from "@roo-code/types"
38+
import { type ProviderSettings, type ModelInfo, type ReasoningEffortExtended, reasoningEfforts } from "@roo-code/types"
4439

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

84-
// Build available reasoning efforts list from capability
79+
// "disable" turns off reasoning entirely; "none" is a valid reasoning level.
80+
// Both display as "None" in the UI but behave differently.
81+
// Arrays from supportsReasoningEffort may include "disable" (e.g. Z.ai GLM), so type the
82+
// full option set as ReasoningEffortExtended | "disable" from the start to avoid casts.
83+
type ReasoningEffortOption = ReasoningEffortExtended | "disable"
8584
const supports = modelInfo?.supportsReasoningEffort
86-
const baseAvailableOptions: ReadonlyArray<ReasoningEffortWithMinimal> =
85+
const baseAvailableOptions: ReadonlyArray<ReasoningEffortOption> =
8786
supports === true
88-
? (reasoningEfforts as readonly ReasoningEffortWithMinimal[])
87+
? (reasoningEfforts as readonly ReasoningEffortOption[])
8988
: Array.isArray(supports)
90-
? (supports as ReadonlyArray<ReasoningEffortWithMinimal>)
91-
: (reasoningEfforts as readonly ReasoningEffortWithMinimal[])
89+
? (supports as ReadonlyArray<ReasoningEffortOption>)
90+
: (reasoningEfforts as readonly ReasoningEffortOption[])
9291

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

106102
// Default reasoning effort - use model's default if available
107103
// GPT-5 models have "medium" as their default in the model configuration
108-
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortWithMinimal | undefined
104+
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortExtended | undefined
109105
const defaultReasoningEffort: ReasoningEffortOption = modelInfo?.requiredReasoningEffort
110106
? modelDefaultReasoningEffort || "medium"
111107
: "disable"
112-
// Current reasoning effort from settings, or fall back to default
108+
// Current reasoning effort from settings, or fall back to default.
109+
// Clamp to availableOptions so the Select trigger always renders a valid option.
113110
const storedReasoningEffort = apiConfiguration.reasoningEffort as ReasoningEffortOption | undefined
114-
const currentReasoningEffort: ReasoningEffortOption = storedReasoningEffort || defaultReasoningEffort
111+
const rawReasoningEffort: ReasoningEffortOption = storedReasoningEffort || defaultReasoningEffort
112+
const currentReasoningEffort: ReasoningEffortOption = availableOptions.includes(rawReasoningEffort)
113+
? rawReasoningEffort
114+
: (availableOptions[0] ?? rawReasoningEffort)
115115

116116
// Set default reasoning effort when model supports it and no value is set
117117
useEffect(() => {
118118
if (isReasoningEffortSupported && !apiConfiguration.reasoningEffort) {
119119
// Only set a default if reasoning is required, otherwise leave as undefined (which maps to "disable")
120120
if (modelInfo?.requiredReasoningEffort && defaultReasoningEffort !== "disable") {
121-
setApiConfigurationField("reasoningEffort", defaultReasoningEffort as ReasoningEffortWithMinimal, false)
121+
setApiConfigurationField("reasoningEffort", defaultReasoningEffort as ReasoningEffortExtended, false)
122122
}
123123
}
124124
}, [
@@ -282,7 +282,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
282282
} else {
283283
// "none", "minimal", "low", "medium", "high" all enable reasoning
284284
setApiConfigurationField("enableReasoningEffort", true)
285-
setApiConfigurationField("reasoningEffort", value as ReasoningEffortWithMinimal)
285+
setApiConfigurationField("reasoningEffort", value as ReasoningEffortExtended)
286286
}
287287
}}>
288288
<SelectTrigger className="w-full">

webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// npx vitest src/components/settings/__tests__/ThinkingBudget.spec.tsx
22

3+
import React from "react"
4+
35
import { render, screen, fireEvent } from "@/utils/test-utils"
46

57
import type { ModelInfo } from "@roo-code/types"
@@ -18,16 +20,20 @@ vi.mock("@/components/ui", () => ({
1820
onChange={(e) => onValueChange([parseInt(e.target.value)])}
1921
/>
2022
),
21-
Select: ({ children, value, onValueChange: _onValueChange }: any) => (
22-
<div data-testid="select" data-value={value}>
23-
{children}
23+
Select: ({ children, value, onValueChange }: any) => (
24+
<div data-testid="select" data-value={value} data-onvaluechange={onValueChange}>
25+
{React.Children.map(children, (child) => React.cloneElement(child, { onValueChange }))}
2426
</div>
2527
),
2628
SelectTrigger: ({ children }: any) => <button data-testid="select-trigger">{children}</button>,
2729
SelectValue: ({ placeholder }: any) => <span data-testid="select-value">{placeholder}</span>,
28-
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
29-
SelectItem: ({ children, value }: any) => (
30-
<div data-testid={`select-item-${value}`} data-value={value}>
30+
SelectContent: ({ children, onValueChange }: any) => (
31+
<div data-testid="select-content">
32+
{React.Children.map(children, (child) => React.cloneElement(child, { onValueChange }))}
33+
</div>
34+
),
35+
SelectItem: ({ children, value, onValueChange }: any) => (
36+
<div data-testid={`select-item-${value}`} data-value={value} onClick={() => onValueChange?.(value)}>
3137
{children}
3238
</div>
3339
),
@@ -266,6 +272,40 @@ describe("ThinkingBudget", () => {
266272
expect(screen.getByTestId("select-item-high")).toBeInTheDocument()
267273
})
268274

275+
it("should fall back to first available option when stored value is not in the explicit array", () => {
276+
// Covers the clamp branch: defaultReasoningEffort="disable" but array omits "disable"
277+
render(
278+
<ThinkingBudget
279+
{...defaultProps}
280+
apiConfiguration={{}}
281+
modelInfo={{
282+
...reasoningEffortModelInfo,
283+
supportsReasoningEffort: ["low", "high"],
284+
}}
285+
/>,
286+
)
287+
288+
// The select value should be "low" (first item), not "disable"
289+
expect(screen.getByTestId("select")).toHaveAttribute("data-value", "low")
290+
})
291+
292+
it("should fall back to rawReasoningEffort when availableOptions is empty", () => {
293+
// Covers the ?? rawReasoningEffort branch when availableOptions[0] is undefined
294+
render(
295+
<ThinkingBudget
296+
{...defaultProps}
297+
apiConfiguration={{ reasoningEffort: "medium" }}
298+
modelInfo={{
299+
...reasoningEffortModelInfo,
300+
supportsReasoningEffort: [] as any,
301+
}}
302+
/>,
303+
)
304+
305+
// With an empty options array, falls back to the stored value "medium"
306+
expect(screen.getByTestId("select")).toHaveAttribute("data-value", "medium")
307+
})
308+
269309
it("should show 'disable' option when supportsReasoningEffort array explicitly includes disable", () => {
270310
render(
271311
<ThinkingBudget
@@ -304,6 +344,48 @@ describe("ThinkingBudget", () => {
304344
expect(screen.getByTestId("select-item-medium")).toBeInTheDocument()
305345
expect(screen.getByTestId("select-item-high")).toBeInTheDocument()
306346
})
347+
348+
it("should show 'xhigh' option when supportsReasoningEffort array includes xhigh (e.g. gpt-5.5)", () => {
349+
render(
350+
<ThinkingBudget
351+
{...defaultProps}
352+
modelInfo={{
353+
...reasoningEffortModelInfo,
354+
supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"],
355+
}}
356+
/>,
357+
)
358+
359+
expect(screen.getByTestId("reasoning-effort")).toBeInTheDocument()
360+
// Exactly the declared options — no unsupported tiers or auto-added "disable"
361+
expect(screen.getByTestId("select-item-none")).toBeInTheDocument()
362+
expect(screen.getByTestId("select-item-low")).toBeInTheDocument()
363+
expect(screen.getByTestId("select-item-medium")).toBeInTheDocument()
364+
expect(screen.getByTestId("select-item-high")).toBeInTheDocument()
365+
expect(screen.getByTestId("select-item-xhigh")).toBeInTheDocument()
366+
expect(screen.queryByTestId("select-item-disable")).not.toBeInTheDocument()
367+
expect(screen.queryByTestId("select-item-max")).not.toBeInTheDocument()
368+
})
369+
370+
it("should enable reasoning and persist 'xhigh' when xhigh is selected", () => {
371+
const setApiConfigurationField = vi.fn()
372+
373+
render(
374+
<ThinkingBudget
375+
{...defaultProps}
376+
setApiConfigurationField={setApiConfigurationField}
377+
modelInfo={{
378+
...reasoningEffortModelInfo,
379+
supportsReasoningEffort: ["none", "low", "medium", "high", "xhigh"],
380+
}}
381+
/>,
382+
)
383+
384+
fireEvent.click(screen.getByTestId("select-item-xhigh"))
385+
386+
expect(setApiConfigurationField).toHaveBeenCalledWith("enableReasoningEffort", true)
387+
expect(setApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", "xhigh")
388+
})
307389
})
308390

309391
describe("configurable max output tokens (supportsMaxTokens)", () => {

0 commit comments

Comments
 (0)