From a05830c7a98406f73c6a5e9a9a7149504a42c8fa Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 03:09:39 +0800 Subject: [PATCH 01/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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 146c5c826a070c7cb51ca006963151a7d74f58b4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 15:22:21 +0800 Subject: [PATCH 07/42] feat(task): orchestrator new_task thinking_effort --- packages/types/src/vscode-extension-host.ts | 9 +- src/__tests__/new-task-delegation.spec.ts | 6 + src/__tests__/provider-delegation.spec.ts | 77 +++++ .../prompts/tools/native-tools/new_task.ts | 6 + src/core/task/Task.ts | 58 +++- .../__tests__/Task.new-task-effort.spec.ts | 194 +++++++++++ src/core/tools/NewTaskTool.ts | 64 +++- .../__tests__/newTaskThinkingEffort.spec.ts | 314 ++++++++++++++++++ src/core/tools/__tests__/newTaskTool.spec.ts | 12 + src/core/webview/ClineProvider.ts | 13 +- .../__tests__/webviewMessageHandler.spec.ts | 39 ++- src/core/webview/webviewMessageHandler.ts | 9 +- src/shared/tools.ts | 5 +- webview-ui/src/components/chat/ChatView.tsx | 80 ++++- .../chat/__tests__/ChatView.spec.tsx | 171 ++++++++++ 15 files changed, 1041 insertions(+), 16 deletions(-) create mode 100644 src/core/task/__tests__/Task.new-task-effort.spec.ts create mode 100644 src/core/tools/__tests__/newTaskThinkingEffort.spec.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 337ad22e2c..afc79e0970 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -12,7 +12,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" -import { RouterModelsMessageType, type ModelRecord, type RouterModels } from "./model.js" +import { RouterModelsMessageType, type ModelRecord, type RouterModels, type ReasoningEffortExtended } from "./model.js" import { LmStudioModelsMessageType } from "./providers/lm-studio.js" import { OllamaModelsMessageType } from "./providers/ollama.js" import { OpenAiModelsMessageType } from "./providers/openai.js" @@ -643,6 +643,9 @@ export interface WebviewMessage { | "openRulesDirectory" text?: string taskId?: string + // DTE series 5/5: thinking effort chosen in the pending new_task ask block + // (sent with the ask response, see Task.handleWebviewAskResponse). + thinkingEffort?: ReasoningEffortExtended editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean @@ -892,6 +895,10 @@ export interface ClineSayTool { description?: string // Properties for skill tool skill?: string + // DTE series 5/5: new_task thinking-effort prefill for the ask block and the + // effort levels the target model supports (see NewTaskTool). + thinkingEffort?: ReasoningEffortExtended + supportedThinkingEfforts?: ReasoningEffortExtended[] } export interface ClineAskUseMcpServer { diff --git a/src/__tests__/new-task-delegation.spec.ts b/src/__tests__/new-task-delegation.spec.ts index b6f6d4d36c..1090b00f30 100644 --- a/src/__tests__/new-task-delegation.spec.ts +++ b/src/__tests__/new-task-delegation.spec.ts @@ -20,14 +20,20 @@ describe("Task.startSubtask() metadata-driven delegation", () => { ;(parent as any).taskId = "parent-1" ;(parent as any).providerRef = { deref: () => provider } ;(parent as any).emit = vi.fn() + // DTE series 5/5: startSubtask now passes the parent's effective effort to the + // child's init; this Object.create double bypasses the constructor, so shadow + // the public resolver with the value under test. + parent.resolveNewTaskEffectiveEffort = () => undefined const child = await (Task.prototype as any).startSubtask.call(parent, "Do something", [], "code") + // DTE series 5/5: thinkingEffort is always present (undefined = inherit parent effective). expect(provider.delegateParentAndOpenChild).toHaveBeenCalledWith({ parentTaskId: "parent-1", message: "Do something", initialTodos: [], mode: "code", + thinkingEffort: undefined, }) expect(child.taskId).toBe("child-1") diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0154027753..3e86a234e2 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -386,4 +386,81 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) + + it("applies the parent-supplied starting effort to the child at init (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + const child = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "high", + }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + + expect(child.taskId).toBe("child-1") + // Applied as a task-local override with provenance "parent" before the child's + // first request (the child header shows it from the start). + expect(setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "parent") + }) + + it("leaves the child's effort untouched when no starting effort is supplied (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() + + expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) }) diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f8e29e549d..cc6fb8e374 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos` +const THINKING_EFFORT_PARAMETER_DESCRIPTION = `Optional thinking effort the new task starts with (e.g., "low", "medium", "high"). Must be a level the target model supports. When omitted, the new task starts with the current task's effective effort. The user can still change it before entering the new task.` + export default { type: "function", function: { @@ -31,6 +33,10 @@ export default { type: ["string", "null"], description: TODOS_PARAMETER_DESCRIPTION, }, + thinking_effort: { + type: "string", + description: THINKING_EFFORT_PARAMETER_DESCRIPTION, + }, }, required: ["mode", "message", "todos"], additionalProperties: false, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e448cb16bc..cffe9328fd 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -58,6 +58,7 @@ import { providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { resolveEffectiveReasoningEffort } from "../../api/transform/reasoning" import { CloudService } from "@roo-code/cloud" // api @@ -297,6 +298,9 @@ export class Task extends EventEmitter implements TaskLike { // Settings-derived effort captured when the override activates, so clearing // (undefined) restores it in the in-memory apiConfiguration copy. private preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] + // DTE series 5/5: thinking effort chosen in the webview new_task ask block; carried + // by the ask response (handleWebviewAskResponse) and consumed once by NewTaskTool. + private newTaskAskThinkingEffort?: ReasoningEffortExtended private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -1443,7 +1447,16 @@ export class Task extends EventEmitter implements TaskLike { return result } - handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + /** + * DTE series 5/5: the optional `thinkingEffort` is the user's new_task ask-block + * selection (webview `WebviewMessage.thinkingEffort`), consumed by NewTaskTool. + */ + handleWebviewAskResponse( + askResponse: ClineAskResponse, + text?: string, + images?: string[], + thinkingEffort?: ReasoningEffortExtended, + ) { // Clear any pending auto-approval timeout when user responds this.cancelAutoApprovalTimeout() @@ -1451,6 +1464,10 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = text this.askResponseImages = images + if (thinkingEffort !== undefined) { + this.newTaskAskThinkingEffort = thinkingEffort + } + // Create a checkpoint whenever the user sends a message. // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. @@ -1604,6 +1621,38 @@ export class Task extends EventEmitter implements TaskLike { return this.runtimeThinkingEffort !== undefined ? { reasoningEffort: this.runtimeThinkingEffort } : {} } + /** + * DTE series 5/5: resolves this task's current effective thinking effort — used to + * pre-fill the new_task ask block and to inherit the effort into a child task when + * neither the model nor the user specifies one. + * + * Resolution reuses the PR-2 point (task-local override → settings + * `reasoningEffort` → model default). The settings "disable" sentinel is excluded: + * it is a UI off-switch, not a level a child task can start with. + */ + public resolveNewTaskEffectiveEffort(): ReasoningEffortExtended | undefined { + const { effort: runtimeEffort } = this.getRuntimeThinkingEffort() + if (runtimeEffort !== undefined) { + return runtimeEffort + } + const resolved = resolveEffectiveReasoningEffort({ + settingsReasoningEffort: this.apiConfiguration?.reasoningEffort, + modelDefaultEffort: this.api.getModel().info.reasoningEffort, + }) + return resolved === "disable" ? undefined : resolved + } + + /** + * DTE series 5/5: reads and clears the thinking effort the user chose in the + * pending new_task ask block (set from the webview ask response). The value is + * consumed once by NewTaskTool so a later, different ask cannot reuse it. + */ + public takeNewTaskAskThinkingEffort(): ReasoningEffortExtended | undefined { + const effort = this.newTaskAskThinkingEffort + this.newTaskAskThinkingEffort = undefined + return effort + } + public async submitUserMessage( text: string, images?: string[], @@ -2388,10 +2437,12 @@ export class Task extends EventEmitter implements TaskLike { 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. + // task end so a disposed task never carries it forward. DTE series 5/5: the + // pending new_task ask-block selection is consumed or discarded the same way. this.runtimeThinkingEffort = undefined this.runtimeThinkingEffortSource = undefined this.preOverrideReasoningEffort = undefined + this.newTaskAskThinkingEffort = 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 @@ -2489,6 +2540,9 @@ export class Task extends EventEmitter implements TaskLike { message, initialTodos, mode, + // DTE series 5/5: the child starts with the parent's current effective + // effort (source "parent") so its header shows it from the first request. + thinkingEffort: this.resolveNewTaskEffectiveEffort(), }) return child } diff --git a/src/core/task/__tests__/Task.new-task-effort.spec.ts b/src/core/task/__tests__/Task.new-task-effort.spec.ts new file mode 100644 index 0000000000..04d7a88948 --- /dev/null +++ b/src/core/task/__tests__/Task.new-task-effort.spec.ts @@ -0,0 +1,194 @@ +// npx vitest run src/core/task/__tests__/Task.new-task-effort.spec.ts +// +// DTE series 5/5 — new_task thinking effort plumbing on Task: +// resolveNewTaskEffectiveEffort (task-local override → settings reasoningEffort +// → model default, with the settings "disable" sentinel mapped to undefined), +// the single-consume takeNewTaskAskThinkingEffort, the ask-response capture in +// handleWebviewAskResponse, and the dispose() discard. + +import { ProviderSettings } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +// Mock dependencies (same lightweight set as Task.runtime-thinking-effort.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") + +// The model info object the mocked API handler reports; tests mutate it to steer +// the model-default branch of resolveNewTaskEffectiveEffort. +const { modelInfo } = vi.hoisted(() => ({ + modelInfo: {} as { reasoningEffort?: string }, +})) + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn(() => ({ + getModel: () => ({ info: modelInfo, id: "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, + }, + }), +})) + +describe("Task new_task thinking effort (DTE series 5/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + const makeTask = (apiConfiguration: ProviderSettings) => + new Task({ + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + provider: mockProvider as unknown as ClineProvider, + apiConfiguration, + startTask: false, + }) + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + modelInfo.reasoningEffort = undefined + + 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: "low", + } as ProviderSettings + + task = makeTask(mockApiConfiguration) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("resolveNewTaskEffectiveEffort", () => { + it("prefers the task-local runtime override", () => { + task.setRuntimeThinkingEffort("xhigh", "source") + + expect(task.resolveNewTaskEffectiveEffort()).toBe("xhigh") + }) + + it("falls back to the settings reasoningEffort without an override", () => { + expect(task.resolveNewTaskEffectiveEffort()).toBe("low") + }) + + it("falls back to the model default when settings carries no effort", () => { + modelInfo.reasoningEffort = "high" + const noSettingsTask = makeTask({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + } as ProviderSettings) + + expect(noSettingsTask.resolveNewTaskEffectiveEffort()).toBe("high") + noSettingsTask.dispose() + }) + + it("maps the settings 'disable' sentinel to undefined", () => { + const disableTask = makeTask({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: "disable", + } as ProviderSettings) + + expect(disableTask.resolveNewTaskEffectiveEffort()).toBeUndefined() + disableTask.dispose() + }) + }) + + describe("takeNewTaskAskThinkingEffort", () => { + it("is empty until the ask response carries a selection", () => { + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + + it("stores the selection from handleWebviewAskResponse and consumes it once", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "high") + + expect(task.takeNewTaskAskThinkingEffort()).toBe("high") + // Consumed: a second read (or a later, different ask) cannot reuse it. + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + + it("leaves a stored selection untouched when a later response carries none", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "medium") + // A non-new_task response never carries the field, so the stored value + // survives until the new_task approval consumes it. + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined) + + expect(task.takeNewTaskAskThinkingEffort()).toBe("medium") + }) + }) + + describe("dispose", () => { + it("discards the pending ask-block selection at task end", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "max") + task.dispose() + + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + }) +}) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e37..62c5417c76 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" -import { TodoItem } from "@roo-code/types" +import { TodoItem, type ReasoningEffortExtended } from "@roo-code/types" import { Task } from "../task/Task" import { getModeBySlug } from "../../shared/modes" @@ -15,13 +15,32 @@ interface NewTaskParams { mode: string message: string todos?: string + // DTE series 5/5: optional subtask start effort (validated against the target model). + thinking_effort?: string } +// DTE series 5/5: the effort levels a new task can start with. "disable" is a settings +// off-switch, not a start level, so it is excluded from this list. +const NEW_TASK_EFFORT_LEVELS: readonly ReasoningEffortExtended[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] + +// Narrows a raw tool argument to a reasoning-effort level (single documented cast: +// the literal list above is exactly the value set of ReasoningEffortExtended). +const isNewTaskEffortLevel = (value: string): value is ReasoningEffortExtended => + (NEW_TASK_EFFORT_LEVELS as readonly string[]).includes(value) + export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { - const { mode, message, todos } = params + const { mode, message, todos, thinking_effort } = params const { askApproval, handleError, pushToolResult } = callbacks try { @@ -42,6 +61,27 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the child task is created with the parent's API configuration, + // so the child model is the parent's current model. Validate the optional start + // effort against that model's capability array before asking for approval. + const modelCapabilities = task.api.getModel().info.supportsReasoningEffort + let validatedEffort: ReasoningEffortExtended | undefined + if (thinking_effort !== undefined && thinking_effort !== "") { + const supportedLevels = Array.isArray(modelCapabilities) ? modelCapabilities : [] + if (!isNewTaskEffortLevel(thinking_effort) || !supportedLevels.includes(thinking_effort)) { + const reason = !isNewTaskEffortLevel(thinking_effort) + ? `must be one of: ${NEW_TASK_EFFORT_LEVELS.join(", ")}` + : supportedLevels.length > 0 + ? `the target model only supports: ${ + supportedLevels.filter((level) => level !== "disable").join(", ") || "none" + }` + : "the target model does not support thinking_effort" + pushToolResult(formatResponse.toolError(`Invalid thinking_effort '${thinking_effort}'. ${reason}`)) + return + } + validatedEffort = thinking_effort + } + // Get the VSCode setting for requiring todos. const provider = task.providerRef.deref() @@ -96,11 +136,19 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the ask payload pre-fills the webview effort selector with + // the validated model effort (falling back to the parent's current effective + // effort) and lists the levels the target model supports ("disable" is a + // settings off-switch, not a level a child task can start with). const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, content: message, todos: todoItems, + thinkingEffort: validatedEffort ?? task.resolveNewTaskEffectiveEffort(), + supportedThinkingEfforts: Array.isArray(modelCapabilities) + ? modelCapabilities.filter((level): level is ReasoningEffortExtended => level !== "disable") + : undefined, }) const didApprove = await askApproval("tool", toolMessage) @@ -109,12 +157,24 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the user may have switched the effort in the ask block — + // the ask response carries it (consumed once from Task) and wins over the + // model-specified value, which wins over the parent's effective effort. An + // ask selection the target model does not support falls back the same way. + const askEffort = task.takeNewTaskAskThinkingEffort() + const askEffortSupported = + askEffort !== undefined && Array.isArray(modelCapabilities) && modelCapabilities.includes(askEffort) + const childThinkingEffort = askEffortSupported + ? askEffort + : (validatedEffort ?? task.resolveNewTaskEffectiveEffort()) + // Delegate parent and open child as sole active task const child = await (provider as any).delegateParentAndOpenChild({ parentTaskId: task.taskId, message: unescapedMessage, initialTodos: todoItems, mode, + thinkingEffort: childThinkingEffort, }) // Reflect delegation in tool result (no pause/unpause, no wait) diff --git a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts new file mode 100644 index 0000000000..8db2b0eb36 --- /dev/null +++ b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts @@ -0,0 +1,314 @@ +// npx vitest core/tools/__tests__/newTaskThinkingEffort.spec.ts +// +// DTE series 5/5 — orchestrator new_task thinking_effort: +// - the tool schema exposes the optional thinking_effort param +// - a model-specified effort is validated against the target model's +// capability array (the child starts with the parent's model) +// - the ask payload pre-fills the effort and lists the supported levels +// ("disable" is a settings off-switch, never a start level) +// - the ask-block selection (carried by the ask response) wins over the +// model-specified value, which wins over the parent's effective effort + +import type { AskApproval, HandleError, NativeToolArgs, PushToolResult, ToolUse } from "../../../shared/tools" + +// Mock the vscode module +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => false), + })), + }, +})) + +// Mock Package module +vi.mock("../../../shared/package", () => ({ + Package: { + name: "zoo-code", + publisher: "ZooCodeOrganization", + version: "1.0.0", + outputChannel: "Zoo-Code", + }, +})) + +vi.mock("../../../shared/modes", () => ({ + getModeBySlug: vi.fn(), + defaultModeSlug: "ask", +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Tool Error: ${msg}`), + }, +})) + +vi.mock("../updateTodoListTool", () => ({ + parseMarkdownChecklist: vi.fn().mockReturnValue([]), +})) + +import { newTaskTool } from "../NewTaskTool" +import { getModeBySlug } from "../../../shared/modes" +import newTaskSchema from "../../prompts/tools/native-tools/new_task" +import type { Task } from "../../task/Task" + +interface RunOptions { + /** Target model capability array (boolean/undefined = no known levels). */ + supportsReasoningEffort?: boolean | string[] + /** Effort the user chose in the ask block (carried by the ask response). */ + askEffort?: string + /** Parent's current effective effort (Task.resolveNewTaskEffectiveEffort). */ + parentEffort?: string +} + +/** + * Task double with the members new_task reads: the API handler (target model + * lookup), the PR-2/5/5 Task effort methods, and the provider delegation hook. + */ +function makeTask(options: RunOptions = {}) { + const delegateParentAndOpenChild = vi.fn().mockResolvedValue({ taskId: "child-1" }) + const resolveNewTaskEffectiveEffort = vi.fn().mockReturnValue(options.parentEffort) + const takeNewTaskAskThinkingEffort = vi.fn().mockReturnValue(options.askEffort) + // Structural double; the cast documents that handle() expects a real Task. + const task = { + taskId: "parent-1", + ask: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing param error"), + emit: vi.fn(), + recordToolError: vi.fn(), + consecutiveMistakeCount: 0, + isPaused: false, + pausedModeSlug: "ask", + enableCheckpoints: false, + checkpointSave: vi.fn(), + startSubtask: vi.fn(), + api: { + getModel: () => ({ + id: "test-model", + info: { + supportsReasoningEffort: options.supportsReasoningEffort, + reasoningEffort: undefined, + }, + }), + }, + resolveNewTaskEffectiveEffort, + takeNewTaskAskThinkingEffort, + providerRef: { + deref: vi.fn(() => ({ + getState: vi.fn().mockResolvedValue({ mode: "ask", customModes: [], experiments: {} }), + delegateParentAndOpenChild, + })), + }, + } as unknown as Task + + return { + task, + delegateParentAndOpenChild, + resolveNewTaskEffectiveEffort, + takeNewTaskAskThinkingEffort, + } +} + +const makeCallbacks = () => ({ + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), +}) + +const runNewTask = async ( + task: Task, + params: { mode?: string; message?: string; todos?: string; thinking_effort?: string }, + callbacks: ReturnType, +) => { + const args = { + mode: params.mode ?? "code", + message: params.message ?? "Do the delegated work", + todos: params.todos, + thinking_effort: params.thinking_effort, + } + // Native tool calling: nativeArgs is the source of truth for execution; the + // resolved defaults land on both surfaces so missing mode/message fall back + // identically instead of tripping the missing-param guard. + const block: ToolUse<"new_task"> = { + type: "tool_use", + name: "new_task", + params: { + mode: args.mode, + message: args.message, + todos: args.todos, + thinking_effort: args.thinking_effort, + }, + partial: false, + nativeArgs: { + mode: args.mode, + message: args.message, + todos: args.todos, + thinking_effort: args.thinking_effort, + } as unknown as NativeToolArgs["new_task"], + } + await newTaskTool.handle(task, block, callbacks) +} + +describe("new_task thinking_effort schema (DTE series 5/5)", () => { + it("exposes an optional thinking_effort string parameter", () => { + const parameters = newTaskSchema.function.parameters + + expect(parameters.properties.thinking_effort).toEqual({ + type: "string", + description: expect.stringContaining("thinking effort"), + }) + // Optional: omitting it makes the child start with the parent's current + // effective effort. additionalProperties stays closed. + expect(parameters.required).toEqual(["mode", "message", "todos"]) + expect(parameters.additionalProperties).toBe(false) + }) +}) + +describe("new_task thinking_effort validation (DTE series 5/5)", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getModeBySlug).mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "Test role definition", + groups: ["command", "read", "edit"], + }) + }) + + it("delegates with the model-specified effort when the target model supports it", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium", "high"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "medium" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do the delegated work", + initialTodos: [], + mode: "code", + thinkingEffort: "medium", + }) + }) + + it("rejects a value that is not a reasoning effort level", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "ultra" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Invalid thinking_effort 'ultra'"), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("must be one of")) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + expect(callbacks.askApproval).not.toHaveBeenCalled() + }) + + it("rejects a level the target model does not support", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "high" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("the target model only supports: low"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("rejects an effort when the target model exposes no capability array", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("pre-fills the ask payload with the effort and the supported levels, filtering 'disable'", async () => { + const { task } = makeTask({ + supportsReasoningEffort: ["disable", "low", "medium"], + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.askApproval).toHaveBeenCalledTimes(1) + const [askType, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + expect(askType).toBe("tool") + const payload = JSON.parse(toolMessage as string) as { + tool: string + thinkingEffort?: string + supportedThinkingEfforts?: string[] + } + expect(payload.tool).toBe("newTask") + expect(payload.thinkingEffort).toBe("low") + expect(payload.supportedThinkingEfforts).toEqual(["low", "medium"]) + }) + + it("falls back to the parent's effective effort when no effort is specified", async () => { + const { task, delegateParentAndOpenChild, resolveNewTaskEffectiveEffort } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + parentEffort: "medium", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(resolveNewTaskEffectiveEffort).toHaveBeenCalled() + expect(delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do the delegated work", + initialTodos: [], + mode: "code", + thinkingEffort: "medium", + }) + }) + + it("prefers the ask-block selection over the model-specified effort", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium", "high"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "high" })) + }) + + it("ignores an ask-block selection the target model does not support", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) + + it("falls back to the parent's effective effort when the ask selection is unsupported and no model effort was given", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + askEffort: "high", + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) +}) diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 9e61bc7fab..5789fa50ef 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -97,6 +97,11 @@ const mockCline = { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: mockStartSubtask, + // DTE series 5/5: new_task resolves the target model's capability from the + // task's API handler and consults the pending new_task ask effort on Task. + api: { getModel: () => ({ id: "test-model", info: {} }) }, + resolveNewTaskEffectiveEffort: vi.fn().mockReturnValue(undefined), + takeNewTaskAskThinkingEffort: vi.fn().mockReturnValue(undefined), providerRef: { deref: vi.fn(() => ({ getState: vi.fn(() => ({ customModes: [], mode: "ask" })), @@ -635,6 +640,10 @@ describe("newTaskTool delegation flow", () => { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: localStartSubtask, + // DTE series 5/5: target model lookup + ask-block effort plumbing. + api: { getModel: () => ({ id: "test-model", info: {} }) }, + resolveNewTaskEffectiveEffort: vi.fn().mockReturnValue(undefined), + takeNewTaskAskThinkingEffort: vi.fn().mockReturnValue(undefined), providerRef: { deref: vi.fn(() => providerSpy), }, @@ -659,11 +668,14 @@ describe("newTaskTool delegation flow", () => { }) // Assert: provider method called with correct params + // DTE series 5/5: thinkingEffort is always present; undefined here because the + // tool, the ask block, and the parent's effective resolution all yield none. expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({ parentTaskId: "mock-parent-task-id", message: "Do something", initialTodos: [], mode: "code", + thinkingEffort: undefined, }) // Assert: legacy path not used diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2d00f25107..05558142e0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -35,6 +35,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type ReasoningEffortExtended, type ExtensionMessage, type ExtensionState, type MarketplaceInstalledMetadata, @@ -3714,8 +3715,11 @@ export class ClineProvider message: string initialTodos: TodoItem[] mode: string + // DTE series 5/5: the subtask start effort (model-specified or the parent's + // current effective effort); applied to the child at init below. + thinkingEffort?: ReasoningEffortExtended }): Promise { - const { parentTaskId, message, initialTodos, mode } = params + const { parentTaskId, message, initialTodos, mode, thinkingEffort } = params // Metadata-driven delegation is always enabled @@ -3808,6 +3812,13 @@ export class ClineProvider startTask: false, }) + // DTE series 5/5: apply the subtask start effort as a task-local override before + // the child's first request so the child header shows it from the start. + // Source "parent" — set by the orchestrator, not the child's own settings. + if (thinkingEffort !== undefined) { + child.setRuntimeThinkingEffort(thinkingEffort, "parent") + } + // 5) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a // single lock acquisition — no concurrent writer can slip between the read and diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 815eb08683..e62781cc8d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -73,6 +73,7 @@ import type { ModelRecord } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" +import type { Task } from "../../task/Task" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio" import { getCommands } from "../../../services/command/commands" @@ -311,9 +312,41 @@ describe("webviewMessageHandler - image mentions", () => { }) expect(vi.mocked(resolveImageMentions)).toHaveBeenCalled() - expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "See @/img.png", [ - "data:image/png;base64,from-mention", - ]) + // DTE series 5/5: the handler always forwards the ask-block effort as the 4th + // argument (undefined for responses without a selection). + expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith( + "messageResponse", + "See @/img.png", + ["data:image/png;base64,from-mention"], + undefined, + ) + }) + + it("forwards the new_task ask-block thinking effort to the task (DTE series 5/5)", async () => { + const mockHandleWebviewAskResponse = vi.fn() + // Structural double: the askResponse case only dereferences the current task + // to forward the response (single documented double assertion, last resort). + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ + cwd: "/mock/workspace", + rooIgnoreController: undefined, + handleWebviewAskResponse: mockHandleWebviewAskResponse, + } as unknown as Task) + + await webviewMessageHandler(mockClineProvider, { + type: "askResponse", + askResponse: "yesButtonClicked", + text: "", + thinkingEffort: "high", + }) + + // The ask-block selection is forwarded as the 4th argument; every other ask + // type omits the field, so the task only stores it for new_task approvals. + expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith( + "yesButtonClicked", + "", + ["data:image/png;base64,from-mention"], + "high", + ) }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 8bf1c64777..4f98e3505b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -716,7 +716,14 @@ export const webviewMessageHandler = async ( const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) provider .getCurrentTask() - ?.handleWebviewAskResponse(message.askResponse!, resolved.text, resolved.images) + // DTE series 5/5: forward the new_task ask-block effort selection (undefined + // for all other ask responses). + ?.handleWebviewAskResponse( + message.askResponse!, + resolved.text, + resolved.images, + message.thinkingEffort, + ) } break diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..7225e5ef73 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -81,6 +81,7 @@ export const toolParamNames = [ // read_file legacy format parameter (backward compatibility) "files", "line_ranges", + "thinking_effort", // new_task parameter: optional subtask start effort (DTE series 5/5) ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -102,7 +103,7 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } - new_task: { mode: string; message: string; todos?: string } + new_task: { mode: string; message: string; todos?: string; thinking_effort?: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -240,7 +241,7 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" - params: Partial, "mode" | "message" | "todos">> + params: Partial, "mode" | "message" | "todos" | "thinking_effort">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 25aec24cfc..0aef1e369a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -20,7 +20,15 @@ import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" import { batchNearby } from "@src/utils/batchNearby" import { isBoundary, isIgnorableBetweenTargets } from "@src/utils/chatBatchingPredicates" -import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types" +import type { + ClineAsk, + ClineSayTool, + ClineMessage, + ExtensionMessage, + AudioType, + SuggestionItem, + ReasoningEffortExtended, +} from "@roo-code/types" import { getCompletionCheckpoint, getSuggestionMode, isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" @@ -182,6 +190,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [primaryButtonText, setPrimaryButtonText] = useState(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) + // DTE series 5/5: the effort chosen in the pending new_task ask block (pre-filled + // from the tool payload) and the levels the target model supports for it. + const [newTaskAskEffort, setNewTaskAskEffort] = useState(undefined) + const [newTaskAskSupportedEfforts, setNewTaskAskSupportedEfforts] = useState( + undefined, + ) const [_didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) @@ -305,6 +319,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked", text: trimmedInput, images: images, + thinkingEffort: newTaskAskEffort, }) // Clear input state after sending setInputValue("") setSelectedImages([]) } else { - vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: newTaskAskEffort, + }) } break case "resume_task": @@ -849,7 +885,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : ( <> + {/* DTE series 5/5: the new_task ask effort selector — pre-filled from the + tool payload, switchable before entering the subtask. Rich surfaces are PR-4. */} + {clineAsk === "tool" && + newTaskAskSupportedEfforts && + newTaskAskSupportedEfforts.length > 0 && ( + + )} {primaryButtonText && ( { ) }) }) + +describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => { + // Posts a fresh state snapshot whose last message is the given tool ask. + const postToolAsk = (toolPayload: Record) => + mockPostMessage({ + clineMessages: [ + { type: "say", say: "task", ts: 1, text: "Parent task" }, + { type: "ask", ask: "tool", ts: 2, text: JSON.stringify(toolPayload) }, + ], + }) + + const NEW_TASK_ASK: Record = { + tool: "newTask", + mode: "Code Mode", + content: "Do the delegated work", + todos: [], + thinkingEffort: "low", + supportedThinkingEfforts: ["low", "medium", "high"], + } + + beforeEach(() => { + vi.clearAllMocks() + mockTaskHeaderState.renders.length = 0 + }) + + it("renders the effort selector pre-filled from the newTask ask payload", async () => { + const { getByLabelText } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + // Pre-filled with the effort the extension resolved for the new task... + expect(select).toHaveValue("low") + // ...and offers exactly the levels the target model supports. + expect(Array.from(select.options).map((option) => option.value)).toEqual(["low", "medium", "high"]) + }) + + it("falls back to the first supported level when the pre-fill is not supported", async () => { + const { getByLabelText } = renderChatView() + + await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) + + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + expect(select).toHaveValue("low") + }) + + it("hides the selector for non-newTask tool asks (the ask effect resets the state)", async () => { + const { getByLabelText, queryByLabelText } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + await waitFor(() => { + expect(getByLabelText("Thinking effort")).toBeInTheDocument() + }) + + // A subsequent readFile ask must drop the selector: the effort state is + // cleared for every unanswered ask and only re-set for newTask asks. + mockPostMessage({ + clineMessages: [ + { type: "say", say: "task", ts: 1, text: "Parent task" }, + { type: "ask", ask: "tool", ts: 3, text: JSON.stringify({ tool: "readFile", path: "a.ts" }) }, + ], + }) + + await waitFor(() => { + expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + }) + }) + + it("hides the selector when the payload carries no supported efforts", async () => { + const { getByRole, queryByLabelText } = renderChatView() + + await postToolAsk({ tool: "newTask", mode: "Code Mode", content: "Do the work", todos: [] }) + + // Wait for the ask UI to settle (approve button rendered) before asserting absence. + await waitFor(() => { + expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() + }) + expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + }) + + it("posts the selected effort when the user approves the newTask ask", async () => { + const { getByLabelText, getByRole } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + + // The user switches the effort before entering the subtask... + await act(async () => { + fireEvent.change(select, { target: { value: "high" } }) + }) + + // ...and approves without typing feedback (bare yesButtonClicked branch). + await act(async () => { + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: "high", + }) + }) + + it("posts the selected effort along with feedback text on approval", async () => { + const { getByLabelText, getByRole, getByTestId } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + await act(async () => { + fireEvent.change(select, { target: { value: "medium" } }) + }) + + const input = getByTestId("chat-textarea").querySelector("input")! as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { value: "focus on tests" } }) + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + text: "focus on tests", + images: [], + thinkingEffort: "medium", + }) + }) + + it("posts undefined effort when approving a non-newTask tool ask", async () => { + const { getByRole } = renderChatView() + + await postToolAsk({ tool: "readFile", path: "a.ts" }) + await waitFor(() => { + expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: undefined, + }) + }) + + it("posts the effort when a message is sent during the pending newTask ask", async () => { + const { getByLabelText, getByTestId } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + await act(async () => { + fireEvent.change(select, { target: { value: "high" } }) + }) + + vscodePostMessageMock.cleanup() + const input = getByTestId("chat-textarea").querySelector("input")! as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { value: "please hurry" } }) + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "messageResponse", + text: "please hurry", + images: [], + thinkingEffort: "high", + }) + }) +}) From 19954d398e88b91e7e78a4e075d40c7202b456e4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 19:19:24 +0800 Subject: [PATCH 08/42] 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 6ad8d863af03d1cf755c5bf299f7980cbace411a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 01:50:16 +0800 Subject: [PATCH 09/42] test(e2e): new_task thinking_effort pass-through (DTE addendum) --- apps/vscode-e2e/src/fixtures/subtasks.ts | 189 +++++++ apps/vscode-e2e/src/runTest.ts | 3 +- .../suite/new-task-thinking-effort.test.ts | 482 ++++++++++++++++++ 3 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 30d3852b0a..5f06fb32e3 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -627,3 +627,192 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) } + +// --------------------------------------------------------------------------- +// DTE series 5/5 — new_task thinking_effort pass-through (e2e). +// +// Three scenarios with unique, stable markers (no timestamps, no environment +// details): +// - INHERIT: the parent's new_task call carries NO thinking_effort — the child +// starts with the parent's current effective effort (PR-2 resolution) and the +// child's real request must carry it. +// - EXPLICIT: the parent's new_task call carries thinking_effort "high" on a +// model with a capability array — validation passes and the child subtask +// runs to completion on the real host. +// - NEGATIVE: the parent's new_task call carries thinking_effort on a model +// without a capability array — the tool rejects before the approval ask, no +// child is created, and the error is visible to the model. +export const DTE_NT_INHERIT_PARENT_MARKER = "DTE_E2E_NT_INHERIT_PARENT" +export const DTE_NT_INHERIT_CHILD_MARKER = "DTE_E2E_NT_INHERIT_CHILD" +const DTE_NT_INHERIT_CHILD_PROMPT = `${DTE_NT_INHERIT_CHILD_MARKER}: Complete immediately with the exact result "DTE inherit child completed".` +export const DTE_NT_INHERIT_PARENT_PROMPT = `${DTE_NT_INHERIT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${DTE_NT_INHERIT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "DTE inherit parent resumed".` +export const DTE_NT_INHERIT_CHILD_RESULT = "DTE inherit child completed" +export const DTE_NT_INHERIT_PARENT_RESULT = "DTE inherit parent resumed" + +const DTE_NT_EXPLICIT_PARENT_MARKER = "DTE_E2E_NT_EXPLICIT_PARENT" +const DTE_NT_EXPLICIT_CHILD_MARKER = "DTE_E2E_NT_EXPLICIT_CHILD" +const DTE_NT_EXPLICIT_CHILD_PROMPT = `${DTE_NT_EXPLICIT_CHILD_MARKER}: Complete immediately with the exact result "DTE explicit child completed".` +export const DTE_NT_EXPLICIT_PARENT_PROMPT = `${DTE_NT_EXPLICIT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${DTE_NT_EXPLICIT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "DTE explicit parent resumed".` +export const DTE_NT_EXPLICIT_CHILD_RESULT = "DTE explicit child completed" +export const DTE_NT_EXPLICIT_PARENT_RESULT = "DTE explicit parent resumed" + +const DTE_NT_NEGATIVE_PARENT_MARKER = "DTE_E2E_NT_NEGATIVE_PARENT" +const DTE_NT_NEGATIVE_CHILD_MARKER = "DTE_E2E_NT_NEGATIVE_CHILD" +const DTE_NT_NEGATIVE_CHILD_PROMPT = `${DTE_NT_NEGATIVE_CHILD_MARKER}: Complete immediately with the exact result "DTE negative child completed".` +export const DTE_NT_NEGATIVE_PARENT_PROMPT = `${DTE_NT_NEGATIVE_PARENT_MARKER}: Use the new_task tool exactly once, with thinking_effort set to "high". Create an ask-mode subtask with this exact message: "${DTE_NT_NEGATIVE_CHILD_PROMPT}" Do not answer directly. If the tool call is rejected, complete with the exact result "DTE negative parent completed".` +export const DTE_NT_NEGATIVE_PARENT_RESULT = "DTE negative parent completed" + +export function addDteNewTaskEffortFixtures(mock: InstanceType) { + // INHERIT: parent turn -> new_task without an explicit effort. + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_INHERIT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_INHERIT_CHILD_PROMPT, + }), + id: "call_dte_nt_inherit_new_task_001", + }, + ], + }, + }) + + // Child turn: the child prompt is embedded verbatim in the parent prompt, so the + // parent-marker exclusion keeps parent turns out of this fixture (same collision + // class as the fast-child fixture above). + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, DTE_NT_INHERIT_CHILD_MARKER) && + !requestContains(req, [DTE_NT_INHERIT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_INHERIT_CHILD_RESULT }), + id: "call_dte_nt_inherit_child_completion_002", + }, + ], + }, + }) + + // Parent resume turn: guarded on the child-result injection (not the child result + // text, which the parent prompt embeds verbatim). + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_INHERIT_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_INHERIT_PARENT_RESULT }), + id: "call_dte_nt_inherit_parent_completion_003", + }, + ], + }, + }) + + // EXPLICIT: parent turn -> new_task with thinking_effort "high" (valid on models + // whose capability array accepts it, e.g. deepseek-v4-pro). + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_EXPLICIT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_EXPLICIT_CHILD_PROMPT, + thinking_effort: "high", + }), + id: "call_dte_nt_explicit_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, DTE_NT_EXPLICIT_CHILD_MARKER) && + !requestContains(req, [DTE_NT_EXPLICIT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_EXPLICIT_CHILD_RESULT }), + id: "call_dte_nt_explicit_child_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_EXPLICIT_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_EXPLICIT_PARENT_RESULT }), + id: "call_dte_nt_explicit_parent_completion_003", + }, + ], + }, + }) + + // NEGATIVE: parent turn -> new_task with thinking_effort "high" on a model without a + // capability array. The tool rejects before the approval ask, so the next parent + // turn is the error-recovery completion (matched on the tool-error text, which only + // appears in a request after the rejected call). + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_NEGATIVE_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_NEGATIVE_CHILD_PROMPT, + thinking_effort: "high", + }), + id: "call_dte_nt_negative_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_NEGATIVE_PARENT_MARKER, "Invalid thinking_effort"]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_NEGATIVE_PARENT_RESULT }), + id: "call_dte_nt_negative_parent_completion_002", + }, + ], + }, + }) +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index 8162f34068..7fd4d1e22c 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -18,7 +18,7 @@ import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile" import { addListFilesResultFixtures } from "./fixtures/list-files" import { addReadFileResultFixtures } from "./fixtures/read-file" import { addSearchFilesResultFixtures } from "./fixtures/search-files" -import { addSubtaskFixtures } from "./fixtures/subtasks" +import { addDteNewTaskEffortFixtures, addSubtaskFixtures } from "./fixtures/subtasks" import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool" import { addWriteToFileResultFixtures } from "./fixtures/write-to-file" import { createScenarioWorkspace, removeScenarioWorkspace } from "./restart/scenarioWorkspace" @@ -140,6 +140,7 @@ async function main() { addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) addSubtaskFixtures(mock) + addDteNewTaskEffortFixtures(mock) addUseMcpToolResultFixtures(mock) addWriteToFileResultFixtures(mock) addDeepSeekV4Fixtures(mock) diff --git a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts new file mode 100644 index 0000000000..d5c68d65e4 --- /dev/null +++ b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts @@ -0,0 +1,482 @@ +import * as assert from "assert" +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { + DTE_NT_EXPLICIT_CHILD_RESULT, + DTE_NT_EXPLICIT_PARENT_PROMPT, + DTE_NT_EXPLICIT_PARENT_RESULT, + DTE_NT_INHERIT_CHILD_MARKER, + DTE_NT_INHERIT_CHILD_RESULT, + DTE_NT_INHERIT_PARENT_MARKER, + DTE_NT_INHERIT_PARENT_PROMPT, + DTE_NT_INHERIT_PARENT_RESULT, + DTE_NT_NEGATIVE_PARENT_PROMPT, + DTE_NT_NEGATIVE_PARENT_RESULT, +} from "../fixtures/subtasks" +import { setDefaultSuiteTimeout } from "./test-utils" +import { sleep, waitFor, waitUntilCompleted } from "./utils" + +// Wire-boundary capture (modeled on anthropic-opus-4-7.test.ts): a local 127.0.0.1 +// proxy in front of the Anthropic base URL records every /v1/messages request body +// before forwarding it to the upstream (the aimock server in mock mode). Assertions +// below therefore run against the real request the extension host actually sent. +type CapturedEffortRequest = { + model?: string + thinkingType?: string + outputConfigEffort?: string + lastUserMessage: string +} + +const ANTHROPIC_MESSAGES_PATH = "/v1/messages" +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "host", + "content-length", +]) + +function isMessagesUrl(rawUrl: string): boolean { + try { + return new URL(rawUrl).pathname.endsWith(ANTHROPIC_MESSAGES_PATH) + } catch { + return false + } +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +function writeResponseHeaders(target: ServerResponse, source: Response) { + const headers: Record = {} + source.headers.forEach((value, key) => { + const lower = key.toLowerCase() + // fetch() automatically decompresses the body, so strip content-encoding to + // prevent the SDK from attempting a second decompression (zlib "incorrect + // header check"). Also strip content-length since the decoded body length + // differs from the compressed length. + if (lower !== "content-length" && lower !== "content-encoding") { + headers[key] = value + } + }) + target.writeHead(source.status, headers) +} + +async function pipeFetchResponse(target: ServerResponse, source: Response) { + writeResponseHeaders(target, source) + + if (!source.body) { + target.end() + return + } + + const reader = source.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + target.write(value) + } + + target.end() +} + +function resolveAllowedUpstreamUrl(baseUrl: string): URL { + const upstreamBase = new URL(baseUrl) + const isLocalProxy = upstreamBase.hostname === "127.0.0.1" || upstreamBase.hostname === "localhost" + + if (!isLocalProxy || (upstreamBase.protocol !== "http:" && baseUrl !== "https://api.anthropic.com")) { + throw new Error("Unexpected Anthropic proxy target: " + upstreamBase.origin) + } + + return new URL(ANTHROPIC_MESSAGES_PATH, upstreamBase) +} + +async function withEffortProxy( + baseUrl: string, + run: (args: { proxyUrl: string; requests: CapturedEffortRequest[] }) => Promise, +): Promise { + const requests: CapturedEffortRequest[] = [] + let proxyError: Error | undefined + const server = createServer(async (req, res) => { + try { + const requestUrl = req.url ?? "/" + + if (!isMessagesUrl("http://127.0.0.1" + requestUrl)) { + res.writeHead(404) + res.end("Not found") + return + } + + const bodyText = await readRequestBody(req) + const body = JSON.parse(bodyText) as { + model?: string + thinking?: { type?: string } + output_config?: { effort?: string } + messages?: Array<{ role?: string; content?: unknown }> + } + + const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user") + const lastUserMessage = + typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "") + + requests.push({ + model: body.model, + thinkingType: body.thinking?.type, + outputConfigEffort: body.output_config?.effort, + lastUserMessage, + }) + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && typeof value === "string") { + forwardHeaders[key] = value + } + } + + const upstreamUrl = resolveAllowedUpstreamUrl(baseUrl) + const upstream = await fetch(upstreamUrl, { + method: req.method, + headers: forwardHeaders, + body: bodyText, + }) + + await pipeFetchResponse(res, upstream) + } catch (error) { + proxyError = error instanceof Error ? error : new Error(String(error)) + console.error("Effort proxy request failed:", proxyError) + res.writeHead(500) + res.end("Effort proxy request failed") + } + }) + + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) + const address = server.address() + if (!address || typeof address === "string") { + server.close() + throw new Error("Failed to start effort proxy server") + } + + const proxyUrl = "http://127.0.0.1:" + address.port + + try { + const result = await run({ proxyUrl, requests }) + if (proxyError) { + throw proxyError + } + return result + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} + +// Restore the OpenRouter default config after this suite so other suites are unaffected. +const restoreOpenRouterConfig = async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: aimockUrl + "/v1" }), + }) +} + +suite("new_task thinking effort (DTE series 5/5)", function () { + setDefaultSuiteTimeout(this) + + suiteTeardown(restoreOpenRouterConfig) + + // (b) Inheritance: a new_task call without thinking_effort starts the child with the + // parent's current effective effort (PR-2 resolution: no task-local override is + // reachable in e2e before DTE series 3/5, so the settings value "medium" is the + // strongest source). The child's real /v1/messages request must carry that effort. + test("child started without explicit effort carries the parent's effective effort", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) { + this.skip() + } + + await withEffortProxy(aimockUrl || "https://api.anthropic.com", async ({ proxyUrl, requests }) => { + await api.setConfiguration({ + apiProvider: "anthropic" as const, + apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!, + apiModelId: "claude-opus-4-7", + enableReasoningEffort: true, + reasoningEffort: "medium", + anthropicBaseUrl: proxyUrl, + }) + + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_INHERIT_PARENT_PROMPT, + }) + + // Wait for the child's real request to reach the proxy: an immediate child is + // only observable while its first request is in flight (the parent instance is + // disposed on delegation and re-instantiated on resume, so the UI task stack is + // not a reliable child-liveness signal here). + await waitFor( + () => requests.some((request) => request.lastUserMessage.includes(DTE_NT_INHERIT_CHILD_MARKER)), + { timeout: 45_000 }, + ) + + // The parent's completion is the terminal event of the whole flow. + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 60_000 }) + + assert.ok( + Object.entries(says).some( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === DTE_NT_INHERIT_CHILD_RESULT, + ), + ), + "Immediately-completing child should emit its expected result", + ) + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_INHERIT_PARENT_RESULT, + "Parent should resume after the child completes", + ) + + // Wire assertion: the child's real request (identified by the child prompt + // marker in its last user message) carries the parent's effective effort. + const childRequests = requests.filter((request) => + request.lastUserMessage.includes(DTE_NT_INHERIT_CHILD_MARKER), + ) + assert.ok(childRequests.length > 0, "The child subtask should issue a real API request") + const firstChildRequest = childRequests[0] + assert.ok(firstChildRequest, "Child request should be captured by the proxy") + assert.strictEqual(firstChildRequest.model, "claude-opus-4-7") + assert.strictEqual( + firstChildRequest.thinkingType, + "adaptive", + "The child request should be an adaptive-thinking request", + ) + assert.strictEqual( + firstChildRequest.outputConfigEffort, + "medium", + "The child's request should carry the parent's current effective effort (DTE series 5/5 inheritance via PR-2 resolution)", + ) + + // Control: the parent's own first request carries the same settings-derived + // baseline, confirming the envelope is resolved identically on both sides. + const parentRequests = requests.filter((request) => + request.lastUserMessage.includes(DTE_NT_INHERIT_PARENT_MARKER), + ) + assert.ok(parentRequests.length > 0, "The parent should issue a real API request") + const firstParentRequest = parentRequests[0] + assert.ok(firstParentRequest, "Parent request should be captured by the proxy") + assert.strictEqual(firstParentRequest.outputConfigEffort, "medium") + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) + }) + + // (a) Explicit effort: a new_task call with thinking_effort "high" on a model whose + // capability array accepts it (deepseek-v4-pro: ["disable","low","high","max"]). The + // parameter round-trips schema -> validation -> approval -> delegation and the child + // subtask runs to completion on the real host. + // + // No wire assertion here: the DeepSeek handler resolves the request effort from + // settings only and does not consume the per-request override — and the only handler + // that does consume it (Anthropic) serves catalog models without a capability array, + // so no model today both passes the DTE 5/5 validation and propagates an explicit + // effort to the wire. Documented in the PR body. + test("explicit thinking_effort delegates a child subtask that completes", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.DEEPSEEK_API_KEY) { + this.skip() + } + + await api.setConfiguration({ + apiProvider: "deepseek" as const, + deepSeekApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.DEEPSEEK_API_KEY!, + ...(aimockUrl && { deepSeekBaseUrl: aimockUrl + "/v1" }), + apiModelId: "deepseek-v4-pro", + // Reasoning off for this probe: the test is about the subtask flow carrying + // the explicit effort parameter, not about the reasoning envelope. + enableReasoningEffort: false, + }) + + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_EXPLICIT_PARENT_PROMPT, + }) + + // The parent's completion is the terminal event of the whole flow (the child + // completes on its first response, so its own lifecycle is covered by the + // completion_result assertions below — same pattern as the fast-child test). + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 75_000 }) + + assert.ok( + Object.entries(says).some( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === DTE_NT_EXPLICIT_CHILD_RESULT, + ), + ), + "Explicit-effort child should emit its expected result", + ) + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_EXPLICIT_PARENT_RESULT, + "Parent should resume after the explicit-effort child completes", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) + + // (a) Negative guard: an explicit effort on a model without a capability array is + // rejected by the tool before the approval ask — no child is created and the model + // sees the tool error. claude-opus-4-7 has supportsReasoningBinary (adaptive + // thinking) but no effort capability array, so "high" must be refused. + test("explicit thinking_effort on a capability-less model is rejected without creating a child", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) { + this.skip() + } + + // aimock serves the Anthropic /v1/messages endpoint directly, so the negative + // flow does not need the capturing proxy — just point the base URL at the mock. + await api.setConfiguration({ + apiProvider: "anthropic" as const, + apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!, + apiModelId: "claude-opus-4-7", + ...(aimockUrl && { anthropicBaseUrl: aimockUrl }), + }) + + const says: Record = {} + const seenTaskIds = new Set() + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + seenTaskIds.add(taskId) + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_NEGATIVE_PARENT_PROMPT, + }) + + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 60_000 }) + + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_NEGATIVE_PARENT_RESULT, + "Parent should complete after the rejected tool call", + ) + assert.strictEqual( + seenTaskIds.size, + 1, + "No child subtask should be created for a rejected thinking_effort (task ids: " + + [...seenTaskIds].join(", ") + + ")", + ) + assert.ok( + Object.values(says) + .flat() + .some(({ text }) => (text ?? "").includes("Invalid thinking_effort")), + "The tool error should be visible to the model", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) +}) From 4eb13a99903e133f88dfc53d4f5f796c76063191 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 04:04:01 +0800 Subject: [PATCH 10/42] fix(task): new_task thinking_effort review fixes (boolean capability, post-mode-switch revalidation, ask prefill normalization) --- src/__tests__/provider-delegation.spec.ts | 95 +++++++++++++++++++ src/core/tools/NewTaskTool.ts | 27 ++++-- .../__tests__/newTaskThinkingEffort.spec.ts | 34 ++++++- src/core/webview/ClineProvider.ts | 26 ++++- webview-ui/src/components/chat/ChatView.tsx | 24 +++-- .../chat/__tests__/ChatView.spec.tsx | 23 +++++ 6 files changed, 209 insertions(+), 20 deletions(-) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 3e86a234e2..a4181e5c69 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -396,6 +396,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { start: vi.fn(), run: childRun, setRuntimeThinkingEffort, + // The child's resolved model (post mode switch) supports the requested level. + api: { + getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "medium", "high"] } }), + }, }) const taskHistoryStore = makeStoreStub() @@ -463,4 +467,95 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() }) + + it("falls back with an observable say when the child model (post mode switch) does not support the effort (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const say = vi.fn().mockResolvedValue(undefined) + const childRun = vi.fn().mockResolvedValue(undefined) + // The mode switch resolved a DIFFERENT model than the parent's: it only + // supports low/high, so the parent-validated "xhigh" must not be applied. + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + say, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "high"] } }) }, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + // No task-local override: the child runs with the settings-derived effort. + expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() + // Observable on the child task: the fallback is announced, not silent. + expect(say).toHaveBeenCalledTimes(1) + const [sayType, sayText] = say.mock.calls[0] + expect(sayType).toBe("error") + expect(sayText).toContain("xhigh") + expect(sayText).toContain("child-model") + // Delegation itself still proceeds: the child runs. + expect(childRun).toHaveBeenCalledTimes(1) + }) + + it("applies the effort when the child model (post mode switch) has a boolean-true capability (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + // Boolean-true capability: the child model supports every level. + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: true } }) }, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + expect(setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(setRuntimeThinkingEffort).toHaveBeenCalledWith("xhigh", "parent") + }) }) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index 62c5417c76..7b8e7bd074 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -63,11 +63,24 @@ export class NewTaskTool extends BaseTool<"new_task"> { // DTE series 5/5: the child task is created with the parent's API configuration, // so the child model is the parent's current model. Validate the optional start - // effort against that model's capability array before asking for approval. + // effort against that model's capability before asking for approval. + // + // ModelInfo.supportsReasoningEffort is `boolean | string[] | undefined`: the bare + // `true` means the model supports reasoning effort without an explicit allow-list, + // so normalize it to the full level set. `false`/`undefined` stay unsupported + // (argument rejected below). The normalized array is the single source of truth + // for the argument validation, the ask payload, and the ask-selection check. const modelCapabilities = task.api.getModel().info.supportsReasoningEffort + // "disable" stays in the element type: capability arrays may carry it (it is a + // settings off-switch, not a start level) and is filtered where levels are listed. + const supportedLevels: readonly (ReasoningEffortExtended | "disable")[] = + modelCapabilities === true + ? NEW_TASK_EFFORT_LEVELS + : Array.isArray(modelCapabilities) + ? modelCapabilities + : [] let validatedEffort: ReasoningEffortExtended | undefined if (thinking_effort !== undefined && thinking_effort !== "") { - const supportedLevels = Array.isArray(modelCapabilities) ? modelCapabilities : [] if (!isNewTaskEffortLevel(thinking_effort) || !supportedLevels.includes(thinking_effort)) { const reason = !isNewTaskEffortLevel(thinking_effort) ? `must be one of: ${NEW_TASK_EFFORT_LEVELS.join(", ")}` @@ -146,9 +159,10 @@ export class NewTaskTool extends BaseTool<"new_task"> { content: message, todos: todoItems, thinkingEffort: validatedEffort ?? task.resolveNewTaskEffectiveEffort(), - supportedThinkingEfforts: Array.isArray(modelCapabilities) - ? modelCapabilities.filter((level): level is ReasoningEffortExtended => level !== "disable") - : undefined, + supportedThinkingEfforts: + supportedLevels.length > 0 + ? supportedLevels.filter((level): level is ReasoningEffortExtended => level !== "disable") + : undefined, }) const didApprove = await askApproval("tool", toolMessage) @@ -162,8 +176,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { // model-specified value, which wins over the parent's effective effort. An // ask selection the target model does not support falls back the same way. const askEffort = task.takeNewTaskAskThinkingEffort() - const askEffortSupported = - askEffort !== undefined && Array.isArray(modelCapabilities) && modelCapabilities.includes(askEffort) + const askEffortSupported = askEffort !== undefined && supportedLevels.includes(askEffort) const childThinkingEffort = askEffortSupported ? askEffort : (validatedEffort ?? task.resolveNewTaskEffectiveEffort()) diff --git a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts index 8db2b0eb36..4a3e1dc741 100644 --- a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts +++ b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts @@ -51,7 +51,7 @@ import newTaskSchema from "../../prompts/tools/native-tools/new_task" import type { Task } from "../../task/Task" interface RunOptions { - /** Target model capability array (boolean/undefined = no known levels). */ + /** Target model capability: array = allow-list; true = full level set; false/undefined = unsupported. */ supportsReasoningEffort?: boolean | string[] /** Effort the user chose in the ask block (carried by the ask response). */ askEffort?: string @@ -311,4 +311,36 @@ describe("new_task thinking_effort validation (DTE series 5/5)", () => { expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) }) + + it("accepts a valid level when the capability is boolean true (full level set)", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: true, + }) + const callbacks = makeCallbacks() + + // xhigh is a valid level but is not in any provider allow-list today: only the + // boolean-true normalization (full level set) accepts it. + await runNewTask(task, { thinking_effort: "xhigh" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "xhigh" })) + + // The ask payload lists the full level set for a boolean-true capability. + const [, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + const payload = JSON.parse(toolMessage as string) as { supportedThinkingEfforts?: string[] } + expect(payload.supportedThinkingEfforts).toEqual(["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + }) + + it("rejects an effort when the capability is boolean false", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: false, + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3fc4aa4de5..16ee7b3ece 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3867,11 +3867,29 @@ export class ClineProvider startTask: false, }) - // DTE series 5/5: apply the subtask start effort as a task-local override before - // the child's first request so the child header shows it from the start. - // Source "parent" — set by the orchestrator, not the child's own settings. + // DTE series 5/5: the mode switch above can change the provider profile and + // therefore the model the child actually runs on (mode-specific provider + // profiles), so a level validated against the parent model can be invalid for + // the child's. Re-validate against the child's resolved model immediately before + // applying; when the child model does not support the level, fall back to no + // task-local override (the settings-derived effort applies) with an observable + // say on the child instead of failing the whole delegation. if (thinkingEffort !== undefined) { - child.setRuntimeThinkingEffort(thinkingEffort, "parent") + const childModel = child.api.getModel() + const childCapability = childModel.info.supportsReasoningEffort + const childSupportsEffort = + childCapability === true || (Array.isArray(childCapability) && childCapability.includes(thinkingEffort)) + if (childSupportsEffort) { + // Applied as a task-local override before the child's first request so the + // child header shows it from the start. Source "parent" — set by the + // orchestrator, not the child's own settings. + child.setRuntimeThinkingEffort(thinkingEffort, "parent") + } else { + await child.say( + "error", + `new_task thinking_effort '${thinkingEffort}' is not supported by the child model (${childModel.id}); the child starts without the effort override.`, + ) + } } // 5) Persist parent delegation metadata BEFORE the child starts writing. diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index e45cba5af4..3ec287d973 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -362,9 +362,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 + ? tool.thinkingEffort && supported.includes(tool.thinkingEffort) + ? tool.thinkingEffort + : supported[0] + : tool.thinkingEffort, + ) } switch (tool.tool) { case "editedExistingFile": @@ -1802,12 +1813,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 && ( {newTaskAskSupportedEfforts.map((effort) => ( ))} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 1b062bd1e5..8198ce62bd 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -1561,11 +1561,20 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) // Pre-filled with the effort the extension resolved for the new task... expect(select).toHaveValue("low") // ...and offers exactly the levels the target model supports. expect(Array.from(select.options).map((option) => option.value)).toEqual(["low", "medium", "high"]) + // Option labels are bound to the translated level keys (settings:providers.reasoningEffort.*); + // in this test the effective t() is the identity function, so the raw keys render verbatim. + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + "settings:providers.reasoningEffort.low", + "settings:providers.reasoningEffort.medium", + "settings:providers.reasoningEffort.high", + ]) }) it("falls back to the first supported level when the pre-fill is not supported", async () => { @@ -1573,7 +1582,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) expect(select).toHaveValue("low") }) @@ -1586,7 +1597,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => // not the raw payload value the user never saw. await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) expect(select).toHaveValue("low") await act(async () => { @@ -1605,7 +1618,7 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await postToolAsk(NEW_TASK_ASK) await waitFor(() => { - expect(getByLabelText("Thinking effort")).toBeInTheDocument() + expect(getByLabelText("settings:providers.reasoningEffort.label")).toBeInTheDocument() }) // A subsequent readFile ask must drop the selector: the effort state is @@ -1618,7 +1631,7 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => }) await waitFor(() => { - expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + expect(queryByLabelText("settings:providers.reasoningEffort.label")).not.toBeInTheDocument() }) }) @@ -1631,14 +1644,16 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await waitFor(() => { expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() }) - expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + expect(queryByLabelText("settings:providers.reasoningEffort.label")).not.toBeInTheDocument() }) it("posts the selected effort when the user approves the newTask ask", async () => { const { getByLabelText, getByRole } = renderChatView() await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) // The user switches the effort before entering the subtask... await act(async () => { @@ -1661,7 +1676,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => const { getByLabelText, getByRole, getByTestId } = renderChatView() await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) await act(async () => { fireEvent.change(select, { target: { value: "medium" } }) }) @@ -1704,7 +1721,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => const { getByLabelText, getByTestId } = renderChatView() await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) await act(async () => { fireEvent.change(select, { target: { value: "high" } }) }) From 4f88bce6b7b3e9e721a26c876883ae9d44f34e70 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 07:55:45 +0800 Subject: [PATCH 19/42] test(e2e): allow the live Anthropic upstream in the effort proxy guard --- apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts index ebf8322133..fdc3573318 100644 --- a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts +++ b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts @@ -104,8 +104,10 @@ async function pipeFetchResponse(target: ServerResponse, source: Response) { function resolveAllowedUpstreamUrl(baseUrl: string): URL { const upstreamBase = new URL(baseUrl) const isLocalProxy = upstreamBase.hostname === "127.0.0.1" || upstreamBase.hostname === "localhost" + const isLocalHttp = isLocalProxy && upstreamBase.protocol === "http:" + const isAnthropicUpstream = upstreamBase.origin === "https://api.anthropic.com" - if (!isLocalProxy || (upstreamBase.protocol !== "http:" && baseUrl !== "https://api.anthropic.com")) { + if (!isLocalHttp && !isAnthropicUpstream) { throw new Error("Unexpected Anthropic proxy target: " + upstreamBase.origin) } From 96cf25694e8ff91b91e500f52d7b2305a0fdb9a8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 08:33:49 +0800 Subject: [PATCH 20/42] test(webview): assert the localized accessible name on the thinking effort toggle --- .../chat/__tests__/ThinkingEffortToggle.spec.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx index 4bf7c5246a..5cf0c95d8e 100644 --- a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx @@ -115,6 +115,15 @@ describe("ThinkingEffortToggle (DTE series 4/5)", () => { expect(container.textContent).toBe("") }) + it("exposes the localized accessible name on the icon-only trigger", () => { + renderToggle() + // The mocked i18n returns keys, so the exact localized label is the raw key. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) + }) + it("renders nothing when the model does not advertise effort support", () => { mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } const { container } = renderToggle() From ac84f5e9106e5cc7939dd85f488c61de6ca7ba35 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 09:22:20 +0800 Subject: [PATCH 21/42] test(webview): model the no-override thinking effort shape after the real Task contract --- .../webview/__tests__/ClineProvider.spec.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 7123d89bc5..aa0b61f8e0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -937,26 +937,31 @@ describe("ClineProvider", () => { }) test("getStateToPostToWebview surfaces the task-local thinking effort override (DTE 4/5)", async () => { - const task = (effort: { effort: string; source?: string } | undefined) => ({ - taskId: "effort-task", - clineMessages: [], - todoList: [], - getRuntimeThinkingEffort: () => effort, - }) + // Models the real Task contract: getRuntimeThinkingEffort always returns an + // object; the no-override state is the empty object (effort undefined). + // The double is partial on purpose — the spy only needs the method under test; + // Task has many constructor-dependent required members, hence the cast. + const task = (runtime: { effort?: string; source?: string }) => + ({ + taskId: "effort-task", + clineMessages: [], + todoList: [], + getRuntimeThinkingEffort: () => runtime, + }) as unknown as Task vi.spyOn(provider.taskHistoryStore, "getAll").mockReturnValue([]) const getCurrentTaskSpy = vi.spyOn(provider, "getCurrentTask") - getCurrentTaskSpy.mockReturnValue(task({ effort: "high", source: "you" }) as never) + getCurrentTaskSpy.mockReturnValue(task({ effort: "high", source: "you" })) let state = await provider.getStateToPostToWebview() expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) // A source-less runtime override is reported as the default source. - getCurrentTaskSpy.mockReturnValue(task({ effort: "medium" }) as never) + getCurrentTaskSpy.mockReturnValue(task({ effort: "medium" })) state = await provider.getStateToPostToWebview() expect(state.taskThinkingEffort).toEqual({ effort: "medium", source: "default" }) - // Without an active override the field is omitted. - getCurrentTaskSpy.mockReturnValue(task(undefined) as never) + // Without an active override (the real empty-object shape) the field is omitted. + getCurrentTaskSpy.mockReturnValue(task({})) state = await provider.getStateToPostToWebview() expect(state.taskThinkingEffort).toBeUndefined() From e83af72c13841d2e8351a913031e75a8dd7cc135 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 10:27:03 +0800 Subject: [PATCH 22/42] test(e2e): set_thinking_effort mid-task workflow (DTE addendum) --- .../fixtures/thinking-effort-tool.json | 33 ++ .../src/suite/thinking-effort-tool.test.ts | 335 ++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 apps/vscode-e2e/fixtures/thinking-effort-tool.json create mode 100644 apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts diff --git a/apps/vscode-e2e/fixtures/thinking-effort-tool.json b/apps/vscode-e2e/fixtures/thinking-effort-tool.json new file mode 100644 index 0000000000..8269c8f9ab --- /dev/null +++ b/apps/vscode-e2e/fixtures/thinking-effort-tool.json @@ -0,0 +1,33 @@ +{ + "fixtures": [ + { + "match": { + "sequenceIndex": 0, + "userMessage": "DTE_E2E_EFFORT_APPLY: answer the math question" + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"high\", \"reason\": \"multi-step math\"}", + "id": "call_dte_e2e_001" + } + ] + } + }, + { + "match": { + "toolCallId": "call_dte_e2e_001" + }, + "response": { + "toolCalls": [ + { + "name": "attempt_completion", + "arguments": "{\"result\": \"42\"}", + "id": "call_dte_e2e_002" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts new file mode 100644 index 0000000000..cb5dedd620 --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts @@ -0,0 +1,335 @@ +import * as assert from "assert" +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { setDefaultSuiteTimeout } from "./test-utils" +import { waitUntilCompleted } from "./utils" + +/** + * DTE addendum: set_thinking_effort mid-task workflow. + * + * Exercises the real extension-host boundary end to end with aimock fixtures: + * the model calls set_thinking_effort mid-task (no approval gate), the + * SetThinkingEffortTool display say is emitted, and the FOLLOWING API request + * carries the applied effort in the OpenRouter reasoning envelope. + * + * Determinism notes: + * - Runs against aimock only (replay or record); skips when aimock is absent, + * so no API keys are required. + * - The capture proxy is the local 127.0.0.1 pattern from + * anthropic-opus-4-7.test.ts: it intercepts the OpenRouter-compatible + * chat/completions POST (so request shapes can be asserted) and forwards it + * to aimock for the fixture-driven SSE responses. + * - The OpenRouter model catalog is resolved by the shared model-cache layer + * (fetchers/modelCache.ts) from the public OpenRouter endpoint, exactly like + * the other provider suites. openai/gpt-5 advertises "reasoning" in + * supported_parameters, so the fetcher resolves supportsReasoningEffort and + * the dynamicThinkingEffort gate exposes the tool. + * - The mid-task tool call is dispatched by name in presentAssistantMessage + * (a hard-coded case, not the request-declared tool list), and the tool + * executor re-checks the model capability after the first request has loaded + * the catalog, so the flow is correct even if the first request's tool list + * was built before the catalog fetch resolved. + */ + +const DTE_MODEL_ID = "openai/gpt-5" +const APPLY_MARKER = "DTE_E2E_EFFORT_APPLY" +const SET_EFFORT_TOOL_CALL_ID = "call_dte_e2e_001" +const COMPLETION_EXPECTED = "42" + +type DteReasoningEnvelope = { + effort?: string + max_tokens?: number + exclude?: boolean +} + +type CapturedDteRequest = { + model?: string + reasoning: DteReasoningEnvelope | undefined + carriesSetEffortToolResult: boolean + lastUserMessage: string +} + +type OpenRouterChatCompletionBody = { + model?: string + reasoning?: DteReasoningEnvelope + messages?: Array<{ role?: string; content?: unknown }> +} + +const ALLOWED_PROXY_HOSTS = new Set(["127.0.0.1", "localhost"]) +const CHAT_COMPLETIONS_PATH = "/v1/chat/completions" +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "host", + "content-length", +]) + +function isChatCompletionsUrl(rawUrl: string): boolean { + try { + return new URL(rawUrl).pathname.endsWith(CHAT_COMPLETIONS_PATH) + } catch { + return false + } +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +function writeResponseHeaders(target: ServerResponse, source: Response) { + const headers: Record = {} + source.headers.forEach((value, key) => { + const lower = key.toLowerCase() + // fetch() automatically decompresses the body, so strip content-encoding to + // prevent the SDK from attempting a second decompression. Also strip + // content-length since the decoded body length differs from the compressed one. + if (lower !== "content-length" && lower !== "content-encoding") { + headers[key] = value + } + }) + target.writeHead(source.status, headers) +} + +async function pipeFetchResponse(target: ServerResponse, source: Response) { + writeResponseHeaders(target, source) + + if (!source.body) { + target.end() + return + } + + const reader = source.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + target.write(value) + } + + target.end() +} + +function resolveAllowedUpstreamUrl(baseUrl: string): URL { + const upstreamBase = new URL(baseUrl) + + if (!ALLOWED_PROXY_HOSTS.has(upstreamBase.hostname) || upstreamBase.protocol !== "http:") { + throw new Error("Unexpected OpenRouter proxy target: " + upstreamBase.origin) + } + + return new URL(CHAT_COMPLETIONS_PATH, upstreamBase) +} + +/** + * Serves a loopback capture proxy for the OpenRouter-compatible + * chat/completions endpoint: captures each request body for assertions and + * forwards it unchanged to the upstream (aimock in replay/record mode). + */ +async function withOpenRouterCaptureProxy( + upstreamUrl: string, + run: (args: { proxyUrl: string; requests: CapturedDteRequest[] }) => Promise, +): Promise { + const requests: CapturedDteRequest[] = [] + const upstreamTarget = resolveAllowedUpstreamUrl(upstreamUrl) + let proxyError: Error | undefined + + const server = createServer(async (req, res) => { + try { + const requestUrl = req.url ?? "/" + + if (!isChatCompletionsUrl("http://127.0.0.1" + requestUrl)) { + res.writeHead(404) + res.end("Not found") + return + } + + const bodyText = await readRequestBody(req) + const body = JSON.parse(bodyText) as OpenRouterChatCompletionBody + const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user") + const lastUserMessage = + typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "") + + requests.push({ + model: body.model, + reasoning: body.reasoning, + carriesSetEffortToolResult: JSON.stringify(body.messages ?? []).includes(SET_EFFORT_TOOL_CALL_ID), + lastUserMessage, + }) + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && value !== undefined) { + forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value + } + } + + const upstream = await fetch(upstreamTarget, { + method: req.method, + headers: forwardHeaders, + body: bodyText, + }) + + await pipeFetchResponse(res, upstream) + } catch (error) { + proxyError = error instanceof Error ? error : new Error(String(error)) + console.error("OpenRouter proxy request failed:", proxyError) + if (!res.headersSent) { + res.writeHead(502) + res.end("Capture proxy error") + } else if (!res.writableEnded) { + res.destroy() + } + } + }) + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()) + }) + + const address = server.address() + if (address === null || typeof address === "string") { + server.close() + throw new Error("Capture proxy failed to bind a loopback port") + } + + const proxyUrl = "http://127.0.0.1:" + address.port + + try { + const result = await run({ proxyUrl, requests }) + if (proxyError) { + throw proxyError + } + return result + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} + +suite("set_thinking_effort mid-task workflow (DTE addendum)", function () { + setDefaultSuiteTimeout(this) + + // Restore the default OpenRouter configuration (and switch the experiment off) + // so subsequent suites are unaffected. + suiteTeardown(async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + experiments: { dynamicThinkingEffort: false }, + }) + }) + + test("Should apply set_thinking_effort mid-task, emit the display say, and send the applied effort on the next request", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + + // Deterministic, key-free: aimock replay/record only. A live run would need + // a real model that deterministically emits the tool call. + if (!aimockUrl) { + this.skip() + } + + await withOpenRouterCaptureProxy(aimockUrl, async ({ proxyUrl, requests }) => { + // OpenRouter provider, a model that advertises per-request reasoning + // effort, and the dynamicThinkingEffort experiment enabled. + await api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: "mock-key", + openRouterModelId: DTE_MODEL_ID, + openRouterBaseUrl: `${proxyUrl}/v1`, + enableReasoningEffort: true, + experiments: { dynamicThinkingEffort: true }, + }) + + const messages: ClineMessage[] = [] + const onMessage = ({ message }: { message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + messages.push(message) + } + } + api.on(RooCodeEventName.Message, onMessage) + + const taskId = await api.startNewTask({ + configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, + text: APPLY_MARKER + ": answer the math question", + }) + + await waitUntilCompleted({ api, taskId }) + api.off(RooCodeEventName.Message, onMessage) + + // (a) Real boundary: the task completes with the math answer after the + // mid-task tool round trip. + const completion = messages.find( + ({ say, text }) => + (say === "completion_result" || say === "text") && text?.trim() === COMPLETION_EXPECTED, + ) + assert.ok(completion, "Task should complete with '" + COMPLETION_EXPECTED + "' after set_thinking_effort") + + // (b) Real boundary: the SetThinkingEffortTool display say carries the + // applied effort (not a refusal). + const effortSays = messages.filter( + ({ say, text }) => say === "tool" && typeof text === "string" && text.includes("thinkingEffort"), + ) + const appliedSay = effortSays.find(({ text }) => text?.includes('"high"')) + assert.ok(appliedSay, "SetThinkingEffortTool should emit a 'tool' say carrying the applied effort") + const effortPayload = JSON.parse(appliedSay.text ?? "") as { + tool?: string + effort?: string + reason?: string + refusal?: string + } + assert.strictEqual( + effortPayload.tool, + "thinkingEffort", + "display say should identify the thinkingEffort event", + ) + assert.strictEqual(effortPayload.effort, "high", "display say should carry the applied 'high' effort") + assert.strictEqual(effortPayload.reason, "multi-step math", "display say should carry the model's reason") + assert.strictEqual( + effortPayload.refusal, + undefined, + "the effort change should have been applied, not refused", + ) + + // (c) Real boundary: the request AFTER the tool round trip carries the + // applied effort in the OpenRouter reasoning envelope. + const preToolRequest = requests.find( + (request) => !request.carriesSetEffortToolResult && request.lastUserMessage.includes(APPLY_MARKER), + ) + assert.ok(preToolRequest, "Should have captured the pre-tool request containing the task prompt") + assert.strictEqual(preToolRequest.model, DTE_MODEL_ID) + assert.notStrictEqual( + preToolRequest.reasoning?.effort, + "high", + "the baseline request should not already carry the 'high' effort", + ) + + const postToolRequest = requests.find((request) => request.carriesSetEffortToolResult) + assert.ok(postToolRequest, "The follow-up request should carry the set_thinking_effort tool result") + assert.strictEqual(postToolRequest.model, DTE_MODEL_ID) + assert.ok(postToolRequest.reasoning, "Post-tool request should carry a reasoning envelope") + assert.strictEqual( + postToolRequest.reasoning.effort, + "high", + "Post-tool request should send the applied 'high' effort", + ) + }) + }) +}) From 784ee902e3c71f21441699d421d06594faa48857 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 13:00:13 +0800 Subject: [PATCH 23/42] feat(webview): show thinking-effort toggle + chip without the experiment flag The manual user-facing surfaces (composer bottom-bar toggle and task header chip) are now normal features: they render whenever the selected model advertises per-request reasoning effort support (supportsReasoningEffort boolean-true or a non-empty level array), regardless of the dynamicThinkingEffort experiment flag. - computeThinkingEffortDisplay() no longer takes the experiment flag and returns null only when the model does not advertise per-request effort support. - ThinkingEffortToggle and TaskHeader stop reading `experiments` from the extension state for this display. - The dynamicThinkingEffort experiment now gates only the model-driven set_thinking_effort tool exposure. The rest of the extension-side pipeline (setTaskThinkingEffort handler, taskThinkingEffort state push, per-request effort envelope) was already ungated. - Playwright CT fixture keeps its experiment-on initial state so the baselines render the identical component state (verified: 0 baseline drift). --- webview-ui/src/components/chat/TaskHeader.tsx | 5 +- .../components/chat/ThinkingEffortToggle.tsx | 8 +-- .../TaskHeader.thinking-effort.spec.tsx | 6 +- .../__tests__/ThinkingEffortToggle.spec.tsx | 12 ++-- .../ThinkingEffortToggle.visual.fixture.tsx | 6 +- .../utils/__tests__/thinkingEffort.spec.ts | 65 +++++++++---------- webview-ui/src/utils/thinkingEffort.ts | 12 +--- 7 files changed, 57 insertions(+), 57 deletions(-) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 36c0f345d9..27406da0e1 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -66,7 +66,7 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, experiments, taskThinkingEffort } = useExtensionState() + const { apiConfiguration, currentTaskItem, taskThinkingEffort } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -94,12 +94,11 @@ const TaskHeader = ({ const thinkingEffortDisplay = useMemo( () => computeThinkingEffortDisplay({ - experiments, apiConfiguration, model, taskThinkingEffort, }), - [experiments, apiConfiguration, model, taskThinkingEffort], + [apiConfiguration, model, taskThinkingEffort], ) const thinkingEffortSourceKey = thinkingEffortDisplay?.source === "you" diff --git a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx index f27f843c5e..a210968fe9 100644 --- a/webview-ui/src/components/chat/ThinkingEffortToggle.tsx +++ b/webview-ui/src/components/chat/ThinkingEffortToggle.tsx @@ -36,15 +36,15 @@ export const ThinkingEffortToggle = ({ disabled = false, triggerClassName = "" } const [open, setOpen] = React.useState(false) const portalContainer = useRooPortal("roo-portal") const { t } = useAppTranslation() - const { apiConfiguration, experiments, taskThinkingEffort } = useExtensionState() + const { apiConfiguration, taskThinkingEffort } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) const display = React.useMemo( - () => computeThinkingEffortDisplay({ experiments, apiConfiguration, model, taskThinkingEffort }), - [experiments, apiConfiguration, model, taskThinkingEffort], + () => computeThinkingEffortDisplay({ apiConfiguration, model, taskThinkingEffort }), + [apiConfiguration, model, taskThinkingEffort], ) - // Hidden unless the experiment is enabled and the model advertises effort support. + // Hidden unless the selected model advertises per-request effort support. if (!display) { return null } diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx index 6e8097a82c..5797d262b3 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx @@ -146,10 +146,12 @@ describe("TaskHeader - thinking effort chip (DTE series 4/5)", () => { expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() }) - it("hides the chip when the dynamic-thinking-effort experiment is disabled", () => { + it("shows the chip when the dynamic-thinking-effort experiment is disabled", () => { mockState.experiments = { dynamicThinkingEffort: false } renderChip() - expect(screen.queryByText("medium")).toBeNull() + // The chip is a normal feature: gated by model capability, not the experiment. + expect(screen.getByText("medium")).toBeInTheDocument() + expect(screen.getByText("default")).toBeInTheDocument() }) it("hides the chip when the model does not advertise effort support", () => { diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx index 5cf0c95d8e..6231857dff 100644 --- a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx @@ -108,11 +108,15 @@ describe("ThinkingEffortToggle (DTE series 4/5)", () => { } }) - it("renders nothing when the dynamic-thinking-effort experiment is disabled", () => { + it("renders when the dynamic-thinking-effort experiment is disabled", () => { mockState.experiments = { dynamicThinkingEffort: false } - const { container } = renderToggle() - expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() - expect(container.textContent).toBe("") + renderToggle() + // The manual toggle is a normal feature: gated by model capability, not the experiment. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toBeInTheDocument() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) }) it("exposes the localized accessible name on the icon-only trigger", () => { diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx index a00b61084a..bed1a7f4c5 100644 --- a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx @@ -4,8 +4,10 @@ import { AppProviders } from "../../../../playwright/AppProviders" import { ThinkingEffortToggle } from "../ThinkingEffortToggle" // DTE series 4/5: CT story for the composer thinking-effort toggle. Uses a real -// model (gpt-5.6-sol) that advertises a per-request effort array, with the -// dynamicThinkingEffort experiment enabled — the default state hides the toggle. +// model (gpt-5.6-sol) that advertises a per-request effort array; the toggle +// renders for capable models regardless of the experiment flag. The experiment +// state is kept in the initial state so the story renders exactly the component +// state the baselines were generated with. export function ThinkingEffortToggleStory() { return ( { const modelNone: ModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } - it("returns null when the dynamic-thinking-effort experiment is disabled", () => { - expect(computeThinkingEffortDisplay({ experiments: {}, model: modelWithLevels })).toBeNull() - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: false }, model: modelWithLevels }), - ).toBeNull() - expect(computeThinkingEffortDisplay({ experiments: undefined, model: modelWithLevels })).toBeNull() + it("resolves the display for capable models without the experiment flag", () => { + // The manual surfaces are normal features: resolution is gated only by + // model capability. Settings effort wins over the model default. + const settings = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + }) + expect(settings?.effort).toBe("low") + expect(settings?.source).toBe("default") + // Model default. + const modelDefault = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(modelDefault?.effort).toBe("medium") + expect(modelDefault?.source).toBe("default") + // Boolean/adaptive-class model. + const adaptive = computeThinkingEffortDisplay({ model: modelAdaptive }) + expect(adaptive?.effort).toBe(THINKING_EFFORT_ADAPTIVE_LEVEL) + expect(adaptive?.source).toBe("auto") + }) + + it("shows the task-local value with source 'you' when the experiment flag is absent", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "max", source: "you" }, + }) + expect(display?.effort).toBe("max") + expect(display?.source).toBe("you") }) it("returns null when the model does not advertise effort support", () => { - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: modelNone }), - ).toBeNull() - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: undefined }), - ).toBeNull() + expect(computeThinkingEffortDisplay({ model: modelNone })).toBeNull() + expect(computeThinkingEffortDisplay({ model: undefined })).toBeNull() }) it("returns null when the capability array only advertises the disable sentinel", () => { @@ -44,23 +60,17 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { supportsPromptCache: false, supportsReasoningEffort: ["disable"], } - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: disableOnly }), - ).toBeNull() + expect(computeThinkingEffortDisplay({ model: disableOnly })).toBeNull() }) it("excludes the disable sentinel from the supported levels", () => { - const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, - model: modelWithLevels, - }) + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) expect(display?.supportedLevels).toEqual(["low", "medium", "high", "max"]) expect(display?.isAdaptiveClass).toBe(false) }) it("resolves a task-local override with source 'you'", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, model: modelWithLevels, taskThinkingEffort: { effort: "max", source: "you" }, @@ -72,7 +82,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("resolves task-local overrides from model/parent sources as auto", () => { for (const source of ["model", "parent"]) { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, model: modelWithLevels, taskThinkingEffort: { effort: "high", source }, }) @@ -83,7 +92,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("resolves an unrecognized task-local source as default", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, model: modelWithLevels, taskThinkingEffort: { effort: "high", source: "unknown-origin" }, }) @@ -92,7 +100,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("resolves the settings effort with source 'default' when no override is active", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, model: modelWithLevels, }) @@ -102,7 +109,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("treats the settings 'disable' sentinel as unset and falls through", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, model: modelWithLevels, }) @@ -111,24 +117,18 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { }) it("falls back to the model default effort", () => { - const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, - model: modelWithLevels, - }) + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) expect(display?.effort).toBe("medium") expect(display?.source).toBe("default") }) it("returns null for a level-array model with no settings or model default", () => { const noDefault: ModelInfo = { ...modelWithLevels, reasoningEffort: undefined } - expect( - computeThinkingEffortDisplay({ experiments: { dynamicThinkingEffort: true }, model: noDefault }), - ).toBeNull() + expect(computeThinkingEffortDisplay({ model: noDefault })).toBeNull() }) it("resolves boolean/adaptive-class models to the adaptive soft-guidance level", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, model: modelAdaptive, }) @@ -140,7 +140,6 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { it("lets a task-local override win over the adaptive fallback", () => { const display = computeThinkingEffortDisplay({ - experiments: { dynamicThinkingEffort: true }, model: modelAdaptive, taskThinkingEffort: { effort: "adaptive", source: "you" }, }) diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts index 9fe92033fb..46c0dd3d2c 100644 --- a/webview-ui/src/utils/thinkingEffort.ts +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -1,4 +1,4 @@ -import type { Experiments, ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" +import type { ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" export type ThinkingEffortSource = "default" | "auto" | "you" @@ -22,20 +22,14 @@ export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" * extension-side push via `taskThinkingEffort`) → settings `reasoningEffort` * (provider profile) → model default (`model.reasoningEffort`); boolean/ * adaptive-class models fall back to the "adaptive" soft-guidance display. - * Returns `null` when the dynamic-thinking-effort experiment is disabled or - * the model does not advertise per-request effort support. + * Returns `null` when the model does not advertise per-request effort support. */ export function computeThinkingEffortDisplay(args: { - experiments?: Experiments apiConfiguration?: ProviderSettings model?: ModelInfo taskThinkingEffort?: { effort: string; source: string } }): ThinkingEffortDisplay | null { - const { experiments, apiConfiguration, model, taskThinkingEffort } = args - - if (experiments?.dynamicThinkingEffort !== true) { - return null - } + const { apiConfiguration, model, taskThinkingEffort } = args const capability = model?.supportsReasoningEffort const isAdaptiveClass = capability === true From 42b423dda949da35415516461bf71d899d0e3a8f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 13:01:42 +0800 Subject: [PATCH 24/42] test(e2e): temporary DTE-DEBUG capture of request shapes (revert after diagnosis) Log every request the capture proxy sees and dump aimocks request journal after the wait so CI reveals the exact bodies the aimock matcher saw. --- .../src/suite/thinking-effort-tool.test.ts | 61 ++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts index cb5dedd620..6f7fce8a9d 100644 --- a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts +++ b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts @@ -152,6 +152,8 @@ async function withOpenRouterCaptureProxy( const requestUrl = req.url ?? "/" if (!isChatCompletionsUrl("http://127.0.0.1" + requestUrl)) { + // DTE-DEBUG (temporary): log non-chat requests (e.g. model catalog) the proxy sees. + console.error("DTE-DEBUG non-chat", req.method, requestUrl) res.writeHead(404) res.end("Not found") return @@ -170,6 +172,18 @@ async function withOpenRouterCaptureProxy( lastUserMessage, }) + // DTE-DEBUG (temporary): log every chat request the proxy sees. + console.error( + "DTE-DEBUG chat", + JSON.stringify({ + url: requestUrl, + model: body.model, + reasoning: body.reasoning, + roles: (body.messages ?? []).map((message) => message.role), + lastUserMessage: lastUserMessage.slice(0, 200), + }), + ) + const forwardHeaders: Record = {} for (const [key, value] of Object.entries(req.headers)) { if (!HOP_BY_HOP.has(key.toLowerCase()) && value !== undefined) { @@ -271,8 +285,51 @@ suite("set_thinking_effort mid-task workflow (DTE addendum)", function () { text: APPLY_MARKER + ": answer the math question", }) - await waitUntilCompleted({ api, taskId }) - api.off(RooCodeEventName.Message, onMessage) + try { + await waitUntilCompleted({ api, taskId }) + } finally { + api.off(RooCodeEventName.Message, onMessage) + + // DTE-DEBUG (temporary): dump aimock's request journal and the proxy's + // captured requests so a failure reveals the exact request shapes that + // the aimock matcher saw. + try { + const journalResponse = await fetch(aimockUrl + "/v1/_requests?limit=20") + const journal = (await journalResponse.json()) as Array<{ + method?: string + path?: string + body?: { model?: string; messages?: Array<{ role?: string; content?: unknown }> } + response?: { status?: number } + }> + for (const entry of journal.slice(-10)) { + const entries = entry.body?.messages ?? [] + const lastUser = [...entries].reverse().find((message) => message.role === "user") + const text = + typeof lastUser?.content === "string" + ? lastUser.content + : JSON.stringify(lastUser?.content ?? "") + console.error( + "DTE-DEBUG aimock-journal", + entry.method, + entry.path, + "model=" + String(entry.body?.model ?? "?"), + "status=" + String(entry.response?.status ?? "?"), + "lastUserMessage=" + text.slice(0, 200), + ) + } + console.error( + "DTE-DEBUG captured", + JSON.stringify( + requests.map((request) => ({ + model: request.model, + lastUserMessage: request.lastUserMessage.slice(0, 200), + })), + ), + ) + } catch (error) { + console.error("DTE-DEBUG journal fetch failed", error) + } + } // (a) Real boundary: the task completes with the math answer after the // mid-task tool round trip. From 396a9b109591e1b0ed87a082876dc68a9408cec2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 14:25:24 +0800 Subject: [PATCH 25/42] 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 26/42] 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 27/42] 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 bbec2f95f425df0c2ef874ea9ee183330c4fdab0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 16:16:41 +0800 Subject: [PATCH 28/42] fix(e2e): match post-tool DTE request by model + tool result The aimock toolCallId matcher only inspects the LAST message of the request. The post-tool request ends with a role:user message (fresh environment details appended after the tool result), so the second fixture could never match and every request returned 404 No fixture matched. Match the follow-up on the DTE-only model (openai/gpt-5) plus hasToolResult, with turnIndex 1 tie-breaking the two requests, and remove the temporary DTE-DEBUG capture. --- .../fixtures/thinking-effort-tool.json | 8 ++- .../src/suite/thinking-effort-tool.test.ts | 61 +------------------ 2 files changed, 9 insertions(+), 60 deletions(-) diff --git a/apps/vscode-e2e/fixtures/thinking-effort-tool.json b/apps/vscode-e2e/fixtures/thinking-effort-tool.json index 8269c8f9ab..57058a7b93 100644 --- a/apps/vscode-e2e/fixtures/thinking-effort-tool.json +++ b/apps/vscode-e2e/fixtures/thinking-effort-tool.json @@ -16,8 +16,14 @@ } }, { + // The post-tool request ends with a role:user message (fresh environment details + // appended after the tool result), so aimock's toolCallId matcher — which only + // inspects the LAST message — can never match it. Scope by the DTE-only model plus + // the presence of a tool-result message (turnIndex 1 tie-breaks the two requests). "match": { - "toolCallId": "call_dte_e2e_001" + "model": "openai/gpt-5", + "hasToolResult": true, + "turnIndex": 1 }, "response": { "toolCalls": [ diff --git a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts index 6f7fce8a9d..cb5dedd620 100644 --- a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts +++ b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts @@ -152,8 +152,6 @@ async function withOpenRouterCaptureProxy( const requestUrl = req.url ?? "/" if (!isChatCompletionsUrl("http://127.0.0.1" + requestUrl)) { - // DTE-DEBUG (temporary): log non-chat requests (e.g. model catalog) the proxy sees. - console.error("DTE-DEBUG non-chat", req.method, requestUrl) res.writeHead(404) res.end("Not found") return @@ -172,18 +170,6 @@ async function withOpenRouterCaptureProxy( lastUserMessage, }) - // DTE-DEBUG (temporary): log every chat request the proxy sees. - console.error( - "DTE-DEBUG chat", - JSON.stringify({ - url: requestUrl, - model: body.model, - reasoning: body.reasoning, - roles: (body.messages ?? []).map((message) => message.role), - lastUserMessage: lastUserMessage.slice(0, 200), - }), - ) - const forwardHeaders: Record = {} for (const [key, value] of Object.entries(req.headers)) { if (!HOP_BY_HOP.has(key.toLowerCase()) && value !== undefined) { @@ -285,51 +271,8 @@ suite("set_thinking_effort mid-task workflow (DTE addendum)", function () { text: APPLY_MARKER + ": answer the math question", }) - try { - await waitUntilCompleted({ api, taskId }) - } finally { - api.off(RooCodeEventName.Message, onMessage) - - // DTE-DEBUG (temporary): dump aimock's request journal and the proxy's - // captured requests so a failure reveals the exact request shapes that - // the aimock matcher saw. - try { - const journalResponse = await fetch(aimockUrl + "/v1/_requests?limit=20") - const journal = (await journalResponse.json()) as Array<{ - method?: string - path?: string - body?: { model?: string; messages?: Array<{ role?: string; content?: unknown }> } - response?: { status?: number } - }> - for (const entry of journal.slice(-10)) { - const entries = entry.body?.messages ?? [] - const lastUser = [...entries].reverse().find((message) => message.role === "user") - const text = - typeof lastUser?.content === "string" - ? lastUser.content - : JSON.stringify(lastUser?.content ?? "") - console.error( - "DTE-DEBUG aimock-journal", - entry.method, - entry.path, - "model=" + String(entry.body?.model ?? "?"), - "status=" + String(entry.response?.status ?? "?"), - "lastUserMessage=" + text.slice(0, 200), - ) - } - console.error( - "DTE-DEBUG captured", - JSON.stringify( - requests.map((request) => ({ - model: request.model, - lastUserMessage: request.lastUserMessage.slice(0, 200), - })), - ), - ) - } catch (error) { - console.error("DTE-DEBUG journal fetch failed", error) - } - } + await waitUntilCompleted({ api, taskId }) + api.off(RooCodeEventName.Message, onMessage) // (a) Real boundary: the task completes with the math answer after the // mid-task tool round trip. From 2a1a5971dbc64ff28b4468474deb25795222ed49 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 16:38:37 +0800 Subject: [PATCH 29/42] fix(e2e): keep DTE fixture file valid JSON The previous commit placed a comment block inside thinking-effort-tool.json. JSON does not allow comments, so the aimock fixture-loader JSON.parse failed (Invalid JSON ... line 19 column 4) and SKIPPED the whole file: neither DTE fixture registered, both requests 404 No fixture matched, and the test timed out at 30s (CI run 32705689295). Remove the comment from the JSON, keep the model + hasToolResult + turnIndex match unchanged, and document the matcher rationale plus the plain-JSON constraint in the test file header. --- apps/vscode-e2e/fixtures/thinking-effort-tool.json | 4 ---- apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/vscode-e2e/fixtures/thinking-effort-tool.json b/apps/vscode-e2e/fixtures/thinking-effort-tool.json index 57058a7b93..fab08ae57d 100644 --- a/apps/vscode-e2e/fixtures/thinking-effort-tool.json +++ b/apps/vscode-e2e/fixtures/thinking-effort-tool.json @@ -16,10 +16,6 @@ } }, { - // The post-tool request ends with a role:user message (fresh environment details - // appended after the tool result), so aimock's toolCallId matcher — which only - // inspects the LAST message — can never match it. Scope by the DTE-only model plus - // the presence of a tool-result message (turnIndex 1 tie-breaks the two requests). "match": { "model": "openai/gpt-5", "hasToolResult": true, diff --git a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts index cb5dedd620..70beda76f0 100644 --- a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts +++ b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts @@ -31,6 +31,14 @@ import { waitUntilCompleted } from "./utils" * executor re-checks the model capability after the first request has loaded * the catalog, so the flow is correct even if the first request's tool list * was built before the catalog fetch resolved. + * - Fixture matching (apps/vscode-e2e/fixtures/thinking-effort-tool.json): the + * post-tool request ends with a role:user message (fresh environment details + * are appended after the tool result), so aimock's toolCallId matcher — which + * inspects only the LAST message — can never match it. The follow-up request is + * scoped by the DTE-only model + hasToolResult instead, with turnIndex 1 as a + * tie-break. Note the fixture file is plain JSON: aimock's fixture-loader uses + * JSON.parse and SKIPS the whole file on parse errors, so no // comments may be + * added to it. */ const DTE_MODEL_ID = "openai/gpt-5" From 13967369ba4bc3fe36c68505065d8a09cb9ec1f0 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 17:51:08 +0800 Subject: [PATCH 30/42] 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 9b6c8bc9f88c3d110c20f044c43ecf428551b80a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 17:53:16 +0800 Subject: [PATCH 31/42] fix(webview): include source in thinking-effort say-tool test type (trial-local; lands upstream via dte-3 #1354) --- .../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 da2a73fd46..51c4f92c04 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" } From f7057f0cfd83f5823bf0ef94a55ad7f988d4cde2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 18:20:59 +0800 Subject: [PATCH 32/42] 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" } From e1f3429b2ac67b5f27eccd0fd4b5093d9e6bb2a5 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 01:20:32 +0800 Subject: [PATCH 33/42] feat(dte): let OpenAI-compatible profiles declare supported reasoning effort levels (F7) --- .../src/__tests__/provider-settings.test.ts | 55 +++++ .../types/src/provider-settings/common.ts | 11 +- src/api/__tests__/model-capabilities.spec.ts | 61 +++++ src/api/model-capabilities.ts | 34 +++ .../f7-declared-reasoning-effort.spec.ts | 215 ++++++++++++++++++ .../base-openai-compatible-provider.ts | 5 +- src/api/providers/friendli.ts | 5 +- src/api/providers/lm-studio.ts | 7 +- src/api/providers/native-ollama.ts | 5 +- src/api/providers/openai.ts | 8 +- src/api/providers/router-provider.ts | 26 ++- .../settings/ExperimentalSettings.tsx | 8 + webview-ui/src/i18n/locales/ca/settings.json | 3 +- webview-ui/src/i18n/locales/de/settings.json | 3 +- webview-ui/src/i18n/locales/en/settings.json | 3 +- webview-ui/src/i18n/locales/es/settings.json | 3 +- webview-ui/src/i18n/locales/fr/settings.json | 3 +- webview-ui/src/i18n/locales/hi/settings.json | 3 +- webview-ui/src/i18n/locales/id/settings.json | 3 +- webview-ui/src/i18n/locales/it/settings.json | 3 +- webview-ui/src/i18n/locales/ja/settings.json | 3 +- webview-ui/src/i18n/locales/ko/settings.json | 3 +- webview-ui/src/i18n/locales/nl/settings.json | 3 +- webview-ui/src/i18n/locales/pl/settings.json | 3 +- .../src/i18n/locales/pt-BR/settings.json | 3 +- webview-ui/src/i18n/locales/ru/settings.json | 3 +- webview-ui/src/i18n/locales/tr/settings.json | 3 +- webview-ui/src/i18n/locales/vi/settings.json | 3 +- .../src/i18n/locales/zh-CN/settings.json | 3 +- .../src/i18n/locales/zh-TW/settings.json | 3 +- .../utils/__tests__/thinkingEffort.spec.ts | 124 +++++++++- webview-ui/src/utils/thinkingEffort.ts | 42 +++- 32 files changed, 621 insertions(+), 39 deletions(-) create mode 100644 src/api/__tests__/model-capabilities.spec.ts create mode 100644 src/api/model-capabilities.ts create mode 100644 src/api/providers/__tests__/f7-declared-reasoning-effort.spec.ts diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index b29a93ca3e..f20b9e9f94 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -181,3 +181,58 @@ describe("getApiProtocol", () => { }) }) }) + +describe("supportedReasoningEfforts (F7)", () => { + it("accepts a canonical effort-level declaration on OpenAI-compatible providers", () => { + const settings = { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://localhost:1234/v1", + lmStudioModelId: "qwen3-32b", + supportedReasoningEfforts: ["low", "high", "max"], + } + + const parsed = providerSettingsSchemaDiscriminated.parse(settings) + expect(parsed).toEqual(settings) + }) + + it.each([ + providerIdentifiers.openai, + providerIdentifiers.ollama, + providerIdentifiers.litellm, + providerIdentifiers.baseten, + ])("accepts the declaration on the %s provider branch", (apiProvider) => { + const settings = { + apiProvider, + supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + } + expect(providerSettingsSchemaDiscriminated.safeParse(settings).success).toBe(true) + }) + + it("rejects non-canonical effort values", () => { + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.lmstudio, + supportedReasoningEfforts: ["low", "turbo"], + }).success, + ).toBe(false) + // The UI-level "disable" sentinel is a settings value, not a declarable level. + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.ollama, + supportedReasoningEfforts: ["disable"], + }).success, + ).toBe(false) + }) + + it("accepts an empty declaration and leaves the field omitted when unset", () => { + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.openai, + supportedReasoningEfforts: [], + }).success, + ).toBe(true) + expect(providerSettingsSchemaDiscriminated.safeParse({ apiProvider: providerIdentifiers.openai }).success).toBe( + true, + ) + }) +}) diff --git a/packages/types/src/provider-settings/common.ts b/packages/types/src/provider-settings/common.ts index e73a05f143..c159e4fa8f 100644 --- a/packages/types/src/provider-settings/common.ts +++ b/packages/types/src/provider-settings/common.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js" +import { reasoningEffortExtendedSchema, reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js" import type { ProviderIdentifier } from "../provider-identifiers.js" export const API_PROVIDER_FIELD = "apiProvider" @@ -18,6 +18,15 @@ export const baseProviderSettingsShape = { modelMaxTokens: z.number().optional(), modelMaxThinkingTokens: z.number().optional(), verbosity: verbosityLevelsSchema.optional(), + /** + * F7: per-profile declaration of the canonical reasoning effort levels the + * selected model supports. Self-hosted / OpenAI-compatible models do not + * advertise `supportsReasoningEffort` in the model registry, so a profile can + * declare the levels its model accepts; the resolution rule fills the gap only + * where the model info has no value of its own (registry values are never + * overridden). + */ + supportedReasoningEfforts: z.array(reasoningEffortExtendedSchema).optional(), } export const apiModelIdProviderModelShape = { diff --git a/src/api/__tests__/model-capabilities.spec.ts b/src/api/__tests__/model-capabilities.spec.ts new file mode 100644 index 0000000000..70f913113d --- /dev/null +++ b/src/api/__tests__/model-capabilities.spec.ts @@ -0,0 +1,61 @@ +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { withDeclaredReasoningEffort } from "../model-capabilities" + +describe("withDeclaredReasoningEffort (F7)", () => { + const baseModel: ModelInfo = { + contextWindow: 128_000, + maxTokens: 8_192, + supportsPromptCache: false, + } + + const declared: ProviderSettings["supportedReasoningEfforts"] = ["low", "high", "max"] + + it("fills in the declared levels when the model has no capability of its own", () => { + const result = withDeclaredReasoningEffort(baseModel, { supportedReasoningEfforts: declared }) + expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"]) + // Remaining model fields pass through unchanged. + expect(result.contextWindow).toBe(128_000) + expect(result.maxTokens).toBe(8_192) + }) + + it("never overrides a registry array capability (registry wins)", () => { + const model: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["disable", "low", "medium"], + } + const result = withDeclaredReasoningEffort(model, { supportedReasoningEfforts: declared }) + expect(result).toBe(model) + expect(result.supportsReasoningEffort).toEqual(["disable", "low", "medium"]) + }) + + it("never overrides a boolean registry capability", () => { + for (const capability of [true, false] as const) { + const model: ModelInfo = { ...baseModel, supportsReasoningEffort: capability } + const result = withDeclaredReasoningEffort(model, { supportedReasoningEfforts: declared }) + expect(result).toBe(model) + expect(result.supportsReasoningEffort).toBe(capability) + } + }) + + it("returns the model unchanged when no declaration is present", () => { + expect(withDeclaredReasoningEffort(baseModel, undefined)).toBe(baseModel) + expect(withDeclaredReasoningEffort(baseModel, {})).toBe(baseModel) + }) + + it("returns the model unchanged when the declaration is empty", () => { + expect(withDeclaredReasoningEffort(baseModel, { supportedReasoningEfforts: [] })).toBe(baseModel) + }) + + it("returns a fresh object with its own copy of the declared array (no shared mutation)", () => { + const declaredLevels: string[] = ["low", "high", "max"] + const result = withDeclaredReasoningEffort(baseModel, { + supportedReasoningEfforts: declaredLevels as ProviderSettings["supportedReasoningEfforts"], + }) + expect(result).not.toBe(baseModel) + expect(baseModel.supportsReasoningEffort).toBeUndefined() + const filled = result.supportsReasoningEffort as string[] + expect(filled).not.toBe(declaredLevels) + expect(filled).toEqual(["low", "high", "max"]) + }) +}) diff --git a/src/api/model-capabilities.ts b/src/api/model-capabilities.ts new file mode 100644 index 0000000000..9c04f58179 --- /dev/null +++ b/src/api/model-capabilities.ts @@ -0,0 +1,34 @@ +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +/** + * F7: fill-in-the-gap resolution of user-declared reasoning effort capability. + * + * Self-hosted / OpenAI-compatible models (custom OpenAI endpoints, LM Studio, + * Ollama, and similar) do not advertise `supportsReasoningEffort` in the model + * registry, so the dynamic thinking effort feature is disabled for them. A + * profile can declare the canonical effort levels its model supports via the + * `supportedReasoningEfforts` provider setting. + * + * Resolution rule (single semantic, mirrored on the webview side by + * `resolveReasoningEffortCapability` in webview-ui/src/utils/thinkingEffort.ts): + * when the resolved ModelInfo has no `supportsReasoningEffort` of its own + * (`undefined`) AND the profile declares a non-empty + * `supportedReasoningEfforts`, the model is treated as supporting exactly that + * array. Registry values are NEVER overridden — this is a fill-in-the-gap only, + * so models that already advertise a capability (boolean or array) keep it. + * + * The helper is pure and non-mutating: it returns the original ModelInfo when + * nothing is filled in (callers may share catalog objects). + */ +export function withDeclaredReasoningEffort(modelInfo: ModelInfo, settings: ProviderSettings | undefined): ModelInfo { + if (modelInfo.supportsReasoningEffort !== undefined) { + return modelInfo + } + + const declared = settings?.supportedReasoningEfforts + if (!Array.isArray(declared) || declared.length === 0) { + return modelInfo + } + + return { ...modelInfo, supportsReasoningEffort: [...declared] } +} diff --git a/src/api/providers/__tests__/f7-declared-reasoning-effort.spec.ts b/src/api/providers/__tests__/f7-declared-reasoning-effort.spec.ts new file mode 100644 index 0000000000..1f0d0b4f41 --- /dev/null +++ b/src/api/providers/__tests__/f7-declared-reasoning-effort.spec.ts @@ -0,0 +1,215 @@ +// npx vitest run api/providers/__tests__/f7-declared-reasoning-effort.spec.ts +// +// F7: handler-level coverage for the user-declared reasoning effort fill-in +// (withDeclaredReasoningEffort) at the sites where OpenAI-compatible ModelInfo +// reaches consumers via getModel(). + +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" +import { LiteLLMHandler } from "../lite-llm" +import { LmStudioHandler } from "../lm-studio" +import { getOllamaModels } from "../fetchers/ollama" +import { NativeOllamaHandler } from "../native-ollama" +import { OpenAiHandler } from "../openai" +import { makeApiHandlerOptions } from "../../../test-utils/api" + +vitest.mock("openai", () => ({ + __esModule: true, + default: vitest.fn().mockImplementation(function () { + return { + chat: { + completions: { + create: vitest.fn(), + }, + }, + } + }), + AzureOpenAI: vitest.fn(), +})) + +vi.mock("../fetchers/ollama", () => ({ + getOllamaModels: vi.fn(), +})) + +// Concrete test implementation of the abstract base class (same pattern as +// base-openai-compatible-provider.spec.ts). +class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> { + constructor(options: Record) { + const testModels: Record<"test-model", ModelInfo> = { + "test-model": { + maxTokens: 4096, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + } + + super({ + providerName: "TestProvider", + baseURL: "https://test.example.com/v1", + defaultProviderModelId: "test-model", + providerModels: testModels, + apiKey: "test-api-key", + ...options, + }) + } +} + +const DECLARED: NonNullable = ["low", "high", "max"] + +describe("F7 declared reasoning effort fill-in at handler construction sites", () => { + describe("OpenAiHandler (custom OpenAI endpoint)", () => { + it("fills in declared levels for the sane-default model info", () => { + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "qwen3-32b", + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("fills in declared levels for custom model info without a capability", () => { + const customInfo: ModelInfo = { + contextWindow: 32_768, + maxTokens: 8_192, + supportsPromptCache: false, + } + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "local-model", + openAiCustomModelInfo: customInfo, + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + // The input object is not mutated. + expect(customInfo.supportsReasoningEffort).toBeUndefined() + }) + + it("keeps the model's own capability (registry wins)", () => { + const customInfo: ModelInfo = { + contextWindow: 32_768, + maxTokens: 8_192, + supportsPromptCache: false, + supportsReasoningEffort: ["disable", "low", "high"], + } + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "local-model", + openAiCustomModelInfo: customInfo, + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(["disable", "low", "high"]) + }) + + it("leaves the capability absent without a declaration", () => { + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "qwen3-32b", + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toBeUndefined() + }) + }) + + describe("LmStudioHandler", () => { + it("fills in declared levels when the model falls back to sane defaults", () => { + const handler = new LmStudioHandler( + makeApiHandlerOptions({ + lmStudioBaseUrl: "http://localhost:1234", + lmStudioModelId: "qwen3-32b", + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + }) + + describe("NativeOllamaHandler", () => { + it("fills in declared levels for fetched models without a capability", async () => { + const handler = new NativeOllamaHandler( + makeApiHandlerOptions({ + ollamaModelId: "qwen3:32b", + supportedReasoningEfforts: DECLARED, + }), + ) + vi.mocked(getOllamaModels).mockResolvedValueOnce({ + "qwen3:32b": { + maxTokens: 8_192, + contextWindow: 32_768, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + }) + const result = await handler.fetchModel() + expect(result.info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("keeps the model's own capability (registry wins)", async () => { + const handler = new NativeOllamaHandler( + makeApiHandlerOptions({ + ollamaModelId: "qwen3:32b", + supportedReasoningEfforts: DECLARED, + }), + ) + vi.mocked(getOllamaModels).mockResolvedValueOnce({ + "qwen3:32b": { + maxTokens: 8_192, + contextWindow: 32_768, + supportsImages: false, + supportsPromptCache: false, + supportsReasoningEffort: true, + }, + }) + const result = await handler.fetchModel() + expect(result.info.supportsReasoningEffort).toBe(true) + }) + }) + + describe("BaseOpenAiCompatibleProvider subclasses", () => { + it("fills in declared levels where the model record has no capability", () => { + const handler = new TestOpenAiCompatibleProvider({ supportedReasoningEfforts: DECLARED }) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("leaves the capability absent without a declaration", () => { + const handler = new TestOpenAiCompatibleProvider({}) + expect(handler.getModel().info.supportsReasoningEffort).toBeUndefined() + }) + }) + + describe("RouterProvider subclasses (LiteLLM)", () => { + it("fills in declared levels for the default model fallback", () => { + const handler = new LiteLLMHandler( + makeApiHandlerOptions({ + litellmBaseUrl: "http://localhost:4000", + litellmModelId: "custom/model", + supportedReasoningEfforts: DECLARED, + }), + ) + // No catalog fetched yet: getModel() falls back to defaultModelInfo. + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("leaves the capability absent without a declaration", () => { + const handler = new LiteLLMHandler( + makeApiHandlerOptions({ + litellmBaseUrl: "http://localhost:4000", + litellmModelId: "custom/model", + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toBeUndefined() + }) + }) +}) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index f4928b0b0a..6aa3a32511 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -9,6 +9,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" +import { withDeclaredReasoningEffort } from "../model-capabilities" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import { handleOpenAIError } from "./utils/error-handler" @@ -248,6 +249,8 @@ export abstract class BaseOpenAiCompatibleProvider ? (this.options.apiModelId as ModelName) : this.defaultProviderModelId - return { id, info: this.providerModels[id] } + // F7: fill in user-declared reasoning effort levels where the model does not + // advertise its own capability (registry values are never overridden). + return { id, info: withDeclaredReasoningEffort(this.providerModels[id], this.options) } } } diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index a5507e355a..9f34009720 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -6,6 +6,7 @@ import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@r import type { ApiHandlerOptions } from "../../shared/api" import { shouldUseReasoningEffort, getModelMaxOutputTokens } from "../../shared/api" +import { withDeclaredReasoningEffort } from "../model-capabilities" import { convertToOpenAiMessages } from "../transform/openai-format" import { getModelParams } from "../transform/model-params" @@ -78,7 +79,9 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider + {/* F7: hint for declaring supported effort levels on OpenAI-compatible profiles */} + {config[0] === "DYNAMIC_THINKING_EFFORT" && ( +

+ {t("settings:experimental.DYNAMIC_THINKING_EFFORT.hint")} +

+ )} ) })} diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index df83dbc841..a42ecaeebd 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "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)" + "description": "Permet que el model decideixi el seu esforç de pensament per pas i que tu l'ajustis en el xat. (experimental)", + "hint": "Els perfils compatibles amb OpenAI (punt final d'OpenAI personalitzat, LM Studio, Ollama, LiteLLM i similars) poden declarar a la configuració supportedReasoningEfforts els nivells d'esforç de raonament que el model admet. Els nivells declarats s'envien amb cada sol·licitud, però el servidor local pot ignorar el paràmetre." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 769aecce8a..293b632e78 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "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)" + "description": "Lässt das Modell die Denkintensität pro Schritt selbst bestimmen und ermöglicht Ihnen, sie im Chat anzupassen. (experimentell)", + "hint": "OpenAI-kompatible Profile (eigener OpenAI-Endpunkt, LM Studio, Ollama, LiteLLM und ähnliche) können in der supportedReasoningEfforts-Einstellung deklarieren, welche Reasoning-Bemühungsstufen ihr Modell unterstützt. Deklarierte Stufen werden mit jeder Anfrage gesendet, aber der lokale Server kann den Parameter ignorieren." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 4c7b59992f..0c7f6f10c7 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1056,7 +1056,8 @@ }, "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)" + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)", + "hint": "OpenAI-compatible profiles (custom OpenAI endpoint, LM Studio, Ollama, LiteLLM, and similar) can declare in the supportedReasoningEfforts setting the reasoning effort levels their model supports. Declared efforts are sent with every request, but the local server may ignore the parameter." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 72b4780329..8161945ba7 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -976,7 +976,8 @@ }, "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)" + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)", + "hint": "Los perfiles compatibles con OpenAI (punto de conexión personalizado de OpenAI, LM Studio, Ollama, LiteLLM y similares) pueden declarar en la configuración supportedReasoningEfforts los niveles de esfuerzo de razonamiento que su modelo admite. Los niveles declarados se envían con cada solicitud, pero el servidor local puede ignorar el parámetro." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 15b5f35c2d..0abfe4263d 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -976,7 +976,8 @@ }, "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)" + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)", + "hint": "Les profils compatibles OpenAI (point de terminaison OpenAI personnalisé, LM Studio, Ollama, LiteLLM et similaires) peuvent déclarer dans le paramètre supportedReasoningEfforts les niveaux d'effort de raisonnement pris en charge par leur modèle. Les niveaux déclarés sont envoyés avec chaque requête, mais le serveur local peut ignorer le paramètre." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 149f7bc4f6..9ac2358946 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -976,7 +976,8 @@ }, "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)" + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)", + "hint": "OpenAI-संगत प्रोफ़ाइल (कस्टम OpenAI एंडपॉइंट, LM Studio, Ollama, LiteLLM और समान) supportedReasoningEfforts सेटिंग में अपने मॉडल द्वारा समर्थित रीज़निंग एफर्ट स्तरों की घोषणा कर सकती हैं। घोषित स्तर हर अनुरोध के साथ भेजे जाते हैं, लेकिन लोकल सर्वर पैरामीटर को नज़रअंदाज़ कर सकता है।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index ce33b6a018..6b4b930fb1 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -976,7 +976,8 @@ }, "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)" + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)", + "hint": "Profil yang kompatibel dengan OpenAI (endpoint OpenAI kustom, LM Studio, Ollama, LiteLLM, dan sejenisnya) dapat mendeklarasikan level usaha penalaran yang didukung modelnya dalam pengaturan supportedReasoningEfforts. Level yang dideklarasikan dikirim dengan setiap permintaan, tetapi server lokal dapat mengabaikan parameter ini." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4d4d80d61c..b0c423c352 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -976,7 +976,8 @@ }, "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)" + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)", + "hint": "I profili compatibili con OpenAI (endpoint OpenAI personalizzato, LM Studio, Ollama, LiteLLM e simili) possono dichiarare nelle impostazioni supportedReasoningEfforts i livelli di impegno di ragionamento supportati dal modello. I livelli dichiarati vengono inviati con ogni richiesta, ma il server locale potrebbe ignorare il parametro." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 47a61f85e8..17eab7410a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "name": "ダイナミック思考強度", - "description": "ステップごとにモデルが思考強度を決定し、チャット内で調整できます。 (実験的機能)" + "description": "ステップごとにモデルが思考強度を決定し、チャット内で調整できます。 (実験的機能)", + "hint": "OpenAI互換のプロファイル(カスタムOpenAIエンドポイント、LM Studio、Ollama、LiteLLM など)は、supportedReasoningEfforts の設定で、モデルがサポートする思考レベルを宣言できます。宣言されたレベルはリクエストごとに送信されますが、ローカルサーバーがそのパラメータを無視する場合があります。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 10f8b45d0a..c40a1080ae 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "name": "동적 사고 노력", - "description": "단계별로 모델이 사고 노력을 결정하도록 하고, 채팅에서 조정할 수 있습니다. (실험적 기능)" + "description": "단계별로 모델이 사고 노력을 결정하도록 하고, 채팅에서 조정할 수 있습니다. (실험적 기능)", + "hint": "OpenAI 호환 프로필(사용자 지정 OpenAI 엔드포인트, LM Studio, Ollama, LiteLLM 등)은 supportedReasoningEfforts 설정에서 모델이 지원하는 추론 수준을 선언할 수 있습니다. 선언된 수준은 각 요청과 함께 전송되지만 로컬 서버가 해당 파라미터를 무시할 수 있습니다." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 6dad8b184c..9f383abf4c 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "name": "Dynamisch denkwerk", - "description": "Laat het model per stap het denkwerk zelf bepalen en u dat in het gesprek aanpassen. (experimenteel)" + "description": "Laat het model per stap het denkwerk zelf bepalen en u dat in het gesprek aanpassen. (experimenteel)", + "hint": "OpenAI-compatibiele profielen (aangepast OpenAI-endpoint, LM Studio, Ollama, LiteLLM en vergelijkbare) kunnen in de supportedReasoningEfforts-instelling de redeneer-inspanningsniveaus declareren die hun model ondersteunt. Gedecreëerde inspanningen worden met elk verzoek verzonden, maar de lokale server kan de parameter negeren." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index d0fae082b5..38c81c1d7b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "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)" + "description": "Pozwala modelowi samodzielnie decydować o wysiłku myślowym na każdym kroku oraz dostosowywać go w czacie. (eksperymentalne)", + "hint": "Profile zgodne z OpenAI (własny endpoint OpenAI, LM Studio, Ollama, LiteLLM i podobne) mogą w ustawieniu supportedReasoningEfforts zadeklarować poziomy wysiłku rozumowania obsługiwane przez model. Zadeklarowane poziomy są wysyłane z każdym żądaniem, ale lokalny serwer może zignorować ten parametr." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index f4ea5bd427..ac919f0c4b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "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)" + "description": "Permite que o modelo decida seu esforço de pensamento em cada etapa e que você o ajuste na conversa. (experimental)", + "hint": "Perfis compatíveis com OpenAI (endpoint OpenAI personalizado, LM Studio, Ollama, LiteLLM e similares) podem declarar na configuração supportedReasoningEfforts os níveis de esforço de raciocínio que o modelo suporta. Os níveis declarados são enviados em cada solicitação, mas o servidor local pode ignorar o parâmetro." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index efeadaad19..6cd0cf05f2 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "name": "Динамическое усилие размышления", - "description": "Позволяет модели самостоятельно определять усилие размышления на каждом шаге и корректировать его в чате. (экспериментальная функция)" + "description": "Позволяет модели самостоятельно определять усилие размышления на каждом шаге и корректировать его в чате. (экспериментальная функция)", + "hint": "Профили, совместимые с OpenAI (собственный эндпоинт OpenAI, LM Studio, Ollama, LiteLLM и подобные), могут в настройке supportedReasoningEfforts задекларировать уровни усилия рассуждений, которые поддерживает модель. Заявленные уровни отправляются с каждым запросом, но локальный сервер может игнорировать параметр." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 506a80a062..8c4bc16d88 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "name": "Dinamik düşünme çabası", - "description": "Modelin her adımda kendi düşünme çabasını belirlemesini ve onu sohbette ayarlamayı sağlar. (deneysel)" + "description": "Modelin her adımda kendi düşünme çabasını belirlemesini ve onu sohbette ayarlamayı sağlar. (deneysel)", + "hint": "OpenAI uyumlu profiller (özel OpenAI uç noktası, LM Studio, Ollama, LiteLLM ve benzerleri) supportedReasoningEfforts ayarında modellerinin desteklediği akıl yürütme çaba düzeylerini bildirebilir. Bildirilen düzeyler her istekle gönderilir, ancak yerel sunucu bu parametreyi yok sayabilir." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index bfd90413c1..5511da27c6 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "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)" + "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)", + "hint": "Các hồ sơ tương thích OpenAI (điểm cuối OpenAI tùy chỉnh, LM Studio, Ollama, LiteLLM và tương tự) có thể khai báo trong cài đặt supportedReasoningEfforts các mức nỗ lực suy luận mà mô hình hỗ trợ. Các mức đã khai báo được gửi theo mỗi yêu cầu, nhưng máy chủ cục bộ có thể bỏ qua tham số này." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 2c9fd775cf..4c7fbb5383 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -976,7 +976,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "name": "动态思考强度", - "description": "让模型按步骤自行决定思考强度,并允许你在对话中调整。 (实验性功能)" + "description": "让模型按步骤自行决定思考强度,并允许你在对话中调整。 (实验性功能)", + "hint": "OpenAI 兼容配置文件(自定义 OpenAI 端点、LM Studio、Ollama、LiteLLM 等)可在 supportedReasoningEfforts 设置中声明模型支持的思考力度等级。声明的等级会随每个请求发送,但本地服务器可能会忽略该参数。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 34eee9201b..73cd8e5952 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1003,7 +1003,8 @@ }, "DYNAMIC_THINKING_EFFORT": { "name": "動態思考強度", - "description": "讓模型自行決定每一步的思考強度,並讓您在對話中調整。(實驗性)" + "description": "讓模型自行決定每一步的思考強度,並讓您在對話中調整。(實驗性)", + "hint": "OpenAI 相容的設定檔(自訂 OpenAI 端點、LM Studio、Ollama、LiteLLM 等)可在 supportedReasoningEfforts 設定中宣告模型支援的思考強度等級。宣告的等級會隨每個請求傳送,但本機伺服器可能會忽略此參數。" } }, "promptCaching": { diff --git a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts index 71ec0e43e4..2b708c3532 100644 --- a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts +++ b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts @@ -1,6 +1,10 @@ import type { ModelInfo, ProviderSettings } from "@roo-code/types" -import { computeThinkingEffortDisplay, THINKING_EFFORT_ADAPTIVE_LEVEL } from "../thinkingEffort" +import { + computeThinkingEffortDisplay, + resolveReasoningEffortCapability, + THINKING_EFFORT_ADAPTIVE_LEVEL, +} from "../thinkingEffort" describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { const modelWithLevels: ModelInfo = { @@ -147,3 +151,121 @@ describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { expect(display?.source).toBe("you") }) }) + +describe("resolveReasoningEffortCapability (F7)", () => { + const modelNoCapability: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + } + + it("fills in the declared levels when the model has no capability of its own", () => { + const result = resolveReasoningEffortCapability(modelNoCapability, { + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings) + expect(result?.supportsReasoningEffort).toEqual(["low", "high", "max"]) + // Other model fields pass through unchanged. + expect(result?.contextWindow).toBe(1_000_000) + }) + + it("never overrides a registry capability (registry wins over declaration)", () => { + const registryModel: ModelInfo = { + ...modelNoCapability, + supportsReasoningEffort: ["low", "medium"], + } + const result = resolveReasoningEffortCapability(registryModel, { + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings) + expect(result).toBe(registryModel) + expect(result?.supportsReasoningEffort).toEqual(["low", "medium"]) + }) + + it("never overrides a boolean registry capability", () => { + const adaptiveModel: ModelInfo = { ...modelNoCapability, supportsReasoningEffort: true } + const result = resolveReasoningEffortCapability(adaptiveModel, { + supportedReasoningEfforts: ["low", "high"], + } as ProviderSettings) + expect(result).toBe(adaptiveModel) + expect(result?.supportsReasoningEffort).toBe(true) + }) + + it("returns the model unchanged without a declaration or with an empty one", () => { + expect(resolveReasoningEffortCapability(modelNoCapability, undefined)).toBe(modelNoCapability) + expect(resolveReasoningEffortCapability(modelNoCapability, {} as ProviderSettings)).toBe(modelNoCapability) + expect( + resolveReasoningEffortCapability(modelNoCapability, { supportedReasoningEfforts: [] } as ProviderSettings), + ).toBe(modelNoCapability) + }) + + it("returns undefined for an undefined model", () => { + expect( + resolveReasoningEffortCapability(undefined, { + supportedReasoningEfforts: ["low"], + } as ProviderSettings), + ).toBeUndefined() + }) + + it("does not mutate the input model or share the declared array", () => { + const declaredLevels: string[] = ["low", "high"] + const result = resolveReasoningEffortCapability(modelNoCapability, { + supportedReasoningEfforts: declaredLevels as ProviderSettings["supportedReasoningEfforts"], + }) + expect(result).not.toBe(modelNoCapability) + expect(modelNoCapability.supportsReasoningEffort).toBeUndefined() + expect(result?.supportsReasoningEffort).not.toBe(declaredLevels) + }) +}) + +describe("computeThinkingEffortDisplay with declared capability (F7)", () => { + const selfHostedModel: ModelInfo = { + contextWindow: 32_768, + maxTokens: 8_192, + supportsPromptCache: false, + } + + it("resolves with the declared levels when the model has no capability of its own", () => { + const display = computeThinkingEffortDisplay({ + model: selfHostedModel, + apiConfiguration: { + reasoningEffort: "high", + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings, + }) + expect(display?.supportedLevels).toEqual(["low", "high", "max"]) + expect(display?.effort).toBe("high") + expect(display?.isAdaptiveClass).toBe(false) + }) + + it("excludes the disable sentinel from declared levels", () => { + // "disable" cannot be declared (not a canonical level), but the menu must + // still stay sentinel-free for arrays carrying it defensively. + const display = computeThinkingEffortDisplay({ + model: selfHostedModel, + apiConfiguration: { + supportedReasoningEfforts: ["low", "high"], + } as ProviderSettings, + taskThinkingEffort: { effort: "high", source: "you" }, + }) + expect(display?.supportedLevels).toEqual(["low", "high"]) + }) + + it("keeps the registry capability over the declaration", () => { + const registryModel: ModelInfo = { + ...selfHostedModel, + supportsReasoningEffort: ["low", "medium"], + reasoningEffort: "medium", + } + const display = computeThinkingEffortDisplay({ + model: registryModel, + apiConfiguration: { + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings, + }) + expect(display?.supportedLevels).toEqual(["low", "medium"]) + expect(display?.effort).toBe("medium") + }) + + it("returns null without a declaration (existing behavior)", () => { + expect(computeThinkingEffortDisplay({ model: selfHostedModel })).toBeNull() + }) +}) diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts index 46c0dd3d2c..9daf3ec011 100644 --- a/webview-ui/src/utils/thinkingEffort.ts +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -13,6 +13,37 @@ export interface ThinkingEffortDisplay { export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" +/** + * F7: webview-side mirror of the extension fill-in + * (`withDeclaredReasoningEffort` in src/api/model-capabilities.ts). + * + * Self-hosted / OpenAI-compatible models do not advertise + * `supportsReasoningEffort` in the model registry, so the webview state + * ModelInfo has no capability of its own and the DTE surfaces are hidden. + * When the profile declares a non-empty `supportedReasoningEfforts` and the + * model has no value of its own (`undefined`), the model is treated as + * supporting exactly that array. Registry values are NEVER overridden + * (fill-in-the-gap only), so models that already advertise a capability + * (boolean or array) keep it. + * + * Pure and non-mutating: returns the original model when nothing is filled in. + */ +export function resolveReasoningEffortCapability( + model: ModelInfo | undefined, + apiConfiguration: ProviderSettings | undefined, +): ModelInfo | undefined { + if (!model || model.supportsReasoningEffort !== undefined) { + return model + } + + const declared = apiConfiguration?.supportedReasoningEfforts + if (!Array.isArray(declared) || declared.length === 0) { + return model + } + + return { ...model, supportsReasoningEffort: [...declared] } +} + /** * DTE series 4/5: webview-side computation of the current effective thinking * effort and its source, shared by the TaskHeader chip and the composer @@ -31,7 +62,12 @@ export function computeThinkingEffortDisplay(args: { }): ThinkingEffortDisplay | null { const { apiConfiguration, model, taskThinkingEffort } = args - const capability = model?.supportsReasoningEffort + // F7: apply the profile-declared reasoning effort capability fill-in so the + // composer toggle and TaskHeader chip render for models whose registry entry + // does not advertise the capability (OpenAI-compatible / self-hosted). + const effectiveModel = resolveReasoningEffortCapability(model, apiConfiguration) + + const capability = effectiveModel?.supportsReasoningEffort const isAdaptiveClass = capability === true // The "disable" sentinel is a UI off-switch (settings value), not a level a // task can be set to — keep it out of the menu even when a model advertises it. @@ -66,8 +102,8 @@ export function computeThinkingEffortDisplay(args: { if (isAdaptiveClass) { return { effort: THINKING_EFFORT_ADAPTIVE_LEVEL, source: "auto", supportedLevels, isAdaptiveClass } } - if (model?.reasoningEffort) { - return { effort: model.reasoningEffort, source: "default", supportedLevels, isAdaptiveClass } + if (effectiveModel?.reasoningEffort) { + return { effort: effectiveModel.reasoningEffort, source: "default", supportedLevels, isAdaptiveClass } } return null } From 37c80401725d103ec2510b90fd224e7b02cdf36c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 02:11:30 +0800 Subject: [PATCH 34/42] test(e2e): set_thinking_effort switching workflow (DTE addendum) Adds a second DTE e2e suite that drives one task through a scripted switching sequence (baseline -> applied -> no-op -> applied -> oscillation refusal) against openai/gpt-5.1, asserting the per-request OpenRouter reasoning envelope plus the display says and tool results. Extracts the shared OpenRouter capture proxy from thinking-effort-tool.test.ts into thinking-effort-proxy.ts and switches that suite's request lookups to raw-body tool-call-id matching. Fixtures are scoped by model + hasToolResult + unique turnIndex because aimock's toolCallId matcher only inspects the last message and post-tool requests end with a fresh user message. --- .../fixtures/thinking-effort-switching.json | 84 ++++++ .../src/suite/thinking-effort-proxy.ts | 194 ++++++++++++++ .../suite/thinking-effort-switching.test.ts | 239 ++++++++++++++++++ .../src/suite/thinking-effort-tool.test.ts | 189 +------------- 4 files changed, 522 insertions(+), 184 deletions(-) create mode 100644 apps/vscode-e2e/fixtures/thinking-effort-switching.json create mode 100644 apps/vscode-e2e/src/suite/thinking-effort-proxy.ts create mode 100644 apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts diff --git a/apps/vscode-e2e/fixtures/thinking-effort-switching.json b/apps/vscode-e2e/fixtures/thinking-effort-switching.json new file mode 100644 index 0000000000..995447230f --- /dev/null +++ b/apps/vscode-e2e/fixtures/thinking-effort-switching.json @@ -0,0 +1,84 @@ +{ + "fixtures": [ + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": false, + "turnIndex": 0 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"medium\", \"reason\": \"start at medium\"}", + "id": "call_dte_sw_001" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 1 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"medium\", \"reason\": \"confirm current level\"}", + "id": "call_dte_sw_002" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 2 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"high\", \"reason\": \"raise to high\"}", + "id": "call_dte_sw_003" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 3 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"medium\", \"reason\": \"try returning to medium\"}", + "id": "call_dte_sw_004" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 4 + }, + "response": { + "toolCalls": [ + { + "name": "attempt_completion", + "arguments": "{\"result\": \"DTE_E2E_SWITCH_DONE\"}", + "id": "call_dte_sw_005" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts b/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts new file mode 100644 index 0000000000..c6ef8a7a03 --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts @@ -0,0 +1,194 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +/** + * Shared loopback capture proxy for the DTE e2e suites + * (thinking-effort-tool / thinking-effort-switching). + * + * Pattern from anthropic-opus-4-7.test.ts: it intercepts the + * OpenRouter-compatible chat/completions POST so request shapes can be + * asserted (model, reasoning envelope, message content), then forwards the + * request unchanged to the upstream — aimock in replay/record mode — which + * answers with the fixture-driven SSE. + */ + +export type DteReasoningEnvelope = { + effort?: string + max_tokens?: number + exclude?: boolean +} + +export type CapturedDteRequest = { + model?: string + reasoning: DteReasoningEnvelope | undefined + /** Raw JSON body, so assertions can inspect any part of the wire request (e.g. tool result text). */ + bodyText: string + lastUserMessage: string +} + +type OpenRouterChatCompletionBody = { + model?: string + reasoning?: DteReasoningEnvelope + messages?: Array<{ role?: string; content?: unknown }> +} + +const ALLOWED_PROXY_HOSTS = new Set(["127.0.0.1", "localhost"]) +const CHAT_COMPLETIONS_PATH = "/v1/chat/completions" +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "host", + "content-length", +]) + +function isChatCompletionsUrl(rawUrl: string): boolean { + try { + return new URL(rawUrl).pathname.endsWith(CHAT_COMPLETIONS_PATH) + } catch { + return false + } +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +function writeResponseHeaders(target: ServerResponse, source: Response) { + const headers: Record = {} + source.headers.forEach((value, key) => { + const lower = key.toLowerCase() + // fetch() automatically decompresses the body, so strip content-encoding to + // prevent the SDK from attempting a second decompression. Also strip + // content-length since the decoded body length differs from the compressed one. + if (lower !== "content-length" && lower !== "content-encoding") { + headers[key] = value + } + }) + target.writeHead(source.status, headers) +} + +async function pipeFetchResponse(target: ServerResponse, source: Response) { + writeResponseHeaders(target, source) + + if (!source.body) { + target.end() + return + } + + const reader = source.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + target.write(value) + } + + target.end() +} + +function resolveAllowedUpstreamUrl(baseUrl: string): URL { + const upstreamBase = new URL(baseUrl) + + if (!ALLOWED_PROXY_HOSTS.has(upstreamBase.hostname) || upstreamBase.protocol !== "http:") { + throw new Error("Unexpected OpenRouter proxy target: " + upstreamBase.origin) + } + + return new URL(CHAT_COMPLETIONS_PATH, upstreamBase) +} + +/** + * Serves a loopback capture proxy for the OpenRouter-compatible + * chat/completions endpoint: captures each request body for assertions and + * forwards it unchanged to the upstream (aimock in replay/record mode). + */ +export async function withOpenRouterCaptureProxy( + upstreamUrl: string, + run: (args: { proxyUrl: string; requests: CapturedDteRequest[] }) => Promise, +): Promise { + const requests: CapturedDteRequest[] = [] + const upstreamTarget = resolveAllowedUpstreamUrl(upstreamUrl) + let proxyError: Error | undefined + + const server = createServer(async (req, res) => { + try { + const requestUrl = req.url ?? "/" + + if (!isChatCompletionsUrl("http://127.0.0.1" + requestUrl)) { + res.writeHead(404) + res.end("Not found") + return + } + + const bodyText = await readRequestBody(req) + const body = JSON.parse(bodyText) as OpenRouterChatCompletionBody + const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user") + const lastUserMessage = + typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "") + + requests.push({ + model: body.model, + reasoning: body.reasoning, + bodyText, + lastUserMessage, + }) + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && value !== undefined) { + forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value + } + } + + const upstream = await fetch(upstreamTarget, { + method: req.method, + headers: forwardHeaders, + body: bodyText, + }) + + await pipeFetchResponse(res, upstream) + } catch (error) { + proxyError = error instanceof Error ? error : new Error(String(error)) + console.error("OpenRouter proxy request failed:", proxyError) + if (!res.headersSent) { + res.writeHead(502) + res.end("Capture proxy error") + } else if (!res.writableEnded) { + res.destroy() + } + } + }) + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()) + }) + + const address = server.address() + if (address === null || typeof address === "string") { + server.close() + throw new Error("Capture proxy failed to bind a loopback port") + } + + const proxyUrl = "http://127.0.0.1:" + address.port + + try { + const result = await run({ proxyUrl, requests }) + if (proxyError) { + throw proxyError + } + return result + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} diff --git a/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts new file mode 100644 index 0000000000..d4a2b052fc --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts @@ -0,0 +1,239 @@ +import * as assert from "assert" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { withOpenRouterCaptureProxy, type CapturedDteRequest } from "./thinking-effort-proxy" +import { setDefaultSuiteTimeout } from "./test-utils" +import { waitUntilCompleted } from "./utils" + +/** + * DTE addendum: set_thinking_effort switching within a single task. + * + * Complements thinking-effort-tool.test.ts (single apply) by driving one task + * through a scripted switching sequence and asserting the per-request wire + * envelope after every call: + * + * baseline (settings reasoningEffort "low") + * -> set "medium" applied -> next request sends { effort: "medium" } + * -> set "medium" no-op -> next request still "medium" (result: "already 'medium'", no display say) + * -> set "high" applied -> next request sends { effort: "high" } + * -> set "medium" refused -> next request still "high" (A -> B -> A oscillation refusal) + * -> attempt_completion + * + * Determinism notes: + * - Runs against aimock only (replay or record); skips when aimock is absent, + * so no API keys are required. + * - The capture proxy is shared with thinking-effort-tool.test.ts via + * ./thinking-effort-proxy: it intercepts the OpenRouter-compatible + * chat/completions POST (so request shapes can be asserted) and forwards it + * to aimock for the fixture-driven SSE responses. + * - Model: openai/gpt-5.1, which advertises "reasoning" in supported_parameters + * in the public OpenRouter catalog, so the model-cache fetcher resolves + * supportsReasoningEffort: true and the dynamicThinkingEffort gate exposes + * the tool. The suite picks a model that no other fixture file uses, and the + * fixtures are scoped by model, so the two DTE suites cannot cross-match. + * - Baseline: the suite sets reasoningEffort "low" explicitly. setConfiguration + * replaces the whole provider profile (ProviderSettingsManager.saveConfig), + * so the baseline is deterministic and cannot inherit state from other + * suites; "low" is distinct from every level the tool applies here. + * - Fixture matching (apps/vscode-e2e/fixtures/thinking-effort-switching.json): + * post-tool requests end with a role:user message (fresh environment details + * are appended after the tool result), so aimock's toolCallId matcher — which + * inspects only the LAST message — can never match them. Each fixture is + * scoped by model + hasToolResult + turnIndex instead; aimock's + * selectByTurnIndex picks the highest turnIndex <= the request's assistant + * count, and each turnIndex is unique per fixture, so every request matches + * exactly one fixture. Note the fixture file is plain JSON: aimock's + * fixture loader uses JSON.parse and SKIPS the whole file on parse errors, + * so no // comments may be added to it. + */ + +const SWITCH_MODEL_ID = "openai/gpt-5.1" +const BASELINE_EFFORT = "low" +const SWITCH_MARKER = "DTE_E2E_SWITCH" +const COMPLETION_EXPECTED = "DTE_E2E_SWITCH_DONE" + +// Tool call ids; the first request whose body carries call N is the request +// made right after call N executed, so its reasoning envelope reflects N's outcome. +const CALL_APPLY_MEDIUM = "call_dte_sw_001" +const CALL_NOOP_MEDIUM = "call_dte_sw_002" +const CALL_APPLY_HIGH = "call_dte_sw_003" +const CALL_REFUSED_MEDIUM = "call_dte_sw_004" + +type ThinkingEffortSay = { + tool?: string + effort?: string + reason?: string + refusal?: string +} + +function firstRequestCarrying(requests: CapturedDteRequest[], callId: string): CapturedDteRequest | undefined { + return requests.find((request) => request.bodyText.includes(callId)) +} + +suite("set_thinking_effort switching within a task (DTE addendum)", function () { + setDefaultSuiteTimeout(this) + + // Replace the provider profile with the defaults (the suite setup replaces it + // again, and saveConfig is a full replacement) so subsequent suites are + // unaffected, including this suite's baseline effort and the experiment flag. + suiteTeardown(async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + experiments: { dynamicThinkingEffort: false }, + }) + }) + + test("Should apply and refuse effort switches, updating the wire envelope only on applied changes", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + + // Deterministic, key-free: aimock replay/record only. A live run would need + // a real model that deterministically emits the scripted switching sequence. + if (!aimockUrl) { + this.skip() + } + + await withOpenRouterCaptureProxy(aimockUrl, async ({ proxyUrl, requests }) => { + // OpenRouter provider, a model that advertises per-request reasoning effort + // (public catalog: "reasoning" in supported_parameters), the + // dynamicThinkingEffort experiment enabled, and an explicit baseline effort so + // the baseline request carries a deterministic { effort: "low" } envelope. + await api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: "mock-key", + openRouterModelId: SWITCH_MODEL_ID, + openRouterBaseUrl: `${proxyUrl}/v1`, + enableReasoningEffort: true, + reasoningEffort: BASELINE_EFFORT, + experiments: { dynamicThinkingEffort: true }, + }) + + const messages: ClineMessage[] = [] + const onMessage = ({ message }: { message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + messages.push(message) + } + } + api.on(RooCodeEventName.Message, onMessage) + + const taskId = await api.startNewTask({ + configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, + text: SWITCH_MARKER + ": manage the thinking effort for this task", + }) + + await waitUntilCompleted({ api, taskId }) + api.off(RooCodeEventName.Message, onMessage) + + // (a) Real boundary: the task completes after the full switching sequence. + const completion = messages.find( + ({ say, text }) => + (say === "completion_result" || say === "text") && text?.trim() === COMPLETION_EXPECTED, + ) + assert.ok( + completion, + "Task should complete with '" + COMPLETION_EXPECTED + "' after the switching sequence", + ) + + // (b) Real boundary: display says carry the applied efforts and the refusal; + // the no-op call deliberately emits no display say. + const effortSays = messages + .filter( + ({ say, text }) => say === "tool" && typeof text === "string" && text.includes("thinkingEffort"), + ) + .map(({ text }) => JSON.parse(text ?? "") as ThinkingEffortSay) + assert.strictEqual(effortSays.length, 3, "Should emit exactly three thinkingEffort display says") + + const appliedMedium = effortSays.find((say) => say.effort === "medium") + assert.ok(appliedMedium, "Should emit an applied 'medium' display say") + assert.strictEqual( + appliedMedium?.reason, + "start at medium", + "The 'medium' say should carry the model's reason", + ) + assert.strictEqual( + appliedMedium?.refusal, + undefined, + "The 'medium' change should have been applied, not refused", + ) + + const appliedHigh = effortSays.find((say) => say.effort === "high") + assert.ok(appliedHigh, "Should emit an applied 'high' display say") + assert.strictEqual(appliedHigh?.reason, "raise to high", "The 'high' say should carry the model's reason") + assert.strictEqual( + appliedHigh?.refusal, + undefined, + "The 'high' change should have been applied, not refused", + ) + + const refusal = effortSays.find((say) => say.refusal === "oscillation") + assert.ok(refusal, "The A -> B -> A return (medium -> high -> medium) should be refused as oscillation") + assert.strictEqual(refusal?.effort, undefined, "A refused say must not carry an applied effort") + + // (c) Real boundary: the wire envelope per request. Each request below is the + // first one carrying the given tool call id, i.e. the request made right after + // that call executed, so its reasoning envelope reflects the call's outcome. + const baselineRequest = requests.find((request) => request.lastUserMessage.includes(SWITCH_MARKER)) + assert.ok(baselineRequest, "Should have captured the baseline request containing the task prompt") + assert.strictEqual(baselineRequest.model, SWITCH_MODEL_ID) + assert.strictEqual( + baselineRequest.reasoning?.effort, + BASELINE_EFFORT, + "The baseline request should carry the settings-derived baseline effort", + ) + + const afterApplyMedium = firstRequestCarrying(requests, CALL_APPLY_MEDIUM) + assert.ok(afterApplyMedium, "Should have captured the request after the 'medium' change was applied") + assert.strictEqual( + afterApplyMedium.reasoning?.effort, + "medium", + "The request after the applied change should send the 'medium' effort", + ) + assert.ok( + afterApplyMedium.bodyText.includes("Thinking effort is now 'medium'."), + "The tool result after the applied change should confirm the new effort", + ) + + const afterNoOp = firstRequestCarrying(requests, CALL_NOOP_MEDIUM) + assert.ok(afterNoOp, "Should have captured the request after the no-op change") + assert.strictEqual( + afterNoOp.reasoning?.effort, + "medium", + "A no-op change must not alter the effort envelope", + ) + assert.ok( + afterNoOp.bodyText.includes("Thinking effort is already 'medium'."), + "The no-op tool result should confirm the current effort", + ) + + const afterApplyHigh = firstRequestCarrying(requests, CALL_APPLY_HIGH) + assert.ok(afterApplyHigh, "Should have captured the request after the 'high' change was applied") + assert.strictEqual( + afterApplyHigh.reasoning?.effort, + "high", + "The request after the applied change should send the 'high' effort", + ) + assert.ok( + afterApplyHigh.bodyText.includes("Thinking effort is now 'high'."), + "The tool result after the applied change should confirm the new effort", + ) + + const afterRefusal = firstRequestCarrying(requests, CALL_REFUSED_MEDIUM) + assert.ok(afterRefusal, "Should have captured the request after the refused change") + assert.strictEqual( + afterRefusal.reasoning?.effort, + "high", + "A refused change must not alter the effort envelope", + ) + assert.ok( + afterRefusal.bodyText.includes("oscillation between 'medium' and 'high' detected"), + "The refusal tool result should name the oscillation", + ) + }) + }) +}) diff --git a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts index 70beda76f0..f97120efe9 100644 --- a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts +++ b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts @@ -1,8 +1,8 @@ import * as assert from "assert" -import { createServer, type IncomingMessage, type ServerResponse } from "http" import { RooCodeEventName, type ClineMessage } from "@roo-code/types" +import { withOpenRouterCaptureProxy } from "./thinking-effort-proxy" import { setDefaultSuiteTimeout } from "./test-utils" import { waitUntilCompleted } from "./utils" @@ -46,187 +46,6 @@ const APPLY_MARKER = "DTE_E2E_EFFORT_APPLY" const SET_EFFORT_TOOL_CALL_ID = "call_dte_e2e_001" const COMPLETION_EXPECTED = "42" -type DteReasoningEnvelope = { - effort?: string - max_tokens?: number - exclude?: boolean -} - -type CapturedDteRequest = { - model?: string - reasoning: DteReasoningEnvelope | undefined - carriesSetEffortToolResult: boolean - lastUserMessage: string -} - -type OpenRouterChatCompletionBody = { - model?: string - reasoning?: DteReasoningEnvelope - messages?: Array<{ role?: string; content?: unknown }> -} - -const ALLOWED_PROXY_HOSTS = new Set(["127.0.0.1", "localhost"]) -const CHAT_COMPLETIONS_PATH = "/v1/chat/completions" -const HOP_BY_HOP = new Set([ - "connection", - "keep-alive", - "transfer-encoding", - "te", - "trailer", - "upgrade", - "proxy-connection", - "proxy-authenticate", - "proxy-authorization", - "host", - "content-length", -]) - -function isChatCompletionsUrl(rawUrl: string): boolean { - try { - return new URL(rawUrl).pathname.endsWith(CHAT_COMPLETIONS_PATH) - } catch { - return false - } -} - -function readRequestBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) - req.on("error", reject) - }) -} - -function writeResponseHeaders(target: ServerResponse, source: Response) { - const headers: Record = {} - source.headers.forEach((value, key) => { - const lower = key.toLowerCase() - // fetch() automatically decompresses the body, so strip content-encoding to - // prevent the SDK from attempting a second decompression. Also strip - // content-length since the decoded body length differs from the compressed one. - if (lower !== "content-length" && lower !== "content-encoding") { - headers[key] = value - } - }) - target.writeHead(source.status, headers) -} - -async function pipeFetchResponse(target: ServerResponse, source: Response) { - writeResponseHeaders(target, source) - - if (!source.body) { - target.end() - return - } - - const reader = source.body.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) { - break - } - target.write(value) - } - - target.end() -} - -function resolveAllowedUpstreamUrl(baseUrl: string): URL { - const upstreamBase = new URL(baseUrl) - - if (!ALLOWED_PROXY_HOSTS.has(upstreamBase.hostname) || upstreamBase.protocol !== "http:") { - throw new Error("Unexpected OpenRouter proxy target: " + upstreamBase.origin) - } - - return new URL(CHAT_COMPLETIONS_PATH, upstreamBase) -} - -/** - * Serves a loopback capture proxy for the OpenRouter-compatible - * chat/completions endpoint: captures each request body for assertions and - * forwards it unchanged to the upstream (aimock in replay/record mode). - */ -async function withOpenRouterCaptureProxy( - upstreamUrl: string, - run: (args: { proxyUrl: string; requests: CapturedDteRequest[] }) => Promise, -): Promise { - const requests: CapturedDteRequest[] = [] - const upstreamTarget = resolveAllowedUpstreamUrl(upstreamUrl) - let proxyError: Error | undefined - - const server = createServer(async (req, res) => { - try { - const requestUrl = req.url ?? "/" - - if (!isChatCompletionsUrl("http://127.0.0.1" + requestUrl)) { - res.writeHead(404) - res.end("Not found") - return - } - - const bodyText = await readRequestBody(req) - const body = JSON.parse(bodyText) as OpenRouterChatCompletionBody - const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user") - const lastUserMessage = - typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "") - - requests.push({ - model: body.model, - reasoning: body.reasoning, - carriesSetEffortToolResult: JSON.stringify(body.messages ?? []).includes(SET_EFFORT_TOOL_CALL_ID), - lastUserMessage, - }) - - const forwardHeaders: Record = {} - for (const [key, value] of Object.entries(req.headers)) { - if (!HOP_BY_HOP.has(key.toLowerCase()) && value !== undefined) { - forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value - } - } - - const upstream = await fetch(upstreamTarget, { - method: req.method, - headers: forwardHeaders, - body: bodyText, - }) - - await pipeFetchResponse(res, upstream) - } catch (error) { - proxyError = error instanceof Error ? error : new Error(String(error)) - console.error("OpenRouter proxy request failed:", proxyError) - if (!res.headersSent) { - res.writeHead(502) - res.end("Capture proxy error") - } else if (!res.writableEnded) { - res.destroy() - } - } - }) - - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => resolve()) - }) - - const address = server.address() - if (address === null || typeof address === "string") { - server.close() - throw new Error("Capture proxy failed to bind a loopback port") - } - - const proxyUrl = "http://127.0.0.1:" + address.port - - try { - const result = await run({ proxyUrl, requests }) - if (proxyError) { - throw proxyError - } - return result - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) - } -} - suite("set_thinking_effort mid-task workflow (DTE addendum)", function () { setDefaultSuiteTimeout(this) @@ -319,7 +138,9 @@ suite("set_thinking_effort mid-task workflow (DTE addendum)", function () { // (c) Real boundary: the request AFTER the tool round trip carries the // applied effort in the OpenRouter reasoning envelope. const preToolRequest = requests.find( - (request) => !request.carriesSetEffortToolResult && request.lastUserMessage.includes(APPLY_MARKER), + (request) => + !request.bodyText.includes(SET_EFFORT_TOOL_CALL_ID) && + request.lastUserMessage.includes(APPLY_MARKER), ) assert.ok(preToolRequest, "Should have captured the pre-tool request containing the task prompt") assert.strictEqual(preToolRequest.model, DTE_MODEL_ID) @@ -329,7 +150,7 @@ suite("set_thinking_effort mid-task workflow (DTE addendum)", function () { "the baseline request should not already carry the 'high' effort", ) - const postToolRequest = requests.find((request) => request.carriesSetEffortToolResult) + const postToolRequest = requests.find((request) => request.bodyText.includes(SET_EFFORT_TOOL_CALL_ID)) assert.ok(postToolRequest, "The follow-up request should carry the set_thinking_effort tool result") assert.strictEqual(postToolRequest.model, DTE_MODEL_ID) assert.ok(postToolRequest.reasoning, "Post-tool request should carry a reasoning envelope") From 298397525f58b9c7f942515d639781529cb6982a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 02:58:16 +0800 Subject: [PATCH 35/42] test(e2e): document DTE capture proxy + switching helper functions CodeRabbit pre-merge check on the addendum (docstring coverage 14.29% < 80%, 7 functions across 3 files): add JSDoc to the five internal proxy helpers and firstRequestCarrying so every function touched by this diff is self-documenting (withOpenRouterCaptureProxy was already documented). --- .../src/suite/thinking-effort-proxy.ts | 18 ++++++++++++++++++ .../suite/thinking-effort-switching.test.ts | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts b/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts index c6ef8a7a03..0a8ae434e2 100644 --- a/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts +++ b/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts @@ -47,6 +47,9 @@ const HOP_BY_HOP = new Set([ "content-length", ]) +/** + * Whether a raw URL targets the OpenRouter-compatible chat/completions endpoint. + */ function isChatCompletionsUrl(rawUrl: string): boolean { try { return new URL(rawUrl).pathname.endsWith(CHAT_COMPLETIONS_PATH) @@ -55,6 +58,9 @@ function isChatCompletionsUrl(rawUrl: string): boolean { } } +/** + * Collects the full request body as a UTF-8 string. + */ function readRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = [] @@ -64,6 +70,10 @@ function readRequestBody(req: IncomingMessage): Promise { }) } +/** + * Mirrors the upstream response headers onto the proxy response, dropping the + * headers that would break fetch()-decoded streaming (content-encoding / length). + */ function writeResponseHeaders(target: ServerResponse, source: Response) { const headers: Record = {} source.headers.forEach((value, key) => { @@ -78,6 +88,10 @@ function writeResponseHeaders(target: ServerResponse, source: Response) { target.writeHead(source.status, headers) } +/** + * Streams the upstream (already-decoded) fetch body through to the proxy + * response, ending the response when the body completes. + */ async function pipeFetchResponse(target: ServerResponse, source: Response) { writeResponseHeaders(target, source) @@ -98,6 +112,10 @@ async function pipeFetchResponse(target: ServerResponse, source: Response) { target.end() } +/** + * Resolves the upstream chat/completions URL, rejecting any target that is not + * a loopback HTTP origin (the proxy must never forward to a real endpoint). + */ function resolveAllowedUpstreamUrl(baseUrl: string): URL { const upstreamBase = new URL(baseUrl) diff --git a/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts index d4a2b052fc..2f356cbe7b 100644 --- a/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts +++ b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts @@ -67,6 +67,10 @@ type ThinkingEffortSay = { refusal?: string } +/** + * Finds the first captured wire request whose body carries the given tool + * call id — i.e. the post-tool request that follows a specific tool call. + */ function firstRequestCarrying(requests: CapturedDteRequest[], callId: string): CapturedDteRequest | undefined { return requests.find((request) => request.bodyText.includes(callId)) } From f756aab992e2e76a6f065b0e519e59e4a5871496 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 03:07:18 +0800 Subject: [PATCH 36/42] test(e2e): settle expected display says before detaching listener (CI event race) CI e2e-mock failed 2 !== 3 on "exactly three thinkingEffort display says": the final display say is observed on the Message channel after the TaskCompleted event resolved waitUntilCompleted (separate event channels, no cross-channel ordering guarantee; under CI load the queue lags by more than one turn). Await the expected says with a bounded settle (5s, 100ms) before detaching the listener: a genuine shortfall still fails the same assertion, the race no longer does. --- .../src/suite/thinking-effort-switching.test.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts index 2f356cbe7b..08c7adcbb1 100644 --- a/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts +++ b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts @@ -4,7 +4,7 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types" import { withOpenRouterCaptureProxy, type CapturedDteRequest } from "./thinking-effort-proxy" import { setDefaultSuiteTimeout } from "./test-utils" -import { waitUntilCompleted } from "./utils" +import { waitUntilCompleted, waitFor } from "./utils" /** * DTE addendum: set_thinking_effort switching within a single task. @@ -131,7 +131,19 @@ suite("set_thinking_effort switching within a task (DTE addendum)", function () text: SWITCH_MARKER + ": manage the thinking effort for this task", }) + const countEffortSays = () => + messages.filter( + ({ say, text }) => say === "tool" && typeof text === "string" && text.includes("thinkingEffort"), + ).length + await waitUntilCompleted({ api, taskId }) + + // Event delivery race: the final display say can be observed after the + // TaskCompleted event (separate event channels, no cross-channel + // ordering guarantee). Settle the expected display says before + // detaching the listener; a genuine shortfall still fails below. + await waitFor(() => countEffortSays() >= 3, { timeout: 5_000, interval: 100 }) + api.off(RooCodeEventName.Message, onMessage) // (a) Real boundary: the task completes after the full switching sequence. From 2620a5f531855b9085bb119754d2eaae3a5d81fa Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 03:35:04 +0800 Subject: [PATCH 37/42] docs(api): document provider getModel overrides touched by the F7 fill-in CodeRabbit docstring-coverage pre-merge check on the stacked diff flags the six provider getModel() overrides this PR touches (base-openai-compatible, friendli, openai, lm-studio, native-ollama, router-provider). Document each with the F7 fill-in-the-gap semantic so every function introduced or touched by this PR's own delta is self-documenting. --- src/api/providers/base-openai-compatible-provider.ts | 7 +++++++ src/api/providers/friendli.ts | 7 +++++++ src/api/providers/lm-studio.ts | 7 +++++++ src/api/providers/native-ollama.ts | 7 +++++++ src/api/providers/openai.ts | 7 +++++++ src/api/providers/router-provider.ts | 7 +++++++ 6 files changed, 42 insertions(+) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 6aa3a32511..d163800570 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -243,6 +243,13 @@ export abstract class BaseOpenAiCompatibleProvider } } + /** + * Resolves the active model and its capability metadata. + * + * F7: fills in user-declared `supportedReasoningEfforts` (registry-wins) + * for self-hosted / OpenAI-compatible profiles that do not advertise the + * capability in the registry. + */ override getModel() { const id = this.options.apiModelId && this.options.apiModelId in this.providerModels diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index 9f34009720..ed9c128221 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -73,6 +73,13 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider Date: Tue, 25 Aug 2026 03:55:07 +0800 Subject: [PATCH 38/42] docs: document remaining DTE functions touched by the stacked diff Follow-up to the CodeRabbit docstring-coverage pre-merge check. Document the functions introduced or touched by this PR's stacked diff that still lacked JSDoc: - SetThinkingEffortTool: effortRank, getGuardState, execute, handlePartial - filter-tools-for-mode: applyModelToolCustomization (its doc block was orphaned by an intervening interface; moved it directly above the function) - router-provider: supportsTemperature (line re-touched by the F7 diff) Comments only; no behavior change. --- src/api/providers/router-provider.ts | 4 ++++ .../prompts/tools/filter-tools-for-mode.ts | 20 +++++++++--------- src/core/tools/SetThinkingEffortTool.ts | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index e358fc8a33..93b893e653 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -171,6 +171,10 @@ export abstract class RouterProvider extends BaseProvider { return { id, info: withDeclaredReasoningEffort(this.defaultModelInfo, this.options) } } + /** + * Router/LiteLLM model ids are opaque, so temperature support is + * inferred: only the known openai/o3-mini ids reject temperature. + */ protected supportsTemperature(modelId: string): boolean { return !modelId.startsWith("openai/o3-mini") } diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 166a4c64e6..5d1b293e31 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -128,6 +128,16 @@ export function getToolAliasGroup(toolName: string): readonly string[] { return ALIAS_GROUPS.get(toolName) ?? [toolName] } +/** + * Result of applying model tool customization. + * Contains the set of allowed tools and any alias renames to apply. + */ +interface ModelToolCustomizationResult { + allowedTools: Set + /** Maps canonical tool name to alias name for tools that should be renamed */ + aliasRenames: Map +} + /** * Apply model-specific tool customization to a set of allowed tools. * @@ -140,16 +150,6 @@ export function getToolAliasGroup(toolName: string): readonly string[] { * @param modelInfo - Model configuration with tool customization * @returns Modified set of tools after applying model customization */ -/** - * Result of applying model tool customization. - * Contains the set of allowed tools and any alias renames to apply. - */ -interface ModelToolCustomizationResult { - allowedTools: Set - /** Maps canonical tool name to alias name for tools that should be renamed */ - aliasRenames: Map -} - export function applyModelToolCustomization( allowedTools: Set, modeConfig: ModeConfig, diff --git a/src/core/tools/SetThinkingEffortTool.ts b/src/core/tools/SetThinkingEffortTool.ts index 0c83f69a08..80d532ff7a 100644 --- a/src/core/tools/SetThinkingEffortTool.ts +++ b/src/core/tools/SetThinkingEffortTool.ts @@ -62,6 +62,11 @@ interface EffortGuardState { history: string[] } +/** + * Ordinal rank of a settable effort level (drives nearest-level + * clamping and the escalation/oscillation guardrails). Unknown or + * undefined values rank as "disable" — the bottom of the scale. + */ function effortRank(level: string | undefined): number { return level === undefined ? EFFORT_RANK.disable : (EFFORT_RANK[level] ?? EFFORT_RANK.disable) } @@ -118,6 +123,10 @@ export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { */ private guardState = new WeakMap() + /** + * Returns the per-task guardrail state, creating it on first use with + * the task's effective baseline seeded into the history. + */ private getGuardState(task: Task, baseline: string | undefined): EffortGuardState { let state = this.guardState.get(task) if (!state) { @@ -133,6 +142,14 @@ export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { return state } + /** + * Applies a model-requested thinking-effort change with guardrails: + * clamps to the model's capability array, refuses oscillation + * (A -> B -> A returns) and escalation-cap violations, applies the + * task-local runtime effort, and publishes the one-line display say. + * There is no approval gate — the model decides, and the user can + * adjust the effort in chat at any time. + */ async execute(params: SetThinkingEffortParams, task: Task, callbacks: ToolCallbacks): Promise { const { effort, reason } = params const { handleError, pushToolResult } = callbacks @@ -297,6 +314,10 @@ export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { } } + /** + * Streams a partial display say while the model's tool arguments are + * still streaming in (updates the same one-line display as it arrives). + */ 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 From 106a389e84775d930a189d91a4e4f39f053e63dc Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 04:35:45 +0800 Subject: [PATCH 39/42] fix(dte): address CodeRabbit full-review findings on the stacked diff Follow-up to the 2026-08-24 CodeRabbit full review of this stacked PR. Four major findings, three fixed, one documented as design: 1. new_task schema strict-mode violation: thinking_effort was in properties but not required, which the Anthropic API rejects under strict: true + additionalProperties: false (the whole tool definition fails). It now uses the same ["string", "null"] + required pattern as todos; null is the omitted-value sentinel the tool treats as absent (unit-tested). 2. NewTaskTool: the invalid thinking_effort path now advances the consecutive-mistake guardrail and records the tool error like every other failure path, so a model repeating an unsupported effort trips the mistake loop (unit-tested). 3. E2E suite teardown: the new_task suite switches the profile to the Anthropic provider with an ephemeral proxy base URL and sets the global reasoning-effort fields; the teardown now explicitly clears them (anthropicBaseUrl, apiModelId, enableReasoningEffort, reasoningEffort) so a later suite selecting the anthropic provider is not pointed at the closed local port and does not inherit this suite's effort baseline. 4. Task-local effort on OpenAI-compatible providers: documented in the PR discussion rather than changed - setRuntimeThinkingEffort rewrites the per-task apiConfiguration and rebuilds the API handler, so provider requests are built from the task-local config (the switching e2e asserts the wire envelope changes on the request after an applied change); the metadata.reasoningEffort per-request override is the PR-2 Anthropic channel, and this PR's design deliberately keeps existing wire emit unchanged (plan section 12.1, user-confirmed caveat that some local servers ignore the parameter). Type surfaces: NativeToolArgs.new_task.thinking_effort and ToolUse.params now admit the null sentinel. tsc clean, eslint clean, 55 unit tests + 5 DTE e2e suites passing locally. --- .../suite/new-task-thinking-effort.test.ts | 11 +++++ .../prompts/tools/native-tools/new_task.ts | 8 +++- src/core/tools/NewTaskTool.ts | 13 +++++- .../__tests__/newTaskThinkingEffort.spec.ts | 40 ++++++++++++++++--- src/shared/tools.ts | 10 +++-- 5 files changed, 69 insertions(+), 13 deletions(-) diff --git a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts index fdc3573318..d96fac2873 100644 --- a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts +++ b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts @@ -202,6 +202,17 @@ const restoreOpenRouterConfig = async () => { openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, openRouterModelId: "openai/gpt-4.1", ...(aimockUrl && { openRouterBaseUrl: aimockUrl + "/v1" }), + // This suite switches the profile to the Anthropic provider with an + // ephemeral proxy base URL and sets the global reasoning-effort fields. + // saveConfig is a full profile replacement, so the persisted profile is + // clean either way; the explicit clears also reset the in-memory provider + // settings, so a later suite selecting the anthropic provider is not + // pointed at the (closed) local port and does not inherit this suite's + // effort baseline. + anthropicBaseUrl: undefined, + apiModelId: undefined, + enableReasoningEffort: undefined, + reasoningEffort: undefined, }) } diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index cc6fb8e374..17c2f5b524 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -34,11 +34,15 @@ export default { description: TODOS_PARAMETER_DESCRIPTION, }, thinking_effort: { - type: "string", + // strict: true + additionalProperties: false requires every property to be + // listed in `required` (the Anthropic API rejects the tool definition + // otherwise), so the optional parameter uses the same ["string", "null"] + // pattern as `todos`: the model sends null to omit it. + type: ["string", "null"], description: THINKING_EFFORT_PARAMETER_DESCRIPTION, }, }, - required: ["mode", "message", "todos"], + required: ["mode", "message", "todos", "thinking_effort"], additionalProperties: false, }, }, diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index 7b8e7bd074..bda316f846 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -16,7 +16,10 @@ interface NewTaskParams { message: string todos?: string // DTE series 5/5: optional subtask start effort (validated against the target model). - thinking_effort?: string + // "null" is the strict-mode "omitted" sentinel (the schema type is + // ["string", "null"] so the parameter can be required without forcing a + // value); treated as absent, same as undefined/"". + thinking_effort?: string | null } // DTE series 5/5: the effort levels a new task can start with. "disable" is a settings @@ -80,8 +83,14 @@ export class NewTaskTool extends BaseTool<"new_task"> { ? modelCapabilities : [] let validatedEffort: ReasoningEffortExtended | undefined - if (thinking_effort !== undefined && thinking_effort !== "") { + if (thinking_effort !== undefined && thinking_effort !== null && thinking_effort !== "") { if (!isNewTaskEffortLevel(thinking_effort) || !supportedLevels.includes(thinking_effort)) { + // Consistent with every other failure path in this tool: advance the + // consecutive-mistake guardrail and record the failure for telemetry, + // so a model repeating an unsupported effort trips the mistake loop. + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + task.didToolFailInCurrentTurn = true const reason = !isNewTaskEffortLevel(thinking_effort) ? `must be one of: ${NEW_TASK_EFFORT_LEVELS.join(", ")}` : supportedLevels.length > 0 diff --git a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts index 4a3e1dc741..611cbf6337 100644 --- a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts +++ b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts @@ -115,7 +115,7 @@ const makeCallbacks = () => ({ const runNewTask = async ( task: Task, - params: { mode?: string; message?: string; todos?: string; thinking_effort?: string }, + params: { mode?: string; message?: string; todos?: string; thinking_effort?: string | null }, callbacks: ReturnType, ) => { const args = { @@ -148,17 +148,23 @@ const runNewTask = async ( } describe("new_task thinking_effort schema (DTE series 5/5)", () => { - it("exposes an optional thinking_effort string parameter", () => { + it("exposes the optional thinking_effort parameter in strict-mode form", () => { const parameters = newTaskSchema.function.parameters + // strict: true + additionalProperties: false requires every property to be + // listed in `required` (the Anthropic API rejects the tool definition + // otherwise), so the optional parameter uses the same ["string", "null"] + // pattern as `todos`: the model sends null when it wants to omit it. expect(parameters.properties.thinking_effort).toEqual({ - type: "string", + type: ["string", "null"], description: expect.stringContaining("thinking effort"), }) - // Optional: omitting it makes the child start with the parent's current - // effective effort. additionalProperties stays closed. - expect(parameters.required).toEqual(["mode", "message", "todos"]) + expect(parameters.required).toEqual(["mode", "message", "todos", "thinking_effort"]) expect(parameters.additionalProperties).toBe(false) + // Strict-mode invariant: no property may be optional. + for (const key of Object.keys(parameters.properties)) { + expect((parameters.required as string[]).includes(key)).toBe(true) + } }) }) @@ -204,6 +210,28 @@ describe("new_task thinking_effort validation (DTE series 5/5)", () => { expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("must be one of")) expect(delegateParentAndOpenChild).not.toHaveBeenCalled() expect(callbacks.askApproval).not.toHaveBeenCalled() + // The invalid-effort failure path advances the mistake guardrail and records + // the tool error like every other failure path, so a model repeating an + // unsupported effort trips the consecutive-mistake loop. + expect(task.consecutiveMistakeCount).toBe(1) + expect(task.recordToolError).toHaveBeenCalledWith("new_task") + expect(task.didToolFailInCurrentTurn).toBe(true) + }) + + it("treats an explicit null thinking_effort as omitted (strict-mode null sentinel)", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + parentEffort: "medium", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: null }, callbacks) + + // null is the strict-mode "omitted" sentinel: validation is skipped and the + // child starts with the parent's effective effort. + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "medium" })) + expect(callbacks.pushToolResult).not.toHaveBeenCalledWith(expect.stringContaining("Invalid thinking_effort")) + expect(task.consecutiveMistakeCount).toBe(0) }) it("rejects a level the target model does not support", async () => { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 64601053f4..5d3ef45459 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -104,7 +104,10 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } - new_task: { mode: string; message: string; todos?: string; thinking_effort?: string } + // thinking_effort is ["string", "null"] in the strict-mode schema: null is the + // "omitted" sentinel the model sends (the parameter must be required under + // strict: true + additionalProperties: false). + new_task: { mode: string; message: string; todos?: string; thinking_effort?: string | null } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -137,8 +140,9 @@ export interface ToolUse { * Used to preserve tool names in API conversation history. */ originalName?: string - // params is a partial record, allowing only some or none of the possible parameters to be used - params: Partial> + // params is a partial record, allowing only some or none of the possible parameters to be used. + // new_task.thinking_effort may be the strict-mode null sentinel (see NativeToolArgs.new_task). + params: Omit>, "thinking_effort"> & { thinking_effort?: string | null } partial: boolean // nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never From a6c5ac1ee7517a0d3f9d63eee6a5266cb511de1d Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 23:29:03 +0800 Subject: [PATCH 40/42] fix(webview): surface DTE controls for OpenAI-compatible profiles OpenAI-compatible (self-hosted) profiles never declared a reasoning effort capability, so the dynamic thinking effort surfaces stayed hidden and the per-request effort envelope was not offered: - add a SupportedEffortLevels declaration control to the OpenAI-compatible provider settings (bound through setApiConfigurationField, persisted on Save); an empty declaration unchecks the Enable Reasoning Effort switch so UI and wire state cannot drift; - ThinkingBudget options now derive from the custom-model entry's own capability, else the declared levels, else the default set; - resolveReasoningEffortCapability synthesizes a minimal ModelInfo from the declaration when no model info reaches the webview at all; - new i18n keys in all 18 locales (parity-checked). --- .../settings/SupportedEffortLevels.tsx | 67 ++++++++++++++++ .../settings/providers/OpenAICompatible.tsx | 57 +++++++++----- .../__tests__/OpenAICompatible.spec.tsx | 78 +++++++++++++++++++ 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 + .../utils/__tests__/thinkingEffort.spec.ts | 20 ++++- webview-ui/src/utils/thinkingEffort.ts | 21 ++++- 23 files changed, 286 insertions(+), 29 deletions(-) create mode 100644 webview-ui/src/components/settings/SupportedEffortLevels.tsx diff --git a/webview-ui/src/components/settings/SupportedEffortLevels.tsx b/webview-ui/src/components/settings/SupportedEffortLevels.tsx new file mode 100644 index 0000000000..68c9698281 --- /dev/null +++ b/webview-ui/src/components/settings/SupportedEffortLevels.tsx @@ -0,0 +1,67 @@ +import { Checkbox } from "vscrui" +import type { ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" + +import { useAppTranslation } from "@src/i18n/TranslationContext" + +/** + * F7 (webview): per-profile declaration of the reasoning effort levels the + * selected model supports. + * + * Self-hosted / OpenAI-compatible endpoints do not advertise + * `supportsReasoningEffort` in the model registry, so the dynamic thinking + * effort feature has no capability to work from. This control lets the user + * declare the canonical levels the model accepts; the extension + * (`withDeclaredReasoningEffort`) and the webview mirror + * (`resolveReasoningEffortCapability`) fill the gap only where the model info + * has no capability of its own, and the per-request effort envelope is sent + * once a non-empty declaration exists and "Enable Reasoning Effort" is on. + * + * Bound through `setApiConfigurationField` like every other provider field, so + * the value buffers in `cachedState` and persists on Save via the + * `updateSettings` payload (Persisted Setting Checklist). + */ +const EFFORT_LEVELS: ReasoningEffortExtended[] = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] + +type SupportedEffortLevelsProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: ( + field: K, + value: ProviderSettings[K], + isUserAction?: boolean, + ) => void +} + +export const SupportedEffortLevels = ({ apiConfiguration, setApiConfigurationField }: SupportedEffortLevelsProps) => { + const { t } = useAppTranslation() + const declared = apiConfiguration.supportedReasoningEfforts ?? [] + + const handleToggle = (level: ReasoningEffortExtended) => { + const next = declared.includes(level) ? declared.filter((value) => value !== level) : [...declared, level] + // Keep the canonical level order regardless of toggle order. + const ordered = EFFORT_LEVELS.filter((value) => next.includes(value)) + setApiConfigurationField("supportedReasoningEfforts", ordered) + // An empty declaration makes the fill-in a no-op, which would silently + // disable the per-request effort envelope while the "Enable Reasoning + // Effort" checkbox still claims it is on — mirror the off-switch so UI + // and wire state stay in sync. + if (ordered.length === 0 && apiConfiguration.enableReasoningEffort) { + setApiConfigurationField("enableReasoningEffort", false) + } + } + + return ( +
+
{t("settings:providers.supportedEffortLevels.label")}
+
+ {t("settings:providers.supportedEffortLevels.description")} +
+
+ {EFFORT_LEVELS.map((level) => ( + handleToggle(level)}> + {t(`settings:providers.reasoningEffort.${level}`)} + + ))} +
+
+ ) +} diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 7870b21f32..9504a77256 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -23,6 +23,7 @@ import { inputEventTransform, noTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" import { R1FormatSetting } from "../R1FormatSetting" import { ThinkingBudget } from "../ThinkingBudget" +import { SupportedEffortLevels } from "../SupportedEffortLevels" type OpenAICompatibleProps = { apiConfiguration: ProviderSettings @@ -267,27 +268,41 @@ export const OpenAICompatible = ({ {t("settings:providers.setReasoningLevel")} {!!apiConfiguration.enableReasoningEffort && ( - { - if (field === "reasoningEffort") { - const openAiCustomModelInfo = - apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults - - setApiConfigurationField("openAiCustomModelInfo", { - ...openAiCustomModelInfo, - reasoningEffort: value as ReasoningEffortExtended, - }) - } - }} - modelInfo={{ - ...(apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults), - supportsReasoningEffort: ["low", "medium", "high", "xhigh", "max"], - }} - /> + <> + + { + if (field === "reasoningEffort") { + const openAiCustomModelInfo = + apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults + + setApiConfigurationField("openAiCustomModelInfo", { + ...openAiCustomModelInfo, + reasoningEffort: value as ReasoningEffortExtended, + }) + } + }} + modelInfo={{ + ...(apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults), + // F7: the custom-model entry's own capability wins (registry-wins); + // otherwise the user-declared profile levels; otherwise the + // OpenAI-compatible default set. + supportsReasoningEffort: + (apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults) + .supportsReasoningEffort ?? + (apiConfiguration.supportedReasoningEfforts?.length + ? apiConfiguration.supportedReasoningEfforts + : ["low", "medium", "high", "xhigh", "max"]), + }} + /> + )}
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx index 196d067755..3f77560c8b 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx @@ -429,4 +429,82 @@ describe("OpenAICompatible Component - includeMaxTokens checkbox", () => { }) }) }) + + describe("reasoning effort level declaration (F7)", () => { + const baseConfiguration: Partial = { + enableReasoningEffort: true, + openAiCustomModelInfo: { + contextWindow: 128_000, + supportsPromptCache: false, + }, + } + + function renderConfig(overrides: Partial = {}) { + render( + , + ) + } + + it("renders the level declaration checkboxes when reasoning effort is enabled", () => { + renderConfig() + + expect(screen.getByTestId("checkbox-settings:providers.reasoningeffort.low")).toBeInTheDocument() + expect(screen.getByTestId("checkbox-settings:providers.reasoningeffort.xhigh")).toBeInTheDocument() + }) + + it("does not render the level declaration checkboxes when reasoning effort is disabled", () => { + renderConfig({ enableReasoningEffort: false }) + + expect(screen.queryByTestId("checkbox-settings:providers.reasoningeffort.low")).not.toBeInTheDocument() + }) + + it("appends a toggled level in canonical order", () => { + renderConfig({ supportedReasoningEfforts: ["high"] }) + + fireEvent.click(screen.getByTestId("checkbox-input-settings:providers.reasoningeffort.low")) + + expect(mockSetApiConfigurationField).toHaveBeenLastCalledWith("supportedReasoningEfforts", ["low", "high"]) + }) + + it("removes a toggled-off level", () => { + renderConfig({ supportedReasoningEfforts: ["low", "high"] }) + + fireEvent.click(screen.getByTestId("checkbox-input-settings:providers.reasoningeffort.low")) + + expect(mockSetApiConfigurationField).toHaveBeenLastCalledWith("supportedReasoningEfforts", ["high"]) + }) + + it("disables the reasoning-effort switch when the declaration becomes empty", () => { + renderConfig({ supportedReasoningEfforts: ["low"] }) + + fireEvent.click(screen.getByTestId("checkbox-input-settings:providers.reasoningeffort.low")) + + expect(mockSetApiConfigurationField).toHaveBeenLastCalledWith("enableReasoningEffort", false) + }) + + it("drives the ThinkingBudget options from the declared levels when the custom model has no capability", () => { + renderConfig({ supportedReasoningEfforts: ["low", "max"] }) + + const thinkingBudgetProps = mockThinkingBudget.mock.calls[0][0] + expect(thinkingBudgetProps.modelInfo.supportsReasoningEffort).toEqual(["low", "max"]) + }) + + it("keeps a custom-model capability over the declared levels (registry wins)", () => { + renderConfig({ + supportedReasoningEfforts: ["low", "max"], + openAiCustomModelInfo: { + contextWindow: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: ["medium"], + }, + }) + + const thinkingBudgetProps = mockThinkingBudget.mock.calls[0][0] + expect(thinkingBudgetProps.modelInfo.supportsReasoningEffort).toEqual(["medium"]) + }) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index a42ecaeebd..07a98c25d2 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -678,6 +678,10 @@ "description": "Controla el nivell de detall de les respostes del model. La verbositat baixa produeix respostes concises, mentre que la verbositat alta proporciona explicacions exhaustives." }, "setReasoningLevel": "Activa l'esforç de raonament", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Ruta del Codi Claude", "description": "Ruta opcional al teu CLI de Claude Code. Per defecte, 'claude' si no s'estableix.", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 293b632e78..9b45970502 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -678,6 +678,10 @@ "description": "Steuert, wie detailliert die Antworten des Modells sind. Niedrige Ausführlichkeit erzeugt knappe Antworten, während hohe Ausführlichkeit gründliche Erklärungen liefert." }, "setReasoningLevel": "Denkaufwand aktivieren", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Claude-Code-Pfad", "description": "Optionaler Pfad zu Ihrer Claude Code CLI. Standard ist 'claude', wenn nicht festgelegt.", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 0c7f6f10c7..2950d82c7b 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -765,6 +765,10 @@ "description": "Controls how detailed the model's responses are. Low verbosity produces concise answers, while high verbosity provides thorough explanations." }, "setReasoningLevel": "Enable Reasoning Effort", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Claude Code Path", "description": "Optional path to your Claude Code CLI. Defaults to 'claude' if not set.", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 9ae1418501..08dc300a5e 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -678,6 +678,10 @@ "description": "Controla qué tan detalladas son las respuestas del modelo. La verbosidad baja produce respuestas concisas, mientras que la verbosidad alta proporciona explicaciones exhaustivas." }, "setReasoningLevel": "Habilitar esfuerzo de razonamiento", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Ruta de Claude Code", "description": "Ruta opcional a su CLI de Claude Code. Por defecto, es 'claude' si no se establece.", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 0b267b3a47..eb28b8f5b5 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -678,6 +678,10 @@ "description": "Contrôle le niveau de détail des réponses du modèle. Une faible verbosité produit des réponses concises, tandis qu'une verbosité élevée fournit des explications approfondies." }, "setReasoningLevel": "Activer l'effort de raisonnement", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Chemin du code Claude", "description": "Chemin facultatif vers votre CLI Claude Code. La valeur par défaut est 'claude' si non défini.", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index b052096c0e..51e9ac93e1 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -678,6 +678,10 @@ "description": "मॉडल की प्रतिक्रियाएं कितनी विस्तृत हैं, इसे नियंत्रित करता है। कम वर्बोसिटी संक्षिप्त उत्तर देती है, जबकि उच्च वर्बोसिटी विस्तृत स्पष्टीकरण प्रदान करती है।" }, "setReasoningLevel": "तर्क प्रयास सक्षम करें", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "क्लाउड कोड पथ", "description": "आपके क्लाउड कोड सीएलआई का वैकल्पिक पथ। यदि सेट नहीं है तो डिफ़ॉल्ट 'claude' है।", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index d75d2a3f8d..7632090f84 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -678,6 +678,10 @@ "description": "Mengontrol seberapa detail respons model. Verbositas rendah menghasilkan jawaban singkat, sedangkan verbositas tinggi memberikan penjelasan menyeluruh." }, "setReasoningLevel": "Aktifkan Upaya Reasoning", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Jalur Kode Claude", "description": "Jalur opsional ke Claude Code CLI Anda. Defaultnya adalah 'claude' jika tidak diatur.", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index d70767cc5d..357b739dd2 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -678,6 +678,10 @@ "description": "Controlla il livello di dettaglio delle risposte del modello. Una verbosity bassa produce risposte concise, mentre una verbosity alta fornisce spiegazioni approfondite." }, "setReasoningLevel": "Abilita sforzo di ragionamento", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Percorso Claude Code", "description": "Percorso facoltativo per la tua CLI Claude Code. Predefinito 'claude' se non impostato.", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 17eab7410a..82c5495178 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -678,6 +678,10 @@ "description": "モデルの応答の詳細度を制御します。冗長性が低いと簡潔な回答が生成され、高いと詳細な説明が提供されます。" }, "setReasoningLevel": "推論労力を有効にする", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "クロードコードパス", "description": "Claude Code CLIへのオプションパス。設定されていない場合、デフォルトは「claude」です。", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index c40a1080ae..7e70b5b956 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -678,6 +678,10 @@ "description": "모델 응답의 상세도를 제어합니다. 낮은 상세도는 간결한 답변을 생성하고, 높은 상세도는 상세한 설명을 제공합니다." }, "setReasoningLevel": "추론 노력 활성화", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "클로드 코드 경로", "description": "Claude Code CLI의 선택적 경로입니다. 설정하지 않으면 'claude'가 기본값입니다.", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 9f383abf4c..5253c994af 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -678,6 +678,10 @@ "description": "Bepaalt hoe gedetailleerd de reacties van het model zijn. Lage uitvoerbaarheid levert beknopte antwoorden op, terwijl hoge uitvoerbaarheid uitgebreide uitleg geeft." }, "setReasoningLevel": "Redeneervermogen inschakelen", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Claude Code Pad", "description": "Optioneel pad naar uw Claude Code CLI. Standaard 'claude' als niet ingesteld.", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 38c81c1d7b..ccdcf2e5d0 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -678,6 +678,10 @@ "description": "Kontroluje, jak szczegółowe są odpowiedzi modelu. Niska szczegółowość generuje zwięzłe odpowiedzi, podczas gdy wysoka szczegółowość dostarcza dokładnych wyjaśnień." }, "setReasoningLevel": "Włącz wysiłek rozumowania", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Ścieżka Claude Code", "description": "Opcjonalna ścieżka do Twojego CLI Claude Code. Domyślnie 'claude', jeśli nie ustawiono.", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index ac919f0c4b..54c774a071 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -678,6 +678,10 @@ "description": "Controla o quão detalhadas são as respostas do modelo. A verbosidade baixa produz respostas concisas, enquanto a verbosidade alta fornisce explicações detalhadas." }, "setReasoningLevel": "Habilitar esforço de raciocínio", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Caminho do Claude Code", "description": "Caminho opcional para o seu Claude Code CLI. O padrão é 'claude' se não for definido.", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 6cd0cf05f2..5efe1680fc 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -678,6 +678,10 @@ "description": "Контролирует, насколько подробны ответы модели. Низкая подробность дает краткие ответы, а высокая — подробные объяснения." }, "setReasoningLevel": "Включить усилие рассуждения", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Путь к Claude Code", "description": "Необязательный путь к вашему Claude Code CLI. По умолчанию используется 'claude', если не установлено.", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 8c4bc16d88..7edec4d25f 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -678,6 +678,10 @@ "description": "Modelin yanıtlarının ne kadar ayrıntılı olduğunu kontrol eder. Düşük ayrıntı düzeyi kısa yanıtlar üretirken, yüksek ayrıntı düzeyi kapsamlı açıklamalar sunar." }, "setReasoningLevel": "Akıl Yürütme Çabasını Etkinleştir", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Claude Code Yolu", "description": "Claude Code CLI'nize isteğe bağlı yol. Ayarlanmazsa varsayılan olarak 'claude' kullanılır.", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 5511da27c6..1100770da5 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -678,6 +678,10 @@ "description": "Kiểm soát mức độ chi tiết của các câu trả lời của mô hình. Mức độ chi tiết thấp tạo ra các câu trả lời ngắn gọn, trong khi mức độ chi tiết cao cung cấp giải thích kỹ lưỡng." }, "setReasoningLevel": "Kích hoạt nỗ lực suy luận", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Đường dẫn Claude Code", "description": "Đường dẫn tùy chọn đến Claude Code CLI của bạn. Mặc định là 'claude' nếu không được đặt.", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 4c7fbb5383..a36c921a48 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -678,6 +678,10 @@ "description": "控制模型响应的详细程度。低详细度产生简洁的回答,而高详细度提供详尽的解释。" }, "setReasoningLevel": "启用推理工作量", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Claude Code 路径", "description": "您的 Claude Code CLI 的可选路径。如果未设置,则默认为 “claude”。", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 73cd8e5952..bf9fdb1818 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -705,6 +705,10 @@ "description": "控制模型回應的詳細程度。低詳細度產生簡潔的回答,而高詳細度提供詳盡的解釋。" }, "setReasoningLevel": "啟用推理強度", + "supportedEffortLevels": { + "label": "Supported Reasoning Effort Levels", + "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + }, "claudeCode": { "pathLabel": "Claude Code 路徑", "description": "選用的 Claude Code CLI 路徑。若未設定,預設為 'claude'。", diff --git a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts index 2b708c3532..5275ab3658 100644 --- a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts +++ b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts @@ -197,11 +197,23 @@ describe("resolveReasoningEffortCapability (F7)", () => { ).toBe(modelNoCapability) }) - it("returns undefined for an undefined model", () => { + it("synthesizes a minimal model from the declaration when no model info reaches the webview", () => { + // Self-hosted providers can resolve `model` to undefined when their model + // list is empty; the profile declaration is then the only capability source. + const result = resolveReasoningEffortCapability(undefined, { + supportedReasoningEfforts: ["low", "high"], + } as ProviderSettings) + expect(result?.supportsReasoningEffort).toEqual(["low", "high"]) + // Minimal ModelInfo shape: the required fields are present, nothing else implied. + expect(result?.contextWindow).toBe(0) + expect(result?.supportsPromptCache).toBe(false) + }) + + it("returns undefined for an undefined model without a declaration", () => { + expect(resolveReasoningEffortCapability(undefined, undefined)).toBeUndefined() + expect(resolveReasoningEffortCapability(undefined, {} as ProviderSettings)).toBeUndefined() expect( - resolveReasoningEffortCapability(undefined, { - supportedReasoningEfforts: ["low"], - } as ProviderSettings), + resolveReasoningEffortCapability(undefined, { supportedReasoningEfforts: [] } as ProviderSettings), ).toBeUndefined() }) diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts index 9daf3ec011..2af511711e 100644 --- a/webview-ui/src/utils/thinkingEffort.ts +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -26,18 +26,31 @@ export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" * (fill-in-the-gap only), so models that already advertise a capability * (boolean or array) keep it. * - * Pure and non-mutating: returns the original model when nothing is filled in. + * Pure and non-mutating: returns the original model when nothing is filled in, + * and `undefined` when the model itself is `undefined` with no declaration. */ export function resolveReasoningEffortCapability( model: ModelInfo | undefined, apiConfiguration: ProviderSettings | undefined, ): ModelInfo | undefined { - if (!model || model.supportsReasoningEffort !== undefined) { + const declared = apiConfiguration?.supportedReasoningEfforts + const hasDeclaration = Array.isArray(declared) && declared.length > 0 + + // F7: when no model info reaches the webview at all (self-hosted providers + // can resolve `undefined` when their model list is empty), the profile + // declaration is the only capability source — synthesize a minimal ModelInfo + // carrying it so the DTE surfaces still render. + if (!model) { + return hasDeclaration + ? { contextWindow: 0, supportsPromptCache: false, supportsReasoningEffort: [...declared] } + : model + } + + if (model.supportsReasoningEffort !== undefined) { return model } - const declared = apiConfiguration?.supportedReasoningEfforts - if (!Array.isArray(declared) || declared.length === 0) { + if (!hasDeclaration) { return model } From f11c19ea3b652d183e9d387074162435550aa186 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 23:44:04 +0800 Subject: [PATCH 41/42] fix(webview): treat 'disable' effort selection as unchecking reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A custom model's own capability array may include the "disable" level (e.g. ollama think/no-think style models). Selecting it previously stored reasoningEffort="disable" while enableReasoningEffort stayed true, so the parent switch showed reasoning as enabled although the selected effort disables it. The effort binding now clears the stored model effort and unchecks the switch — the same end state as unchecking it by hand. CodeRabbit finding (functional correctness, minor) on PR #1366. --- .../settings/providers/OpenAICompatible.tsx | 12 ++++++++ .../__tests__/OpenAICompatible.spec.tsx | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 9504a77256..f700e8b1a3 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -283,6 +283,18 @@ export const OpenAICompatible = ({ const openAiCustomModelInfo = apiConfiguration.openAiCustomModelInfo || openAiModelInfoSaneDefaults + if (value === "disable") { + // "disable" is an option a custom model's own capability array can + // expose. Selecting it must turn reasoning effort off end-to-end — + // same as unchecking the parent switch — rather than store a value + // the wire path never emits. + const { reasoningEffort: _, ...rest } = openAiCustomModelInfo + + setApiConfigurationField("openAiCustomModelInfo", rest) + setApiConfigurationField("enableReasoningEffort", false) + return + } + setApiConfigurationField("openAiCustomModelInfo", { ...openAiCustomModelInfo, reasoningEffort: value as ReasoningEffortExtended, diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx index 3f77560c8b..9763477ebe 100644 --- a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.spec.tsx @@ -506,5 +506,33 @@ describe("OpenAICompatible Component - includeMaxTokens checkbox", () => { const thinkingBudgetProps = mockThinkingBudget.mock.calls[0][0] expect(thinkingBudgetProps.modelInfo.supportsReasoningEffort).toEqual(["medium"]) }) + + it("turns reasoning effort off when a custom-model 'disable' option is selected", () => { + renderConfig({ + openAiCustomModelInfo: { + contextWindow: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "disable"], + }, + }) + + const thinkingBudgetProps = mockThinkingBudget.mock.calls[0][0] + // The custom-model capability array is respected exactly, so "disable" is offered. + expect(thinkingBudgetProps.modelInfo.supportsReasoningEffort).toContain("disable") + + thinkingBudgetProps.setApiConfigurationField("reasoningEffort", "disable") + + // The stored effort is cleared and the parent switch is unchecked — the same + // end state as unchecking "Enable Reasoning Effort" by hand. + const modelCalls = mockSetApiConfigurationField.mock.calls.filter( + (call) => call[0] === "openAiCustomModelInfo", + ) + expect(modelCalls[modelCalls.length - 1]?.[1]).toEqual({ + contextWindow: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: ["low", "disable"], + }) + expect(mockSetApiConfigurationField).toHaveBeenLastCalledWith("enableReasoningEffort", false) + }) }) }) From 86c40fbe5de786743046832a4a331f330aa2924a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 26 Aug 2026 02:42:47 +0800 Subject: [PATCH 42/42] feat(i18n): translate supported effort levels setting (17 locales) --- webview-ui/src/i18n/locales/ca/settings.json | 4 ++-- webview-ui/src/i18n/locales/de/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 ++-- 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 ++-- webview-ui/src/i18n/locales/zh-TW/settings.json | 4 ++-- 17 files changed, 34 insertions(+), 34 deletions(-) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 07a98c25d2..98068965d0 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Activa l'esforç de raonament", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Nivells d'esforç de raonament admesos", + "description": "Els nivells d'esforç de raonament que accepta el teu model. Els models autoallotjats i compatibles amb OpenAI no anuncien aquesta capacitat, per tant declara els nivells aquí per utilitzar els controls d'esforç de raonament." }, "claudeCode": { "pathLabel": "Ruta del Codi Claude", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 9b45970502..1f6b3dd855 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Denkaufwand aktivieren", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Unterstützte Denkaufwandsstufen", + "description": "Die Denkaufwandsstufen, die dein Modell akzeptiert. Selbst gehostete und OpenAI-kompatible Modelle bewerben diese Fähigkeit nicht, daher deklarierst du die Stufen hier, um die Denkaufwand-Steuerung zu verwenden." }, "claudeCode": { "pathLabel": "Claude-Code-Pfad", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 08dc300a5e..7a5f85c5ef 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Habilitar esfuerzo de razonamiento", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Niveles de esfuerzo de razonamiento admitidos", + "description": "Niveles de esfuerzo de razonamiento que acepta tu modelo. Los modelos autoalojados y compatibles con OpenAI no anuncian esta capacidad, por lo que declara los niveles aquí para usar los controles de esfuerzo de razonamiento." }, "claudeCode": { "pathLabel": "Ruta de Claude Code", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index eb28b8f5b5..916ef2dd30 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Activer l'effort de raisonnement", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Niveaux d'effort de raisonnement pris en charge", + "description": "Niveaux d'effort de raisonnement acceptés par votre modèle. Les modèles auto-hébergés et compatibles OpenAI n'annoncent pas cette capacité ; déclarez les niveaux ici pour utiliser les contrôles d'effort de raisonnement." }, "claudeCode": { "pathLabel": "Chemin du code Claude", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 51e9ac93e1..f3bb79ba64 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "तर्क प्रयास सक्षम करें", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "समर्थित तर्क प्रयास स्तर", + "description": "वे तर्क प्रयास स्तर जो आपका मॉडल स्वीकार करता है। स्व-होस्टेड और OpenAI-संगत मॉडल इस क्षमता की घोषणा नहीं करते हैं, इसलिए तर्क प्रयास नियंत्रणों का उपयोग करने के लिए स्तर यहाँ घोषित करें।" }, "claudeCode": { "pathLabel": "क्लाउड कोड पथ", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 7632090f84..3a0394b36c 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Aktifkan Upaya Reasoning", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Level Upaya Reasoning yang Didukung", + "description": "Level upaya reasoning yang diterima oleh model Anda. Model self-hosted dan yang kompatibel dengan OpenAI tidak mengiklankan kemampuan ini, jadi nyatakan levelnya di sini untuk menggunakan kontrol upaya reasoning." }, "claudeCode": { "pathLabel": "Jalur Kode Claude", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 357b739dd2..c44ae7bde2 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Abilita sforzo di ragionamento", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Livelli di sforzo di ragionamento supportati", + "description": "Livelli di sforzo di ragionamento accettati dal tuo modello. I modelli self-hosted e compatibili con OpenAI non dichiarano questa capacità, quindi dichiara i livelli qui per utilizzare i controlli dello sforzo di ragionamento." }, "claudeCode": { "pathLabel": "Percorso Claude Code", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 82c5495178..107b474741 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "推論労力を有効にする", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "サポートされる推論労力レベル", + "description": "モデルが受け付ける推論労力レベルです。セルフホスト型やOpenAI互換のモデルはこの機能を通知しないため、思考労力のコントロールを使用するにはここにレベルを宣言してください。" }, "claudeCode": { "pathLabel": "クロードコードパス", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 7e70b5b956..494ee29dcb 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "추론 노력 활성화", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "지원되는 추론 노력 수준", + "description": "모델이 받아들이는 추론 노력 수준입니다. 셀프호스팅 및 OpenAI 호환 모델은 이 기능을 광고하지 않으므로, 사고 노력 제어를 사용하려면 여기에 수준을 선언하세요." }, "claudeCode": { "pathLabel": "클로드 코드 경로", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 5253c994af..aef8fb32a2 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Redeneervermogen inschakelen", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Ondersteunde redeneervermogensniveaus", + "description": "De redeneervermogensniveaus die je model accepteert. Zelfgehostede en OpenAI-compatibele modellen adverteren deze mogelijkheid niet, dus verklaar je de niveaus hier om de redeneervermogen-besturing te gebruiken." }, "claudeCode": { "pathLabel": "Claude Code Pad", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ccdcf2e5d0..31b4aa028a 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Włącz wysiłek rozumowania", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Obsługiwane poziomy wysiłku rozumowania", + "description": "Poziomy wysiłku rozumowania akceptowane przez twój model. Modele hostowane samodzielnie i kompatybilne z OpenAI nie ogłaszają tej możliwości, dlatego zadeklaruj poziomy tutaj, aby używać kontrolek wysiłku rozumowania." }, "claudeCode": { "pathLabel": "Ścieżka Claude Code", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 54c774a071..14413a774a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Habilitar esforço de raciocínio", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Níveis de esforço de raciocínio suportados", + "description": "Níveis de esforço de raciocínio aceitos pelo seu modelo. Modelos self-hosted e compatíveis com OpenAI não anunciam essa capacidade, então declare os níveis aqui para usar os controles de esforço de raciocínio." }, "claudeCode": { "pathLabel": "Caminho do Claude Code", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 5efe1680fc..57910ff2a8 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Включить усилие рассуждения", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Поддерживаемые уровни усилия рассуждения", + "description": "Уровни усилия рассуждения, которые принимает ваша модель. Self-hosted и совместимые с OpenAI модели не сообщают об этой возможности, поэтому объявите уровни здесь, чтобы использовать элементы управления усилием рассуждения." }, "claudeCode": { "pathLabel": "Путь к Claude Code", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 7edec4d25f..634a964862 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Akıl Yürütme Çabasını Etkinleştir", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Desteklenen akıl yürütme çabası düzeyleri", + "description": "Modelinizin kabul ettiği akıl yürütme çabası düzeyleri. Kendi barındırmanızdaki ve OpenAI uyumlu modeller bu yeteneği belirtmez; akıl yürütme çabası kontrollerini kullanmak için düzeyleri burada tanımlayın." }, "claudeCode": { "pathLabel": "Claude Code Yolu", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 1100770da5..95de0074bf 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "Kích hoạt nỗ lực suy luận", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "Cấp độ nỗ lực suy luận được hỗ trợ", + "description": "Các cấp độ nỗ lực suy luận mà model của bạn chấp nhận. Các model tự lưu trữ và tương thích OpenAI không công bố khả năng này, vì vậy hãy khai báo các cấp độ ở đây để sử dụng các điều khiển nỗ lực suy luận." }, "claudeCode": { "pathLabel": "Đường dẫn Claude Code", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index a36c921a48..8f552e7ea3 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -679,8 +679,8 @@ }, "setReasoningLevel": "启用推理工作量", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "支持的推理工作量级别", + "description": "你的模型接受的推理工作量级别。自托管和 OpenAI 兼容模型不会声明其支持此能力,因此请在此处声明级别,以使用思考工作量控制。" }, "claudeCode": { "pathLabel": "Claude Code 路径", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index bf9fdb1818..0faaf46d6c 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -706,8 +706,8 @@ }, "setReasoningLevel": "啟用推理強度", "supportedEffortLevels": { - "label": "Supported Reasoning Effort Levels", - "description": "Reasoning effort levels your model accepts. Self-hosted and OpenAI-compatible models do not advertise this capability, so declare the levels here to use the thinking effort controls." + "label": "支援的推理強度等級", + "description": "你的模型接受的推理強度等級。自架設與 OpenAI 相容模型不會聲明其支援此功能,因此請在此處宣告等級,以使用思考強度控制。" }, "claudeCode": { "pathLabel": "Claude Code 路徑",