From a05830c7a98406f73c6a5e9a9a7149504a42c8fa Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 03:09:39 +0800 Subject: [PATCH 01/19] feat(experiments): add dynamic thinking effort experimental setting --- .../types/src/__tests__/experiment.test.ts | 19 ++++++++++++ packages/types/src/experiment.ts | 2 ++ src/shared/__tests__/experiments.spec.ts | 25 +++++++++++++++- src/shared/experiments.ts | 2 ++ .../__tests__/ExperimentalSettings.spec.tsx | 29 ++++++++++++++++++- webview-ui/src/i18n/locales/ca/settings.json | 4 +++ webview-ui/src/i18n/locales/de/settings.json | 4 +++ webview-ui/src/i18n/locales/en/settings.json | 4 +++ webview-ui/src/i18n/locales/es/settings.json | 4 +++ webview-ui/src/i18n/locales/fr/settings.json | 4 +++ webview-ui/src/i18n/locales/hi/settings.json | 4 +++ webview-ui/src/i18n/locales/id/settings.json | 4 +++ webview-ui/src/i18n/locales/it/settings.json | 4 +++ webview-ui/src/i18n/locales/ja/settings.json | 4 +++ webview-ui/src/i18n/locales/ko/settings.json | 4 +++ webview-ui/src/i18n/locales/nl/settings.json | 4 +++ webview-ui/src/i18n/locales/pl/settings.json | 4 +++ .../src/i18n/locales/pt-BR/settings.json | 4 +++ webview-ui/src/i18n/locales/ru/settings.json | 4 +++ webview-ui/src/i18n/locales/tr/settings.json | 4 +++ webview-ui/src/i18n/locales/vi/settings.json | 4 +++ .../src/i18n/locales/zh-CN/settings.json | 4 +++ .../src/i18n/locales/zh-TW/settings.json | 4 +++ 23 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/__tests__/experiment.test.ts diff --git a/packages/types/src/__tests__/experiment.test.ts b/packages/types/src/__tests__/experiment.test.ts new file mode 100644 index 0000000000..0ef4ed2e02 --- /dev/null +++ b/packages/types/src/__tests__/experiment.test.ts @@ -0,0 +1,19 @@ +import { experimentIds, experimentIdsSchema, experimentsSchema } from "../experiment.js" + +describe("dynamicThinkingEffort experiment", () => { + it("is part of the experiment id enum", () => { + expect(experimentIds).toContain("dynamicThinkingEffort") + expect(experimentIdsSchema.safeParse("dynamicThinkingEffort").success).toBe(true) + }) + + it("parses enabled and disabled states", () => { + expect(experimentsSchema.parse({ dynamicThinkingEffort: true })).toEqual({ dynamicThinkingEffort: true }) + expect(experimentsSchema.parse({ dynamicThinkingEffort: false })).toEqual({ dynamicThinkingEffort: false }) + expect(experimentsSchema.parse({})).toEqual({}) + }) + + it("rejects non-boolean values", () => { + expect(experimentsSchema.safeParse({ dynamicThinkingEffort: "yes" }).success).toBe(false) + expect(experimentIdsSchema.safeParse("dynamic-thinking-effort").success).toBe(false) + }) +}) diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 5d511859b1..f4b3a1c0a8 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -12,6 +12,7 @@ export const experimentIds = [ "runSlashCommand", "customTools", "parallelToolExecution", + "dynamicThinkingEffort", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({ runSlashCommand: z.boolean().optional(), customTools: z.boolean().optional(), parallelToolExecution: z.boolean().optional(), + dynamicThinkingEffort: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index f2261a5c09..84e4251639 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -2,7 +2,7 @@ import type { ExperimentId } from "@roo-code/types" -import { EXPERIMENT_IDS, experimentConfigsMap, experiments as Experiments } from "../experiments" +import { EXPERIMENT_IDS, experimentConfigsMap, experimentDefault, experiments as Experiments } from "../experiments" describe("experiments", () => { describe("PREVENT_FOCUS_DISRUPTION", () => { @@ -22,6 +22,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -33,6 +34,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) }) @@ -44,6 +46,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -66,4 +69,24 @@ describe("experiments", () => { expect(Experiments.isEnabled({ parallelToolExecution: true }, "parallelToolExecution")).toBe(true) }) }) + + describe("DYNAMIC_THINKING_EFFORT", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT).toBe("dynamicThinkingEffort") + expect(experimentConfigsMap.DYNAMIC_THINKING_EFFORT).toMatchObject({ + enabled: false, + }) + // Visible in the Settings panel (showInSettings defaults to true). + expect(experimentConfigsMap.DYNAMIC_THINKING_EFFORT.showInSettings).toBeUndefined() + }) + + it("is disabled by default", () => { + expect(experimentDefault.dynamicThinkingEffort).toBe(false) + expect(Experiments.isEnabled({}, "dynamicThinkingEffort")).toBe(false) + }) + + it("returns true when enabled", () => { + expect(Experiments.isEnabled({ dynamicThinkingEffort: true }, "dynamicThinkingEffort")).toBe(true) + }) + }) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index ae538b9138..c0d461a454 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -6,6 +6,7 @@ export const EXPERIMENT_IDS = { RUN_SLASH_COMMAND: "runSlashCommand", CUSTOM_TOOLS: "customTools", PARALLEL_TOOL_EXECUTION: "parallelToolExecution", + DYNAMIC_THINKING_EFFORT: "dynamicThinkingEffort", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -25,6 +26,7 @@ export const experimentConfigsMap: Record = { CUSTOM_TOOLS: { enabled: false }, // TODO: add i18n keys (settings:experimental.PARALLEL_TOOL_EXECUTION.name/.description) in the same PR that sets showInSettings: true PARALLEL_TOOL_EXECUTION: { enabled: false, showInSettings: false }, + DYNAMIC_THINKING_EFFORT: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx index b31f87dc7e..a1318a29ad 100644 --- a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" import { experimentDefault } from "@roo/experiments" @@ -32,4 +32,31 @@ describe("ExperimentalSettings", () => { expect(screen.getByText("settings:experimental.CUSTOM_TOOLS.name")).toBeInTheDocument() expect(screen.queryByText("settings:experimental.PARALLEL_TOOL_EXECUTION.name")).not.toBeInTheDocument() }) + + it("renders the dynamic thinking effort toggle", () => { + render() + + expect(screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name")).toBeInTheDocument() + }) + + it("binds the dynamic thinking effort toggle to setExperimentEnabled", () => { + const setExperimentEnabled = vi.fn() + render( + , + ) + + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + const checkbox = label?.querySelector("input[type='checkbox']") + expect(checkbox).not.toBeNull() + expect(checkbox).toBeChecked() + + fireEvent.click(checkbox!) + + expect(setExperimentEnabled).toHaveBeenCalledTimes(1) + expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", false) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index fa5cc11d65..f72be60431 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Eines actualitzades correctament", "refreshError": "Error en actualitzar les eines", "toolParameters": "Paràmetres" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 2cb83f7893..31cc01278a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Tools erfolgreich aktualisiert", "refreshError": "Fehler beim Aktualisieren der Tools", "toolParameters": "Parameter" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a5967792a1..304fe8b092 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1039,6 +1039,10 @@ "refreshSuccess": "Tools refreshed successfully", "refreshError": "Failed to refresh tools", "toolParameters": "Parameters" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 305a8dd5d7..f66114a5da 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Herramientas actualizadas correctamente", "refreshError": "Error al actualizar las herramientas", "toolParameters": "Parámetros" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index d5728833dd..3713a6cdcd 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Outils actualisés avec succès", "refreshError": "Échec de l'actualisation des outils", "toolParameters": "Paramètres" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 3fce97a378..86a050782c 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "टूल्स सफलतापूर्वक रिफ्रेश हुए", "refreshError": "टूल्स रिफ्रेश करने में विफल", "toolParameters": "पैरामीटर्स" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bcdd0ae76d..5e0890a3e5 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Tool berhasil direfresh", "refreshError": "Gagal merefresh tool", "toolParameters": "Parameter" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b22fb4c652..7786ff2c00 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Strumenti aggiornati con successo", "refreshError": "Impossibile aggiornare gli strumenti", "toolParameters": "Parametri" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index bbdc5c8e8a..e43177b8e4 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "ツールが正常に更新されました", "refreshError": "ツールの更新に失敗しました", "toolParameters": "パラメーター" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index c2062a5335..203467f924 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "도구가 성공적으로 새로고침되었습니다", "refreshError": "도구 새로고침에 실패했습니다", "toolParameters": "매개변수" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index cf148f5617..e02df845c0 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Tools succesvol vernieuwd", "refreshError": "Fout bij vernieuwen van tools", "toolParameters": "Parameters" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ace780f529..53fd2e4ece 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Narzędzia odświeżone pomyślnie", "refreshError": "Nie udało się odświeżyć narzędzi", "toolParameters": "Parametry" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 446aa8ac02..90dfa16302 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Ferramentas atualizadas com sucesso", "refreshError": "Falha ao atualizar ferramentas", "toolParameters": "Parâmetros" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index f2719ad06e..af5905ac37 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Инструменты успешно обновлены", "refreshError": "Не удалось обновить инструменты", "toolParameters": "Параметры" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 08374b6d20..43e0e00373 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Araçlar başarıyla yenilendi", "refreshError": "Araçlar yenilenemedi", "toolParameters": "Parametreler" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index a8611ca687..d6e2268962 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Làm mới công cụ thành công", "refreshError": "Không thể làm mới công cụ", "toolParameters": "Thông số" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9f3913e872..e6a0772995 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "工具刷新成功", "refreshError": "工具刷新失败", "toolParameters": "参数" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 80d0d18735..2e99a8e940 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -986,6 +986,10 @@ "refreshSuccess": "工具重新整理成功", "refreshError": "工具重新整理失敗", "toolParameters": "參數" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "動態思考強度", + "description": "讓模型自行決定每一步的思考強度,並讓您在對話中調整。(實驗性)" } }, "promptCaching": { From 1cf4f0d4a7bde8d6d7f96edd768f47dc637d4319 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 05:31:20 +0800 Subject: [PATCH 02/19] test(experiments): cover explicit false and omitted dynamic thinking effort states --- src/shared/__tests__/experiments.spec.ts | 4 ++ .../__tests__/ExperimentalSettings.spec.tsx | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 84e4251639..b6e8993df4 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -88,5 +88,9 @@ describe("experiments", () => { it("returns true when enabled", () => { expect(Experiments.isEnabled({ dynamicThinkingEffort: true }, "dynamicThinkingEffort")).toBe(true) }) + + it("returns false when explicitly disabled", () => { + expect(Experiments.isEnabled({ dynamicThinkingEffort: false }, "dynamicThinkingEffort")).toBe(false) + }) }) }) diff --git a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx index a1318a29ad..3feddad1d4 100644 --- a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx @@ -39,6 +39,32 @@ describe("ExperimentalSettings", () => { expect(screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name")).toBeInTheDocument() }) + it("leaves the dynamic thinking effort toggle unchecked when the value is false or omitted", () => { + const getCheckbox = () => { + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + return label?.querySelector("input[type='checkbox']") + } + + // Explicit false + let result = render( + , + ) + expect(getCheckbox()).not.toBeNull() + expect(getCheckbox()).not.toBeChecked() + result.unmount() + + // Omitted (absent from the persisted config) + const omitted: Record = { ...experimentDefault } + delete omitted.dynamicThinkingEffort + result = render() + expect(getCheckbox()).not.toBeNull() + expect(getCheckbox()).not.toBeChecked() + result.unmount() + }) + it("binds the dynamic thinking effort toggle to setExperimentEnabled", () => { const setExperimentEnabled = vi.fn() render( @@ -59,4 +85,25 @@ describe("ExperimentalSettings", () => { expect(setExperimentEnabled).toHaveBeenCalledTimes(1) expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", false) }) + + it("toggles the dynamic thinking effort on when clicked from the unchecked state", () => { + const setExperimentEnabled = vi.fn() + render( + , + ) + + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + const checkbox = label?.querySelector("input[type='checkbox']") + expect(checkbox).not.toBeNull() + expect(checkbox).not.toBeChecked() + + fireEvent.click(checkbox!) + + expect(setExperimentEnabled).toHaveBeenCalledTimes(1) + expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", true) + }) }) From 6ea45b36a2bf0cf7787fca11ebd1969da567e611 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 06:31:23 +0800 Subject: [PATCH 03/19] feat(task): task-local thinking effort state, per-request override, and adaptive effort envelope DTE series 2/5 (part of #1329). - ApiHandlerCreateMessageMetadata.reasoningEffort: per-request override channel - resolveEffectiveReasoningEffort: single shared resolution point (override > settings > model default) - AnthropicHandler: adaptive output_config.effort envelope in both requestParams branches (in-range only) - Task: setRuntimeThinkingEffort/getRuntimeThinkingEffort with in-memory apiConfiguration merge/restore, per-request metadata at all four createMessage sites, dispose() reset; never persisted --- src/api/index.ts | 9 + .../anthropic-adaptive-effort.spec.ts | 297 ++++++++++++++++++ src/api/providers/anthropic.ts | 29 +- .../dte-effective-reasoning-effort.spec.ts | 58 ++++ src/api/transform/reasoning.ts | 45 +++ src/core/task/Task.ts | 82 +++++ .../Task.runtime-thinking-effort.test.ts | 249 +++++++++++++++ 7 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts create mode 100644 src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts create mode 100644 src/core/task/__tests__/Task.runtime-thinking-effort.test.ts diff --git a/src/api/index.ts b/src/api/index.ts index 8e7f20d66f..d6a88971ba 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,6 +7,7 @@ import { retiredProviderIdentifiers, type ProviderSettings, type ModelInfo, + type ReasoningEffortExtended, } from "@roo-code/types" import { getRouterRemovalMessage } from "../core/config/routerRemoval" @@ -115,6 +116,14 @@ export interface ApiHandlerCreateMessageMetadata { * when the user clicks stop, preventing wasted API tokens/compute on the provider side. */ abortSignal?: AbortSignal + /** + * Per-request thinking effort override (DTE series 2/5). + * When defined, takes precedence over the settings-derived `reasoningEffort` + * wherever the effective effort is resolved (see `resolveEffectiveReasoningEffort`). + * Task-scoped and transient: it applies to this request only (the next request + * after being set — no mid-stream effect) and is never persisted to settings. + */ + reasoningEffort?: ReasoningEffortExtended } export interface ApiHandler { diff --git a/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts b/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts new file mode 100644 index 0000000000..7f37b8dcc9 --- /dev/null +++ b/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts @@ -0,0 +1,297 @@ +// npx vitest run src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts +// +// DTE series 2/5 — per-request adaptive thinking effort envelope +// (output_config.effort) on the main Anthropic handler. +// +// Kept in a dedicated file (rather than anthropic.spec.ts) so the DTE series PRs +// stay mergeable while other series PRs extend the shared spec file. + +import { AnthropicHandler } from "../anthropic" +import type { ApiHandlerOptions } from "../../../shared/api" +import type { ReasoningEffortExtended } from "@roo-code/types" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" +import type { ApiHandlerCreateMessageMetadata } from "../../../api" + +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), + }, + }, +})) + +const mockCreate = vitest.fn() + +// Same SDK mock pattern as anthropic.spec.ts: createMessage resolves to a short +// finite stream so the handler's for-await loop terminates cleanly. +vitest.mock("@anthropic-ai/sdk", () => { + const mockAnthropicConstructor = vitest.fn().mockImplementation(function () { + return { + messages: { + create: mockCreate.mockImplementation(async (options: { stream?: boolean; model?: string }) => { + if (!options.stream) { + return { + id: "test-completion", + content: [{ type: "text", text: "Test response" }], + role: "assistant", + model: options.model, + usage: { input_tokens: 10, output_tokens: 5 }, + } + } + return asyncStreamFrom([ + { + type: "message_start", + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "Hello" }, + }, + { + type: "content_block_delta", + delta: { type: "text_delta", text: " world" }, + }, + ]) + }), + }, + } + }) + + return { + Anthropic: mockAnthropicConstructor, + } +}) + +const userMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: "Hi" }], +} + +/** Runs createMessage to completion and returns the request params sent to the SDK. */ +async function sentRequestParams( + handler: AnthropicHandler, + metadata?: ApiHandlerCreateMessageMetadata, +): Promise> { + const stream = handler.createMessage("system prompt", [userMessage], metadata) + await collectStream(stream) + const call = mockCreate.mock.calls.at(-1) + if (!call) { + throw new Error("Expected the SDK messages.create to have been called") + } + return call[0] as Record +} + +function makeHandler(options: { + apiModelId?: string + enableReasoningEffort?: boolean + reasoningEffort?: ApiHandlerOptions["reasoningEffort"] +}): AnthropicHandler { + return new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: options.apiModelId ?? "claude-opus-4-7", + enableReasoningEffort: options.enableReasoningEffort, + reasoningEffort: options.reasoningEffort, + }) +} + +describe("AnthropicHandler adaptive effort envelope (DTE series 2/5)", () => { + beforeEach(() => { + clearAllMocks() + }) + + describe("output_config.effort on adaptive-thinking requests", () => { + const inRangeEfforts: ReasoningEffortExtended[] = ["low", "medium", "high", "xhigh", "max"] + + it.each(inRangeEfforts)( + "sends the settings effort %s as output_config.effort for an adaptive model", + async (effort) => { + const handler = makeHandler({ enableReasoningEffort: true, reasoningEffort: effort }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort }) + }, + ) + + it("sends the envelope from the first (cache-control) requestParams branch", async () => { + // claude-opus-4-8 takes the first (cache-control) requestParams branch; + // the default branch is covered below via an unknown model id. + const handler = makeHandler({ + apiModelId: "claude-opus-4-8", + enableReasoningEffort: true, + reasoningEffort: "xhigh", + }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort: "xhigh" }) + }) + + it("sends the envelope from the default requestParams branch", async () => { + // Unknown model id -> falls through to the default switch branch, while the + // guessed model info (claude-opus-4-7 substring) is adaptive-capable. + const handler = makeHandler({ + apiModelId: "claude-opus-4-7-custom", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + const params = await sentRequestParams(handler) + + expect(params.model).toBe("claude-opus-4-7-custom") + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort: "high" }) + }) + }) + + describe("envelope omission (out-of-range or non-adaptive)", () => { + const settingsEfforts: ApiHandlerOptions["reasoningEffort"][] = ["none", "minimal", "disable"] + + it.each(settingsEfforts)( + "omits output_config when the settings effort is %s on an adaptive model", + async (effort) => { + const handler = makeHandler({ enableReasoningEffort: true, reasoningEffort: effort }) + + const params = await sentRequestParams(handler) + + // Adaptive thinking is still requested, but no envelope is sent so the + // API applies its own default effort. + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }, + ) + + it("omits output_config when no effort is set anywhere on an adaptive model", async () => { + const handler = makeHandler({ enableReasoningEffort: true }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }) + + it("omits output_config for a non-adaptive model even with an in-range effort", async () => { + // Budget-based extended thinking (type: "enabled") never carries the + // adaptive envelope. + const handler = makeHandler({ + apiModelId: "claude-sonnet-4-5", + enableReasoningEffort: true, + reasoningEffort: "xhigh", + }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toMatchObject({ type: "enabled" }) + expect(params).not.toHaveProperty("output_config") + }) + + it("omits output_config when adaptive thinking itself is not requested", async () => { + // enableReasoningEffort=false -> thinking is undefined -> no envelope even + // with an in-range settings effort. + const handler = makeHandler({ enableReasoningEffort: false, reasoningEffort: "xhigh" }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toBeUndefined() + expect(params).not.toHaveProperty("output_config") + }) + + it("keeps the pre-DTE request shape for a plain model with no reasoning settings", async () => { + // Guard: no reasoning settings and no metadata -> no output_config. + const handler = makeHandler({ apiModelId: "claude-3-5-haiku-20241022" }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toBeUndefined() + expect(params).not.toHaveProperty("output_config") + }) + }) + + describe("per-request override (metadata.reasoningEffort) precedence", () => { + const baseOptions: { + apiModelId?: string + enableReasoningEffort?: boolean + reasoningEffort?: ApiHandlerOptions["reasoningEffort"] + } = { + apiModelId: "claude-opus-4-7", + enableReasoningEffort: true, + } + + it("lets metadata.reasoningEffort override the settings value", async () => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "low" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "xhigh", + }) + + expect(params.output_config).toEqual({ effort: "xhigh" }) + }) + + it("suppresses the envelope when the metadata override is out-of-range", async () => { + // Settings would send "high"; the override wins and is out-of-range, so + // the envelope is omitted entirely. + const handler = makeHandler({ ...baseOptions, reasoningEffort: "high" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "minimal", + }) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }) + + const overrideEfforts: ReasoningEffortExtended[] = ["none", "minimal"] + + it.each(overrideEfforts)( + "suppresses the envelope for metadata override %s even with an in-range settings value", + async (effort) => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "max" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: effort, + }) + + expect(params).not.toHaveProperty("output_config") + }, + ) + + it("applies the settings value when metadata carries no override", async () => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "medium" }) + + const params = await sentRequestParams(handler, { taskId: "task-1" }) + + expect(params.output_config).toEqual({ effort: "medium" }) + }) + + it("keeps non-adaptive requests envelope-free even with a metadata override", async () => { + const handler = makeHandler({ + apiModelId: "claude-sonnet-4-5", + enableReasoningEffort: true, + reasoningEffort: "low", + }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "xhigh", + }) + + expect(params.thinking).toMatchObject({ type: "enabled" }) + expect(params).not.toHaveProperty("output_config") + }) + }) +}) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index b55c8b3089..2e9555cc8e 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -18,7 +18,11 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" -import { getAnthropicProviderReasoning } from "../transform/reasoning" +import { + ADAPTIVE_OUTPUT_CONFIG_EFFORTS, + getAnthropicProviderReasoning, + resolveEffectiveReasoningEffort, +} from "../transform/reasoning" import { handleProviderError } from "./utils/error-handler" import { BaseProvider } from "./base-provider" @@ -79,6 +83,25 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa settings: this.options, }) + // DTE series 2/5: per-request adaptive effort envelope (output_config.effort). + // The task-local per-request override (metadata.reasoningEffort) takes + // precedence over the settings-derived value (shared resolution in + // resolveEffectiveReasoningEffort). Only adaptive-thinking requests whose + // effective effort is in-range get the envelope; everything else (unset, + // "disable", "none", "minimal") omits it and lets the API apply its default. + const effectiveReasoningEffort = resolveEffectiveReasoningEffort({ + override: metadata?.reasoningEffort, + settingsReasoningEffort: this.options.reasoningEffort, + modelDefaultEffort: info.reasoningEffort, + }) + const adaptiveEffort = + thinking?.type === "adaptive" && + effectiveReasoningEffort !== undefined && + effectiveReasoningEffort !== "disable" && + ADAPTIVE_OUTPUT_CONFIG_EFFORTS.includes(effectiveReasoningEffort) + ? effectiveReasoningEffort + : undefined + // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API const sanitizedMessages = filterNonAnthropicBlocks(messages) @@ -141,6 +164,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), // Setting cache breakpoint for system prompt so new tasks can reuse it. system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], messages: sanitizedMessages.map((message, index) => { @@ -216,6 +241,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), system: [{ text: systemPrompt, type: "text" }], messages: sanitizedMessages, stream: true, diff --git a/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts new file mode 100644 index 0000000000..f126cae97c --- /dev/null +++ b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts @@ -0,0 +1,58 @@ +// npx vitest run src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts + +import { ADAPTIVE_OUTPUT_CONFIG_EFFORTS, resolveEffectiveReasoningEffort } from "../reasoning" + +describe("DTE series 2/5 — resolveEffectiveReasoningEffort", () => { + const settingsEffort = "high" + const modelDefault = "medium" + + it("returns the per-request override when present (strongest precedence)", () => { + expect( + resolveEffectiveReasoningEffort({ + override: "xhigh", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("xhigh") + }) + + it("lets the override win even when it is out-of-range for the adaptive envelope", () => { + // "minimal" is a valid override value but outside the adaptive envelope set; + // resolution still returns it — envelope gating is the caller's concern. + expect( + resolveEffectiveReasoningEffort({ + override: "minimal", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("minimal") + }) + + it("falls back to the settings value when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "low", modelDefaultEffort: modelDefault }), + ).toBe("low") + }) + + it("preserves the settings 'disable' sentinel when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "disable", modelDefaultEffort: modelDefault }), + ).toBe("disable") + }) + + it("an explicit override wins over a settings 'disable' sentinel", () => { + expect(resolveEffectiveReasoningEffort({ override: "low", settingsReasoningEffort: "disable" })).toBe("low") + }) + + it("falls back to the model default when neither override nor settings is set", () => { + expect(resolveEffectiveReasoningEffort({ modelDefaultEffort: "low" })).toBe("low") + }) + + it("returns undefined when nothing is set", () => { + expect(resolveEffectiveReasoningEffort({})).toBeUndefined() + }) + + it("exposes exactly the in-range adaptive envelope efforts", () => { + expect([...ADAPTIVE_OUTPUT_CONFIG_EFFORTS]).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) +}) diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index c51111125a..14bdaba889 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -22,6 +22,51 @@ export type AnthropicProviderReasoningParams = AnthropicReasoningParams | { type export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] } +/** + * DTE series 2/5 — effort levels accepted by the Claude 4.7+ adaptive-thinking + * `output_config.effort` envelope. Efforts outside this set (e.g. "none", + * "minimal", "disable") omit the envelope so the API applies its own default. + */ +export const ADAPTIVE_OUTPUT_CONFIG_EFFORTS: readonly ReasoningEffortExtended[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +] + +/** + * DTE series 2/5 — resolves the effective thinking effort for a single request. + * + * Resolution order (strongest first): + * 1. `override` — the per-request task-local effort + * (`ApiHandlerCreateMessageMetadata.reasoningEffort`), + * 2. `settingsReasoningEffort` — the settings-derived value, + * 3. `modelDefaultEffort` — the model's default effort. + * + * This is the single shared resolution point for the per-request override: + * providers that resolve the effective effort through it inherit the override + * without duplicating precedence logic. The override is transient (next request + * only) and never persisted to settings. + */ +export const resolveEffectiveReasoningEffort = ({ + override, + settingsReasoningEffort, + modelDefaultEffort, +}: { + override?: ReasoningEffortExtended + settingsReasoningEffort?: ReasoningEffortExtended | "disable" + modelDefaultEffort?: ReasoningEffortExtended +}): ReasoningEffortExtended | "disable" | undefined => { + if (override !== undefined) { + return override + } + if (settingsReasoningEffort !== undefined) { + return settingsReasoningEffort + } + return modelDefaultEffort +} + // Valid Gemini thinking levels for effort-based reasoning const GEMINI_THINKING_LEVELS = ["minimal", "low", "medium", "high"] as const diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4be087394e..2a139923c1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -22,6 +22,7 @@ import { type TaskMetadata, type TaskEvents, type ProviderSettings, + type ReasoningEffortExtended, type TokenUsage, type ToolUsage, type ToolName, @@ -289,6 +290,13 @@ export class Task extends EventEmitter implements TaskLike { // API apiConfiguration: ProviderSettings api: ApiHandler + // DTE series 2/5: task-local thinking effort override. Transient per-task state — + // never persisted to settings; cleared on dispose (see dispose()). + private runtimeThinkingEffort?: ReasoningEffortExtended + private runtimeThinkingEffortSource?: string + // Settings-derived effort captured when the override activates, so clearing + // (undefined) restores it in the in-memory apiConfiguration copy. + private preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -1521,6 +1529,66 @@ export class Task extends EventEmitter implements TaskLike { this.api = buildApiHandler(this.apiConfiguration) } + /** + * DTE series 2/5: sets — or clears with `undefined` — the task-local thinking + * effort override. + * + * Resolution order for the affected requests (strongest first): this + * task-local override → settings `reasoningEffort` → model default. The + * override applies to the NEXT API request only (no mid-stream effect): it is + * passed per request as `metadata.reasoningEffort` and, while active, is + * merged into the in-memory `apiConfiguration` copy (profile-switch / + * `updateApiConfiguration` precedent) so the rebuilt handler reflects it too. + * `undefined` clears the override and restores the settings-derived value in + * the copy. Nothing is ever written to persisted settings. + * + * @param effort - The task-local effort, or `undefined` to clear. + * @param source - Optional provenance label (UI wiring lands in a later PR). + */ + public setRuntimeThinkingEffort(effort: ReasoningEffortExtended | undefined, source?: string): void { + const wasActive = this.runtimeThinkingEffort !== undefined + this.runtimeThinkingEffort = effort + this.runtimeThinkingEffortSource = effort === undefined ? undefined : source + + if (effort !== undefined) { + // Capture the settings-derived value once so clearing can restore it. + if (!wasActive) { + this.preOverrideReasoningEffort = this.apiConfiguration.reasoningEffort + } + // Merge into the in-memory copy (never the persisted settings object). + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: effort } + } else if (wasActive) { + // Restore the settings-derived value captured when the override activated. + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: this.preOverrideReasoningEffort } + this.preOverrideReasoningEffort = undefined + } else { + // Already inactive: nothing to clear. + return + } + + // Rebuild the handler from the updated copy so the next request uses it. + this.api = buildApiHandler(this.apiConfiguration) + } + + /** + * DTE series 2/5: reads the current task-local thinking effort override. + */ + public getRuntimeThinkingEffort(): { effort?: ReasoningEffortExtended; source?: string } { + return { + effort: this.runtimeThinkingEffort, + source: this.runtimeThinkingEffortSource, + } + } + + /** + * DTE series 2/5: metadata fragment carrying the active task-local effort + * override on a single request. Empty when no override is active, so the + * existing settings resolution applies unchanged. + */ + private getRuntimeThinkingEffortMetadata(): Pick { + return this.runtimeThinkingEffort !== undefined ? { reasoningEffort: this.runtimeThinkingEffort } : {} + } + public async submitUserMessage( text: string, images?: string[], @@ -1637,6 +1705,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Generate environment details to include in the condensed summary const environmentDetails = await getEnvironmentDetails(this, true) @@ -2295,6 +2365,12 @@ export class Task extends EventEmitter implements TaskLike { public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // DTE series 2/5: the task-local effort override is transient — clear it on + // task end so a disposed task never carries it forward. + this.runtimeThinkingEffort = undefined + this.runtimeThinkingEffortSource = undefined + this.preOverrideReasoningEffort = undefined + // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. @@ -3955,6 +4031,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } try { @@ -4181,6 +4259,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Only generate environment details when context management will actually run. @@ -4346,6 +4426,8 @@ export class Task extends EventEmitter implements TaskLike { taskId: this.taskId, suppressPreviousResponseId: this.skipPrevResponseIdOnce, abortSignal, + // DTE series 2/5: carry the active task-local effort override for this request. + ...this.getRuntimeThinkingEffortMetadata(), // Include tools whenever they are present. ...(shouldIncludeTools ? { diff --git a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts new file mode 100644 index 0000000000..2ff7e046f8 --- /dev/null +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -0,0 +1,249 @@ +// npx vitest run src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +// +// DTE series 2/5 — task-local thinking effort state on Task: +// setRuntimeThinkingEffort / getRuntimeThinkingEffort, the in-memory +// apiConfiguration merge + restore, and the task-end reset in dispose(). + +import { ProviderSettings, type ReasoningEffortExtended } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { buildApiHandler } from "../../../api" + +// Mock dependencies (same lightweight set as Task.throttle.test.ts) +vi.mock("../../webview/ClineProvider") +vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") +vi.mock("../../context-tracking/FileContextTracker") +vi.mock("../../../integrations/editor/DiffViewProvider") +vi.mock("../../tools/ToolRepetitionDetector") +vi.mock("../../../api", () => ({ + // Returns a fresh handler object per call so tests can assert on the exact + // configuration each rebuild received (via vi.mocked(buildApiHandler).mock.calls). + buildApiHandler: vi.fn((configuration: { apiModelId?: string }) => ({ + getModel: () => ({ info: {}, id: configuration.apiModelId ?? "test-model" }), + })), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + }, + }, +})) + +// Mock task persistence to avoid disk writes +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + readTaskMessages: vi.fn().mockResolvedValue([]), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + taskMetadata: vi.fn().mockResolvedValue({ + historyItem: { + id: "test-task-id", + number: 1, + task: "Test task", + ts: Date.now(), + totalCost: 0.01, + tokensIn: 100, + tokensOut: 50, + }, + tokenUsage: { + totalTokensIn: 100, + totalTokensOut: 50, + totalCost: 0.01, + contextTokens: 150, + totalCacheWrites: 0, + totalCacheReads: 0, + }, + }), +})) + +// Typed access to the intentionally-private DTE state, mirroring the +// getTaskTestAccess pattern in Task.spec.ts (single double assertion, documented). +type RuntimeThinkingEffortAccess = { + runtimeThinkingEffort?: ReasoningEffortExtended + runtimeThinkingEffortSource?: string + preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] + getRuntimeThinkingEffortMetadata: () => { reasoningEffort?: ReasoningEffortExtended } +} + +function getPrivateAccess(task: Task): RuntimeThinkingEffortAccess { + return task as unknown as RuntimeThinkingEffortAccess +} + +const SETTINGS_EFFORT: ReasoningEffortExtended = "low" + +describe("Task runtime thinking effort (DTE series 2/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/path" }, + }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + } as ProviderSettings + + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + startTask: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("setRuntimeThinkingEffort", () => { + it("stores effort + source, merges into the in-memory apiConfiguration, and rebuilds the handler", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + expect(getPrivateAccess(task).runtimeThinkingEffort).toBe("xhigh") + expect(getPrivateAccess(task).runtimeThinkingEffortSource).toBe("test-source") + + // The in-memory copy carries the override... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + reasoningEffort: "xhigh", + }), + ) + // ...without mutating the settings object the provider handed in. + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + reasoningEffort: SETTINGS_EFFORT, + }), + ) + // The handler is rebuilt from the merged copy (last build call). + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "xhigh" })) + // The merged copy is a fresh object, not the settings object. + expect(lastCall?.[0]).not.toBe(mockApiConfiguration) + }) + + it("does not re-capture the settings value when re-set while active", () => { + task.setRuntimeThinkingEffort("high", "first") + task.setRuntimeThinkingEffort("medium", "second") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "medium", source: "second" }) + // The settings-derived value captured at first activation is preserved. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe(SETTINGS_EFFORT) + + // Clearing restores the original settings value, not the intermediate one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + }) + + it("restores the settings-derived effort when cleared with undefined", () => { + task.setRuntimeThinkingEffort("max") + expect(task.apiConfiguration.reasoningEffort).toBe("max") + + task.setRuntimeThinkingEffort(undefined) + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + // The rest of the configuration is preserved through the restore. + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + }), + ) + // The handler is rebuilt from the restored copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: SETTINGS_EFFORT })) + }) + + it("is a no-op when cleared while inactive (no handler rebuild)", () => { + const callsBefore = vi.mocked(buildApiHandler).mock.calls.length + + task.setRuntimeThinkingEffort(undefined) + + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(callsBefore) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration).toBe(mockApiConfiguration) + }) + + it("never writes to the provider or persisted settings", () => { + task.setRuntimeThinkingEffort("xhigh") + task.setRuntimeThinkingEffort(undefined) + + // Nothing is posted to the webview and the handed-in settings object is intact. + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + }), + ) + }) + }) + + describe("request metadata fragment", () => { + it("is empty while inactive and carries the override while active", () => { + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + + task.setRuntimeThinkingEffort("high") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "high" }) + + task.setRuntimeThinkingEffort("low") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "low" }) + + task.setRuntimeThinkingEffort(undefined) + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + }) + }) + + describe("dispose", () => { + it("clears the task-local override at task end", () => { + task.setRuntimeThinkingEffort("xhigh", "source") + task.dispose() + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + const access = getPrivateAccess(task) + expect(access.runtimeThinkingEffort).toBeUndefined() + expect(access.runtimeThinkingEffortSource).toBeUndefined() + expect(access.preOverrideReasoningEffort).toBeUndefined() + }) + }) +}) From 14d1f35a8e1ec1f9d15567ed3c483b66477ddb61 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 08:38:27 +0800 Subject: [PATCH 04/19] fix(task): keep override restore value current across profile switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTE series 2/5 — addresses the CodeRabbit review finding on #1338: when a task-local thinking-effort override is active, updateApiConfiguration() now re-captures the incoming profile's reasoningEffort as the restore value and re-applies the override on top of the new in-memory copy, so clearing the override restores the NEW profile value instead of the stale one. Additive: activation and clearing semantics are otherwise unchanged. Adds two regression tests (override active + profile switch restores new value; inactive updateApiConfiguration unchanged behavior). --- src/core/task/Task.ts | 11 +++- .../Task.runtime-thinking-effort.test.ts | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2a139923c1..ac0e321382 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1525,7 +1525,16 @@ export class Task extends EventEmitter implements TaskLike { */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { // Update the configuration and rebuild the API handler - this.apiConfiguration = newApiConfiguration + if (this.runtimeThinkingEffort !== undefined) { + // DTE series 2/5: a task-local override is active, so re-capture the + // incoming profile's value as the restore value and re-apply the + // override on top of the new in-memory copy — clearing the override + // must restore the NEW profile's value, not the stale one. + this.preOverrideReasoningEffort = newApiConfiguration.reasoningEffort + this.apiConfiguration = { ...newApiConfiguration, reasoningEffort: this.runtimeThinkingEffort } + } else { + this.apiConfiguration = newApiConfiguration + } this.api = buildApiHandler(this.apiConfiguration) } diff --git a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts index 2ff7e046f8..4fce91b475 100644 --- a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -219,6 +219,68 @@ describe("Task runtime thinking effort (DTE series 2/5)", () => { }) }) + describe("updateApiConfiguration while an override is active", () => { + it("re-captures the incoming profile's effort as the restore value and keeps the override applied", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + // A profile switch lands a different settings-derived effort while the override is active. + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + task.updateApiConfiguration(newConfig) + + // The override still wins in the in-memory copy... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "xhigh", + }), + ) + // ...the override remains active... + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + // ...and the NEW profile's value is now the restore target. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe("medium") + // The handler was rebuilt from the merged new copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual( + expect.objectContaining({ apiModelId: "claude-opus-4-8", reasoningEffort: "xhigh" }), + ) + expect(lastCall?.[0]).not.toBe(newConfig) + + // Clearing restores the NEW profile's effort, not the stale original one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + }), + ) + const lastCallAfterClear = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCallAfterClear?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "medium" })) + }) + + it("replaces the configuration as usual while inactive", () => { + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + + task.updateApiConfiguration(newConfig) + + expect(task.apiConfiguration).toBe(newConfig) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toBe(newConfig) + }) + }) + describe("request metadata fragment", () => { it("is empty while inactive and carries the override while active", () => { expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) From 90b47b05399b2dabe299937946be20eb92f5dc9a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 09:38:00 +0800 Subject: [PATCH 05/19] docs(task): JSDoc for diff-touched functions flagged by CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTE series 2/5 — addresses the CodeRabbit docstring-coverage warning on #1338 (33.33% < 80% across the functions touched by the diff): - AnthropicHandler.createMessage: documents the shared effective-effort resolution and the adaptive output_config.effort envelope (in-range only). - Task.dispose: documents centralized teardown incl. the transient task-local override reset. - Task.updateApiConfiguration: documents the override-preservation behavior (re-captured restore value + re-applied override on the new in-memory copy). Comment-only change: 30/30 patch lines and 10/10 branches unchanged; 317/317 tests and tsc --noEmit re-verified green. --- src/api/providers/anthropic.ts | 15 +++++++++++++++ src/core/task/Task.ts | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 2e9555cc8e..c0843d29a8 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -62,6 +62,21 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa }) } + /** + * Creates a streaming Anthropic message for the current model. + * + * Resolves the effective thinking effort for this request through the shared + * `resolveEffectiveReasoningEffort` point (per-request override → settings → + * model default). For adaptive-thinking models, when the resolved effort is one + * of `ADAPTIVE_OUTPUT_CONFIG_EFFORTS` (low|medium|high|xhigh|max), the request + * carries `output_config: { effort }` (DTE series 2/5); out-of-range or unset + * efforts omit it so the API default applies. + * + * @param systemPrompt - The system prompt for the request. + * @param messages - The message history to send. + * @param metadata - Per-request metadata (carries the task-local effort override). + * @returns An async iterator of parsed Anthropic stream events. + */ async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ac0e321382..e448cb16bc 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1521,6 +1521,12 @@ export class Task extends EventEmitter implements TaskLike { * Updates the API configuration and rebuilds the API handler. * There is no tool-protocol switching or tool parser swapping. * + * DTE series 2/5: when a task-local thinking effort override is active + * (`setRuntimeThinkingEffort`), the incoming configuration's `reasoningEffort` + * becomes the new restore value and the override is re-applied on top of the + * fresh in-memory copy — clearing the override later restores the NEW profile's + * value, not a stale one. + * * @param newApiConfiguration - The new API configuration to use */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { @@ -2371,6 +2377,13 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Centralized task teardown: releases task resources and resets transient + * task-local state. + * + * DTE series 2/5: also clears the task-local thinking effort override (the + * `setRuntimeThinkingEffort` state) — the override never outlives the task. + */ public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) From fcc3cf453ada33bf08dbefd182e8ba46c7e58ae2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 14:04:53 +0800 Subject: [PATCH 06/19] feat(task): set_thinking_effort native tool Add the set_thinking_effort native tool (DTE series 3/5): the model adjusts its own per-turn thinking effort mid-task with no approval gate. - Guardrails: one-line chat notification (success or refusal), escalation cap (max 3 upward changes per task), A->B->A oscillation refusal, hard clamp to the model capability array (ties toward the lower level). - Gating: dynamicThinkingEffort experiment + model supportsReasoningEffort (non-empty array or true), evaluated at task start so the tool list stays stable within a task (prompt-cache safety). - Display: webview ChatRow one-line row (applied / oscillation / escalation refusal), i18n keys in all 17 locales; partial streaming updates the same line. - Tests: executor (clamp/cap/oscillation/no-op/no-approval/display), parser (partial + complete), dispatch, gating matrix, schema wiring, ChatRow display. Stacked on DTE PR-1 (experiment flag) and PR-2 (task-local runtime effort state). Closes Zoo-Code-Org/Zoo-Code#1330. --- packages/types/src/tool.ts | 1 + packages/types/src/vscode-extension-host.ts | 4 + .../assistant-message/NativeToolCallParser.ts | 18 + ...veToolCallParser.setThinkingEffort.spec.ts | 89 +++++ ...AssistantMessage-setThinkingEffort.spec.ts | 204 ++++++++++ .../presentAssistantMessage.ts | 12 + .../__tests__/filter-thinking-effort.spec.ts | 137 +++++++ .../prompts/tools/filter-tools-for-mode.ts | 38 ++ src/core/prompts/tools/native-tools/index.ts | 2 + .../tools/native-tools/set_thinking_effort.ts | 49 +++ src/core/tools/SetThinkingEffortTool.ts | 278 +++++++++++++ .../__tests__/setThinkingEffortTool.spec.ts | 378 ++++++++++++++++++ src/shared/tools.ts | 4 + webview-ui/src/components/chat/ChatRow.tsx | 26 ++ .../ChatRow.thinking-effort.spec.tsx | 110 +++++ webview-ui/src/i18n/locales/ca/chat.json | 5 + webview-ui/src/i18n/locales/de/chat.json | 5 + webview-ui/src/i18n/locales/en/chat.json | 5 + webview-ui/src/i18n/locales/es/chat.json | 5 + webview-ui/src/i18n/locales/fr/chat.json | 5 + webview-ui/src/i18n/locales/hi/chat.json | 5 + webview-ui/src/i18n/locales/id/chat.json | 5 + webview-ui/src/i18n/locales/it/chat.json | 5 + webview-ui/src/i18n/locales/ja/chat.json | 5 + webview-ui/src/i18n/locales/ko/chat.json | 5 + webview-ui/src/i18n/locales/nl/chat.json | 5 + webview-ui/src/i18n/locales/pl/chat.json | 5 + webview-ui/src/i18n/locales/pt-BR/chat.json | 5 + webview-ui/src/i18n/locales/ru/chat.json | 5 + webview-ui/src/i18n/locales/tr/chat.json | 5 + webview-ui/src/i18n/locales/vi/chat.json | 5 + webview-ui/src/i18n/locales/zh-CN/chat.json | 5 + webview-ui/src/i18n/locales/zh-TW/chat.json | 5 + 33 files changed, 1440 insertions(+) create mode 100644 src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts create mode 100644 src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts create mode 100644 src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts create mode 100644 src/core/prompts/tools/native-tools/set_thinking_effort.ts create mode 100644 src/core/tools/SetThinkingEffortTool.ts create mode 100644 src/core/tools/__tests__/setThinkingEffortTool.spec.ts create mode 100644 webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index d89a8107c1..712dc8adf4 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -45,6 +45,7 @@ export const toolNames = [ "run_slash_command", "skill", "generate_image", + "set_thinking_effort", "custom_tool", "invalid_tool_call", ] as const diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 337ad22e2c..136f504216 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -835,6 +835,7 @@ export interface ClineSayTool { | "runSlashCommand" | "updateTodoList" | "skill" + | "thinkingEffort" path?: string // For readCommandOutput readStart?: number @@ -892,6 +893,9 @@ export interface ClineSayTool { description?: string // Properties for skill tool skill?: string + // Properties for thinkingEffort (DTE series 3/5) + effort?: string + refusal?: string } export interface ClineAskUseMcpServer { diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 9639ae1baa..c3e74c2c3b 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -510,6 +510,15 @@ export class NativeToolCallParser { } break + case "set_thinking_effort": + if (partialArgs.effort !== undefined || partialArgs.reason !== undefined) { + nativeArgs = { + effort: partialArgs.effort, + reason: partialArgs.reason, + } + } + break + case "run_slash_command": if (partialArgs.command !== undefined) { nativeArgs = { @@ -852,6 +861,15 @@ export class NativeToolCallParser { } break + case "set_thinking_effort": + if (args.effort !== undefined && args.reason !== undefined) { + nativeArgs = { + effort: args.effort, + reason: args.reason, + } as NativeArgsFor + } + break + case "run_slash_command": if (args.command !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts new file mode 100644 index 0000000000..dff4bdfbb0 --- /dev/null +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts @@ -0,0 +1,89 @@ +// npx vitest run src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts +// +// DTE series 3/5 — set_thinking_effort parsing in NativeToolCallParser: +// complete, partial-streaming, and finalize paths. + +import { NativeToolCallParser } from "../NativeToolCallParser" + +describe("NativeToolCallParser — set_thinking_effort", () => { + beforeEach(() => { + NativeToolCallParser.clearAllStreamingToolCalls() + NativeToolCallParser.clearRawChunkState() + }) + + describe("parseToolCall (complete)", () => { + it("parses effort and reason into nativeArgs", () => { + const toolCall = { + id: "toolu_dte_1", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + if (result?.type === "tool_use") { + expect(result.name).toBe("set_thinking_effort") + expect(result.nativeArgs).toEqual({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }) + expect(result.params).toEqual({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }) + } + }) + + it("returns null when the required reason is missing", () => { + const toolCall = { + id: "toolu_dte_2", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ effort: "high" }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + expect(result).toBeNull() + }) + }) + + describe("processStreamingChunk (partial)", () => { + it("emits a partial ToolUse carrying the streamed effort", () => { + const id = "toolu_dte_stream_1" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + const result = NativeToolCallParser.processStreamingChunk( + id, + JSON.stringify({ effort: "high", reason: "escalating" }), + ) + + expect(result).not.toBeNull() + const nativeArgs = result?.nativeArgs as { effort?: string; reason?: string } | undefined + expect(nativeArgs).toBeDefined() + expect(nativeArgs?.effort).toBe("high") + expect(nativeArgs?.reason).toBe("escalating") + }) + }) + + describe("finalizeStreamingToolCall", () => { + it("parses complete args on finalize", () => { + const id = "toolu_dte_final_1" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ effort: "low", reason: "mechanical step" })) + + const result = NativeToolCallParser.finalizeStreamingToolCall(id) + + expect(result).not.toBeNull() + if (result?.type === "tool_use") { + expect(result.nativeArgs).toEqual({ + effort: "low", + reason: "mechanical step", + }) + } + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts new file mode 100644 index 0000000000..b5df7e68f6 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts @@ -0,0 +1,204 @@ +// npx vitest run src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts +// +// DTE series 3/5 — set_thinking_effort dispatch in presentAssistantMessage: +// a completed native tool_use block is routed to SetThinkingEffortTool.handle +// with the standard callbacks (no approval gate). + +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import type { ModelInfo } from "@roo-code/types" + +import { presentAssistantMessage } from "../presentAssistantMessage" +import { setThinkingEffortTool } from "../../tools/SetThinkingEffortTool" +import type { Task } from "../../task/Task" + +// Mock dependencies +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn((toolName: string) => toolName === "set_thinking_effort"), +})) +// The mock handler mirrors the real tool: it pushes exactly one tool result +// through the callbacks (the pushToolResultToUserContent mock records it). +vi.mock("../../tools/SetThinkingEffortTool", () => ({ + setThinkingEffortTool: { + handle: vi.fn( + async (_task: unknown, _block: unknown, callbacks: { pushToolResult: (content: string) => void }) => { + callbacks.pushToolResult("Thinking effort applied") + }, + ), + }, +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +/** Structural double covering every Task surface this dispatch path touches. */ +interface PamTaskDouble { + taskId: string + instanceId: string + abort: boolean + presentAssistantMessageLocked: boolean + presentAssistantMessageHasPendingUpdates: boolean + currentStreamingContentIndex: number + assistantMessageContent: unknown[] + userMessageContent: unknown[] + didCompleteReadingStream: boolean + didRejectTool: boolean + didAlreadyUseTool: boolean + consecutiveMistakeCount: number + clineMessages: unknown[] + api: { getModel: () => { id: string; info: ModelInfo } } + recordToolUsage: Mock + recordToolError: Mock + toolRepetitionDetector: { check: Mock } + providerRef: { + deref: () => { + getState: () => Promise<{ mode: string; customModes: unknown[] }> + } + } + say: Mock + ask: Mock + pushToolResultToUserContent: Mock +} + +describe("presentAssistantMessage - set_thinking_effort dispatch", () => { + let mockTask: PamTaskDouble + + beforeEach(() => { + vi.clearAllMocks() + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + clineMessages: [], + api: { + getModel: () => ({ + id: "test-model", + info: { contextWindow: 1, supportsPromptCache: false }, + }), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + // Records tool results so the dispatched tool_result can be asserted. + pushToolResultToUserContent: vi.fn().mockImplementation((toolResult: unknown) => { + mockTask.userMessageContent.push(toolResult) + return true + }), + } + }) + + // The structural double covers every Task surface presentAssistantMessage + // touches for this dispatch path; a full Task is not needed here. + function asTask(): Task { + return mockTask as unknown as Task + } + + function toolCallId() { + return "tool_call_dte_dispatch_1" + } + + function makeBlock() { + const id = toolCallId() + return { + type: "tool_use" as const, + id, + name: "set_thinking_effort" as const, + params: { effort: "high", reason: "deep analysis ahead" }, + partial: false, + nativeArgs: { effort: "high", reason: "deep analysis ahead" }, + } + } + + function dispatchedToolResult(): unknown { + return mockTask.userMessageContent.find( + (item) => + typeof item === "object" && + item !== null && + (item as { type?: string; tool_use_id?: string }).type === "tool_result" && + (item as { type?: string; tool_use_id?: string }).tool_use_id === toolCallId(), + ) + } + + it("routes a completed set_thinking_effort block to the tool handler", async () => { + mockTask.assistantMessageContent = [makeBlock()] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).toHaveBeenCalledTimes(1) + const [taskArg, blockArg, callbacksArg] = handle.mock.calls[0] + expect(taskArg).toBe(mockTask) + expect(blockArg).toMatchObject({ + name: "set_thinking_effort", + nativeArgs: { effort: "high", reason: "deep analysis ahead" }, + }) + expect(callbacksArg).toEqual( + expect.objectContaining({ + askApproval: expect.any(Function), + handleError: expect.any(Function), + pushToolResult: expect.any(Function), + }), + ) + + // Usage is recorded under the real tool name (not a telemetry alias). + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("set_thinking_effort") + // The handler pushes a tool_result for the tool call id. + expect(dispatchedToolResult()).toBeDefined() + }) + + it("does not route other tools through the set_thinking_effort handler", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use" as const, + id: "tool_call_other_1", + name: "nonexistent_tool", + params: { some: "param" }, + partial: false, + }, + ] + + await presentAssistantMessage(asTask()) + }) + + it("describes a skipped set_thinking_effort block via the tool description when the task already rejected a tool", async () => { + mockTask.didRejectTool = true + mockTask.assistantMessageContent = [makeBlock()] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).not.toHaveBeenCalled() + const result = dispatchedToolResult() + expect(result).toBeDefined() + const content = (result as { content?: string }).content + expect(content).toContain("set_thinking_effort to 'high'") + expect(content).toContain("rejecting") + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7383a7a35a..cc23495250 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -34,6 +34,7 @@ import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" +import { setThinkingEffortTool } from "../tools/SetThinkingEffortTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" @@ -405,6 +406,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` + case "set_thinking_effort": + return `[${block.name} to '${block.params.effort ?? ""}']` default: return `[${block.name}]` } @@ -878,6 +881,15 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "set_thinking_effort": + // DTE series 3/5: model-driven thinking effort — no approval gate, + // no checkpoint (non-destructive, task-local, clamped). + await setThinkingEffortTool.handle(cline, block as ToolUse<"set_thinking_effort">, { + askApproval, + handleError, + pushToolResult, + }) + break default: { // Handle unknown/invalid tool names OR custom tools // This is critical for native tool calling where every tool_use MUST have a tool_result diff --git a/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts new file mode 100644 index 0000000000..2ffa95bc96 --- /dev/null +++ b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts @@ -0,0 +1,137 @@ +// npx vitest run src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts +// +// DTE series 3/5 — set_thinking_effort task-start gating: experiment flag +// AND model capability, stable tool list within a task. + +import { describe, it, expect } from "vitest" +import type OpenAI from "openai" +import type { ModelInfo } from "@roo-code/types" + +import { filterNativeToolsForMode, isSetThinkingEffortEnabled, isToolAllowedInMode } from "../filter-tools-for-mode" + +import { getNativeTools } from "../native-tools/index" + +function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { + return { + type: "function", + function: { + name, + description: name + " tool", + parameters: { type: "object", properties: {} }, + }, + } as OpenAI.Chat.ChatCompletionTool +} + +/** Minimal ModelInfo (contextWindow + supportsPromptCache are the only required fields). */ +function modelInfo(supportsReasoningEffort: ModelInfo["supportsReasoningEffort"]): ModelInfo { + return { contextWindow: 1, supportsPromptCache: false, supportsReasoningEffort } +} + +const TOOLS = [makeTool("execute_command"), makeTool("set_thinking_effort")] + +function toolNames(tools: OpenAI.Chat.ChatCompletionTool[]): string[] { + // The union also includes custom tools (no .function); only function tools carry names. + return tools.flatMap((t) => (t.type === "function" ? [t.function.name] : [])) +} + +describe("isSetThinkingEffortEnabled", () => { + it("is false when the experiment is off, even with capability", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: false }, modelInfo(["low", "high"]))).toBe(false) + expect(isSetThinkingEffortEnabled(undefined, modelInfo(["low", "high"]))).toBe(false) + }) + + it("is false when the model lacks per-request effort support", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, undefined)).toBe(false) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(false))).toBe(false) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo([]))).toBe(false) + }) + + it("is true for a capability array or boolean support", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(["low", "high"]))).toBe(true) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(true))).toBe(true) + }) +}) + +describe("filterNativeToolsForMode set_thinking_effort gate", () => { + it("removes the tool when the experiment is off", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: false }, undefined, { + modelInfo: modelInfo(["low", "high"]), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + expect(toolNames(result)).toContain("execute_command") + }) + + it("keeps the tool when experiment on and model supports effort", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(["low", "high"]), + }) + expect(toolNames(result)).toContain("set_thinking_effort") + }) + + it("removes the tool when the model does not support effort", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(false), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + }) + + it("keeps the tool list stable across repeated calls (prompt-cache safety)", () => { + const experiments = { dynamicThinkingEffort: true } + const settings = { modelInfo: modelInfo(["low", "high"]) } + const a = filterNativeToolsForMode(TOOLS, "code", undefined, experiments, undefined, settings) + const b = filterNativeToolsForMode(TOOLS, "code", undefined, experiments, undefined, settings) + expect(toolNames(a)).toEqual(toolNames(b)) + }) +}) + +describe("getNativeTools — set_thinking_effort schema", () => { + it("exposes the tool with strict effort + reason parameters", () => { + const schema = getNativeTools().find((t) => t.type === "function" && t.function.name === "set_thinking_effort") + if (!schema || schema.type !== "function") { + expect(schema).toBeDefined() + return + } + expect(schema.function.strict).toBe(true) + const parameters = schema.function.parameters as { + required?: string[] + properties?: Record + } + expect(parameters.required).toEqual(["effort", "reason"]) + expect(parameters.properties?.effort?.type).toBe("string") + expect(parameters.properties?.reason?.type).toBe("string") + expect(schema.function.description).toContain("no user approval") + }) +}) + +describe("isToolAllowedInMode — set_thinking_effort gate (prompt-side)", () => { + it("allows the tool only when the experiment is on and the model supports effort", () => { + const settings = { modelInfo: modelInfo(["low", "high"]) } + expect( + isToolAllowedInMode( + "set_thinking_effort", + "code", + undefined, + { dynamicThinkingEffort: true }, + undefined, + settings, + ), + ).toBe(true) + expect( + isToolAllowedInMode( + "set_thinking_effort", + "code", + undefined, + { dynamicThinkingEffort: false }, + undefined, + settings, + ), + ).toBe(false) + expect( + isToolAllowedInMode("set_thinking_effort", "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(false), + }), + ).toBe(false) + // Other always-available tools remain unconditional. + expect(isToolAllowedInMode("execute_command", "code", undefined, undefined, undefined, undefined)).toBe(true) + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..1756ce7800 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -6,6 +6,7 @@ import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" +import { EXPERIMENT_IDS } from "../../../shared/experiments" /** * Reverse lookup map - maps alias name to canonical tool name. @@ -295,6 +296,14 @@ export function filterNativeToolsForMode( allowedToolNames.delete("run_slash_command") } + // DTE series 3/5: conditionally exclude set_thinking_effort unless the + // dynamicThinkingEffort experiment is enabled AND the current model supports + // per-request reasoning effort. The gate is evaluated here at task start so + // the tool list stays stable within a task (prompt-cache safety). + if (!isSetThinkingEffortEnabled(experiments, settings?.modelInfo as ModelInfo | undefined)) { + allowedToolNames.delete("set_thinking_effort") + } + // Remove tools that are explicitly disabled via the disabledTools setting if (settings?.disabledTools?.length) { for (const toolName of settings.disabledTools) { @@ -354,6 +363,32 @@ function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean return servers.some((server) => server.resources && server.resources.length > 0) } +/** + * DTE series 3/5: whether the set_thinking_effort tool should be exposed. + * + * Requires both the dynamicThinkingEffort experiment to be enabled and the + * model to advertise per-request reasoning effort support (a non-empty + * `supportsReasoningEffort` capability array, or boolean/adaptive-class + * support). Evaluated at task start only (prompt-cache safety). + * + * @param experiments - Experiment flags from the current state + * @param modelInfo - Current model info (from apiConfiguration) + * @returns true when the tool should be included in the task tool list + */ +export function isSetThinkingEffortEnabled( + experiments: Record | undefined, + modelInfo: ModelInfo | undefined, +): boolean { + if (experiments?.[EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT] !== true) { + return false + } + const capability = modelInfo?.supportsReasoningEffort + if (Array.isArray(capability)) { + return capability.length > 0 + } + return capability === true +} + /** * Checks if a specific tool is allowed in the current mode. * This is useful for dynamically filtering system prompt content. @@ -396,6 +431,9 @@ export function isToolAllowedInMode( if (toolName === "run_slash_command") { return experiments?.runSlashCommand === true } + if (toolName === "set_thinking_effort") { + return isSetThinkingEffortEnabled(experiments, settings?.modelInfo as ModelInfo | undefined) + } return true } diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..28836a902a 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -13,6 +13,7 @@ import newTask from "./new_task" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" +import setThinkingEffort from "./set_thinking_effort" import skill from "./skill" import searchReplace from "./search_replace" import edit_file from "./edit_file" @@ -60,6 +61,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch readCommandOutput, createReadFileTool(readFileOptions), runSlashCommand, + setThinkingEffort, skill, searchReplace, edit_file, diff --git a/src/core/prompts/tools/native-tools/set_thinking_effort.ts b/src/core/prompts/tools/native-tools/set_thinking_effort.ts new file mode 100644 index 0000000000..029c8451e6 --- /dev/null +++ b/src/core/prompts/tools/native-tools/set_thinking_effort.ts @@ -0,0 +1,49 @@ +import type OpenAI from "openai" + +/** + * DTE series 3/5: native tool schema for model-driven per-turn thinking effort. + * + * The tool is only exposed when the dynamicThinkingEffort experiment is on and + * the current model supports per-request reasoning effort (see + * filter-tools-for-mode.ts). The gate is evaluated at task start only so the + * tool list stays stable within a task (prompt-cache safety). + */ +const SET_THINKING_EFFORT_DESCRIPTION = `Adjust your own thinking (reasoning) effort for the remainder of this task. Use it when the task complexity changes mid-task — for example, when a simple lookup turns into a deep multi-file refactor, or when a straightforward step follows a hard one. The change takes effect from the next model request and applies to the current task only; it is never written to persisted settings and requires no user approval. + +Parameters: +- effort: (required) The new thinking effort level. Must be one of the levels supported by the current model. +- reason: (required) A one-sentence explanation of why the effort is changing. It is shown to the user alongside the new level. + +Example: Escalating after a complex bug +{ "effort": "high", "reason": "The refactor spans 6 files with cross-cutting type changes; deeper reasoning is needed." } + +Example: De-escalating after a hard phase +{ "effort": "low", "reason": "Remaining work is mechanical test updates for already-verified behavior." }` + +const EFFORT_PARAMETER_DESCRIPTION = `The new thinking effort level (one of the levels supported by the current model)` + +const REASON_PARAMETER_DESCRIPTION = `A one-sentence explanation of why the effort is changing; shown to the user` + +export default { + type: "function", + function: { + name: "set_thinking_effort", + description: SET_THINKING_EFFORT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + effort: { + type: "string", + description: EFFORT_PARAMETER_DESCRIPTION, + }, + reason: { + type: "string", + description: REASON_PARAMETER_DESCRIPTION, + }, + }, + required: ["effort", "reason"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/SetThinkingEffortTool.ts b/src/core/tools/SetThinkingEffortTool.ts new file mode 100644 index 0000000000..2710717c2b --- /dev/null +++ b/src/core/tools/SetThinkingEffortTool.ts @@ -0,0 +1,278 @@ +import { type ClineSayTool, type ModelInfo } from "@roo-code/types" + +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import type { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { Task } from "../task/Task" +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** + * DTE series 3/5: model-driven per-turn thinking effort. + * + * The model calls this tool to adjust its own thinking effort mid-task. + * There is NO approval gate (non-destructive, clamped to the model + * capability, instantly undoable); guardrails replace approval: + * - always a one-line chat notification (success or refusal) + * - escalation cap: max 3 upward changes per task + * - oscillation detection: A -> B -> A ping-pong within a task is refused + * - hard clamp to the model capability array + * + * The tool is only exposed when the dynamicThinkingEffort experiment is on + * and the model supports per-request effort (see filter-tools-for-mode.ts); + * the checks below are defense in depth for stale or direct invocations. + */ + +interface SetThinkingEffortParams { + effort: string + reason: string +} + +/** + * Canonical effort ordering used to detect upward changes. "disable" ranks + * lowest: it is a UI/control value that can only appear as the + * settings-derived baseline, never as a value this tool may set. + */ +export const EFFORT_RANK: Record = { + disable: 0, + none: 1, + minimal: 2, + low: 3, + medium: 4, + high: 5, + xhigh: 6, + max: 7, +} + +/** Effort levels this tool may set (disable excluded — see above). */ +export const SETTABLE_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const + +type SettableEffort = (typeof SETTABLE_EFFORTS)[number] + +/** Max upward (escalating) changes per task before the tool refuses. */ +export const MAX_UPWARD_CHANGES = 3 + +/** Per-task guardrail state (scoped per Task; see guardState WeakMap). */ +interface EffortGuardState { + upwardChanges: number + /** Model-driven applied efforts, most recent last. */ + history: string[] +} + +function effortRank(level: string | undefined): number { + return level === undefined ? EFFORT_RANK.disable : (EFFORT_RANK[level] ?? EFFORT_RANK.disable) +} + +/** + * Hard clamp to the model capability array: an in-array request passes + * through unchanged; any other valid level is mapped to the nearest + * supported level (ties resolved toward the lower level). + */ +function clampToCapability( + requested: SettableEffort, + capability: ModelInfo["supportsReasoningEffort"], +): SettableEffort | "disable" { + if (!Array.isArray(capability) || capability.length === 0) { + return requested + } + const supported = capability + if (supported.includes(requested)) { + return requested + } + const requestedRank = effortRank(requested) + let best = supported[0] + let bestDistance = Number.POSITIVE_INFINITY + for (const level of supported) { + const distance = Math.abs(effortRank(level) - requestedRank) + // Ties resolve toward the lower effort level. + if (distance < bestDistance || (distance === bestDistance && effortRank(level) < effortRank(best))) { + best = level + bestDistance = distance + } + } + return best +} + +export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { + readonly name = "set_thinking_effort" as const + + /** + * Guardrail state is per-task. The tool instance is a module singleton, + * so state is keyed by Task instance in a WeakMap: each task starts + * fresh and state is garbage-collected with the task. + */ + private guardState = new WeakMap() + + private getGuardState(task: Task): EffortGuardState { + let state = this.guardState.get(task) + if (!state) { + state = { upwardChanges: 0, history: [] } + this.guardState.set(task, state) + } + return state + } + + async execute(params: SetThinkingEffortParams, task: Task, callbacks: ToolCallbacks): Promise { + const { effort, reason } = params + const { handleError, pushToolResult } = callbacks + + if (!effort) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + pushToolResult(await task.sayAndCreateMissingParamError("set_thinking_effort", "effort")) + return + } + + if (!reason) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + pushToolResult(await task.sayAndCreateMissingParamError("set_thinking_effort", "reason")) + return + } + + try { + // Defense in depth: the tool is only exposed when the experiment is + // on and the model supports per-request effort (task-start gate in + // filter-tools-for-mode.ts), but stale or direct calls can reach here. + const provider = task.providerRef.deref() + const state = await provider?.getState() + if (!experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT)) { + pushToolResult( + formatResponse.toolError( + "set_thinking_effort is unavailable: the dynamic thinking effort experiment is not enabled.", + ), + ) + return + } + + const capability = task.api.getModel().info.supportsReasoningEffort + const hasCapability = capability === true || (Array.isArray(capability) && capability.length > 0) + if (!hasCapability) { + pushToolResult( + formatResponse.toolError("The current model does not support per-request thinking effort."), + ) + return + } + + if (!(SETTABLE_EFFORTS as readonly string[]).includes(effort)) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + "Invalid thinking effort '" + effort + "'. Valid levels: " + SETTABLE_EFFORTS.join(", ") + ".", + ), + ) + return + } + // Validated above: `effort` is one of the settable literal levels. + const requested = effort as SettableEffort + + // Hard clamp to the model capability array. + const clamped = clampToCapability(requested, capability) + if (clamped === "disable") { + // The clamp landed on "disable", which this tool cannot set (the + // task-local API takes an effort level, not a UI off-switch). + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + const supported = Array.isArray(capability) + ? capability.filter((l) => l !== "disable").join(", ") + : "none" + pushToolResult( + formatResponse.toolError( + "'" + effort + "' is not supported by the current model. Supported levels: " + supported + ".", + ), + ) + return + } + + const guard = this.getGuardState(task) + const current = task.getRuntimeThinkingEffort().effort ?? task.apiConfiguration.reasoningEffort + + // No-op: already at the requested level — confirm without churn. + if (clamped === current) { + pushToolResult("Thinking effort is already '" + clamped + "'.") + return + } + + // Oscillation: A -> B -> A ping-pong within the task is refused. + const last = guard.history[guard.history.length - 1] + const secondLast = guard.history[guard.history.length - 2] + if (secondLast !== undefined && secondLast === clamped && last !== clamped) { + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", refusal: "oscillation" } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult( + formatResponse.toolError( + "Thinking effort change refused: oscillation between '" + + secondLast + + "' and '" + + last + + "' detected. Keep the current effort.", + ), + ) + return + } + + const isUpward = effortRank(clamped) > effortRank(current) + if (isUpward && guard.upwardChanges >= MAX_UPWARD_CHANGES) { + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", refusal: "escalation_cap" } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult( + formatResponse.toolError( + "Thinking effort change refused: the escalation limit of " + + MAX_UPWARD_CHANGES + + " upward changes per task has been reached.", + ), + ) + return + } + + // Apply (no approval gate) and notify with a single chat line. + task.consecutiveMistakeCount = 0 + task.setRuntimeThinkingEffort(clamped, "model") + if (isUpward) { + guard.upwardChanges++ + } + guard.history.push(clamped) + + const clampNote = + clamped === effort + ? "" + : " Requested '" + effort + "' was clamped to '" + clamped + "' (model capability)." + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: clamped, reason } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult("Thinking effort is now '" + clamped + "'." + clampNote + " (Reason: " + reason + ")") + } catch (error) { + await handleError("setting thinking effort", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"set_thinking_effort">): Promise { + const effort: string | undefined = block.params.effort + const reason: string | undefined = block.params.reason + if (!effort && !reason) { + return + } + const message = JSON.stringify({ + tool: "thinkingEffort", + effort: effort ?? "", + reason: reason ?? "", + } satisfies ClineSayTool) + // Partial say: updates the same one-line display as it streams in. + await task.say("tool", message, undefined, true).catch(() => {}) + } +} + +export const setThinkingEffortTool = new SetThinkingEffortTool() diff --git a/src/core/tools/__tests__/setThinkingEffortTool.spec.ts b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts new file mode 100644 index 0000000000..c869ed64d7 --- /dev/null +++ b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts @@ -0,0 +1,378 @@ +// npx vitest run src/core/tools/__tests__/setThinkingEffortTool.spec.ts +// +// DTE series 3/5 — set_thinking_effort executor: clamp, escalation cap, +// oscillation, no-op, no-approval, and one-line chat display. + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest" + +import { setThinkingEffortTool, MAX_UPWARD_CHANGES } from "../SetThinkingEffortTool" +import { Task } from "../../task/Task" +import type { ToolUse } from "../../../shared/tools" + +type Capability = string[] | true | false | undefined + +/** Structural double covering every Task surface this tool touches. */ +interface TaskDouble { + taskId: string + consecutiveMistakeCount: number + didToolFailInCurrentTurn: boolean + recordToolError: Mock + sayAndCreateMissingParamError: Mock + say: Mock + setRuntimeThinkingEffort: Mock + getRuntimeThinkingEffort: Mock + apiConfiguration: { reasoningEffort?: string } + api: { getModel: () => { id: string; info: { supportsReasoningEffort: Capability } } } + providerRef: { + deref: () => { + getState: () => Promise<{ experiments: Record }> + } + } +} + +interface CallbackDoubles { + askApproval: Mock + handleError: Mock + pushToolResult: Mock +} + +function makeTask( + overrides: { capability?: Capability; experimentsOn?: boolean; settingsEffort?: string } = {}, +): TaskDouble { + const { capability = ["low", "medium", "high", "max"], experimentsOn = true, settingsEffort } = overrides + // Mirrors the real Task API: getRuntimeThinkingEffort() reflects only the + // task-local override (undefined until setRuntimeThinkingEffort is called); + // the settings baseline is read separately from apiConfiguration. + let override: string | undefined = undefined + return { + taskId: "task-1", + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + recordToolError: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing parameter error"), + say: vi.fn().mockResolvedValue(undefined), + setRuntimeThinkingEffort: vi.fn((effort: string | undefined) => { + override = effort + }), + getRuntimeThinkingEffort: vi.fn().mockImplementation(() => ({ + effort: override, + source: override === undefined ? undefined : "model", + })), + apiConfiguration: { reasoningEffort: settingsEffort }, + api: { getModel: () => ({ id: "test-model", info: { supportsReasoningEffort: capability } }) }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { dynamicThinkingEffort: experimentsOn }, + }), + }), + }, + } +} + +function sayPayloads(double: TaskDouble): unknown[] { + return double.say.mock.calls.filter((call) => call[0] === "tool").map((call) => JSON.parse(call[1] as string)) +} + +describe("setThinkingEffortTool", () => { + let double: TaskDouble + let task: Task + let callbacks: CallbackDoubles + + // Rebuild the double and bind it to the Task-typed reference the tool + // expects. The structural double covers every Task surface this unit + // exercises, so a full Task construction is unnecessary here. + function use(overrides?: { capability?: Capability; experimentsOn?: boolean; settingsEffort?: string }) { + double = makeTask(overrides) + task = double as unknown as Task + } + + beforeEach(() => { + vi.clearAllMocks() + use() + callbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn().mockResolvedValue(undefined), + pushToolResult: vi.fn(), + } + }) + + describe("parameter validation", () => { + it("reports a missing effort parameter and records a tool error", async () => { + await setThinkingEffortTool.execute({ effort: "", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.sayAndCreateMissingParamError).toHaveBeenCalledWith("set_thinking_effort", "effort") + expect(callbacks.pushToolResult).toHaveBeenCalledWith("missing parameter error") + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + }) + + it("reports a missing reason parameter and records a tool error", async () => { + await setThinkingEffortTool.execute({ effort: "high", reason: "" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.sayAndCreateMissingParamError).toHaveBeenCalledWith("set_thinking_effort", "reason") + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + }) + + describe("defense-in-depth gating", () => { + it("rejects when the experiment is off", async () => { + use({ experimentsOn: false }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("experiment") + expect(result).toContain("error") + }) + + it("rejects when the model does not support per-request effort", async () => { + use({ capability: false }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("does not support") + }) + + it("rejects an empty capability array", async () => { + use({ capability: [] }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("does not support") + }) + }) + + describe("clamp to model capability", () => { + it("rejects an unknown effort level", async () => { + await setThinkingEffortTool.execute({ effort: "ultra", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("Invalid thinking effort") + expect(result).toContain("ultra") + }) + + it("rejects 'disable' (a UI off-switch the tool cannot set)", async () => { + await setThinkingEffortTool.execute({ effort: "disable", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("Invalid thinking effort") + }) + + it("clamps an out-of-array request to the nearest supported level", async () => { + use({ capability: ["low", "medium", "high"], settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "max", reason: "deeper reasoning" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "high", reason: "deeper reasoning" }) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'high'") + expect(result).toContain("deeper reasoning") + }) + + it("resolves nearest-level ties toward the lower level", async () => { + use({ capability: ["high", "low"], settingsEffort: "high" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "tie-break" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "low", reason: "tie-break" }) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'low'") + }) + + it("rejects a request that clamps to 'disable' (capability without settable levels)", async () => { + use({ capability: ["disable"], settingsEffort: "disable" }) + await setThinkingEffortTool.execute({ effort: "low", reason: "some reasoning" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("not supported by the current model") + }) + }) + + describe("successful application (no approval gate)", () => { + it("applies the effort, notifies with a one-line say, and never asks for approval", async () => { + use({ settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "high", reason: "deep analysis" }, task, callbacks) + + expect(callbacks.askApproval).not.toHaveBeenCalled() + expect(double.consecutiveMistakeCount).toBe(0) + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "high", reason: "deep analysis" }) + expect(double.say).toHaveBeenCalledWith("tool", JSON.stringify(display), undefined, false) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("high") + expect(result).toContain("deep analysis") + }) + + it("passes through unchanged for a boolean-capability model (all levels supported)", async () => { + use({ capability: true, settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "xhigh", reason: "all levels" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("xhigh", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("xhigh") + expect(result).not.toContain("clamped") + }) + it("is a no-op (without a chat line) when already at the requested level", async () => { + use({ settingsEffort: "medium" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "confirm" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("already") + }) + }) + + describe("escalation cap", () => { + it("allows up to MAX_UPWARD_CHANGES upward changes and refuses the next", async () => { + use({ capability: ["low", "medium", "high", "xhigh", "max"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "up" }, task, callbacks) + + await step("medium") + await step("high") + await step("xhigh") + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(MAX_UPWARD_CHANGES) + + await step("max") // 4th upward change: refused + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(MAX_UPWARD_CHANGES) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "escalation_cap" }) + const result = callbacks.pushToolResult.mock.calls.at(-1)?.[0] as string + expect(result).toContain("escalation limit") + }) + + it("does not count downward changes toward the cap", async () => { + use({ capability: ["low", "medium", "high", "xhigh", "max"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("medium") // upward 1 + await step("low") // downward: not counted + await step("high") // upward 2 + await step("medium") // downward: not counted + await step("xhigh") // upward 3 + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(5) + + await step("max") // 4th upward change: refused + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(5) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "escalation_cap" }) + }) + }) + + describe("oscillation detection", () => { + it("refuses an A -> B -> A ping-pong within the task", async () => { + use({ capability: ["low", "medium", "high"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("medium") + await step("low") // downward, allowed + await step("medium") // ping-pong: refused + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(2) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "oscillation" }) + const result = callbacks.pushToolResult.mock.calls.at(-1)?.[0] as string + expect(result).toContain("oscillation") + expect(result).toContain("'medium'") + expect(result).toContain("'low'") + }) + + it("does not refuse the same level twice in a row (no-op path instead)", async () => { + use({ settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("medium") + await step("medium") // identical level: no-op, not oscillation + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(sayPayloads(double).some((p) => (p as Record).refusal !== undefined)).toBe(false) + }) + }) + + describe("error handling", () => { + it("routes unexpected errors to handleError", async () => { + use({ settingsEffort: "low" }) + double.setRuntimeThinkingEffort = vi.fn().mockImplementation(() => { + throw new Error("boom") + }) + + await setThinkingEffortTool.execute({ effort: "high", reason: "x" }, task, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith("setting thinking effort", expect.any(Error)) + }) + }) + + describe("handle() entry point", () => { + it("emits a partial say with the streamed effort and reason", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: { effort: "high", reason: "deep" }, + partial: true, + nativeArgs: { effort: "high", reason: "deep" }, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).toHaveBeenCalledWith( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: "high", reason: "deep" }), + undefined, + true, + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("ignores a partial block with no args yet", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: {}, + partial: true, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).not.toHaveBeenCalled() + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("reports a parse error when a complete block carries no native args", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: {}, + partial: false, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith( + "parsing set_thinking_effort args", + expect.objectContaining({ message: expect.stringContaining("missing native arguments") }), + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..6a0c76ac2b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -66,6 +66,7 @@ export const toolParamNames = [ "new_string", // search_replace and edit_file parameter "replace_all", // edit tool parameter for replacing all occurrences "expected_replacements", // edit_file parameter for multiple occurrences + "effort", // set_thinking_effort parameter "timeout", // execute_command parameter "artifact_id", // read_command_output parameter "search", // read_command_output parameter for grep-like search @@ -109,6 +110,7 @@ export type NativeToolArgs = { } codebase_search: { query: string; path?: string } generate_image: GenerateImageParams + set_thinking_effort: { effort: string; reason: string } run_slash_command: { command: string; args?: string } skill: { skill: string; args?: string } search_files: { path: string; regex: string; file_pattern?: string | null } @@ -289,6 +291,7 @@ export const TOOL_DISPLAY_NAMES: Record = { run_slash_command: "run slash command", skill: "load skill", generate_image: "generate images", + set_thinking_effort: "set thinking effort", custom_tool: "use custom tools", invalid_tool_call: "invalid tool call", } as const @@ -323,6 +326,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "update_todo_list", "run_slash_command", "skill", + "set_thinking_effort", ] as const /** diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 3c48b2fdd1..9f260c95d8 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -65,6 +65,7 @@ import { SquareArrowOutUpRight, FileCode2, PocketKnife, + Brain, FolderTree, SquareTerminal, MessageCircle, @@ -1549,6 +1550,31 @@ export const ChatRowContent = ({ ) } + case "thinkingEffort": { + const info = sayTool + return ( +
+ + + {info.refusal ? ( + info.refusal === "oscillation" ? ( + t("chat:thinkingEffort.oscillationRefused") + ) : ( + t("chat:thinkingEffort.escalationCapRefused") + ) + ) : ( + {info.effort}, + }} + values={{ effort: info.effort, reason: info.reason }} + /> + )} + +
+ ) + } default: return null } diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx new file mode 100644 index 0000000000..5fb0a54aab --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx @@ -0,0 +1,110 @@ +import React from "react" +import { render, screen } from "@/utils/test-utils" +import { ChatRowContent } from "../ChatRow" +import type { ClineMessage } from "@roo-code/types" + +// Mock vscode API +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (msg: unknown) => mockPostMessage(msg), + }, +})) + +// Mock i18n (value-substituting Trans for the one-line display) +const tMap: Record = { + "chat:thinkingEffort.applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "chat:thinkingEffort.escalationCapRefused": + "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "chat:thinkingEffort.oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected", +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => tMap[key] ?? key, + i18n: { exists: () => true }, + }), + Trans: ({ i18nKey, values }: { i18nKey?: string; values?: Record }) => { + const raw = (i18nKey && (tMap[i18nKey] ?? i18nKey)) || "" + return <>{String(raw).replace(/{{(\w+)}}/g, (_, k: string) => String(values?.[k] ?? ""))} + }, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock extension state context +let mockClineMessages: ClineMessage[] = [] +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpServers: [], + alwaysAllowMcp: false, + currentCheckpoint: null, + mode: "code", + apiConfiguration: {}, + clineMessages: mockClineMessages, + currentTaskItem: undefined, + }), +})) + +// Mock useSelectedModel hook +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ info: { supportsImages: true } }), +})) + +function renderChatRow(message: any) { + mockClineMessages = [message] + return render( + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + />, + ) +} + +function sayToolMessage(text: object): any { + return { + ts: Date.now(), + type: "say" as const, + say: "tool" as const, + text: JSON.stringify(text), + } +} + +describe("ChatRow - thinkingEffort display (DTE series 3/5)", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("renders the one-line applied display with effort and reason", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", reason: "deep analysis ahead" })) + + expect(screen.getByText("🧠 Thinking effort: high (Zoo) — deep analysis ahead")).toBeInTheDocument() + }) + + it("renders the oscillation refusal line", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", refusal: "oscillation" })) + + expect( + screen.getByText("🧠 Thinking effort unchanged: oscillation between levels detected"), + ).toBeInTheDocument() + }) + + it("renders the escalation-cap refusal line", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", refusal: "escalation_cap" })) + + expect( + screen.getByText("🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached"), + ).toBeInTheDocument() + }) + + it("renders nothing for unknown say-tool payloads", () => { + const { container } = renderChatRow(sayToolMessage({ tool: "someOtherTool" })) + + expect(container.textContent).toBe("") + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index d5b63ea886..d48c70d2c5 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -466,6 +466,11 @@ "wantsToRun": "Zoo vol executar una comanda slash", "didRun": "Zoo ha executat una comanda slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "contextMenu": { "noResults": "Sense resultats", "problems": "Problemes", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index ad94856234..25f5d31fc8 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo möchte einen Slash-Befehl ausführen", "didRun": "Zoo hat einen Slash-Befehl ausgeführt" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} von {{total}} To-Dos erledigt", "complete": "{{total}} To-Dos erledigt", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 89f6c2f488..9155909f26 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -450,6 +450,11 @@ "wantsToRun": "Zoo wants to run a slash command", "didRun": "Zoo ran a slash command" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "queuedMessages": { "title": "Queued Messages", "clickToEdit": "Click to edit message" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 7876845932..13f0ce5a21 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo quiere ejecutar un comando slash", "didRun": "Zoo ejecutó un comando slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} de {{total}} tareas pendientes realizadas", "complete": "{{total}} tareas pendientes realizadas", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 032d43ce27..e02f2bc95c 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo veut exécuter une commande slash", "didRun": "Zoo a exécuté une commande slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} sur {{total}} tâches terminées", "complete": "{{total}} tâches terminées", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 94a805f328..6503af242c 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo एक स्लैश कमांड चलाना चाहता है", "didRun": "Zoo ने एक स्लैश कमांड चलाया" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}} में से {{completed}} टू-डू हो गए", "complete": "{{total}} टू-डू हो गए", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index d58d80db00..8bd53802cc 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -478,6 +478,11 @@ "wantsToRun": "Zoo ingin menjalankan perintah slash", "didRun": "Zoo telah menjalankan perintah slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} dari {{total}} to-do selesai", "complete": "{{total}} to-do selesai", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 7c4c657b35..2941e9d9db 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo vuole eseguire un comando slash", "didRun": "Zoo ha eseguito un comando slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} di {{total}} cose da fare completate", "complete": "{{total}} cose da fare completate", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index f2f17b3fa5..3233fa0e11 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zooはスラッシュコマンドを実行したい", "didRun": "Zooはスラッシュコマンドを実行しました" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}}件中{{completed}}件のTo-Doが完了", "complete": "{{total}}件のTo-Doが完了", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 090bb1a706..cc971291cb 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo가 슬래시 명령어를 실행하려고 합니다", "didRun": "Zoo가 슬래시 명령어를 실행했습니다" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}}개의 할 일 중 {{completed}}개 완료", "complete": "{{total}}개의 할 일 완료", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 0f1ce14084..76360d37a6 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo wil een slash commando uitvoeren", "didRun": "Zoo heeft een slash commando uitgevoerd" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} van {{total}} to-do's voltooid", "complete": "{{total}} to-do's voltooid", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 53ae2013e1..e3d29238ab 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo chce uruchomić komendę slash", "didRun": "Zoo uruchomił komendę slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "Ukończono {{completed}} z {{total}} zadań do wykonania", "complete": "Ukończono {{total}} zadań do wykonania", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 4769341a7b..8071dbe5c7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo quer executar um comando slash", "didRun": "Zoo executou um comando slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} de {{total}} tarefas concluídas", "complete": "{{total}} tarefas concluídas", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index de34a0e0b8..ec49a053b9 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo хочет выполнить слеш-команду", "didRun": "Zoo выполнил слеш-команду" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} из {{total}} задач выполнено", "complete": "{{total}} задач выполнено", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 752e5bff9f..3448e5cd67 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo bir slash komutu çalıştırmak istiyor", "didRun": "Zoo bir slash komutu çalıştırdı" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}} yapılacaklar listesinden {{completed}} tanesi tamamlandı", "complete": "{{total}} yapılacaklar listesi tamamlandı", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 3caa0e8d3d..b257eb6519 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo muốn chạy lệnh slash", "didRun": "Zoo đã chạy lệnh slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} trong tổng số {{total}} công việc đã hoàn thành", "complete": "{{total}} công việc đã hoàn thành", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 76989eb473..82d2f3c5d8 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo 想要运行斜杠命令", "didRun": "Zoo 运行了斜杠命令" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "已完成 {{completed}} / {{total}} 个待办事项", "complete": "已完成 {{total}} 个待办事项", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index e096c27af4..5fffb203a2 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -453,6 +453,11 @@ "wantsToRun": "Zoo 想要執行斜線指令", "didRun": "Zoo 執行了斜線指令" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "queuedMessages": { "title": "佇列中的訊息", "clickToEdit": "點選以編輯訊息" From c3df095353d0f12085ffbfc0b2fccd46ae0bf23d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 05:23:45 +0800 Subject: [PATCH 07/19] =?UTF-8?q?feat(webview):=20thinking=20effort=20surf?= =?UTF-8?q?aces=20=E2=80=94=20header=20chip,=20composer=20toggle,=20in-cha?= =?UTF-8?q?t=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/types/src/vscode-extension-host.ts | 12 ++ src/core/webview/ClineProvider.ts | 8 + ...viewMessageHandler.thinking-effort.spec.ts | 97 +++++++++ src/core/webview/webviewMessageHandler.ts | 35 +++ webview-ui/src/components/chat/ChatRow.tsx | 6 +- .../src/components/chat/ChatTextArea.tsx | 2 + webview-ui/src/components/chat/TaskHeader.tsx | 39 +++- .../components/chat/ThinkingEffortToggle.tsx | 114 ++++++++++ .../ChatRow.thinking-effort.spec.tsx | 15 ++ .../TaskHeader.thinking-effort.spec.tsx | 160 ++++++++++++++ .../__tests__/ThinkingEffortToggle.spec.tsx | 200 ++++++++++++++++++ .../ThinkingEffortToggle.visual.fixture.tsx | 25 +++ .../__tests__/ThinkingEffortToggle.visual.tsx | 26 +++ .../thinking-effort-toggle-menu-dark.png | Bin 0 -> 2297 bytes .../thinking-effort-toggle-menu-light.png | Bin 0 -> 2442 bytes .../thinking-effort-toggle-resting-dark.png | Bin 0 -> 2297 bytes .../thinking-effort-toggle-resting-light.png | Bin 0 -> 2435 bytes webview-ui/src/i18n/locales/ca/chat.json | 7 + webview-ui/src/i18n/locales/de/chat.json | 7 + webview-ui/src/i18n/locales/en/chat.json | 7 + webview-ui/src/i18n/locales/es/chat.json | 7 + webview-ui/src/i18n/locales/fr/chat.json | 7 + webview-ui/src/i18n/locales/hi/chat.json | 7 + webview-ui/src/i18n/locales/id/chat.json | 7 + webview-ui/src/i18n/locales/it/chat.json | 7 + webview-ui/src/i18n/locales/ja/chat.json | 7 + webview-ui/src/i18n/locales/ko/chat.json | 7 + webview-ui/src/i18n/locales/nl/chat.json | 7 + webview-ui/src/i18n/locales/pl/chat.json | 7 + webview-ui/src/i18n/locales/pt-BR/chat.json | 7 + webview-ui/src/i18n/locales/ru/chat.json | 7 + webview-ui/src/i18n/locales/tr/chat.json | 7 + webview-ui/src/i18n/locales/vi/chat.json | 7 + webview-ui/src/i18n/locales/zh-CN/chat.json | 7 + webview-ui/src/i18n/locales/zh-TW/chat.json | 7 + .../utils/__tests__/thinkingEffort.spec.ts | 150 +++++++++++++ webview-ui/src/utils/thinkingEffort.ts | 79 +++++++ 37 files changed, 1092 insertions(+), 2 deletions(-) create mode 100644 src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts create mode 100644 webview-ui/src/components/chat/ThinkingEffortToggle.tsx create mode 100644 webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx create mode 100644 webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png create mode 100644 webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-light.png create mode 100644 webview-ui/src/utils/__tests__/thinkingEffort.spec.ts create mode 100644 webview-ui/src/utils/thinkingEffort.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f4e4d79547..16693993c2 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -336,6 +336,12 @@ export type ExtensionState = Pick< clineMessages: ClineMessage[] currentTaskId?: string currentTaskItem?: HistoryItem + // DTE series 4/5: task-local thinking effort override for the current task. + // Present only while a task-local override is active (set by the composer + // toggle, the set_thinking_effort tool, or a parent orchestrator); undefined + // otherwise, in which case the webview derives the display from settings -> + // model default. Authoritative state stays extension-side. + taskThinkingEffort?: { effort: string; source: string } currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings uriScheme?: string @@ -501,6 +507,7 @@ export interface WebviewMessage { | "openMention" | "cancelTask" | "cancelAutoApproval" + | "setTaskThinkingEffort" | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" @@ -648,6 +655,11 @@ export interface WebviewMessage { | "themeFixtureProbeResponse" text?: string taskId?: string + // DTE series 4/5: task-local thinking effort set from the composer toggle + // (message type "setTaskThinkingEffort"). Task-local only; persisted settings + // are never touched. + effort?: string + reason?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4621cb3fc4..98a72d7aee 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2649,6 +2649,11 @@ export class ClineProvider const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd const currentTask = this.getCurrentTask() + // DTE series 4/5: task-local thinking effort override for the current task + // (undefined while no override is active — the webview then derives the + // display from settings -> model default). The optional call keeps this + // tolerant of partial task doubles in extension tests. + const currentTaskRuntimeEffort = currentTask?.getRuntimeThinkingEffort?.() let zooCodeState: { zooCodeIsAuthenticated: boolean zooCodeUserName: string | undefined @@ -2706,6 +2711,9 @@ export class ClineProvider currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, clineMessages: currentTask?.clineMessages || [], currentTaskTodos: currentTask?.todoList || [], + taskThinkingEffort: currentTaskRuntimeEffort?.effort + ? { effort: currentTaskRuntimeEffort.effort, source: currentTaskRuntimeEffort.source ?? "default" } + : undefined, messageQueue: currentTask?.messageQueueService?.messages, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task) diff --git a/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts new file mode 100644 index 0000000000..b4c06d60be --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi } from "vitest" + +import { webviewMessageHandler } from "../webviewMessageHandler" + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), + changeLanguage: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + workspace: { + workspaceFolders: undefined, + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + update: vi.fn(), + })), + }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, + Uri: { + parse: vi.fn((str) => ({ toString: () => str })), + file: vi.fn((path) => ({ fsPath: path })), + }, +})) + +describe("webviewMessageHandler setTaskThinkingEffort (DTE series 4/5)", () => { + const makeTask = (supportsReasoningEffort: unknown) => { + const say = vi.fn(async (_say: string, _text?: string) => {}) + return { + taskId: "test-task-id", + api: { getModel: () => ({ id: "test-model", info: { supportsReasoningEffort } }) }, + setRuntimeThinkingEffort: vi.fn(), + say, + } + } + + const makeProvider = (task: unknown) => ({ + getCurrentTask: vi.fn(() => task), + postStateToWebviewWithoutTaskHistory: vi.fn(async () => {}), + }) + + const apply = (provider: ReturnType, message: Record) => + webviewMessageHandler(provider as never, message as never) + + it("applies a task-local effort for a supported level, records the chat line, and pushes state", async () => { + const task = makeTask(["low", "medium", "high"]) + const provider = makeProvider(task) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + + expect(task.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "you") + const [say, text] = task.say.mock.calls[0] + expect(say).toBe("tool") + expect(text).toBe(JSON.stringify({ tool: "thinkingEffort", effort: "high", source: "you" })) + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it("accepts boolean/adaptive-class capability", async () => { + const provider = makeProvider(makeTask(true)) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "medium" }) + + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it.each([ + ["an unsupported level", ["low", "medium", "high"], { effort: "max" }], + ["an effort outside the canonical enum", ["low", "medium", "high"], { effort: "bogus" }], + ["a missing effort", ["low", "medium", "high"], {}], + ["a model without effort support", false, { effort: "high" }], + ])("ignores %s", async (_name, capability, message) => { + const task = makeTask(capability) + const provider = makeProvider(task) + + await apply(provider, { type: "setTaskThinkingEffort", ...message }) + + expect(task.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) + + it("ignores the message when there is no current task", async () => { + const provider = makeProvider(undefined) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + + expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..feda5e0345 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -16,6 +16,8 @@ import { type Command as SlashCommand, type WebviewMessage, type EditQueuedMessagePayload, + type ClineSayTool, + reasoningEffortExtendedSchema, TelemetryEventName, RooCodeSettings, ExperimentId, @@ -1655,6 +1657,39 @@ export const webviewMessageHandler = async ( // Cancel any pending auto-approval timeout for the current task provider.getCurrentTask()?.cancelAutoApprovalTimeout() break + case "setTaskThinkingEffort": { + // DTE series 4/5: task-local thinking effort set from the composer + // toggle. Task-local only — persisted settings are never touched. + // Defense in depth: the composer menu only offers model-supported + // levels, but the webview is never trusted blindly. + const setEffortTask = provider.getCurrentTask() + // Validate the webview-supplied effort against the canonical enum. + const setEffortParsed = reasoningEffortExtendedSchema.safeParse(message.effort) + if (setEffortTask && setEffortParsed.success) { + const setEffortValue = setEffortParsed.data + const capability = setEffortTask.api.getModel().info.supportsReasoningEffort + const supported = Array.isArray(capability) + ? (capability as string[]).includes(setEffortValue) + : capability === true + if (supported) { + setEffortTask.setRuntimeThinkingEffort(setEffortValue, "you") + // Single in-chat line (same ChatRow case as model-initiated changes). + await setEffortTask.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: setEffortValue, + source: "you", + } satisfies ClineSayTool), + undefined, + false, + ) + // Push the authoritative display state to the webview. + await provider.postStateToWebviewWithoutTaskHistory() + } + } + break + } case "allowedCommands": { // Validate and sanitize the commands array const commands = message.commands ?? [] diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 5312913137..a849c4b597 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1564,7 +1564,11 @@ export const ChatRowContent = ({ ) ) : ( {info.effort}, }} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 3b94fb6e74..eab4830443 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -28,6 +28,7 @@ import Thumbnails from "../common/Thumbnails" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" +import { ThinkingEffortToggle } from "./ThinkingEffortToggle" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" import { IndexingStatusBadge } from "./IndexingStatusBadge" @@ -1311,6 +1312,7 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..36c0f345d9 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -8,6 +8,7 @@ import { ListChevronsDownUp, ArrowLeft, ArrowRight, + Brain, } from "lucide-react" import prettyBytes from "pretty-bytes" @@ -29,6 +30,8 @@ import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" import { LucideIconButton } from "./LucideIconButton" +import { computeThinkingEffortDisplay } from "@/utils/thinkingEffort" + export interface TaskHeaderProps { task: ClineMessage tokensIn: number @@ -63,7 +66,7 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem } = useExtensionState() + const { apiConfiguration, currentTaskItem, experiments, taskThinkingEffort } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -86,6 +89,25 @@ const TaskHeader = ({ // vscode-lm reports maxTokens: -1 (unlimited); a negative reserve must not distort the window math. const reservedForOutput = maxTokens && maxTokens > 0 ? maxTokens : 0 + // DTE series 4/5: current effective thinking effort + source badge + // (task-local override → settings → model default/adaptive). + const thinkingEffortDisplay = useMemo( + () => + computeThinkingEffortDisplay({ + experiments, + apiConfiguration, + model, + taskThinkingEffort, + }), + [experiments, apiConfiguration, model, taskThinkingEffort], + ) + const thinkingEffortSourceKey = + thinkingEffortDisplay?.source === "you" + ? "chat:thinkingEffort.sourceYou" + : thinkingEffortDisplay?.source === "auto" + ? "chat:thinkingEffort.sourceAuto" + : "chat:thinkingEffort.sourceDefault" + const condenseButton = (
e.stopPropagation()}> + {thinkingEffortDisplay && ( + + + + + {thinkingEffortDisplay.effort} + + {t(thinkingEffortSourceKey)} + + + )} + ))} + + + ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx index 5fb0a54aab..ff0361444e 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx @@ -14,6 +14,7 @@ vi.mock("@src/utils/vscode", () => ({ // Mock i18n (value-substituting Trans for the one-line display) const tMap: Record = { "chat:thinkingEffort.applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "chat:thinkingEffort.appliedByUser": "🧠 Thinking effort set to: {{effort}}", "chat:thinkingEffort.escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", "chat:thinkingEffort.oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected", @@ -102,6 +103,20 @@ describe("ChatRow - thinkingEffort display (DTE series 3/5)", () => { ).toBeInTheDocument() }) + // DTE series 4/5: user-set (composer toggle) changes reuse the same one-line + // display with a user-specific phrasing. + it("renders the applied display with user phrasing for source 'you'", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", source: "you" })) + + expect(screen.getByText("🧠 Thinking effort set to: high")).toBeInTheDocument() + }) + + it("keeps the model phrasing for non-user sources", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", source: "model", reason: "deep dive" })) + + expect(screen.getByText("🧠 Thinking effort: high (Zoo) — deep dive")).toBeInTheDocument() + }) + it("renders nothing for unknown say-tool payloads", () => { const { container } = renderChatRow(sayToolMessage({ tool: "someOtherTool" })) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx new file mode 100644 index 0000000000..6e8097a82c --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx @@ -0,0 +1,160 @@ +import React from "react" +import { renderWithExtensionState, screen } from "@/utils/test-utils" +import type { ProviderSettings } from "@roo-code/types" + +import TaskHeader, { TaskHeaderProps } from "../TaskHeader" + +// i18n: keys, with exact badge strings for the thinking-effort keys +const effortKeys: Record = { + "chat:thinkingEffort.sourceYou": "you", + "chat:thinkingEffort.sourceAuto": "Zoo (auto)", + "chat:thinkingEffort.sourceDefault": "default", + "chat:thinkingEffort.chipTooltip": "thinking-effort-chip", +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => effortKeys[key] ?? key, + }), + initReactI18next: { + type: "3rdParty", + init: vi.fn(), + }, +})) + +const { mockPostMessage } = vi.hoisted(() => ({ mockPostMessage: vi.fn() })) +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const mockState: { + apiConfiguration: ProviderSettings + currentTaskItem: { id: string } | null + clineMessages: any[] + taskHistory: any[] + experiments: Record + taskThinkingEffort: { effort: string; source: string } | undefined +} = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id" }, + clineMessages: [], + taskHistory: [], + experiments: { dynamicThinkingEffort: true }, + taskThinkingEffort: undefined, +} +vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, + useExtensionState: () => mockState, +})) + +vi.mock("@roo/array", () => ({ + findLastIndex: (array: any[], predicate: (item: any) => boolean) => array.map(predicate).findLastIndex(Boolean), +})) + +let mockModelInfo: any = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", +} +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ + provider: "anthropic", + id: "test-model", + info: mockModelInfo, + isLoading: false, + isError: false, + }), +})) + +let mockMaxOutputTokens = 0 +vi.mock("@roo/api", () => ({ + getModelMaxOutputTokens: () => mockMaxOutputTokens, +})) + +describe("TaskHeader - thinking effort chip (DTE series 4/5)", () => { + const defaultProps: TaskHeaderProps = { + task: { type: "say", ts: Date.now(), text: "Test task", images: [] }, + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + contextTokens: 200, + buttonsDisabled: false, + handleCondenseContext: vi.fn(), + } as TaskHeaderProps + + beforeEach(() => { + mockMaxOutputTokens = 0 + mockState.experiments = { dynamicThinkingEffort: true } + mockState.taskThinkingEffort = undefined + mockState.apiConfiguration = { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + } + }) + + const renderChip = () => renderWithExtensionState() + + it("shows the effective effort with a 'you' source badge for a task-local override", () => { + mockState.taskThinkingEffort = { effort: "high", source: "you" } + renderChip() + expect(screen.getByText("high")).toBeInTheDocument() + expect(screen.getByText("you")).toBeInTheDocument() + }) + + it("shows the 'Zoo (auto)' source badge for model/parent-sourced overrides", () => { + mockState.taskThinkingEffort = { effort: "low", source: "model" } + renderChip() + expect(screen.getByText("low")).toBeInTheDocument() + expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() + }) + + it("shows the settings-derived effort with a 'default' source badge", () => { + mockState.apiConfiguration = { apiProvider: "anthropic", reasoningEffort: "medium" } as ProviderSettings + renderChip() + expect(screen.getByText("medium")).toBeInTheDocument() + expect(screen.getByText("default")).toBeInTheDocument() + }) + + it("shows the adaptive soft-guidance level with 'Zoo (auto)' for boolean-class models", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + renderChip() + expect(screen.getByText("adaptive")).toBeInTheDocument() + expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() + }) + + it("hides the chip when the dynamic-thinking-effort experiment is disabled", () => { + mockState.experiments = { dynamicThinkingEffort: false } + renderChip() + expect(screen.queryByText("medium")).toBeNull() + }) + + it("hides the chip when the model does not advertise effort support", () => { + mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + renderChip() + expect(screen.queryByText("medium")).toBeNull() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx new file mode 100644 index 0000000000..4bf7c5246a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx @@ -0,0 +1,200 @@ +import React from "react" +import { fireEvent, render, screen, within } from "@/utils/test-utils" +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { ThinkingEffortToggle } from "../ThinkingEffortToggle" + +const mockPostMessage = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@src/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +const mockState: { + experiments: Record + apiConfiguration: ProviderSettings + taskThinkingEffort: { effort: string; source: string } | undefined +} = { + experiments: { dynamicThinkingEffort: true }, + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + taskThinkingEffort: undefined, +} +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockState, +})) + +let mockModelInfo: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], +} +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ id: "test-model", info: mockModelInfo }), +})) + +// Faithful popover double: the trigger flips the open state; content mounts only while open. +const PopoverState = React.createContext<{ open: boolean; setOpen: (open: boolean) => void } | null>(null) +vi.mock("@src/components/ui", () => ({ + Popover: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( + onOpenChange?.(next) }}> + {children} + + ), + PopoverTrigger: (props: { + children?: React.ReactNode + disabled?: boolean + className?: string + "data-testid"?: string + }) => { + const state = React.useContext(PopoverState) + const { children, ...rest } = props + return ( + + ) + }, + PopoverContent: (props: { children?: React.ReactNode; "data-testid"?: string }) => { + const state = React.useContext(PopoverState) + if (!state?.open) { + return null + } + const { children, ...rest } = props + return
{children}
+ }, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +const renderToggle = (props: { disabled?: boolean } = {}) => render() + +const openMenu = () => { + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + return screen.getByTestId("thinking-effort-toggle-menu") +} + +const option = (level: string) => screen.getByTestId("thinking-effort-option-" + level) + +describe("ThinkingEffortToggle (DTE series 4/5)", () => { + beforeEach(() => { + mockPostMessage.mockClear() + mockState.experiments = { dynamicThinkingEffort: true } + mockState.apiConfiguration = { reasoningEffort: "low" } as ProviderSettings + mockState.taskThinkingEffort = undefined + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + }) + + it("renders nothing when the dynamic-thinking-effort experiment is disabled", () => { + mockState.experiments = { dynamicThinkingEffort: false } + const { container } = renderToggle() + expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("renders nothing when the model does not advertise effort support", () => { + mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + const { container } = renderToggle() + expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("renders nothing when the capability array only advertises the disable sentinel", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable"], + } + const { container } = renderToggle() + expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("lists only the model-supported levels (never the disable sentinel)", () => { + renderToggle() + const menu = openMenu() + expect(menu).toHaveTextContent("chat:thinkingEffort.toggleTitle") + for (const level of ["low", "medium", "high", "max"]) { + expect(within(menu).getByTestId("thinking-effort-option-" + level)).toBeInTheDocument() + } + expect(screen.queryByTestId("thinking-effort-option-disable")).toBeNull() + }) + + it("marks the currently effective level and follows the task-local override", () => { + const view = renderToggle() + openMenu() + expect(option("low").querySelector("svg")).not.toBeNull() + expect(option("high").querySelector("svg")).toBeNull() + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + + mockState.taskThinkingEffort = { effort: "high", source: "you" } + view.rerender() + openMenu() + expect(option("high").querySelector("svg")).not.toBeNull() + expect(option("low").querySelector("svg")).toBeNull() + }) + + it("posts a task-local set request when a level is selected and closes the menu", () => { + renderToggle() + openMenu() + fireEvent.click(option("max")) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "setTaskThinkingEffort", effort: "max" }) + expect(screen.queryByTestId("thinking-effort-toggle-menu")).toBeNull() + }) + + it("dims and disables the trigger when the disabled prop is set", () => { + renderToggle({ disabled: true }) + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveClass("opacity-50") + // The trigger button still mounts while disabled (Radix blocks the open). + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + expect(screen.queryByTestId("thinking-effort-toggle-menu")).toBeNull() + }) + + it("highlights the trigger icon for a user-sourced override", () => { + mockState.taskThinkingEffort = { effort: "medium", source: "you" } + renderToggle() + const icon = screen.getByTestId("thinking-effort-toggle-trigger").querySelector("svg") + expect(icon).toHaveClass("text-vscode-textLink-foreground") + }) + + it("shows the adaptive soft-guidance hint and a single adaptive level for boolean-class models", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + mockState.apiConfiguration = {} as ProviderSettings + renderToggle() + const menu = openMenu() + expect(menu).toHaveTextContent("chat:thinkingEffort.adaptiveHint") + expect(within(menu).getByTestId("thinking-effort-option-adaptive")).toBeInTheDocument() + fireEvent.click(within(menu).getByTestId("thinking-effort-option-adaptive")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "setTaskThinkingEffort", effort: "adaptive" }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx new file mode 100644 index 0000000000..a00b61084a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx @@ -0,0 +1,25 @@ +import React from "react" + +import { AppProviders } from "../../../../playwright/AppProviders" +import { ThinkingEffortToggle } from "../ThinkingEffortToggle" + +// DTE series 4/5: CT story for the composer thinking-effort toggle. Uses a real +// model (gpt-5.6-sol) that advertises a per-request effort array, with the +// dynamicThinkingEffort experiment enabled — the default state hides the toggle. +export function ThinkingEffortToggleStory() { + return ( + +
+ Composer bottom bar + + +
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx new file mode 100644 index 0000000000..86d379905b --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx @@ -0,0 +1,26 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" +import { ThinkingEffortToggleStory } from "./ThinkingEffortToggle.visual.fixture" + +// DTE series 4/5: the toggle only renders for models that advertise per-request +// effort support, so the story pins such a model (see the fixture). +for (const theme of visualThemes.filter((candidate) => candidate.name === "dark" || candidate.name === "light")) { + test(`renders the thinking effort toggle in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await applyVisualTheme(page, theme) + // The full provider bundle leaves a bare Zod reference after CT tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + const component = await mount() + const story = component.getByTestId("thinking-effort-toggle-story") + const trigger = story.getByTestId("thinking-effort-toggle-trigger") + await expect(trigger).toBeVisible() + await expect(story).toHaveScreenshot(`thinking-effort-toggle-resting-${theme.name}.png`) + + await trigger.click() + const menu = page.getByTestId("thinking-effort-toggle-menu") + await expect(menu).toBeVisible() + await expect(menu.getByTestId("thinking-effort-option-high")).toBeVisible() + await expect(story).toHaveScreenshot(`thinking-effort-toggle-menu-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5272ceb5f9d19194a9b41052e15cfc822effa5b8 GIT binary patch literal 2297 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 IM6N<$g53hB{Qv*} literal 0 HcmV?d00001 diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e445bfb7a433fa54c1364d95a1b0942e30e7fc55 GIT binary patch literal 2297 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 { + const modelWithLevels: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + reasoningEffort: "medium", + } + + const modelAdaptive: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + + const modelNone: ModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + + it("returns null when the dynamic-thinking-effort experiment is disabled", () => { + expect(computeThinkingEffortDisplay({ experiments: {}, model: modelWithLevels })).toBeNull() + expect( + computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: false }, model: modelWithLevels }), + ).toBeNull() + expect(computeThinkingEffortDisplay({ experiments: undefined, model: modelWithLevels })).toBeNull() + }) + + it("returns null when the model does not advertise effort support", () => { + expect( + computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: modelNone }), + ).toBeNull() + expect( + computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: undefined }), + ).toBeNull() + }) + + it("returns null when the capability array only advertises the disable sentinel", () => { + const disableOnly: ModelInfo = { + contextWindow: 1, + maxTokens: 1, + supportsPromptCache: false, + supportsReasoningEffort: ["disable"], + } + expect( + computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: disableOnly }), + ).toBeNull() + }) + + it("excludes the disable sentinel from the supported levels", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + model: modelWithLevels, + }) + expect(display?.supportedLevels).toEqual(["low", "medium", "high", "max"]) + expect(display?.isAdaptiveClass).toBe(false) + }) + + it("resolves a task-local override with source 'you'", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + taskThinkingEffort: { effort: "max", source: "you" }, + }) + expect(display?.effort).toBe("max") + expect(display?.source).toBe("you") + }) + + it("resolves task-local overrides from model/parent sources as auto", () => { + for (const source of ["model", "parent"]) { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source }, + }) + expect(display?.effort).toBe("high") + expect(display?.source).toBe("auto") + } + }) + + it("resolves an unrecognized task-local source as default", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source: "unknown-origin" }, + }) + expect(display?.source).toBe("default") + }) + + it("resolves the settings effort with source 'default' when no override is active", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + }) + expect(display?.effort).toBe("low") + expect(display?.source).toBe("default") + }) + + it("treats the settings 'disable' sentinel as unset and falls through", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, + model: modelWithLevels, + }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("falls back to the model default effort", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + model: modelWithLevels, + }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("returns null for a level-array model with no settings or model default", () => { + const noDefault: ModelInfo = { ...modelWithLevels, reasoningEffort: undefined } + expect( + computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: noDefault }), + ).toBeNull() + }) + + it("resolves boolean/adaptive-class models to the adaptive soft-guidance level", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, + model: modelAdaptive, + }) + expect(display?.effort).toBe(THINKING_EFFORT_ADAPTIVE_LEVEL) + expect(display?.source).toBe("auto") + expect(display?.supportedLevels).toEqual([THINKING_EFFORT_ADAPTIVE_LEVEL]) + expect(display?.isAdaptiveClass).toBe(true) + }) + + it("lets a task-local override win over the adaptive fallback", () => { + const display = computeThinkingEffortDisplay({ + experiments: { dynamicThinkingEffort: true }, + model: modelAdaptive, + taskThinkingEffort: { effort: "adaptive", source: "you" }, + }) + expect(display?.effort).toBe("adaptive") + expect(display?.source).toBe("you") + }) +}) diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts new file mode 100644 index 0000000000..9fe92033fb --- /dev/null +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -0,0 +1,79 @@ +import type { Experiments, ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" + +export type ThinkingEffortSource = "default" | "auto" | "you" + +export interface ThinkingEffortDisplay { + effort: string + source: ThinkingEffortSource + /** Levels the model advertises (menu entries); "adaptive" for boolean-class models. */ + supportedLevels: string[] + /** True for boolean/adaptive-class models (soft guidance). */ + isAdaptiveClass: boolean +} + +export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" + +/** + * DTE series 4/5: webview-side computation of the current effective thinking + * effort and its source, shared by the TaskHeader chip and the composer + * bottom-bar toggle. + * + * Resolution (strongest first): task-local override (authoritative + * extension-side push via `taskThinkingEffort`) → settings `reasoningEffort` + * (provider profile) → model default (`model.reasoningEffort`); boolean/ + * adaptive-class models fall back to the "adaptive" soft-guidance display. + * Returns `null` when the dynamic-thinking-effort experiment is disabled or + * the model does not advertise per-request effort support. + */ +export function computeThinkingEffortDisplay(args: { + experiments?: Experiments + apiConfiguration?: ProviderSettings + model?: ModelInfo + taskThinkingEffort?: { effort: string; source: string } +}): ThinkingEffortDisplay | null { + const { experiments, apiConfiguration, model, taskThinkingEffort } = args + + if (experiments?.dynamicThinkingEffort !== true) { + return null + } + + const capability = model?.supportsReasoningEffort + const isAdaptiveClass = capability === true + // The "disable" sentinel is a UI off-switch (settings value), not a level a + // task can be set to — keep it out of the menu even when a model advertises it. + const supportedLevels = Array.isArray(capability) + ? capability.filter((level) => level !== "disable") + : isAdaptiveClass + ? [THINKING_EFFORT_ADAPTIVE_LEVEL] + : [] + if (supportedLevels.length === 0) { + return null + } + + // 1. Task-local override (authoritative extension push). + if (taskThinkingEffort?.effort) { + const source: ThinkingEffortSource = + taskThinkingEffort.source === "you" + ? "you" + : taskThinkingEffort.source === "model" || taskThinkingEffort.source === "parent" + ? "auto" + : "default" + return { effort: taskThinkingEffort.effort, source, supportedLevels, isAdaptiveClass } + } + + // 2. Settings-derived effort (provider profile). The "disable" sentinel + // means "no effort" for the per-request envelope resolution. + const settingsEffort = apiConfiguration?.reasoningEffort as ReasoningEffortExtended | "disable" | undefined + if (settingsEffort && settingsEffort !== "disable") { + return { effort: settingsEffort, source: "default", supportedLevels, isAdaptiveClass } + } + + // 3. Model default / adaptive soft guidance. + if (isAdaptiveClass) { + return { effort: THINKING_EFFORT_ADAPTIVE_LEVEL, source: "auto", supportedLevels, isAdaptiveClass } + } + if (model?.reasoningEffort) { + return { effort: model.reasoningEffort, source: "default", supportedLevels, isAdaptiveClass } + } + return null +} From 7dde456f22a05e0703e67aca6aab823f6230d79b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 06:45:29 +0800 Subject: [PATCH 08/19] fix(webview): add accessible name to the icon-only thinking effort toggle --- webview-ui/src/components/chat/ThinkingEffortToggle.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx index 960d68bbc2..f27f843c5e 100644 --- a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx +++ b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx @@ -69,6 +69,7 @@ export const ThinkingEffortToggle = ({ disabled = false, triggerClassName = "" } Date: Mon, 24 Aug 2026 07:20:07 +0800 Subject: [PATCH 09/19] test(webview): cover the task-local thinking effort state push branch --- .../webview/__tests__/ClineProvider.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 731124cccc..7123d89bc5 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -936,6 +936,33 @@ describe("ClineProvider", () => { expect(state.taskHistory).toEqual([historyItem]) }) + test("getStateToPostToWebview surfaces the task-local thinking effort override (DTE 4/5)", async () => { + const task = (effort: { effort: string; source?: string } | undefined) => ({ + taskId: "effort-task", + clineMessages: [], + todoList: [], + getRuntimeThinkingEffort: () => effort, + }) + vi.spyOn(provider.taskHistoryStore, "getAll").mockReturnValue([]) + const getCurrentTaskSpy = vi.spyOn(provider, "getCurrentTask") + + getCurrentTaskSpy.mockReturnValue(task({ effort: "high", source: "you" }) as never) + let state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) + + // A source-less runtime override is reported as the default source. + getCurrentTaskSpy.mockReturnValue(task({ effort: "medium" }) as never) + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "medium", source: "default" }) + + // Without an active override the field is omitted. + getCurrentTaskSpy.mockReturnValue(task(undefined) as never) + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toBeUndefined() + + getCurrentTaskSpy.mockRestore() + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() From 96cf25694e8ff91b91e500f52d7b2305a0fdb9a8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 08:33:49 +0800 Subject: [PATCH 10/19] test(webview): assert the localized accessible name on the thinking effort toggle --- .../chat/__tests__/ThinkingEffortToggle.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx index 4bf7c5246a..5cf0c95d8e 100644 --- a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx @@ -115,6 +115,15 @@ describe("ThinkingEffortToggle (DTE series 4/5)", () => { expect(container.textContent).toBe("") }) + it("exposes the localized accessible name on the icon-only trigger", () => { + renderToggle() + // The mocked i18n returns keys, so the exact localized label is the raw key. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) + }) + it("renders nothing when the model does not advertise effort support", () => { mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } const { container } = renderToggle() From ac84f5e9106e5cc7939dd85f488c61de6ca7ba35 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 09:22:20 +0800 Subject: [PATCH 11/19] test(webview): model the no-override thinking effort shape after the real Task contract --- .../webview/__tests__/ClineProvider.spec.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7123d89bc5..aa0b61f8e0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -937,26 +937,31 @@ describe("ClineProvider", () => { }) test("getStateToPostToWebview surfaces the task-local thinking effort override (DTE 4/5)", async () => { - const task = (effort: { effort: string; source?: string } | undefined) => ({ - taskId: "effort-task", - clineMessages: [], - todoList: [], - getRuntimeThinkingEffort: () => effort, - }) + // Models the real Task contract: getRuntimeThinkingEffort always returns an + // object; the no-override state is the empty object (effort undefined). + // The double is partial on purpose — the spy only needs the method under test; + // Task has many constructor-dependent required members, hence the cast. + const task = (runtime: { effort?: string; source?: string }) => + ({ + taskId: "effort-task", + clineMessages: [], + todoList: [], + getRuntimeThinkingEffort: () => runtime, + }) as unknown as Task vi.spyOn(provider.taskHistoryStore, "getAll").mockReturnValue([]) const getCurrentTaskSpy = vi.spyOn(provider, "getCurrentTask") - getCurrentTaskSpy.mockReturnValue(task({ effort: "high", source: "you" }) as never) + getCurrentTaskSpy.mockReturnValue(task({ effort: "high", source: "you" })) let state = await provider.getStateToPostToWebview() expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) // A source-less runtime override is reported as the default source. - getCurrentTaskSpy.mockReturnValue(task({ effort: "medium" }) as never) + getCurrentTaskSpy.mockReturnValue(task({ effort: "medium" })) state = await provider.getStateToPostToWebview() expect(state.taskThinkingEffort).toEqual({ effort: "medium", source: "default" }) - // Without an active override the field is omitted. - getCurrentTaskSpy.mockReturnValue(task(undefined) as never) + // Without an active override (the real empty-object shape) the field is omitted. + getCurrentTaskSpy.mockReturnValue(task({})) state = await provider.getStateToPostToWebview() expect(state.taskThinkingEffort).toBeUndefined() From 784ee902e3c71f21441699d421d06594faa48857 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 13:00:13 +0800 Subject: [PATCH 12/19] feat(webview): show thinking-effort toggle + chip without the experiment flag The manual user-facing surfaces (composer bottom-bar toggle and task header chip) are now normal features: they render whenever the selected model advertises per-request reasoning effort support (supportsReasoningEffort boolean-true or a non-empty level array), regardless of the dynamicThinkingEffort experiment flag. - computeThinkingEffortDisplay() no longer takes the experiment flag and returns null only when the model does not advertise per-request effort support. - ThinkingEffortToggle and TaskHeader stop reading `experiments` from the extension state for this display. - The dynamicThinkingEffort experiment now gates only the model-driven set_thinking_effort tool exposure. The rest of the extension-side pipeline (setTaskThinkingEffort handler, taskThinkingEffort state push, per-request effort envelope) was already ungated. - Playwright CT fixture keeps its experiment-on initial state so the baselines render the identical component state (verified: 0 baseline drift). --- webview-ui/src/components/chat/TaskHeader.tsx | 5 +- .../components/chat/ThinkingEffortToggle.tsx | 8 +-- .../TaskHeader.thinking-effort.spec.tsx | 6 +- .../__tests__/ThinkingEffortToggle.spec.tsx | 12 ++-- .../ThinkingEffortToggle.visual.fixture.tsx | 6 +- .../utils/__tests__/thinkingEffort.spec.ts | 65 +++++++++---------- webview-ui/src/utils/thinkingEffort.ts | 12 +--- 7 files changed, 57 insertions(+), 57 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 36c0f345d9..27406da0e1 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -66,7 +66,7 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, experiments, taskThinkingEffort } = useExtensionState() + const { apiConfiguration, currentTaskItem, taskThinkingEffort } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -94,12 +94,11 @@ const TaskHeader = ({ const thinkingEffortDisplay = useMemo( () => computeThinkingEffortDisplay({ - experiments, apiConfiguration, model, taskThinkingEffort, }), - [experiments, apiConfiguration, model, taskThinkingEffort], + [apiConfiguration, model, taskThinkingEffort], ) const thinkingEffortSourceKey = thinkingEffortDisplay?.source === "you" diff --git a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx index f27f843c5e..a210968fe9 100644 --- a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx +++ b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx @@ -36,15 +36,15 @@ export const ThinkingEffortToggle = ({ disabled = false, triggerClassName = "" } const [open, setOpen] = React.useState(false) const portalContainer = useRooPortal("roo-portal") const { t } = useAppTranslation() - const { apiConfiguration, experiments, taskThinkingEffort } = useExtensionState() + const { apiConfiguration, taskThinkingEffort } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) const display = React.useMemo( - () => computeThinkingEffortDisplay({ experiments, apiConfiguration, model, taskThinkingEffort }), - [experiments, apiConfiguration, model, taskThinkingEffort], + () => computeThinkingEffortDisplay({ apiConfiguration, model, taskThinkingEffort }), + [apiConfiguration, model, taskThinkingEffort], ) - // Hidden unless the experiment is enabled and the model advertises effort support. + // Hidden unless the selected model advertises per-request effort support. if (!display) { return null } diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx index 6e8097a82c..5797d262b3 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx @@ -146,10 +146,12 @@ describe("TaskHeader - thinking effort chip (DTE series 4/5)", () => { expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() }) - it("hides the chip when the dynamic-thinking-effort experiment is disabled", () => { + it("shows the chip when the dynamic-thinking-effort experiment is disabled", () => { mockState.experiments = { dynamicThinkingEffort: false } renderChip() - expect(screen.queryByText("medium")).toBeNull() + // The chip is a normal feature: gated by model capability, not the experiment. + expect(screen.getByText("medium")).toBeInTheDocument() + expect(screen.getByText("default")).toBeInTheDocument() }) it("hides the chip when the model does not advertise effort support", () => { diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx index 5cf0c95d8e..6231857dff 100644 --- a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx @@ -108,11 +108,15 @@ describe("ThinkingEffortToggle (DTE series 4/5)", () => { } }) - it("renders nothing when the dynamic-thinking-effort experiment is disabled", () => { + it("renders when the dynamic-thinking-effort experiment is disabled", () => { mockState.experiments = { dynamicThinkingEffort: false } - const { container } = renderToggle() - expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() - expect(container.textContent).toBe("") + renderToggle() + // The manual toggle is a normal feature: gated by model capability, not the experiment. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toBeInTheDocument() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) }) it("exposes the localized accessible name on the icon-only trigger", () => { diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx index a00b61084a..bed1a7f4c5 100644 --- a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx @@ -4,8 +4,10 @@ import { AppProviders } from "../../../../playwright/AppProviders" import { ThinkingEffortToggle } from "../ThinkingEffortToggle" // DTE series 4/5: CT story for the composer thinking-effort toggle. Uses a real -// model (gpt-5.6-sol) that advertises a per-request effort array, with the -// dynamicThinkingEffort experiment enabled — the default state hides the toggle. +// model (gpt-5.6-sol) that advertises a per-request effort array; the toggle +// renders for capable models regardless of the experiment flag. The experiment +// state is kept in the initial state so the story renders exactly the component +// state the baselines were generated with. export function ThinkingEffortToggleStory() { return ( { const modelNone: ModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } - it("returns null when the dynamic-thinking-effort experiment is disabled", () => { - expect(computeThinkingEffortDisplay({ experiments: {}, model: modelWithLevels })).toBeNull() - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: false }, model: modelWithLevels }), - ).toBeNull() - expect(computeThinkingEffortDisplay({ experiments: undefined, model: modelWithLevels })).toBeNull() + it("resolves the display for capable models without the experiment flag", () => { + // The manual surfaces are normal features: resolution is gated only by + // model capability. Settings effort wins over the model default. + const settings = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + }) + expect(settings?.effort).toBe("low") + expect(settings?.source).toBe("default") + // Model default. + const modelDefault = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(modelDefault?.effort).toBe("medium") + expect(modelDefault?.source).toBe("default") + // Boolean/adaptive-class model. + const adaptive = computeThinkingEffortDisplay({ model: modelAdaptive }) + expect(adaptive?.effort).toBe(THINKING_EFFORT_ADAPTIVE_LEVEL) + expect(adaptive?.source).toBe("auto") + }) + + it("shows the task-local value with source 'you' when the experiment flag is absent", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "max", source: "you" }, + }) + expect(display?.effort).toBe("max") + expect(display?.source).toBe("you") }) it("returns null when the model does not advertise effort support", () => { - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: modelNone }), - ).toBeNull() - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: undefined }), - ).toBeNull() + expect(computeThinkingEffortDisplay({ model: modelNone })).toBeNull() + expect(computeThinkingEffortDisplay({ model: undefined })).toBeNull() }) it("returns null when the capability array only advertises the disable sentinel", () => { @@ -44,23 +60,17 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { supportsPromptCache: false, supportsReasoningEffort: ["disable"], } - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: disableOnly }), - ).toBeNull() + expect(computeThinkingEffortDisplay({ model: disableOnly })).toBeNull() }) it("excludes the disable sentinel from the supported levels", () => { - const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, - model: modelWithLevels, - }) + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) expect(display?.supportedLevels).toEqual(["low", "medium", "high", "max"]) expect(display?.isAdaptiveClass).toBe(false) }) it("resolves a task-local override with source 'you'", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, model: modelWithLevels, taskThinkingEffort: { effort: "max", source: "you" }, @@ -72,7 +82,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("resolves task-local overrides from model/parent sources as auto", () => { for (const source of ["model", "parent"]) { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, model: modelWithLevels, taskThinkingEffort: { effort: "high", source }, }) @@ -83,7 +92,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("resolves an unrecognized task-local source as default", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, model: modelWithLevels, taskThinkingEffort: { effort: "high", source: "unknown-origin" }, }) @@ -92,7 +100,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("resolves the settings effort with source 'default' when no override is active", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, model: modelWithLevels, }) @@ -102,7 +109,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("treats the settings 'disable' sentinel as unset and falls through", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, model: modelWithLevels, }) @@ -111,24 +117,18 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { }) it("falls back to the model default effort", () => { - const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, - model: modelWithLevels, - }) + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) expect(display?.effort).toBe("medium") expect(display?.source).toBe("default") }) it("returns null for a level-array model with no settings or model default", () => { const noDefault: ModelInfo = { ...modelWithLevels, reasoningEffort: undefined } - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: noDefault }), - ).toBeNull() + expect(computeThinkingEffortDisplay({ model: noDefault })).toBeNull() }) it("resolves boolean/adaptive-class models to the adaptive soft-guidance level", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, model: modelAdaptive, }) @@ -140,7 +140,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("lets a task-local override win over the adaptive fallback", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, model: modelAdaptive, taskThinkingEffort: { effort: "adaptive", source: "you" }, }) diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts index 9fe92033fb..46c0dd3d2c 100644 --- a/webview-ui/src/utils/thinkingEffort.ts +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -1,4 +1,4 @@ -import type { Experiments, ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" +import type { ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" export type ThinkingEffortSource = "default" | "auto" | "you" @@ -22,20 +22,14 @@ export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" * extension-side push via `taskThinkingEffort`) → settings `reasoningEffort` * (provider profile) → model default (`model.reasoningEffort`); boolean/ * adaptive-class models fall back to the "adaptive" soft-guidance display. - * Returns `null` when the dynamic-thinking-effort experiment is disabled or - * the model does not advertise per-request effort support. + * Returns `null` when the model does not advertise per-request effort support. */ export function computeThinkingEffortDisplay(args: { - experiments?: Experiments apiConfiguration?: ProviderSettings model?: ModelInfo taskThinkingEffort?: { effort: string; source: string } }): ThinkingEffortDisplay | null { - const { experiments, apiConfiguration, model, taskThinkingEffort } = args - - if (experiments?.dynamicThinkingEffort !== true) { - return null - } + const { apiConfiguration, model, taskThinkingEffort } = args const capability = model?.supportsReasoningEffort const isAdaptiveClass = capability === true From e298f263063ee74aae4c0ea7088b6ff09d7ebd68 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 20:18:18 +0800 Subject: [PATCH 13/19] fix(i18n): drop emoji prefix and add missing space in thinking-effort display strings (18 locales) --- webview-ui/src/i18n/locales/ca/chat.json | 8 ++++---- webview-ui/src/i18n/locales/de/chat.json | 8 ++++---- webview-ui/src/i18n/locales/en/chat.json | 8 ++++---- webview-ui/src/i18n/locales/es/chat.json | 8 ++++---- webview-ui/src/i18n/locales/fr/chat.json | 8 ++++---- webview-ui/src/i18n/locales/hi/chat.json | 8 ++++---- webview-ui/src/i18n/locales/id/chat.json | 8 ++++---- webview-ui/src/i18n/locales/it/chat.json | 8 ++++---- webview-ui/src/i18n/locales/ja/chat.json | 8 ++++---- webview-ui/src/i18n/locales/ko/chat.json | 8 ++++---- webview-ui/src/i18n/locales/nl/chat.json | 8 ++++---- webview-ui/src/i18n/locales/pl/chat.json | 8 ++++---- webview-ui/src/i18n/locales/pt-BR/chat.json | 8 ++++---- webview-ui/src/i18n/locales/ru/chat.json | 8 ++++---- webview-ui/src/i18n/locales/tr/chat.json | 8 ++++---- webview-ui/src/i18n/locales/vi/chat.json | 8 ++++---- webview-ui/src/i18n/locales/zh-CN/chat.json | 10 +++++----- webview-ui/src/i18n/locales/zh-TW/chat.json | 10 +++++----- 18 files changed, 74 insertions(+), 74 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 9838399e80..865253b43a 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -467,16 +467,16 @@ "didRun": "Zoo ha executat una comanda slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Esforç de raonament establert a: {{effort}}", + "appliedByUser": "Esforç de raonament establert a: {{effort}}", "chipTooltip": "Esforç de raonament: {{effort}} ({{source}})", "sourceDefault": "per defecte", "sourceAuto": "Zoo (auto)", "sourceYou": "tu", "toggleTitle": "Esforç de raonament", "adaptiveHint": "Aquest model decideix el seu esforç automàticament — la teva selecció és només una guia suau.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "contextMenu": { "noResults": "Sense resultats", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 5998cca748..6aead97a2d 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo hat einen Slash-Befehl ausgeführt" }, "thinkingEffort": { - "appliedByUser": "🧠 Denkanstrengung festgelegt auf: {{effort}}", + "appliedByUser": "Denkanstrengung festgelegt auf: {{effort}}", "chipTooltip": "Denkanstrengung: {{effort}} ({{source}})", "sourceDefault": "Standard", "sourceAuto": "Zoo (auto)", "sourceYou": "Sie", "toggleTitle": "Denkanstrengung", "adaptiveHint": "Dieses Modell bestimmt seine Denkanstrengung automatisch — Ihre Auswahl ist nur eine sanfte Vorgabe.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} von {{total}} To-Dos erledigt", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 385a6566e5..03cb12fa6f 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -451,16 +451,16 @@ "didRun": "Zoo ran a slash command" }, "thinkingEffort": { - "appliedByUser": "🧠 Thinking effort set to: {{effort}}", + "appliedByUser": "Thinking effort set to: {{effort}}", "chipTooltip": "Thinking effort: {{effort}} ({{source}})", "sourceDefault": "default", "sourceAuto": "Zoo (auto)", "sourceYou": "you", "toggleTitle": "Thinking effort", "adaptiveHint": "This model decides its effort automatically — your selection is soft guidance only.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "queuedMessages": { "title": "Queued Messages", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 6308d448bc..8005ad420c 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo ejecutó un comando slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Esfuerzo de razonamiento establecido en: {{effort}}", + "appliedByUser": "Esfuerzo de razonamiento establecido en: {{effort}}", "chipTooltip": "Esfuerzo de razonamiento: {{effort}} ({{source}})", "sourceDefault": "predeterminado", "sourceAuto": "Zoo (auto)", "sourceYou": "tú", "toggleTitle": "Esfuerzo de razonamiento", "adaptiveHint": "Este modelo decide su esfuerzo automáticamente — tu selección es solo una guía suave.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} de {{total}} tareas pendientes realizadas", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 5ea971c22c..5e1a28dbec 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo a exécuté une commande slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Effort de raisonnement défini sur : {{effort}}", + "appliedByUser": "Effort de raisonnement défini sur : {{effort}}", "chipTooltip": "Effort de raisonnement : {{effort}} ({{source}})", "sourceDefault": "par défaut", "sourceAuto": "Zoo (auto)", "sourceYou": "vous", "toggleTitle": "Effort de raisonnement", "adaptiveHint": "Ce modèle détermine son effort automatiquement — votre choix n'est qu'un guide souple.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} sur {{total}} tâches terminées", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 8845a22b36..db3bba27da 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo ने एक स्लैश कमांड चलाया" }, "thinkingEffort": { - "appliedByUser": "🧠 सोच प्रयास सेट किया गया: {{effort}}", + "appliedByUser": "सोच प्रयास सेट किया गया: {{effort}}", "chipTooltip": "सोच प्रयास: {{effort}} ({{source}})", "sourceDefault": "डिफ़ॉल्ट", "sourceAuto": "Zoo (ऑटो)", "sourceYou": "आप", "toggleTitle": "सोच प्रयास", "adaptiveHint": "यह मॉडल अपने प्रयास को स्वतः निर्धारित करता है — आपका चयन केवल एक लचीली मार्गदर्शिका है।", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{total}} में से {{completed}} टू-डू हो गए", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 9eecc3b513..89a4964046 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -479,16 +479,16 @@ "didRun": "Zoo telah menjalankan perintah slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Upaya pemikiran diatur ke: {{effort}}", + "appliedByUser": "Upaya pemikiran diatur ke: {{effort}}", "chipTooltip": "Upaya pemikiran: {{effort}} ({{source}})", "sourceDefault": "bawaan", "sourceAuto": "Zoo (otomatis)", "sourceYou": "anda", "toggleTitle": "Upaya pemikiran", "adaptiveHint": "Model ini menentukan usahanya secara otomatis — pilihan Anda hanya panduan lunak.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} dari {{total}} to-do selesai", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 21ffdc05e2..f44c0d7422 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo ha eseguito un comando slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Sforzo di ragionamento impostato su: {{effort}}", + "appliedByUser": "Sforzo di ragionamento impostato su: {{effort}}", "chipTooltip": "Sforzo di ragionamento: {{effort}} ({{source}})", "sourceDefault": "predefinito", "sourceAuto": "Zoo (auto)", "sourceYou": "tu", "toggleTitle": "Sforzo di ragionamento", "adaptiveHint": "Questo modello decide il proprio sforzo automaticamente — la tua selezione è solo una guida flessibile.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} di {{total}} cose da fare completate", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 801db14c91..dba8df4088 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -473,16 +473,16 @@ "didRun": "Zooはスラッシュコマンドを実行しました" }, "thinkingEffort": { - "appliedByUser": "🧠 思考努力度を {{effort}} に設定しました", + "appliedByUser": "思考努力度を {{effort}} に設定しました", "chipTooltip": "思考努力度: {{effort}} ({{source}})", "sourceDefault": "デフォルト", "sourceAuto": "Zoo (自動)", "sourceYou": "ユーザー", "toggleTitle": "思考努力度", "adaptiveHint": "このモデルは自動的に努力度を決定します。選択は参考情報です。", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{total}}件中{{completed}}件のTo-Doが完了", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index cb06ef0c7a..c6af1eadd4 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo가 슬래시 명령어를 실행했습니다" }, "thinkingEffort": { - "appliedByUser": "🧠 사고 노력이 {{effort}}(으)로 설정됨", + "appliedByUser": "사고 노력이 {{effort}}(으)로 설정됨", "chipTooltip": "사고 노력: {{effort}} ({{source}})", "sourceDefault": "기본값", "sourceAuto": "Zoo (자동)", "sourceYou": "사용자", "toggleTitle": "사고 노력", "adaptiveHint": "이 모델은 자동으로 노력도를 결정합니다. 선택은 단순 참고용입니다.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{total}}개의 할 일 중 {{completed}}개 완료", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 06ea914575..7c7eb8c0d2 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo heeft een slash commando uitgevoerd" }, "thinkingEffort": { - "appliedByUser": "🧠 Denkinspanning ingesteld op: {{effort}}", + "appliedByUser": "Denkinspanning ingesteld op: {{effort}}", "chipTooltip": "Denkinspanning: {{effort}} ({{source}})", "sourceDefault": "standaard", "sourceAuto": "Zoo (auto)", "sourceYou": "jij", "toggleTitle": "Denkinspanning", "adaptiveHint": "Dit model bepaalt zijn inspanning automatisch — je keuze is slechts een zachte aanwijzing.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} van {{total}} to-do's voltooid", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index e90d4a5607..a413f57551 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo uruchomił komendę slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Wysiłek myślowy ustawiony na: {{effort}}", + "appliedByUser": "Wysiłek myślowy ustawiony na: {{effort}}", "chipTooltip": "Wysiłek myślowy: {{effort}} ({{source}})", "sourceDefault": "domyślny", "sourceAuto": "Zoo (auto)", "sourceYou": "ty", "toggleTitle": "Wysiłek myślowy", "adaptiveHint": "Ten model samodzielnie decyduje o wysiłku — Twój wybór to tylko luźna wskazówka.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "Ukończono {{completed}} z {{total}} zadań do wykonania", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 1b334e66c7..25a177e4a6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -473,16 +473,16 @@ "didRun": "Zoo executou um comando slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Esforço de raciocínio definido para: {{effort}}", + "appliedByUser": "Esforço de raciocínio definido para: {{effort}}", "chipTooltip": "Esforço de raciocínio: {{effort}} ({{source}})", "sourceDefault": "padrão", "sourceAuto": "Zoo (auto)", "sourceYou": "você", "toggleTitle": "Esforço de raciocínio", "adaptiveHint": "Este modelo decide seu esforço automaticamente — sua seleção é apenas um guia suave.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} de {{total}} tarefas concluídas", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index ba0200a993..1018f55c90 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -474,16 +474,16 @@ "didRun": "Zoo выполнил слеш-команду" }, "thinkingEffort": { - "appliedByUser": "🧠 Усиление размышлений установлено: {{effort}}", + "appliedByUser": "Усиление размышлений установлено: {{effort}}", "chipTooltip": "Усиление размышлений: {{effort}} ({{source}})", "sourceDefault": "по умолчанию", "sourceAuto": "Zoo (авто)", "sourceYou": "вы", "toggleTitle": "Усиление размышлений", "adaptiveHint": "Эта модель сама определяет усиление — ваш выбор носит рекомендательный характер.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} из {{total}} задач выполнено", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 9a6927ab3d..ee993a7129 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -474,16 +474,16 @@ "didRun": "Zoo bir slash komutu çalıştırdı" }, "thinkingEffort": { - "appliedByUser": "🧠 Düşünme çabası şuna ayarlandı: {{effort}}", + "appliedByUser": "Düşünme çabası şuna ayarlandı: {{effort}}", "chipTooltip": "Düşünme çabası: {{effort}} ({{source}})", "sourceDefault": "varsayılan", "sourceAuto": "Zoo (otomatik)", "sourceYou": "sen", "toggleTitle": "Düşünme çabası", "adaptiveHint": "Bu model çabasını otomatik olarak belirler — seçiminiz yalnızca yumuşak bir yönergedir.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{total}} yapılacaklar listesinden {{completed}} tanesi tamamlandı", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 5e08ed48af..ee9b4c3ee4 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -474,16 +474,16 @@ "didRun": "Zoo đã chạy lệnh slash" }, "thinkingEffort": { - "appliedByUser": "🧠 Đã đặt nỗ lực suy luận: {{effort}}", + "appliedByUser": "Đã đặt nỗ lực suy luận: {{effort}}", "chipTooltip": "Nỗ lực suy luận: {{effort}} ({{source}})", "sourceDefault": "mặc định", "sourceAuto": "Zoo (tự động)", "sourceYou": "bạn", "toggleTitle": "Nỗ lực suy luận", "adaptiveHint": "Mô hình này tự quyết định nỗ lực — lựa chọn của bạn chỉ là gợi ý mềm.", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "{{completed}} trong tổng số {{total}} công việc đã hoàn thành", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index b54340f64b..3f8078f288 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -474,16 +474,16 @@ "didRun": "Zoo 运行了斜杠命令" }, "thinkingEffort": { - "appliedByUser": "🧠 思考强度已设为:{{effort}}", - "chipTooltip": "思考强度:{{effort}}({{source}})", + "appliedByUser": "思考强度已设为: {{effort}}", + "chipTooltip": "思考强度: {{effort}} ({{source}})", "sourceDefault": "默认", "sourceAuto": "Zoo(自动)", "sourceYou": "你", "toggleTitle": "思考强度", "adaptiveHint": "此模型会自动决定思考强度,你的选择仅作为软性指引。", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "todo": { "partial": "已完成 {{completed}} / {{total}} 个待办事项", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index f552e65400..4653a33c4d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -454,16 +454,16 @@ "didRun": "Zoo 執行了斜線指令" }, "thinkingEffort": { - "appliedByUser": "🧠 思考強度已設為:{{effort}}", - "chipTooltip": "思考強度:{{effort}}({{source}})", + "appliedByUser": "思考強度已設為: {{effort}}", + "chipTooltip": "思考強度: {{effort}} ({{source}})", "sourceDefault": "預設", "sourceAuto": "Zoo(自動)", "sourceYou": "你", "toggleTitle": "思考強度", "adaptiveHint": "此模型會自動決定思考強度,你的選擇僅作為軟性指引。", - "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", - "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", - "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" }, "queuedMessages": { "title": "佇列中的訊息", From 03037d58d31dcd20186834b866fae88fe2b10586 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 20:21:05 +0800 Subject: [PATCH 14/19] feat(webview): show current effort value + hover border on composer toggle; default-source label --- .../components/chat/ThinkingEffortToggle.tsx | 9 ++++-- .../__tests__/ThinkingEffortToggle.spec.tsx | 25 ++++++++++++++++ .../utils/__tests__/thinkingEffort.spec.ts | 29 +++++++++++++++++++ webview-ui/src/utils/thinkingEffort.ts | 17 ++++++++--- 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx index a210968fe9..9f3a5f166f 100644 --- a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx +++ b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx @@ -2,7 +2,7 @@ import React from "react" import { Brain, Check } from "lucide-react" import { cn } from "@/lib/utils" -import { selectorTriggerClassName } from "@/components/ui/selectorTriggerStyles" +import { enabledSelectorTriggerClassName, selectorTriggerClassName } from "@/components/ui/selectorTriggerStyles" import { useExtensionState } from "@/context/ExtensionStateContext" @@ -24,7 +24,8 @@ interface ThinkingEffortToggleProps { /** * DTE series 4/5: composer bottom-bar toggle for the task-local thinking - * effort (icon-only, next to the API config selector). + * effort (icon + current value, next to the API config selector). The + * trigger carries the same border/hover treatment as the sibling selectors * * The menu lists the model-advertised levels only; boolean/adaptive-class * models get the single "adaptive" soft-guidance entry. Selecting a level @@ -72,8 +73,9 @@ export const ThinkingEffortToggle = ({ disabled = false, triggerClassName = "" } aria-label={t("chat:thinkingEffort.toggleTitle")} data-testid="thinking-effort-toggle-trigger" className={cn( - "relative inline-flex items-center justify-center whitespace-nowrap px-1.5 py-1", + "relative inline-flex items-center justify-center gap-1 whitespace-nowrap px-1.5 py-1", selectorTriggerClassName, + !disabled && enabledSelectorTriggerClassName, disabled ? "opacity-50 cursor-not-allowed" : "", triggerClassName, )}> @@ -83,6 +85,7 @@ export const ThinkingEffortToggle = ({ disabled = false, triggerClassName = "" } display.source === "you" && "text-vscode-textLink-foreground", )} /> + {display.effort} { expect(icon).toHaveClass("text-vscode-textLink-foreground") }) + it("shows the current effective effort value in the trigger", () => { + // Settings-derived default. + const view = renderToggle() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveTextContent("low") + // Follows the task-local override. + mockState.taskThinkingEffort = { effort: "high", source: "you" } + view.rerender() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveTextContent("high") + }) + + it("applies the sibling-selector hover treatment when enabled", () => { + renderToggle() + const trigger = screen.getByTestId("thinking-effort-toggle-trigger") + expect(trigger).toHaveClass("hover:border-vscode-focusBorder") + expect(trigger).toHaveClass("hover:bg-vscode-toolbar-hoverBackground") + }) + + it("drops the user highlight when the override lands on the resolved default", () => { + // settings effort is "low" — a user override to "low" displays as default. + mockState.taskThinkingEffort = { effort: "low", source: "you" } + renderToggle() + const icon = screen.getByTestId("thinking-effort-toggle-trigger").querySelector("svg") + expect(icon).not.toHaveClass("text-vscode-textLink-foreground") + }) + it("shows the adaptive soft-guidance hint and a single adaptive level for boolean-class models", () => { mockModelInfo = { contextWindow: 1_000_000, diff --git a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts index 71ec0e43e4..4ae1190873 100644 --- a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts +++ b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts @@ -90,6 +90,35 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { } }) + it("shows a user-set task-local effort as default when it equals the model default", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "medium", source: "you" }, + }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("shows a user-set task-local effort as default when it equals the settings effort", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + taskThinkingEffort: { effort: "low", source: "you" }, + }) + expect(display?.effort).toBe("low") + expect(display?.source).toBe("default") + }) + + it("keeps the user badge when the task-local effort differs from the resolved default", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source: "you" }, + }) + expect(display?.effort).toBe("high") + expect(display?.source).toBe("you") + }) + it("resolves an unrecognized task-local source as default", () => { const display = computeThinkingEffortDisplay({ model: modelWithLevels, diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts index 46c0dd3d2c..516c68f874 100644 --- a/webview-ui/src/utils/thinkingEffort.ts +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -23,6 +23,10 @@ export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" * (provider profile) → model default (`model.reasoningEffort`); boolean/ * adaptive-class models fall back to the "adaptive" soft-guidance display. * Returns `null` when the model does not advertise per-request effort support. + * + * Source-label rule: a task-local override that lands exactly on the resolved + * default (settings effort, else model default) is displayed with source + * "default" — the "you" badge only marks a non-default choice. */ export function computeThinkingEffortDisplay(args: { apiConfiguration?: ProviderSettings @@ -44,10 +48,17 @@ export function computeThinkingEffortDisplay(args: { return null } + // Settings-derived effort (provider profile). The "disable" sentinel means + // "no effort" for the per-request envelope resolution. Resolved before the + // task-local branch so the default-source rule can compare against it. + const settingsEffort = apiConfiguration?.reasoningEffort as ReasoningEffortExtended | "disable" | undefined + const resolvedDefault = settingsEffort && settingsEffort !== "disable" ? settingsEffort : model?.reasoningEffort + // 1. Task-local override (authoritative extension push). if (taskThinkingEffort?.effort) { + const isAtResolvedDefault = resolvedDefault !== undefined && taskThinkingEffort.effort === resolvedDefault const source: ThinkingEffortSource = - taskThinkingEffort.source === "you" + taskThinkingEffort.source === "you" && !isAtResolvedDefault ? "you" : taskThinkingEffort.source === "model" || taskThinkingEffort.source === "parent" ? "auto" @@ -55,9 +66,7 @@ export function computeThinkingEffortDisplay(args: { return { effort: taskThinkingEffort.effort, source, supportedLevels, isAdaptiveClass } } - // 2. Settings-derived effort (provider profile). The "disable" sentinel - // means "no effort" for the per-request envelope resolution. - const settingsEffort = apiConfiguration?.reasoningEffort as ReasoningEffortExtended | "disable" | undefined + // 2. Settings-derived effort (provider profile). if (settingsEffort && settingsEffort !== "disable") { return { effort: settingsEffort, source: "default", supportedLevels, isAdaptiveClass } } From e0b2112299bceaaf25a9e6751b1097d3f86c4c7f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 20:42:32 +0800 Subject: [PATCH 15/19] test(webview): regenerate thinking-effort toggle baselines (value + hover border) --- .../thinking-effort-toggle-menu-dark.png | Bin 2297 -> 2709 bytes .../thinking-effort-toggle-menu-light.png | Bin 2442 -> 2734 bytes .../thinking-effort-toggle-resting-dark.png | Bin 2297 -> 2492 bytes .../thinking-effort-toggle-resting-light.png | Bin 2435 -> 2627 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png index 5272ceb5f9d19194a9b41052e15cfc822effa5b8..1ea8fc910eec3a491bfdcd81ce95593700079617 100644 GIT binary patch delta 2702 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 IM6N<$f)nRv=Kufz diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png index aa82329d369c432786cf0142ee370baa5e102198..f65c4a4db550698fa1d5359bb99e31aed4688af0 100644 GIT binary patch delta 2727 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 h00006NklK_0A002ovPDHLkV1lEXO@#me delta 2432 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 delta 2424 zcmV-;35WK>6oV6xB!4(bL_t(|oa~)tY*b4d$EQn6O9d)8#T`Ph;E+IqLkJ0O2?2sj z2<}eMAi?1S!9BP;355{conpaiTkoax{ju}jbDM5u%jqrM%k%rtoSn{`o!!iT9y{l( zYf@5@`kNm=endq@{bhq=B?v;!z+$nu+3@l4adB}m+Fc9*$A84cL_|dJZ&z1W`sK=% zO9_IIGe}HK#H@b*{@rS|`uh5MdV1>i{|h)eIyyc+-rwI}34)Nb2@MT(cX#*l^5WQ{ zY_?a_D+ta4QjLp?i;a!t*g~v}=Pc?K1ScXOARsI(4ENb3JUpCm*W24$34-7}kZ(r7 z7Q$k(R!R^Ar+)(3CL|=dlG-BYtOP-DDv&MWbtMLtijg2V9mp2(T9hCNPKyXw5S$nh zupl@wB49yqVx)j12w6s`jo8>&s+83xZj2o3Xt`ixyR;;UrXdIiZ97MZkh!7WC%p>#MQ|!e9}wAY>neuf)CSba?&x z^`S$DtTs5{WjdWXmA+Nb5hRHv2xcMYoKD=kWy_XbyLMrs7cXASOXB49>(}@0-MeSc zo~icdRDX^qu|>dw@GpX~-Lz@b>eZ{ORjY<#$x&_Fw(ZWHJ0nMqY|^Aj@#4jmIWplw zlj+{PdGok&;}$Ji^!4jkm1BsDi#vGmV20fyA|m4B<2fMT@a4-FWrF?t_w#KiK}eVT z_wNrHG>Gi9eheLQHA+Mje*5?D|LD;pWsXb;cz@EQN#DMG8!=)8S+W^3X5^g5RwCdd zM~-CJZPu(=TDK1$K3uzYtug`og$P*4knP*Ick0w>>C&a;%9T^L?3BJvqe6uWGfcJ9dm|4IDU-Le{NYx7cj( z_d(^b?Ve9PL3afAdP49!O^2fuUxrOt5&UU-MaBIGBT1r zZ{ECV-n@B>7A^2$9zT9Od-m+sty|L*4}W6n)Twjl&P~ycfrk$tMw~%GL45W8{{HLM zt*cU{O0QnMlD`++yLXRl1XX*!3J(tt=6U@1aYoUCr&Op=p|s{H{5gb$g#`ozuqO3O z7PdZMJ=CmO^YrP{;o;%R9FAJx=g*%vXwbm8a~KPTdT-pgf$1ARetgxcRk6vmqkpvB zyLXQoHHvu;9Xgb$4<9~U|LfVaXQ-%4mo8nqcBKU|+Yd*phzDJgZOrAV>$&w}K&YfdosNLSeU%Ys6+O%mTB-X51 z!^@pJcd{4EoH>)ghJk6#Q~0A`P2RkDQy*i%K7IQ1`SWKEbj$7Aw;j(mr-h>eW_4l@ zQ#9g9_UO?gU%q^8+O%P2%#X(U_3KlNZrHFP7M0^-#fs4Zx$>_@jT#~Q>VMU%<3ccd z`{BIBrAwEPZly|G27 z;lqct<|+I+u);C0WU!RYUw^|PWmU8CIUtCmapT5~#H9R7nZQv27b#LCI5^mNiAX|1 zLU{Y!xpV6l7COGMvPI|mukolYB$1eyX#5Mjyu8RK*$+oW7+VgeOqnuv>{#|E9CB^; z`HRKVo$W99HmY#i4^Mt(I_vGjo>aRTlQcd8^S2N9wOutfH7W#q_kZrCC!v869%uH# zbm6}Q%b4|`pWe)>*s)^=ekB6t0GE?SZ*OmwAEr^6z)=A=Yt{@eTJIK5pFUl%U;*z< zq^es;4!wQ*Hp84a{;jeV&8}a+&WEudPSB5N`}XZi@QlmNhez00SBxaeG(re_-oJmZ z!wLFe zc8!S|)+{YhKz=Dtk1O^6D9HO*gwOW7SMi+)KFgQ4vELWx;Y9}$U zAY={HIDbMzLn#;i@?&L*mNWZk9ucr0WEuW8R5l_mv>-S!B49yqVno1#;KYc41;L4- zd}Yx$>k5M4L?GLr>UDE-ljpMug43a|zIM&jQ=|n!$RTI}TYP+ctX8WM1i`6@jEqFQ z78e&6UteFxe@sabgx^j`NQj@GAL9L)h4%FH^l|X;ph*dWkmEtBd2Dd(l9ZIBTlEiw zW0c)&7>|G!1mU+s&Y7T>bQJ+Fw%52}_#Xi)+m8m}SpQ{19<3+{IR{-Z0!*xaGM?st q00030|0M)LA^-pY21!IgR09CU#4&4 From 90916a1b3341934f7096794fd7622869ea46b0d0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 00:45:27 +0800 Subject: [PATCH 16/19] Apply the composer thinking effort to the next task when none is open The composer toggle is reachable with no task open, but the setTaskThinkingEffort handler was a silent no-op in that state (task-gated). Park the enum-validated selection as a provider-level pending effort; createTask consumes it on the next top-level task, re-checking the new task model capability before applying (source you, single in-chat line, same as the task-local path). The webview state falls back to the pending value so the toggle keeps showing the selection until the task consumes or discards it (unsupported levels are consumed, not leaked). --- src/core/webview/ClineProvider.ts | 45 ++++++++- .../webview/__tests__/ClineProvider.spec.ts | 96 ++++++++++++++++++- ...viewMessageHandler.thinking-effort.spec.ts | 16 +++- src/core/webview/webviewMessageHandler.ts | 50 ++++++---- 4 files changed, 182 insertions(+), 25 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 98a72d7aee..8f9518d82e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -17,6 +17,7 @@ import { type ProviderName, type ProviderSettings, type RooCodeSettings, + type ReasoningEffortExtended, type ProviderSettingsEntry, type StaticAppProperties, type DynamicAppProperties, @@ -35,6 +36,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type ClineSayTool, type ExtensionMessage, type ExtensionState, type WebviewThemeFixture, @@ -201,6 +203,10 @@ export class ClineProvider private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void + // DTE: thinking effort selected in the composer while no task was open. + // Parked here and applied to the next top-level task in createTask + // (non-persisted, like task-local overrides). + private pendingTaskThinkingEffort: { effort: ReasoningEffortExtended; source: string } | undefined private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false @@ -2713,7 +2719,7 @@ export class ClineProvider currentTaskTodos: currentTask?.todoList || [], taskThinkingEffort: currentTaskRuntimeEffort?.effort ? { effort: currentTaskRuntimeEffort.effort, source: currentTaskRuntimeEffort.source ?? "default" } - : undefined, + : this.pendingTaskThinkingEffort, messageQueue: currentTask?.messageQueueService?.messages, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task) @@ -3365,6 +3371,16 @@ export class ClineProvider // from the stack and the caller is resumed in this way we can have a chain // of tasks, each one being a sub task of the previous one until the main // task is finished. + // DTE: park a composer-selected thinking effort for the next top-level + // task (no task open yet). createTask consumes and validates it. + public setPendingTaskThinkingEffort(effort: ReasoningEffortExtended): void { + this.pendingTaskThinkingEffort = { effort, source: "you" } + } + + public getPendingTaskThinkingEffort(): { effort: ReasoningEffortExtended; source: string } | undefined { + return this.pendingTaskThinkingEffort + } + public async createTask( text?: string, images?: string[], @@ -3455,6 +3471,33 @@ export class ClineProvider }) await this.addClineToStack(task) + + // DTE: apply the composer-parked thinking effort (selected while no task + // was open) to the new top-level task when its model supports the level. + // Consumed regardless of support so a stale selection never leaks into a + // later task. + if (!parentTask && this.pendingTaskThinkingEffort) { + const pendingEffort = this.pendingTaskThinkingEffort + this.pendingTaskThinkingEffort = undefined + const pendingCapability = task.api.getModel().info.supportsReasoningEffort + const pendingSupported = Array.isArray(pendingCapability) + ? (pendingCapability as string[]).includes(pendingEffort.effort) + : pendingCapability === true + if (pendingSupported) { + task.setRuntimeThinkingEffort(pendingEffort.effort, pendingEffort.source) + await task.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: pendingEffort.effort, + source: pendingEffort.source, + } satisfies ClineSayTool), + undefined, + false, + ) + } + } + if (options.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTask") } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index aa0b61f8e0..678ba922f1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -9,6 +9,7 @@ import axios from "axios" import { type ProviderSettingsEntry, + type RooCodeSettings, type ClineMessage, type ExtensionMessage, type ExtensionState, @@ -263,8 +264,31 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { vi.mock("../../task/Task", () => ({ Task: vi.fn().mockImplementation(function (options: any) { + // DTE: per-instance runtime-effort state and a capability-advertising + // api model (mirrors the deepseek catalog levels) so createTask's + // pending-effort consumption path can be exercised in these unit tests. + let runtimeEffort: string | undefined + let runtimeSource: string | undefined + const messages: Array> = [] return { - api: undefined, + api: { + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + info: { + supportsReasoningEffort: ["disable", "low", "high", "max"], + }, + }), + }, + setRuntimeThinkingEffort: vi.fn((effort: string, source?: string) => { + runtimeEffort = effort + runtimeSource = source + }), + getRuntimeThinkingEffort: vi.fn(() => + runtimeEffort !== undefined ? { effort: runtimeEffort, source: runtimeSource } : {}, + ), + say: vi.fn(async (type: string, text?: string) => { + messages.push({ type: "say", say: type, text, ts: Date.now() }) + }), abortTask: vi.fn(), handleWebviewAskResponse: vi.fn(), clineMessages: [], @@ -410,11 +434,34 @@ afterAll(() => { describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { + // DTE: per-instance runtime-effort state and a capability-advertising + // api model (mirrors the deepseek catalog levels) so createTask's + // pending-effort consumption path can be exercised in these unit tests. + let runtimeEffort: string | undefined + let runtimeSource: string | undefined + const messages: Array> = [] const task: any = { - api: undefined, + api: { + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + info: { + supportsReasoningEffort: ["disable", "low", "high", "max"], + }, + }), + }, + setRuntimeThinkingEffort: vi.fn((effort: string, source?: string) => { + runtimeEffort = effort + runtimeSource = source + }), + getRuntimeThinkingEffort: vi.fn(() => + runtimeEffort !== undefined ? { effort: runtimeEffort, source: runtimeSource } : {}, + ), + say: vi.fn(async (type: string, text?: string) => { + messages.push({ type: "say", say: type, text, ts: Date.now() }) + }), abortTask: vi.fn(), handleWebviewAskResponse: vi.fn(), - clineMessages: [], + clineMessages: messages, apiConversationHistory: [], overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), @@ -965,9 +1012,52 @@ describe("ClineProvider", () => { state = await provider.getStateToPostToWebview() expect(state.taskThinkingEffort).toBeUndefined() + // A parked composer effort (selected while no task was open) surfaces as + // the webview state fallback until the next task consumes it. + provider.setPendingTaskThinkingEffort("high") + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) + // Reset the private field for the other tests (no public clear needed — + // createTask consumes it; the narrow cast documents the intent). + ;(provider as unknown as { pendingTaskThinkingEffort?: unknown }).pendingTaskThinkingEffort = undefined + getCurrentTaskSpy.mockRestore() }) + test("createTask applies a parked composer effort to a new top-level task when the model supports it (DTE)", async () => { + await provider.setValues({ + apiConfiguration: { + apiProvider: providerIdentifiers.deepseek, + apiModelId: "deepseek-v4-flash", + }, + } as unknown as RooCodeSettings) + + provider.setPendingTaskThinkingEffort("max") + + const task = await provider.createTask("pending effort task", undefined, undefined, { startTask: false }) + + expect(task.api.getModel().info.supportsReasoningEffort).toEqual(["disable", "low", "high", "max"]) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "max", source: "you" }) + expect(provider.getPendingTaskThinkingEffort()).toBeUndefined() + const sayLines = task.clineMessages.filter((m) => m.type === "say" && m.say === "tool") + expect(sayLines.length).toBeGreaterThan(0) + expect(JSON.parse(sayLines[sayLines.length - 1].text ?? ("{}" as string))).toEqual({ + tool: "thinkingEffort", + effort: "max", + source: "you", + }) + }) + + test("createTask consumes a parked effort the new task's model does not support (DTE)", async () => { + // deepseek-v4-flash does not advertise the "xhigh" level. + provider.setPendingTaskThinkingEffort("xhigh") + + const task = await provider.createTask("stale effort task", undefined, undefined, { startTask: false }) + + expect(task.getRuntimeThinkingEffort()).toEqual({}) + expect(provider.getPendingTaskThinkingEffort()).toBeUndefined() + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts index b4c06d60be..707cbf9073 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts @@ -45,6 +45,7 @@ describe("webviewMessageHandler setTaskThinkingEffort (DTE series 4/5)", () => { const makeProvider = (task: unknown) => ({ getCurrentTask: vi.fn(() => task), postStateToWebviewWithoutTaskHistory: vi.fn(async () => {}), + setPendingTaskThinkingEffort: vi.fn(), }) const apply = (provider: ReturnType, message: Record) => @@ -87,11 +88,24 @@ describe("webviewMessageHandler setTaskThinkingEffort (DTE series 4/5)", () => { expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) - it("ignores the message when there is no current task", async () => { + it("parks a pending effort for the next task when there is no current task", async () => { const provider = makeProvider(undefined) await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + expect(provider.setPendingTaskThinkingEffort).toHaveBeenCalledWith("high") + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it.each([ + ["an effort outside the canonical enum", { effort: "bogus" }], + ["a missing effort", {}], + ])("does not park %s when there is no current task", async (_name, message) => { + const provider = makeProvider(undefined) + + await apply(provider, { type: "setTaskThinkingEffort", ...message }) + + expect(provider.setPendingTaskThinkingEffort).not.toHaveBeenCalled() expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index feda5e0345..31809e4f40 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1665,26 +1665,36 @@ export const webviewMessageHandler = async ( const setEffortTask = provider.getCurrentTask() // Validate the webview-supplied effort against the canonical enum. const setEffortParsed = reasoningEffortExtendedSchema.safeParse(message.effort) - if (setEffortTask && setEffortParsed.success) { - const setEffortValue = setEffortParsed.data - const capability = setEffortTask.api.getModel().info.supportsReasoningEffort - const supported = Array.isArray(capability) - ? (capability as string[]).includes(setEffortValue) - : capability === true - if (supported) { - setEffortTask.setRuntimeThinkingEffort(setEffortValue, "you") - // Single in-chat line (same ChatRow case as model-initiated changes). - await setEffortTask.say( - "tool", - JSON.stringify({ - tool: "thinkingEffort", - effort: setEffortValue, - source: "you", - } satisfies ClineSayTool), - undefined, - false, - ) - // Push the authoritative display state to the webview. + if (setEffortParsed.success) { + if (setEffortTask) { + const setEffortValue = setEffortParsed.data + const capability = setEffortTask.api.getModel().info.supportsReasoningEffort + const supported = Array.isArray(capability) + ? (capability as string[]).includes(setEffortValue) + : capability === true + if (supported) { + setEffortTask.setRuntimeThinkingEffort(setEffortValue, "you") + // Single in-chat line (same ChatRow case as model-initiated changes). + await setEffortTask.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: setEffortValue, + source: "you", + } satisfies ClineSayTool), + undefined, + false, + ) + // Push the authoritative display state to the webview. + await provider.postStateToWebviewWithoutTaskHistory() + } + } else { + // No open task: park the selection as the pending effort for the + // next top-level task (createTask applies it after validating the + // new task's model capability). The toggle keeps showing the + // selection because the webview state falls back to the pending + // value while no task is open. + provider.setPendingTaskThinkingEffort(setEffortParsed.data) await provider.postStateToWebviewWithoutTaskHistory() } } From 3eca87cf9e504857be44059d728ca7241f87f6d6 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 00:48:36 +0800 Subject: [PATCH 17/19] Compact the thinking-effort toggle menu padding The menu content inherited the base Popover p-4 (16px) which made the small option list feel airy. Override it with p-1.5 and tighten the header/hint paddings; option rows keep their existing py-1. --- webview-ui/src/components/chat/ThinkingEffortToggle.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx index 9f3a5f166f..c860609678 100644 --- a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx +++ b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx @@ -90,14 +90,14 @@ export const ThinkingEffortToggle = ({ disabled = false, triggerClassName = "" }
-
+
{t("chat:thinkingEffort.toggleTitle")}
{display.isAdaptiveClass && ( -
+
{t("chat:thinkingEffort.adaptiveHint")}
)} From 7c48c6bd3ce57181a1bd6171d5d3e1186edbde9f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 01:59:51 +0800 Subject: [PATCH 18/19] Capture the toggle menu element in the visual baseline The menu renders through a portal as a sibling of the story container (opened below the trigger), so capturing the story element never included the menu. Capture the menu element itself and finish the portal entrance animations before the snapshot so the menu-state baseline actually covers the menu content. --- .../chat/__tests__/ThinkingEffortToggle.visual.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx index 86d379905b..d7cd8c8f58 100644 --- a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx @@ -21,6 +21,12 @@ for (const theme of visualThemes.filter((candidate) => candidate.name === "dark" const menu = page.getByTestId("thinking-effort-toggle-menu") await expect(menu).toBeVisible() await expect(menu.getByTestId("thinking-effort-option-high")).toBeVisible() - await expect(story).toHaveScreenshot(`thinking-effort-toggle-menu-${theme.name}.png`) + // The menu is a portal sibling of the story box (opened below the + // trigger), so capture the menu element itself. Finish the portal + // entrance animations first so the capture is pixel-stable. + await page.evaluate(() => { + document.getAnimations().forEach((animation) => animation.finish()) + }) + await expect(menu).toHaveScreenshot(`thinking-effort-toggle-menu-${theme.name}.png`) }) } From 72402a191b8240a350e2abdf7fe8b46b0b561c3d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 02:10:31 +0800 Subject: [PATCH 19/19] Regenerate thinking-effort toggle menu baselines (menu capture + compact padding) The menu-state baselines now capture the portal menu element itself (previously the story container was captured, which excluded the menu) and reflect the compacted p-1.5 content padding. Resting-state baselines are unchanged. --- .../thinking-effort-toggle-menu-dark.png | Bin 2709 -> 3401 bytes .../thinking-effort-toggle-menu-light.png | Bin 2734 -> 3451 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png index 1ea8fc910eec3a491bfdcd81ce95593700079617..7a5f2fb3a18a9b15cc62d71cb237468ea81fe189 100644 GIT binary patch literal 3401 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 literal 2709 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 ZXJPD9cyn%2I)OVZHkMZ`YA@e-^bflghx`Bl literal 2734 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