From a05830c7a98406f73c6a5e9a9a7149504a42c8fa Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 03:09:39 +0800 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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 19954d398e88b91e7e78a4e075d40c7202b456e4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 19:19:24 +0800 Subject: [PATCH 07/12] fix(task): harden set_thinking_effort per review feedback Address PR review feedback on set_thinking_effort (DTE series 3/5): - Executor: seed the per-task guard history with the task's effective baseline so returning from a changed value to the original baseline is refused as oscillation (A -> B -> A); existing no-op behavior preserved. - Parser: only build nativeArgs when effort AND reason are strings; a non-string payload now fails at parse time and cannot reach the executor. - Gating: a supportsReasoningEffort array that only lists 'disable' no longer exposes the tool (it could apply no level). - i18n: translate the new thinkingEffort chat strings into all 17 non-English webview locales (placeholders preserved). - Tests: regression tests for each change plus branch-coverage for the previously partial lines (non-string args, 'disable'-only capability, baseline oscillation, partial streaming without params, description fallback, capability robustness). All touched patch lines are now fully branch-covered (codecov patch partials resolved). CodeRabbit: https://github.com/Zoo-Code-Org/Zoo-Code/pull/1354 --- .../assistant-message/NativeToolCallParser.ts | 4 +- ...veToolCallParser.setThinkingEffort.spec.ts | 44 +++++++ ...AssistantMessage-setThinkingEffort.spec.ts | 25 ++++ .../__tests__/filter-thinking-effort.spec.ts | 16 ++- .../prompts/tools/filter-tools-for-mode.ts | 11 +- src/core/tools/SetThinkingEffortTool.ts | 28 +++-- .../__tests__/setThinkingEffortTool.spec.ts | 111 ++++++++++++++++-- webview-ui/src/i18n/locales/ca/chat.json | 6 +- webview-ui/src/i18n/locales/de/chat.json | 6 +- webview-ui/src/i18n/locales/es/chat.json | 6 +- webview-ui/src/i18n/locales/fr/chat.json | 6 +- webview-ui/src/i18n/locales/hi/chat.json | 6 +- webview-ui/src/i18n/locales/id/chat.json | 6 +- webview-ui/src/i18n/locales/it/chat.json | 6 +- webview-ui/src/i18n/locales/ja/chat.json | 6 +- webview-ui/src/i18n/locales/ko/chat.json | 6 +- webview-ui/src/i18n/locales/nl/chat.json | 6 +- webview-ui/src/i18n/locales/pl/chat.json | 6 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 6 +- webview-ui/src/i18n/locales/ru/chat.json | 6 +- webview-ui/src/i18n/locales/tr/chat.json | 6 +- webview-ui/src/i18n/locales/vi/chat.json | 6 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 6 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 6 +- 24 files changed, 266 insertions(+), 75 deletions(-) diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index c3e74c2c3b..5cbab11583 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -862,7 +862,9 @@ export class NativeToolCallParser { break case "set_thinking_effort": - if (args.effort !== undefined && args.reason !== undefined) { + // Both values must be strings: a non-string payload is an + // invalid tool call and must not reach the executor. + if (typeof args.effort === "string" && typeof args.reason === "string") { nativeArgs = { effort: args.effort, reason: args.reason, diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts index dff4bdfbb0..b4bbe556ed 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts @@ -48,6 +48,28 @@ describe("NativeToolCallParser — set_thinking_effort", () => { const result = NativeToolCallParser.parseToolCall(toolCall) expect(result).toBeNull() }) + + it("rejects a non-string reason (no nativeArgs, so the executor is not reached)", () => { + const toolCall = { + id: "toolu_dte_3", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ effort: "high", reason: {} }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + expect(result).toBeNull() + }) + + it("rejects a non-string effort (no nativeArgs, so the executor is not reached)", () => { + const toolCall = { + id: "toolu_dte_4", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ effort: 123, reason: "escalating" }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + expect(result).toBeNull() + }) }) describe("processStreamingChunk (partial)", () => { @@ -66,6 +88,28 @@ describe("NativeToolCallParser — set_thinking_effort", () => { expect(nativeArgs?.effort).toBe("high") expect(nativeArgs?.reason).toBe("escalating") }) + + it("emits a partial ToolUse carrying only the streamed reason", () => { + const id = "toolu_dte_stream_2" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + const result = NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ reason: "escalating" })) + + expect(result).not.toBeNull() + const nativeArgs = result?.nativeArgs as { effort?: string; reason?: string } | undefined + expect(nativeArgs?.effort).toBeUndefined() + expect(nativeArgs?.reason).toBe("escalating") + }) + + it("emits a partial ToolUse without nativeArgs when neither param has streamed yet", () => { + const id = "toolu_dte_stream_3" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + const result = NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ other: "value" })) + + expect(result).not.toBeNull() + expect((result as { nativeArgs?: unknown }).nativeArgs).toBeUndefined() + }) }) describe("finalizeStreamingToolCall", () => { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts index b5df7e68f6..a3d1f71536 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts @@ -185,6 +185,9 @@ describe("presentAssistantMessage - set_thinking_effort dispatch", () => { ] await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).not.toHaveBeenCalled() }) it("describes a skipped set_thinking_effort block via the tool description when the task already rejected a tool", async () => { @@ -201,4 +204,26 @@ describe("presentAssistantMessage - set_thinking_effort dispatch", () => { expect(content).toContain("set_thinking_effort to 'high'") expect(content).toContain("rejecting") }) + + it("describes a set_thinking_effort block without an effort param via the tool description fallback", async () => { + mockTask.didRejectTool = true + mockTask.assistantMessageContent = [ + { + type: "tool_use" as const, + id: toolCallId(), + name: "set_thinking_effort", + params: {}, + partial: false, + }, + ] + + 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 ''") + }) }) diff --git a/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts index 2ffa95bc96..44671847db 100644 --- a/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts +++ b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts @@ -50,6 +50,10 @@ describe("isSetThinkingEffortEnabled", () => { expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(["low", "high"]))).toBe(true) expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(true))).toBe(true) }) + + it("is false for a capability array that only lists 'disable' (no settable level)", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(["disable"]))).toBe(false) + }) }) describe("filterNativeToolsForMode set_thinking_effort gate", () => { @@ -75,6 +79,14 @@ describe("filterNativeToolsForMode set_thinking_effort gate", () => { expect(toolNames(result)).not.toContain("set_thinking_effort") }) + it("removes the tool for a capability array that only lists 'disable'", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(["disable"]), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + expect(toolNames(result)).toContain("execute_command") + }) + it("keeps the tool list stable across repeated calls (prompt-cache safety)", () => { const experiments = { dynamicThinkingEffort: true } const settings = { modelInfo: modelInfo(["low", "high"]) } @@ -131,7 +143,9 @@ describe("isToolAllowedInMode — set_thinking_effort gate (prompt-side)", () => modelInfo: modelInfo(false), }), ).toBe(false) - // Other always-available tools remain unconditional. + // Other always-available tools remain unconditional; in particular the + // DTE branch is skipped for them (non-set_thinking_effort path). expect(isToolAllowedInMode("execute_command", "code", undefined, undefined, undefined, undefined)).toBe(true) + expect(isToolAllowedInMode("switch_mode", "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 1756ce7800..166a4c64e6 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -367,9 +367,10 @@ function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean * 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). + * model to advertise per-request reasoning effort support (a + * `supportsReasoningEffort` capability array with at least one settable + * non-`disable` level, 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) @@ -384,7 +385,9 @@ export function isSetThinkingEffortEnabled( } const capability = modelInfo?.supportsReasoningEffort if (Array.isArray(capability)) { - return capability.length > 0 + // A "disable"-only array exposes a tool that cannot apply any level + // (the executor's clamp would land on "disable" and refuse every call). + return capability.some((effort) => effort !== "disable") } return capability === true } diff --git a/src/core/tools/SetThinkingEffortTool.ts b/src/core/tools/SetThinkingEffortTool.ts index 2710717c2b..5fc3857b11 100644 --- a/src/core/tools/SetThinkingEffortTool.ts +++ b/src/core/tools/SetThinkingEffortTool.ts @@ -14,7 +14,8 @@ import { BaseTool, ToolCallbacks } from "./BaseTool" * 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 + * - oscillation detection: A -> B -> A ping-pong within a task (including a + * return to the task baseline) is refused * - hard clamp to the model capability array * * The tool is only exposed when the dynamicThinkingEffort experiment is on @@ -54,7 +55,10 @@ 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. */ + /** + * Applied efforts, most recent last; seeded with the task's effective + * baseline (when defined) so returning to it counts as oscillation. + */ history: string[] } @@ -102,10 +106,16 @@ export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { */ private guardState = new WeakMap() - private getGuardState(task: Task): EffortGuardState { + private getGuardState(task: Task, baseline: string | undefined): EffortGuardState { let state = this.guardState.get(task) if (!state) { - state = { upwardChanges: 0, history: [] } + state = { + upwardChanges: 0, + // Seed the history with the task's effective baseline so that + // returning from a changed value to the original baseline is + // detected as oscillation (A -> B -> A) instead of re-applied. + history: baseline === undefined ? [] : [baseline], + } this.guardState.set(task, state) } return state @@ -175,9 +185,11 @@ export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { task.consecutiveMistakeCount++ task.recordToolError("set_thinking_effort") task.didToolFailInCurrentTurn = true - const supported = Array.isArray(capability) - ? capability.filter((l) => l !== "disable").join(", ") - : "none" + // Invariant: clampToCapability only returns "disable" when the + // capability is a non-empty array containing "disable", so the + // capability is a (non-empty) array here — single documented cast, + // no double assertion. + const supported = (capability as string[]).filter((l) => l !== "disable").join(", ") pushToolResult( formatResponse.toolError( "'" + effort + "' is not supported by the current model. Supported levels: " + supported + ".", @@ -186,8 +198,8 @@ export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { return } - const guard = this.getGuardState(task) const current = task.getRuntimeThinkingEffort().effort ?? task.apiConfiguration.reasoningEffort + const guard = this.getGuardState(task, current) // No-op: already at the requested level — confirm without churn. if (clamped === current) { diff --git a/src/core/tools/__tests__/setThinkingEffortTool.spec.ts b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts index c869ed64d7..218be78ca0 100644 --- a/src/core/tools/__tests__/setThinkingEffortTool.spec.ts +++ b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts @@ -25,7 +25,7 @@ interface TaskDouble { api: { getModel: () => { id: string; info: { supportsReasoningEffort: Capability } } } providerRef: { deref: () => { - getState: () => Promise<{ experiments: Record }> + getState: () => Promise<{ experiments?: Record }> } } } @@ -148,6 +148,17 @@ describe("setThinkingEffortTool", () => { const result = callbacks.pushToolResult.mock.calls[0][0] as string expect(result).toContain("does not support") }) + + it("rejects when the provider state carries no experiment flags", async () => { + use() + double.providerRef.deref().getState = vi.fn().mockResolvedValue({}) + + 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("experiment") + }) }) describe("clamp to model capability", () => { @@ -196,6 +207,24 @@ describe("setThinkingEffortTool", () => { expect(result).toContain("clamped to 'low'") }) + it("resolves nearest-level ties toward the lower level regardless of array order", async () => { + use({ capability: ["low", "high"], settingsEffort: "high" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "tie-break order" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'low'") + }) + + it("clamps robustly when the capability array contains an unknown level", async () => { + use({ capability: ["weird", "low"], settingsEffort: "high" }) + await setThinkingEffortTool.execute({ effort: "max", reason: "robust clamp" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + 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) @@ -242,6 +271,14 @@ describe("setThinkingEffortTool", () => { const result = callbacks.pushToolResult.mock.calls[0][0] as string expect(result).toContain("already") }) + + it("applies normally when the task has no settings baseline (undefined current)", async () => { + use() + await setThinkingEffortTool.execute({ effort: "high", reason: "no baseline" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + expect(sayPayloads(double).some((p) => (p as Record).refusal !== undefined)).toBe(false) + }) }) describe("escalation cap", () => { @@ -263,18 +300,17 @@ describe("setThinkingEffortTool", () => { }) it("does not count downward changes toward the cap", async () => { - use({ capability: ["low", "medium", "high", "xhigh", "max"], settingsEffort: "low" }) + use({ capability: ["none", "low", "medium", "high", "xhigh", "max"], settingsEffort: "max" }) const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + await step("none") // downward: not counted 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) + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(4) await step("max") // 4th upward change: refused - expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(5) + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(4) const refusal = sayPayloads(double).at(-1) expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "escalation_cap" }) }) @@ -282,12 +318,12 @@ describe("setThinkingEffortTool", () => { describe("oscillation detection", () => { it("refuses an A -> B -> A ping-pong within the task", async () => { - use({ capability: ["low", "medium", "high"], settingsEffort: "low" }) + use({ capability: ["low", "medium", "high"], settingsEffort: "high" }) 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 + await step("low") // downward from the baseline, allowed + await step("medium") // upward + await step("low") // ping-pong back: refused expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(2) const refusal = sayPayloads(double).at(-1) @@ -298,6 +334,23 @@ describe("setThinkingEffortTool", () => { expect(result).toContain("'low'") }) + it("refuses a return to the task baseline (baseline oscillation)", async () => { + use({ capability: ["low", "medium"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("low") // at the baseline: no-op, not a change + expect(callbacks.pushToolResult).toHaveBeenLastCalledWith("Thinking effort is already 'low'.") + + await step("medium") // move away from the baseline + await step("low") // return to the baseline: refused as oscillation + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + 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") + }) + 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) @@ -344,6 +397,44 @@ describe("setThinkingEffortTool", () => { expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() }) + it("emits a partial say with the streamed effort when the reason is not streamed yet", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: { effort: "high" }, + partial: true, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).toHaveBeenCalledWith( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: "high", reason: "" }), + undefined, + true, + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("emits a partial say with the streamed reason when the effort is not streamed yet", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: { reason: "deep" }, + partial: true, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).toHaveBeenCalledWith( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: "", 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, diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index a89bed97ba..75e9d1bb41 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -467,9 +467,9 @@ "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" + "applied": "🧠 Esforç de pensament: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Esforç de pensament sense canvis: s'ha assolit el límit d'escalada de 3 canvis cap amunt per tasca", + "oscillationRefused": "🧠 Esforç de pensament sense canvis: s'ha detectat oscil·lació entre nivells" }, "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 0336ef1925..2463798406 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 Denkintensität: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Denkintensität unverändert: Limit von 3 Erhöhungen pro Aufgabe erreicht", + "oscillationRefused": "🧠 Denkintensität unverändert: Oszillation zwischen Stufen erkannt" }, "todo": { "partial": "{{completed}} von {{total}} To-Dos erledigt", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 759041a558..8b8322f72d 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 Esfuerzo de pensamiento: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Esfuerzo de pensamiento sin cambios: se alcanzó el límite de escalada de 3 cambios hacia arriba por tarea", + "oscillationRefused": "🧠 Esfuerzo de pensamiento sin cambios: se detectó una oscilación entre niveles" }, "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 4634463042..e343b4acba 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 Effort de réflexion : {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Effort de réflexion inchangé : limite d'escalade de 3 modifications vers le haut par tâche atteinte", + "oscillationRefused": "🧠 Effort de réflexion inchangé : oscillation entre les niveaux détectée" }, "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 378f25fe26..af3520aa44 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 चिंतन प्रयास: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 चिंतन प्रयास अपरिवर्तित: प्रति कार्य अधिकतम 3 वृद्धि की सीमा पहुँची", + "oscillationRefused": "🧠 चिंतन प्रयास अपरिवर्तित: स्तरों के बीच दोलन का पता चला" }, "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 5153bf1283..73ef0a56bc 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -479,9 +479,9 @@ "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" + "applied": "🧠 Usaha berpikir: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Usaha berpikir tidak berubah: batas eskalasi 3 perubahan naik per tugas tercapai", + "oscillationRefused": "🧠 Usaha berpikir tidak berubah: osilasi antar tingkat terdeteksi" }, "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 eab9c42717..420abde256 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 Sforzo di pensiero: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Sforzo di pensiero invariato: raggiunto il limite di escalation di 3 modifiche in salita per task", + "oscillationRefused": "🧠 Sforzo di pensiero invariato: oscillazione tra i livelli rilevata" }, "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 3c32d55293..f1c3275f74 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 思考強度: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 思考強度を変更できません: タスクごとの最大 3 回の引き上げ制限に達しました", + "oscillationRefused": "🧠 思考強度を変更できません: レベル間での振動を検出しました" }, "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 1c1712e921..46228461fe 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 사고 노력: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 사고 노력 변경 없음: 작업당 최대 3회 상향 조정 한도에 도달했습니다", + "oscillationRefused": "🧠 사고 노력 변경 없음: 레벨 간 진동 감지됨" }, "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 9585d3195a..540049533f 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 Denkwerk: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Denkwerk ongewijzigd: limiet van 3 verhogingen per taak bereikt", + "oscillationRefused": "🧠 Denkwerk ongewijzigd: oscillatie tussen niveaus gedetecteerd" }, "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 5a2dfa307a..e70e2db78e 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 Wysiłek myślowy: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Wysiłek myślowy bez zmian: osiągnięto limit 3 eskalacji w górę na zadanie", + "oscillationRefused": "🧠 Wysiłek myślowy bez zmian: wykryto oscylację między poziomami" }, "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 90aa131e73..2bec24a9ff 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -473,9 +473,9 @@ "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" + "applied": "🧠 Esforço de pensamento: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Esforço de pensamento inalterado: limite de escalonamento de 3 aumentos por tarefa atingido", + "oscillationRefused": "🧠 Esforço de pensamento inalterado: oscilação entre níveis detectada" }, "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 502825b008..0c44ce9570 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -474,9 +474,9 @@ "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" + "applied": "🧠 Усилие размышления: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Усилие размышления не изменено: достигнут предел в 3 повышения на задачу", + "oscillationRefused": "🧠 Усилие размышления не изменено: обнаружена осцилляция между уровнями" }, "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 00616feb64..7354229390 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -474,9 +474,9 @@ "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" + "applied": "🧠 Düşünme çabası: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Düşünme çabası değişmedi: görev başına 3 artış limiti aşıldı", + "oscillationRefused": "🧠 Düşünme çabası değişmedi: seviyeler arası salınım algılandı" }, "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 adea95ca24..bff7c01734 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -474,9 +474,9 @@ "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" + "applied": "🧠 Nỗ lực suy nghĩ: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Nỗ lực suy nghĩ không đổi: đã đạt giới hạn 3 lần nâng cao mỗi tác vụ", + "oscillationRefused": "🧠 Nỗ lực suy nghĩ không đổi: phát hiện dao động giữa các mức" }, "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 b8caef7f61..eea561c69b 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -474,9 +474,9 @@ "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" + "applied": "🧠 思考强度: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 思考强度未改变: 已达到每任务 3 次上调的升级上限", + "oscillationRefused": "🧠 思考强度未改变: 检测到等级间振荡" }, "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 ff077cd26c..1bd74ac18b 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -454,9 +454,9 @@ "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" + "applied": "🧠 思考強度: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 思考強度未改變: 已達到每任務 3 次上調的升級上限", + "oscillationRefused": "🧠 思考強度未改變: 偵測到等級間震盪" }, "queuedMessages": { "title": "佇列中的訊息", From 396a9b109591e1b0ed87a082876dc68a9408cec2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 14:25:24 +0800 Subject: [PATCH 08/12] fix(tool): reject capability arrays with no settable effort Filter model capability entries to SETTABLE_EFFORTS plus disable before nearest-level selection so a capability array containing only unrecognized values (e.g. [weird]) is refused via the standard tool-error path instead of being applied as the runtime effort. Add unit coverage for all-garbage and mixed garbage capability arrays. --- src/core/tools/SetThinkingEffortTool.ts | 30 +++++++++++++++++-- .../__tests__/setThinkingEffortTool.spec.ts | 23 ++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/core/tools/SetThinkingEffortTool.ts b/src/core/tools/SetThinkingEffortTool.ts index 5fc3857b11..0c83f69a08 100644 --- a/src/core/tools/SetThinkingEffortTool.ts +++ b/src/core/tools/SetThinkingEffortTool.ts @@ -70,15 +70,27 @@ function effortRank(level: string | undefined): number { * 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). + * + * Only recognized effort values (SETTABLE_EFFORTS plus "disable") count as + * supported: capability arrays may carry provider-specific garbage, and the + * nearest-level selection must never yield an unrecognized value that would + * be applied as the runtime effort. Returns `undefined` when the array holds + * no recognized value at all, in which case the caller must refuse the call. */ function clampToCapability( requested: SettableEffort, capability: ModelInfo["supportsReasoningEffort"], -): SettableEffort | "disable" { +): SettableEffort | "disable" | undefined { if (!Array.isArray(capability) || capability.length === 0) { return requested } - const supported = capability + const supported = capability.filter( + (level): level is SettableEffort | "disable" => + (SETTABLE_EFFORTS as readonly string[]).includes(level) || level === "disable", + ) + if (supported.length === 0) { + return undefined + } if (supported.includes(requested)) { return requested } @@ -179,6 +191,20 @@ export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { // Hard clamp to the model capability array. const clamped = clampToCapability(requested, capability) + if (clamped === undefined) { + // The capability array contains no recognizable effort level, so no valid + // value can be applied; refuse the call (standard refusal path) instead of + // applying an unrecognized value as the runtime effort. + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + "The current model does not advertise any usable thinking effort levels; keeping the current effort.", + ), + ) + return + } 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). diff --git a/src/core/tools/__tests__/setThinkingEffortTool.spec.ts b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts index 218be78ca0..200c14dec3 100644 --- a/src/core/tools/__tests__/setThinkingEffortTool.spec.ts +++ b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts @@ -225,6 +225,29 @@ describe("setThinkingEffortTool", () => { expect(result).toContain("clamped to 'low'") }) + it("refuses a capability array that contains no settable effort", async () => { + use({ capability: ["weird"], settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "high", reason: "multi-step math" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("error") + expect(result).toContain("usable thinking effort levels") + }) + + it("still passes through a settable level when the capability array also contains garbage", async () => { + use({ capability: ["weird", "high"], settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "high", reason: "passthrough" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).not.toContain("clamped") + }) + 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) From e50241774139eff507053e2f568de01775c986f9 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 14:27:22 +0800 Subject: [PATCH 09/12] test(webview): type thinking-effort test helpers Replace the any-typed renderChatRow/sayToolMessage helpers with the ClineMessage type and a ThinkingEffortSayTool payload shape (thinkingEffort tool discriminator over ClineSayTool fields), preserving the existing fixture values. --- .../__tests__/ChatRow.thinking-effort.spec.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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..68bd3706a6 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 @@ -1,7 +1,17 @@ import React from "react" import { render, screen } from "@/utils/test-utils" import { ChatRowContent } from "../ChatRow" -import type { ClineMessage } from "@roo-code/types" +import type { ClineMessage, ClineSayTool } from "@roo-code/types" + +/** The thinkingEffort say-tool payload shape emitted by SetThinkingEffortTool. */ +type ThinkingEffortSayTool = Pick & { + tool: "thinkingEffort" +} + +/** A non-thinkingEffort say-tool payload (arbitrary tool name) for negative tests. */ +type OtherSayTool = Pick & { + tool: string +} // Mock vscode API const mockPostMessage = vi.fn() @@ -49,7 +59,7 @@ vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ useSelectedModel: () => ({ info: { supportsImages: true } }), })) -function renderChatRow(message: any) { +function renderChatRow(message: ClineMessage) { mockClineMessages = [message] return render( Date: Mon, 24 Aug 2026 15:57:20 +0800 Subject: [PATCH 10/12] feat(i18n): translate dynamic thinking effort setting (11 locales) Translate the DYNAMIC_THINKING_EFFORT name/description out of English in zh-CN, ja, ko, ru, de, ca, pt-BR, tr, vi, nl and pl, matching the terminology already used in each locale chat.json thinkingEffort strings. find-missing-translations --area=webview is clean. --- webview-ui/src/i18n/locales/ca/settings.json | 4 ++-- webview-ui/src/i18n/locales/de/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 ++-- webview-ui/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 ++-- webview-ui/src/i18n/locales/zh-CN/settings.json | 4 ++-- 11 files changed, 22 insertions(+), 22 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index fb11e4b91e..df83dbc841 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Esforç de pensament dinàmic", + "description": "Permet que el model decideixi el seu esforç de pensament per pas i que tu l'ajustis en el xat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index de64197cd2..769aecce8a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Dynamische Denkintensität", + "description": "Lässt das Modell die Denkintensität pro Schritt selbst bestimmen und ermöglicht Ihnen, sie im Chat anzupassen. (experimentell)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 97f890fd6e..47a61f85e8 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "ダイナミック思考強度", + "description": "ステップごとにモデルが思考強度を決定し、チャット内で調整できます。 (実験的機能)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index bf7c219cc1..10f8b45d0a 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "동적 사고 노력", + "description": "단계별로 모델이 사고 노력을 결정하도록 하고, 채팅에서 조정할 수 있습니다. (실험적 기능)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 3214bfe1e1..6dad8b184c 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Dynamisch denkwerk", + "description": "Laat het model per stap het denkwerk zelf bepalen en u dat in het gesprek aanpassen. (experimenteel)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 86376c737b..d0fae082b5 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Dynamiczny wysiłek myślowy", + "description": "Pozwala modelowi samodzielnie decydować o wysiłku myślowym na każdym kroku oraz dostosowywać go w czacie. (eksperymentalne)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 0b23c9b74c..f4ea5bd427 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Esforço de pensamento dinâmico", + "description": "Permite que o modelo decida seu esforço de pensamento em cada etapa e que você o ajuste na conversa. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index a8fd78565a..efeadaad19 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Динамическое усилие размышления", + "description": "Позволяет модели самостоятельно определять усилие размышления на каждом шаге и корректировать его в чате. (экспериментальная функция)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 152bb3c820..506a80a062 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Dinamik düşünme çabası", + "description": "Modelin her adımda kendi düşünme çabasını belirlemesini ve onu sohbette ayarlamayı sağlar. (deneysel)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index e103d6ea5b..bfd90413c1 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Nỗ lực suy nghĩ động", + "description": "Để model tự quyết định nỗ lực suy nghĩ của từng bước và cho phép bạn điều chỉnh trong trò chuyện. (thực nghiệm)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index a48ce6a39d..2c9fd775cf 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "动态思考强度", + "description": "让模型按步骤自行决定思考强度,并允许你在对话中调整。 (实验性功能)" } }, "promptCaching": { From 13967369ba4bc3fe36c68505065d8a09cb9ec1f0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 17:51:08 +0800 Subject: [PATCH 11/12] feat(i18n): translate dynamic thinking effort setting (5 more locales) Complete the CodeRabbit i18n finding beyond the initially reported 11 locales: translate DYNAMIC_THINKING_EFFORT name/description in es, fr, hi, id and it as well (the key was English-identical in every locale except en and zh-TW). Terminology follows each locales chat.json thinkingEffort strings; find-missing-translations --area=webview is clean. --- 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 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 72b4780329..ec903b3665 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Esfuerzo de pensamiento dinámico", + "description": "Permite que el modelo decida su esfuerzo de pensamiento por paso y que tú lo ajustes en el chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 15b5f35c2d..b3a11ed767 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Effort de réflexion dynamique", + "description": "Permet au modèle de décider de son effort de réflexion à chaque étape et de l'ajuster dans le chat. (expérimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 149f7bc4f6..d6d89b9245 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "डायनामिक चिंतन प्रयास", + "description": "मॉडल को हर चरण में अपना चिंतन प्रयास स्वयं तय करने दें, और आप चैट में इसे समायोजित कर सकते हैं। (प्रयोगात्मक)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index ce33b6a018..4af275ca75 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Usaha berpikir dinamis", + "description": "Biarkan model memutuskan usaha berpikirnya per langkah, dan biarkan Anda menyesuaikannya di obrolan. (eksperimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4d4d80d61c..dc590dfa90 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -975,8 +975,8 @@ "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)" + "name": "Sforzo di pensiero dinamico", + "description": "Consente al modello di decidere lo sforzo di pensiero per ogni passaggio e di regolarlo nella chat. (sperimentale)" } }, "promptCaching": { From f7057f0cfd83f5823bf0ef94a55ad7f988d4cde2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 18:20:59 +0800 Subject: [PATCH 12/12] test(webview): include source in thinking-effort say-tool test type --- .../components/chat/__tests__/ChatRow.thinking-effort.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 68bd3706a6..c459ee78a1 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 @@ -4,7 +4,7 @@ import { ChatRowContent } from "../ChatRow" import type { ClineMessage, ClineSayTool } from "@roo-code/types" /** The thinkingEffort say-tool payload shape emitted by SetThinkingEffortTool. */ -type ThinkingEffortSayTool = Pick & { +type ThinkingEffortSayTool = Pick & { tool: "thinkingEffort" }