From a05830c7a98406f73c6a5e9a9a7149504a42c8fa Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 03:09:39 +0800 Subject: [PATCH 01/19] feat(experiments): add dynamic thinking effort experimental setting --- .../types/src/__tests__/experiment.test.ts | 19 ++++++++++++ packages/types/src/experiment.ts | 2 ++ src/shared/__tests__/experiments.spec.ts | 25 +++++++++++++++- src/shared/experiments.ts | 2 ++ .../__tests__/ExperimentalSettings.spec.tsx | 29 ++++++++++++++++++- webview-ui/src/i18n/locales/ca/settings.json | 4 +++ webview-ui/src/i18n/locales/de/settings.json | 4 +++ webview-ui/src/i18n/locales/en/settings.json | 4 +++ webview-ui/src/i18n/locales/es/settings.json | 4 +++ webview-ui/src/i18n/locales/fr/settings.json | 4 +++ webview-ui/src/i18n/locales/hi/settings.json | 4 +++ webview-ui/src/i18n/locales/id/settings.json | 4 +++ webview-ui/src/i18n/locales/it/settings.json | 4 +++ webview-ui/src/i18n/locales/ja/settings.json | 4 +++ webview-ui/src/i18n/locales/ko/settings.json | 4 +++ webview-ui/src/i18n/locales/nl/settings.json | 4 +++ webview-ui/src/i18n/locales/pl/settings.json | 4 +++ .../src/i18n/locales/pt-BR/settings.json | 4 +++ webview-ui/src/i18n/locales/ru/settings.json | 4 +++ webview-ui/src/i18n/locales/tr/settings.json | 4 +++ webview-ui/src/i18n/locales/vi/settings.json | 4 +++ .../src/i18n/locales/zh-CN/settings.json | 4 +++ .../src/i18n/locales/zh-TW/settings.json | 4 +++ 23 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/__tests__/experiment.test.ts diff --git a/packages/types/src/__tests__/experiment.test.ts b/packages/types/src/__tests__/experiment.test.ts new file mode 100644 index 0000000000..0ef4ed2e02 --- /dev/null +++ b/packages/types/src/__tests__/experiment.test.ts @@ -0,0 +1,19 @@ +import { experimentIds, experimentIdsSchema, experimentsSchema } from "../experiment.js" + +describe("dynamicThinkingEffort experiment", () => { + it("is part of the experiment id enum", () => { + expect(experimentIds).toContain("dynamicThinkingEffort") + expect(experimentIdsSchema.safeParse("dynamicThinkingEffort").success).toBe(true) + }) + + it("parses enabled and disabled states", () => { + expect(experimentsSchema.parse({ dynamicThinkingEffort: true })).toEqual({ dynamicThinkingEffort: true }) + expect(experimentsSchema.parse({ dynamicThinkingEffort: false })).toEqual({ dynamicThinkingEffort: false }) + expect(experimentsSchema.parse({})).toEqual({}) + }) + + it("rejects non-boolean values", () => { + expect(experimentsSchema.safeParse({ dynamicThinkingEffort: "yes" }).success).toBe(false) + expect(experimentIdsSchema.safeParse("dynamic-thinking-effort").success).toBe(false) + }) +}) diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 5d511859b1..f4b3a1c0a8 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -12,6 +12,7 @@ export const experimentIds = [ "runSlashCommand", "customTools", "parallelToolExecution", + "dynamicThinkingEffort", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({ runSlashCommand: z.boolean().optional(), customTools: z.boolean().optional(), parallelToolExecution: z.boolean().optional(), + dynamicThinkingEffort: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index f2261a5c09..84e4251639 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -2,7 +2,7 @@ import type { ExperimentId } from "@roo-code/types" -import { EXPERIMENT_IDS, experimentConfigsMap, experiments as Experiments } from "../experiments" +import { EXPERIMENT_IDS, experimentConfigsMap, experimentDefault, experiments as Experiments } from "../experiments" describe("experiments", () => { describe("PREVENT_FOCUS_DISRUPTION", () => { @@ -22,6 +22,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -33,6 +34,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) }) @@ -44,6 +46,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -66,4 +69,24 @@ describe("experiments", () => { expect(Experiments.isEnabled({ parallelToolExecution: true }, "parallelToolExecution")).toBe(true) }) }) + + describe("DYNAMIC_THINKING_EFFORT", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT).toBe("dynamicThinkingEffort") + expect(experimentConfigsMap.DYNAMIC_THINKING_EFFORT).toMatchObject({ + enabled: false, + }) + // Visible in the Settings panel (showInSettings defaults to true). + expect(experimentConfigsMap.DYNAMIC_THINKING_EFFORT.showInSettings).toBeUndefined() + }) + + it("is disabled by default", () => { + expect(experimentDefault.dynamicThinkingEffort).toBe(false) + expect(Experiments.isEnabled({}, "dynamicThinkingEffort")).toBe(false) + }) + + it("returns true when enabled", () => { + expect(Experiments.isEnabled({ dynamicThinkingEffort: true }, "dynamicThinkingEffort")).toBe(true) + }) + }) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index ae538b9138..c0d461a454 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -6,6 +6,7 @@ export const EXPERIMENT_IDS = { RUN_SLASH_COMMAND: "runSlashCommand", CUSTOM_TOOLS: "customTools", PARALLEL_TOOL_EXECUTION: "parallelToolExecution", + DYNAMIC_THINKING_EFFORT: "dynamicThinkingEffort", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -25,6 +26,7 @@ export const experimentConfigsMap: Record = { CUSTOM_TOOLS: { enabled: false }, // TODO: add i18n keys (settings:experimental.PARALLEL_TOOL_EXECUTION.name/.description) in the same PR that sets showInSettings: true PARALLEL_TOOL_EXECUTION: { enabled: false, showInSettings: false }, + DYNAMIC_THINKING_EFFORT: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx index b31f87dc7e..a1318a29ad 100644 --- a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" import { experimentDefault } from "@roo/experiments" @@ -32,4 +32,31 @@ describe("ExperimentalSettings", () => { expect(screen.getByText("settings:experimental.CUSTOM_TOOLS.name")).toBeInTheDocument() expect(screen.queryByText("settings:experimental.PARALLEL_TOOL_EXECUTION.name")).not.toBeInTheDocument() }) + + it("renders the dynamic thinking effort toggle", () => { + render() + + expect(screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name")).toBeInTheDocument() + }) + + it("binds the dynamic thinking effort toggle to setExperimentEnabled", () => { + const setExperimentEnabled = vi.fn() + render( + , + ) + + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + const checkbox = label?.querySelector("input[type='checkbox']") + expect(checkbox).not.toBeNull() + expect(checkbox).toBeChecked() + + fireEvent.click(checkbox!) + + expect(setExperimentEnabled).toHaveBeenCalledTimes(1) + expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", false) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index fa5cc11d65..f72be60431 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Eines actualitzades correctament", "refreshError": "Error en actualitzar les eines", "toolParameters": "Paràmetres" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 2cb83f7893..31cc01278a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Tools erfolgreich aktualisiert", "refreshError": "Fehler beim Aktualisieren der Tools", "toolParameters": "Parameter" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a5967792a1..304fe8b092 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1039,6 +1039,10 @@ "refreshSuccess": "Tools refreshed successfully", "refreshError": "Failed to refresh tools", "toolParameters": "Parameters" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 305a8dd5d7..f66114a5da 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Herramientas actualizadas correctamente", "refreshError": "Error al actualizar las herramientas", "toolParameters": "Parámetros" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index d5728833dd..3713a6cdcd 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Outils actualisés avec succès", "refreshError": "Échec de l'actualisation des outils", "toolParameters": "Paramètres" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 3fce97a378..86a050782c 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "टूल्स सफलतापूर्वक रिफ्रेश हुए", "refreshError": "टूल्स रिफ्रेश करने में विफल", "toolParameters": "पैरामीटर्स" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bcdd0ae76d..5e0890a3e5 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Tool berhasil direfresh", "refreshError": "Gagal merefresh tool", "toolParameters": "Parameter" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b22fb4c652..7786ff2c00 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Strumenti aggiornati con successo", "refreshError": "Impossibile aggiornare gli strumenti", "toolParameters": "Parametri" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index bbdc5c8e8a..e43177b8e4 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "ツールが正常に更新されました", "refreshError": "ツールの更新に失敗しました", "toolParameters": "パラメーター" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index c2062a5335..203467f924 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "도구가 성공적으로 새로고침되었습니다", "refreshError": "도구 새로고침에 실패했습니다", "toolParameters": "매개변수" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index cf148f5617..e02df845c0 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Tools succesvol vernieuwd", "refreshError": "Fout bij vernieuwen van tools", "toolParameters": "Parameters" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ace780f529..53fd2e4ece 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Narzędzia odświeżone pomyślnie", "refreshError": "Nie udało się odświeżyć narzędzi", "toolParameters": "Parametry" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 446aa8ac02..90dfa16302 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Ferramentas atualizadas com sucesso", "refreshError": "Falha ao atualizar ferramentas", "toolParameters": "Parâmetros" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index f2719ad06e..af5905ac37 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Инструменты успешно обновлены", "refreshError": "Не удалось обновить инструменты", "toolParameters": "Параметры" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 08374b6d20..43e0e00373 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Araçlar başarıyla yenilendi", "refreshError": "Araçlar yenilenemedi", "toolParameters": "Parametreler" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index a8611ca687..d6e2268962 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "Làm mới công cụ thành công", "refreshError": "Không thể làm mới công cụ", "toolParameters": "Thông số" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9f3913e872..e6a0772995 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -959,6 +959,10 @@ "refreshSuccess": "工具刷新成功", "refreshError": "工具刷新失败", "toolParameters": "参数" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 80d0d18735..2e99a8e940 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -986,6 +986,10 @@ "refreshSuccess": "工具重新整理成功", "refreshError": "工具重新整理失敗", "toolParameters": "參數" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "動態思考強度", + "description": "讓模型自行決定每一步的思考強度,並讓您在對話中調整。(實驗性)" } }, "promptCaching": { From 1cf4f0d4a7bde8d6d7f96edd768f47dc637d4319 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 05:31:20 +0800 Subject: [PATCH 02/19] test(experiments): cover explicit false and omitted dynamic thinking effort states --- src/shared/__tests__/experiments.spec.ts | 4 ++ .../__tests__/ExperimentalSettings.spec.tsx | 47 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 84e4251639..b6e8993df4 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -88,5 +88,9 @@ describe("experiments", () => { it("returns true when enabled", () => { expect(Experiments.isEnabled({ dynamicThinkingEffort: true }, "dynamicThinkingEffort")).toBe(true) }) + + it("returns false when explicitly disabled", () => { + expect(Experiments.isEnabled({ dynamicThinkingEffort: false }, "dynamicThinkingEffort")).toBe(false) + }) }) }) diff --git a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx index a1318a29ad..3feddad1d4 100644 --- a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx @@ -39,6 +39,32 @@ describe("ExperimentalSettings", () => { expect(screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name")).toBeInTheDocument() }) + it("leaves the dynamic thinking effort toggle unchecked when the value is false or omitted", () => { + const getCheckbox = () => { + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + return label?.querySelector("input[type='checkbox']") + } + + // Explicit false + let result = render( + , + ) + expect(getCheckbox()).not.toBeNull() + expect(getCheckbox()).not.toBeChecked() + result.unmount() + + // Omitted (absent from the persisted config) + const omitted: Record = { ...experimentDefault } + delete omitted.dynamicThinkingEffort + result = render() + expect(getCheckbox()).not.toBeNull() + expect(getCheckbox()).not.toBeChecked() + result.unmount() + }) + it("binds the dynamic thinking effort toggle to setExperimentEnabled", () => { const setExperimentEnabled = vi.fn() render( @@ -59,4 +85,25 @@ describe("ExperimentalSettings", () => { expect(setExperimentEnabled).toHaveBeenCalledTimes(1) expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", false) }) + + it("toggles the dynamic thinking effort on when clicked from the unchecked state", () => { + const setExperimentEnabled = vi.fn() + render( + , + ) + + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + const checkbox = label?.querySelector("input[type='checkbox']") + expect(checkbox).not.toBeNull() + expect(checkbox).not.toBeChecked() + + fireEvent.click(checkbox!) + + expect(setExperimentEnabled).toHaveBeenCalledTimes(1) + expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", true) + }) }) From 6ea45b36a2bf0cf7787fca11ebd1969da567e611 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 06:31:23 +0800 Subject: [PATCH 03/19] feat(task): task-local thinking effort state, per-request override, and adaptive effort envelope DTE series 2/5 (part of #1329). - ApiHandlerCreateMessageMetadata.reasoningEffort: per-request override channel - resolveEffectiveReasoningEffort: single shared resolution point (override > settings > model default) - AnthropicHandler: adaptive output_config.effort envelope in both requestParams branches (in-range only) - Task: setRuntimeThinkingEffort/getRuntimeThinkingEffort with in-memory apiConfiguration merge/restore, per-request metadata at all four createMessage sites, dispose() reset; never persisted --- src/api/index.ts | 9 + .../anthropic-adaptive-effort.spec.ts | 297 ++++++++++++++++++ src/api/providers/anthropic.ts | 29 +- .../dte-effective-reasoning-effort.spec.ts | 58 ++++ src/api/transform/reasoning.ts | 45 +++ src/core/task/Task.ts | 82 +++++ .../Task.runtime-thinking-effort.test.ts | 249 +++++++++++++++ 7 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts create mode 100644 src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts create mode 100644 src/core/task/__tests__/Task.runtime-thinking-effort.test.ts diff --git a/src/api/index.ts b/src/api/index.ts index 8e7f20d66f..d6a88971ba 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,6 +7,7 @@ import { retiredProviderIdentifiers, type ProviderSettings, type ModelInfo, + type ReasoningEffortExtended, } from "@roo-code/types" import { getRouterRemovalMessage } from "../core/config/routerRemoval" @@ -115,6 +116,14 @@ export interface ApiHandlerCreateMessageMetadata { * when the user clicks stop, preventing wasted API tokens/compute on the provider side. */ abortSignal?: AbortSignal + /** + * Per-request thinking effort override (DTE series 2/5). + * When defined, takes precedence over the settings-derived `reasoningEffort` + * wherever the effective effort is resolved (see `resolveEffectiveReasoningEffort`). + * Task-scoped and transient: it applies to this request only (the next request + * after being set — no mid-stream effect) and is never persisted to settings. + */ + reasoningEffort?: ReasoningEffortExtended } export interface ApiHandler { diff --git a/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts b/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts new file mode 100644 index 0000000000..7f37b8dcc9 --- /dev/null +++ b/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts @@ -0,0 +1,297 @@ +// npx vitest run src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts +// +// DTE series 2/5 — per-request adaptive thinking effort envelope +// (output_config.effort) on the main Anthropic handler. +// +// Kept in a dedicated file (rather than anthropic.spec.ts) so the DTE series PRs +// stay mergeable while other series PRs extend the shared spec file. + +import { AnthropicHandler } from "../anthropic" +import type { ApiHandlerOptions } from "../../../shared/api" +import type { ReasoningEffortExtended } from "@roo-code/types" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" +import type { ApiHandlerCreateMessageMetadata } from "../../../api" + +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), + }, + }, +})) + +const mockCreate = vitest.fn() + +// Same SDK mock pattern as anthropic.spec.ts: createMessage resolves to a short +// finite stream so the handler's for-await loop terminates cleanly. +vitest.mock("@anthropic-ai/sdk", () => { + const mockAnthropicConstructor = vitest.fn().mockImplementation(function () { + return { + messages: { + create: mockCreate.mockImplementation(async (options: { stream?: boolean; model?: string }) => { + if (!options.stream) { + return { + id: "test-completion", + content: [{ type: "text", text: "Test response" }], + role: "assistant", + model: options.model, + usage: { input_tokens: 10, output_tokens: 5 }, + } + } + return asyncStreamFrom([ + { + type: "message_start", + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "Hello" }, + }, + { + type: "content_block_delta", + delta: { type: "text_delta", text: " world" }, + }, + ]) + }), + }, + } + }) + + return { + Anthropic: mockAnthropicConstructor, + } +}) + +const userMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: "Hi" }], +} + +/** Runs createMessage to completion and returns the request params sent to the SDK. */ +async function sentRequestParams( + handler: AnthropicHandler, + metadata?: ApiHandlerCreateMessageMetadata, +): Promise> { + const stream = handler.createMessage("system prompt", [userMessage], metadata) + await collectStream(stream) + const call = mockCreate.mock.calls.at(-1) + if (!call) { + throw new Error("Expected the SDK messages.create to have been called") + } + return call[0] as Record +} + +function makeHandler(options: { + apiModelId?: string + enableReasoningEffort?: boolean + reasoningEffort?: ApiHandlerOptions["reasoningEffort"] +}): AnthropicHandler { + return new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: options.apiModelId ?? "claude-opus-4-7", + enableReasoningEffort: options.enableReasoningEffort, + reasoningEffort: options.reasoningEffort, + }) +} + +describe("AnthropicHandler adaptive effort envelope (DTE series 2/5)", () => { + beforeEach(() => { + clearAllMocks() + }) + + describe("output_config.effort on adaptive-thinking requests", () => { + const inRangeEfforts: ReasoningEffortExtended[] = ["low", "medium", "high", "xhigh", "max"] + + it.each(inRangeEfforts)( + "sends the settings effort %s as output_config.effort for an adaptive model", + async (effort) => { + const handler = makeHandler({ enableReasoningEffort: true, reasoningEffort: effort }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort }) + }, + ) + + it("sends the envelope from the first (cache-control) requestParams branch", async () => { + // claude-opus-4-8 takes the first (cache-control) requestParams branch; + // the default branch is covered below via an unknown model id. + const handler = makeHandler({ + apiModelId: "claude-opus-4-8", + enableReasoningEffort: true, + reasoningEffort: "xhigh", + }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort: "xhigh" }) + }) + + it("sends the envelope from the default requestParams branch", async () => { + // Unknown model id -> falls through to the default switch branch, while the + // guessed model info (claude-opus-4-7 substring) is adaptive-capable. + const handler = makeHandler({ + apiModelId: "claude-opus-4-7-custom", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + const params = await sentRequestParams(handler) + + expect(params.model).toBe("claude-opus-4-7-custom") + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort: "high" }) + }) + }) + + describe("envelope omission (out-of-range or non-adaptive)", () => { + const settingsEfforts: ApiHandlerOptions["reasoningEffort"][] = ["none", "minimal", "disable"] + + it.each(settingsEfforts)( + "omits output_config when the settings effort is %s on an adaptive model", + async (effort) => { + const handler = makeHandler({ enableReasoningEffort: true, reasoningEffort: effort }) + + const params = await sentRequestParams(handler) + + // Adaptive thinking is still requested, but no envelope is sent so the + // API applies its own default effort. + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }, + ) + + it("omits output_config when no effort is set anywhere on an adaptive model", async () => { + const handler = makeHandler({ enableReasoningEffort: true }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }) + + it("omits output_config for a non-adaptive model even with an in-range effort", async () => { + // Budget-based extended thinking (type: "enabled") never carries the + // adaptive envelope. + const handler = makeHandler({ + apiModelId: "claude-sonnet-4-5", + enableReasoningEffort: true, + reasoningEffort: "xhigh", + }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toMatchObject({ type: "enabled" }) + expect(params).not.toHaveProperty("output_config") + }) + + it("omits output_config when adaptive thinking itself is not requested", async () => { + // enableReasoningEffort=false -> thinking is undefined -> no envelope even + // with an in-range settings effort. + const handler = makeHandler({ enableReasoningEffort: false, reasoningEffort: "xhigh" }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toBeUndefined() + expect(params).not.toHaveProperty("output_config") + }) + + it("keeps the pre-DTE request shape for a plain model with no reasoning settings", async () => { + // Guard: no reasoning settings and no metadata -> no output_config. + const handler = makeHandler({ apiModelId: "claude-3-5-haiku-20241022" }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toBeUndefined() + expect(params).not.toHaveProperty("output_config") + }) + }) + + describe("per-request override (metadata.reasoningEffort) precedence", () => { + const baseOptions: { + apiModelId?: string + enableReasoningEffort?: boolean + reasoningEffort?: ApiHandlerOptions["reasoningEffort"] + } = { + apiModelId: "claude-opus-4-7", + enableReasoningEffort: true, + } + + it("lets metadata.reasoningEffort override the settings value", async () => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "low" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "xhigh", + }) + + expect(params.output_config).toEqual({ effort: "xhigh" }) + }) + + it("suppresses the envelope when the metadata override is out-of-range", async () => { + // Settings would send "high"; the override wins and is out-of-range, so + // the envelope is omitted entirely. + const handler = makeHandler({ ...baseOptions, reasoningEffort: "high" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "minimal", + }) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }) + + const overrideEfforts: ReasoningEffortExtended[] = ["none", "minimal"] + + it.each(overrideEfforts)( + "suppresses the envelope for metadata override %s even with an in-range settings value", + async (effort) => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "max" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: effort, + }) + + expect(params).not.toHaveProperty("output_config") + }, + ) + + it("applies the settings value when metadata carries no override", async () => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "medium" }) + + const params = await sentRequestParams(handler, { taskId: "task-1" }) + + expect(params.output_config).toEqual({ effort: "medium" }) + }) + + it("keeps non-adaptive requests envelope-free even with a metadata override", async () => { + const handler = makeHandler({ + apiModelId: "claude-sonnet-4-5", + enableReasoningEffort: true, + reasoningEffort: "low", + }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "xhigh", + }) + + expect(params.thinking).toMatchObject({ type: "enabled" }) + expect(params).not.toHaveProperty("output_config") + }) + }) +}) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index b55c8b3089..2e9555cc8e 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -18,7 +18,11 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" -import { getAnthropicProviderReasoning } from "../transform/reasoning" +import { + ADAPTIVE_OUTPUT_CONFIG_EFFORTS, + getAnthropicProviderReasoning, + resolveEffectiveReasoningEffort, +} from "../transform/reasoning" import { handleProviderError } from "./utils/error-handler" import { BaseProvider } from "./base-provider" @@ -79,6 +83,25 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa settings: this.options, }) + // DTE series 2/5: per-request adaptive effort envelope (output_config.effort). + // The task-local per-request override (metadata.reasoningEffort) takes + // precedence over the settings-derived value (shared resolution in + // resolveEffectiveReasoningEffort). Only adaptive-thinking requests whose + // effective effort is in-range get the envelope; everything else (unset, + // "disable", "none", "minimal") omits it and lets the API apply its default. + const effectiveReasoningEffort = resolveEffectiveReasoningEffort({ + override: metadata?.reasoningEffort, + settingsReasoningEffort: this.options.reasoningEffort, + modelDefaultEffort: info.reasoningEffort, + }) + const adaptiveEffort = + thinking?.type === "adaptive" && + effectiveReasoningEffort !== undefined && + effectiveReasoningEffort !== "disable" && + ADAPTIVE_OUTPUT_CONFIG_EFFORTS.includes(effectiveReasoningEffort) + ? effectiveReasoningEffort + : undefined + // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API const sanitizedMessages = filterNonAnthropicBlocks(messages) @@ -141,6 +164,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), // Setting cache breakpoint for system prompt so new tasks can reuse it. system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], messages: sanitizedMessages.map((message, index) => { @@ -216,6 +241,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), system: [{ text: systemPrompt, type: "text" }], messages: sanitizedMessages, stream: true, diff --git a/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts new file mode 100644 index 0000000000..f126cae97c --- /dev/null +++ b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts @@ -0,0 +1,58 @@ +// npx vitest run src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts + +import { ADAPTIVE_OUTPUT_CONFIG_EFFORTS, resolveEffectiveReasoningEffort } from "../reasoning" + +describe("DTE series 2/5 — resolveEffectiveReasoningEffort", () => { + const settingsEffort = "high" + const modelDefault = "medium" + + it("returns the per-request override when present (strongest precedence)", () => { + expect( + resolveEffectiveReasoningEffort({ + override: "xhigh", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("xhigh") + }) + + it("lets the override win even when it is out-of-range for the adaptive envelope", () => { + // "minimal" is a valid override value but outside the adaptive envelope set; + // resolution still returns it — envelope gating is the caller's concern. + expect( + resolveEffectiveReasoningEffort({ + override: "minimal", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("minimal") + }) + + it("falls back to the settings value when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "low", modelDefaultEffort: modelDefault }), + ).toBe("low") + }) + + it("preserves the settings 'disable' sentinel when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "disable", modelDefaultEffort: modelDefault }), + ).toBe("disable") + }) + + it("an explicit override wins over a settings 'disable' sentinel", () => { + expect(resolveEffectiveReasoningEffort({ override: "low", settingsReasoningEffort: "disable" })).toBe("low") + }) + + it("falls back to the model default when neither override nor settings is set", () => { + expect(resolveEffectiveReasoningEffort({ modelDefaultEffort: "low" })).toBe("low") + }) + + it("returns undefined when nothing is set", () => { + expect(resolveEffectiveReasoningEffort({})).toBeUndefined() + }) + + it("exposes exactly the in-range adaptive envelope efforts", () => { + expect([...ADAPTIVE_OUTPUT_CONFIG_EFFORTS]).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) +}) diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index c51111125a..14bdaba889 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -22,6 +22,51 @@ export type AnthropicProviderReasoningParams = AnthropicReasoningParams | { type export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] } +/** + * DTE series 2/5 — effort levels accepted by the Claude 4.7+ adaptive-thinking + * `output_config.effort` envelope. Efforts outside this set (e.g. "none", + * "minimal", "disable") omit the envelope so the API applies its own default. + */ +export const ADAPTIVE_OUTPUT_CONFIG_EFFORTS: readonly ReasoningEffortExtended[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +] + +/** + * DTE series 2/5 — resolves the effective thinking effort for a single request. + * + * Resolution order (strongest first): + * 1. `override` — the per-request task-local effort + * (`ApiHandlerCreateMessageMetadata.reasoningEffort`), + * 2. `settingsReasoningEffort` — the settings-derived value, + * 3. `modelDefaultEffort` — the model's default effort. + * + * This is the single shared resolution point for the per-request override: + * providers that resolve the effective effort through it inherit the override + * without duplicating precedence logic. The override is transient (next request + * only) and never persisted to settings. + */ +export const resolveEffectiveReasoningEffort = ({ + override, + settingsReasoningEffort, + modelDefaultEffort, +}: { + override?: ReasoningEffortExtended + settingsReasoningEffort?: ReasoningEffortExtended | "disable" + modelDefaultEffort?: ReasoningEffortExtended +}): ReasoningEffortExtended | "disable" | undefined => { + if (override !== undefined) { + return override + } + if (settingsReasoningEffort !== undefined) { + return settingsReasoningEffort + } + return modelDefaultEffort +} + // Valid Gemini thinking levels for effort-based reasoning const GEMINI_THINKING_LEVELS = ["minimal", "low", "medium", "high"] as const diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4be087394e..2a139923c1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -22,6 +22,7 @@ import { type TaskMetadata, type TaskEvents, type ProviderSettings, + type ReasoningEffortExtended, type TokenUsage, type ToolUsage, type ToolName, @@ -289,6 +290,13 @@ export class Task extends EventEmitter implements TaskLike { // API apiConfiguration: ProviderSettings api: ApiHandler + // DTE series 2/5: task-local thinking effort override. Transient per-task state — + // never persisted to settings; cleared on dispose (see dispose()). + private runtimeThinkingEffort?: ReasoningEffortExtended + private runtimeThinkingEffortSource?: string + // Settings-derived effort captured when the override activates, so clearing + // (undefined) restores it in the in-memory apiConfiguration copy. + private preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -1521,6 +1529,66 @@ export class Task extends EventEmitter implements TaskLike { this.api = buildApiHandler(this.apiConfiguration) } + /** + * DTE series 2/5: sets — or clears with `undefined` — the task-local thinking + * effort override. + * + * Resolution order for the affected requests (strongest first): this + * task-local override → settings `reasoningEffort` → model default. The + * override applies to the NEXT API request only (no mid-stream effect): it is + * passed per request as `metadata.reasoningEffort` and, while active, is + * merged into the in-memory `apiConfiguration` copy (profile-switch / + * `updateApiConfiguration` precedent) so the rebuilt handler reflects it too. + * `undefined` clears the override and restores the settings-derived value in + * the copy. Nothing is ever written to persisted settings. + * + * @param effort - The task-local effort, or `undefined` to clear. + * @param source - Optional provenance label (UI wiring lands in a later PR). + */ + public setRuntimeThinkingEffort(effort: ReasoningEffortExtended | undefined, source?: string): void { + const wasActive = this.runtimeThinkingEffort !== undefined + this.runtimeThinkingEffort = effort + this.runtimeThinkingEffortSource = effort === undefined ? undefined : source + + if (effort !== undefined) { + // Capture the settings-derived value once so clearing can restore it. + if (!wasActive) { + this.preOverrideReasoningEffort = this.apiConfiguration.reasoningEffort + } + // Merge into the in-memory copy (never the persisted settings object). + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: effort } + } else if (wasActive) { + // Restore the settings-derived value captured when the override activated. + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: this.preOverrideReasoningEffort } + this.preOverrideReasoningEffort = undefined + } else { + // Already inactive: nothing to clear. + return + } + + // Rebuild the handler from the updated copy so the next request uses it. + this.api = buildApiHandler(this.apiConfiguration) + } + + /** + * DTE series 2/5: reads the current task-local thinking effort override. + */ + public getRuntimeThinkingEffort(): { effort?: ReasoningEffortExtended; source?: string } { + return { + effort: this.runtimeThinkingEffort, + source: this.runtimeThinkingEffortSource, + } + } + + /** + * DTE series 2/5: metadata fragment carrying the active task-local effort + * override on a single request. Empty when no override is active, so the + * existing settings resolution applies unchanged. + */ + private getRuntimeThinkingEffortMetadata(): Pick { + return this.runtimeThinkingEffort !== undefined ? { reasoningEffort: this.runtimeThinkingEffort } : {} + } + public async submitUserMessage( text: string, images?: string[], @@ -1637,6 +1705,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Generate environment details to include in the condensed summary const environmentDetails = await getEnvironmentDetails(this, true) @@ -2295,6 +2365,12 @@ export class Task extends EventEmitter implements TaskLike { public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // DTE series 2/5: the task-local effort override is transient — clear it on + // task end so a disposed task never carries it forward. + this.runtimeThinkingEffort = undefined + this.runtimeThinkingEffortSource = undefined + this.preOverrideReasoningEffort = undefined + // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. @@ -3955,6 +4031,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } try { @@ -4181,6 +4259,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Only generate environment details when context management will actually run. @@ -4346,6 +4426,8 @@ export class Task extends EventEmitter implements TaskLike { taskId: this.taskId, suppressPreviousResponseId: this.skipPrevResponseIdOnce, abortSignal, + // DTE series 2/5: carry the active task-local effort override for this request. + ...this.getRuntimeThinkingEffortMetadata(), // Include tools whenever they are present. ...(shouldIncludeTools ? { diff --git a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts new file mode 100644 index 0000000000..2ff7e046f8 --- /dev/null +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -0,0 +1,249 @@ +// npx vitest run src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +// +// DTE series 2/5 — task-local thinking effort state on Task: +// setRuntimeThinkingEffort / getRuntimeThinkingEffort, the in-memory +// apiConfiguration merge + restore, and the task-end reset in dispose(). + +import { ProviderSettings, type ReasoningEffortExtended } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { buildApiHandler } from "../../../api" + +// Mock dependencies (same lightweight set as Task.throttle.test.ts) +vi.mock("../../webview/ClineProvider") +vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") +vi.mock("../../context-tracking/FileContextTracker") +vi.mock("../../../integrations/editor/DiffViewProvider") +vi.mock("../../tools/ToolRepetitionDetector") +vi.mock("../../../api", () => ({ + // Returns a fresh handler object per call so tests can assert on the exact + // configuration each rebuild received (via vi.mocked(buildApiHandler).mock.calls). + buildApiHandler: vi.fn((configuration: { apiModelId?: string }) => ({ + getModel: () => ({ info: {}, id: configuration.apiModelId ?? "test-model" }), + })), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + }, + }, +})) + +// Mock task persistence to avoid disk writes +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + readTaskMessages: vi.fn().mockResolvedValue([]), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + taskMetadata: vi.fn().mockResolvedValue({ + historyItem: { + id: "test-task-id", + number: 1, + task: "Test task", + ts: Date.now(), + totalCost: 0.01, + tokensIn: 100, + tokensOut: 50, + }, + tokenUsage: { + totalTokensIn: 100, + totalTokensOut: 50, + totalCost: 0.01, + contextTokens: 150, + totalCacheWrites: 0, + totalCacheReads: 0, + }, + }), +})) + +// Typed access to the intentionally-private DTE state, mirroring the +// getTaskTestAccess pattern in Task.spec.ts (single double assertion, documented). +type RuntimeThinkingEffortAccess = { + runtimeThinkingEffort?: ReasoningEffortExtended + runtimeThinkingEffortSource?: string + preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] + getRuntimeThinkingEffortMetadata: () => { reasoningEffort?: ReasoningEffortExtended } +} + +function getPrivateAccess(task: Task): RuntimeThinkingEffortAccess { + return task as unknown as RuntimeThinkingEffortAccess +} + +const SETTINGS_EFFORT: ReasoningEffortExtended = "low" + +describe("Task runtime thinking effort (DTE series 2/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/path" }, + }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + } as ProviderSettings + + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + startTask: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("setRuntimeThinkingEffort", () => { + it("stores effort + source, merges into the in-memory apiConfiguration, and rebuilds the handler", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + expect(getPrivateAccess(task).runtimeThinkingEffort).toBe("xhigh") + expect(getPrivateAccess(task).runtimeThinkingEffortSource).toBe("test-source") + + // The in-memory copy carries the override... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + reasoningEffort: "xhigh", + }), + ) + // ...without mutating the settings object the provider handed in. + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + reasoningEffort: SETTINGS_EFFORT, + }), + ) + // The handler is rebuilt from the merged copy (last build call). + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "xhigh" })) + // The merged copy is a fresh object, not the settings object. + expect(lastCall?.[0]).not.toBe(mockApiConfiguration) + }) + + it("does not re-capture the settings value when re-set while active", () => { + task.setRuntimeThinkingEffort("high", "first") + task.setRuntimeThinkingEffort("medium", "second") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "medium", source: "second" }) + // The settings-derived value captured at first activation is preserved. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe(SETTINGS_EFFORT) + + // Clearing restores the original settings value, not the intermediate one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + }) + + it("restores the settings-derived effort when cleared with undefined", () => { + task.setRuntimeThinkingEffort("max") + expect(task.apiConfiguration.reasoningEffort).toBe("max") + + task.setRuntimeThinkingEffort(undefined) + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + // The rest of the configuration is preserved through the restore. + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + }), + ) + // The handler is rebuilt from the restored copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: SETTINGS_EFFORT })) + }) + + it("is a no-op when cleared while inactive (no handler rebuild)", () => { + const callsBefore = vi.mocked(buildApiHandler).mock.calls.length + + task.setRuntimeThinkingEffort(undefined) + + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(callsBefore) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration).toBe(mockApiConfiguration) + }) + + it("never writes to the provider or persisted settings", () => { + task.setRuntimeThinkingEffort("xhigh") + task.setRuntimeThinkingEffort(undefined) + + // Nothing is posted to the webview and the handed-in settings object is intact. + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + }), + ) + }) + }) + + describe("request metadata fragment", () => { + it("is empty while inactive and carries the override while active", () => { + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + + task.setRuntimeThinkingEffort("high") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "high" }) + + task.setRuntimeThinkingEffort("low") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "low" }) + + task.setRuntimeThinkingEffort(undefined) + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + }) + }) + + describe("dispose", () => { + it("clears the task-local override at task end", () => { + task.setRuntimeThinkingEffort("xhigh", "source") + task.dispose() + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + const access = getPrivateAccess(task) + expect(access.runtimeThinkingEffort).toBeUndefined() + expect(access.runtimeThinkingEffortSource).toBeUndefined() + expect(access.preOverrideReasoningEffort).toBeUndefined() + }) + }) +}) From 14d1f35a8e1ec1f9d15567ed3c483b66477ddb61 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 08:38:27 +0800 Subject: [PATCH 04/19] fix(task): keep override restore value current across profile switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTE series 2/5 — addresses the CodeRabbit review finding on #1338: when a task-local thinking-effort override is active, updateApiConfiguration() now re-captures the incoming profile's reasoningEffort as the restore value and re-applies the override on top of the new in-memory copy, so clearing the override restores the NEW profile value instead of the stale one. Additive: activation and clearing semantics are otherwise unchanged. Adds two regression tests (override active + profile switch restores new value; inactive updateApiConfiguration unchanged behavior). --- src/core/task/Task.ts | 11 +++- .../Task.runtime-thinking-effort.test.ts | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2a139923c1..ac0e321382 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1525,7 +1525,16 @@ export class Task extends EventEmitter implements TaskLike { */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { // Update the configuration and rebuild the API handler - this.apiConfiguration = newApiConfiguration + if (this.runtimeThinkingEffort !== undefined) { + // DTE series 2/5: a task-local override is active, so re-capture the + // incoming profile's value as the restore value and re-apply the + // override on top of the new in-memory copy — clearing the override + // must restore the NEW profile's value, not the stale one. + this.preOverrideReasoningEffort = newApiConfiguration.reasoningEffort + this.apiConfiguration = { ...newApiConfiguration, reasoningEffort: this.runtimeThinkingEffort } + } else { + this.apiConfiguration = newApiConfiguration + } this.api = buildApiHandler(this.apiConfiguration) } diff --git a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts index 2ff7e046f8..4fce91b475 100644 --- a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -219,6 +219,68 @@ describe("Task runtime thinking effort (DTE series 2/5)", () => { }) }) + describe("updateApiConfiguration while an override is active", () => { + it("re-captures the incoming profile's effort as the restore value and keeps the override applied", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + // A profile switch lands a different settings-derived effort while the override is active. + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + task.updateApiConfiguration(newConfig) + + // The override still wins in the in-memory copy... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "xhigh", + }), + ) + // ...the override remains active... + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + // ...and the NEW profile's value is now the restore target. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe("medium") + // The handler was rebuilt from the merged new copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual( + expect.objectContaining({ apiModelId: "claude-opus-4-8", reasoningEffort: "xhigh" }), + ) + expect(lastCall?.[0]).not.toBe(newConfig) + + // Clearing restores the NEW profile's effort, not the stale original one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + }), + ) + const lastCallAfterClear = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCallAfterClear?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "medium" })) + }) + + it("replaces the configuration as usual while inactive", () => { + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + + task.updateApiConfiguration(newConfig) + + expect(task.apiConfiguration).toBe(newConfig) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toBe(newConfig) + }) + }) + describe("request metadata fragment", () => { it("is empty while inactive and carries the override while active", () => { expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) From 90b47b05399b2dabe299937946be20eb92f5dc9a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 09:38:00 +0800 Subject: [PATCH 05/19] docs(task): JSDoc for diff-touched functions flagged by CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTE series 2/5 — addresses the CodeRabbit docstring-coverage warning on #1338 (33.33% < 80% across the functions touched by the diff): - AnthropicHandler.createMessage: documents the shared effective-effort resolution and the adaptive output_config.effort envelope (in-range only). - Task.dispose: documents centralized teardown incl. the transient task-local override reset. - Task.updateApiConfiguration: documents the override-preservation behavior (re-captured restore value + re-applied override on the new in-memory copy). Comment-only change: 30/30 patch lines and 10/10 branches unchanged; 317/317 tests and tsc --noEmit re-verified green. --- src/api/providers/anthropic.ts | 15 +++++++++++++++ src/core/task/Task.ts | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 2e9555cc8e..c0843d29a8 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -62,6 +62,21 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa }) } + /** + * Creates a streaming Anthropic message for the current model. + * + * Resolves the effective thinking effort for this request through the shared + * `resolveEffectiveReasoningEffort` point (per-request override → settings → + * model default). For adaptive-thinking models, when the resolved effort is one + * of `ADAPTIVE_OUTPUT_CONFIG_EFFORTS` (low|medium|high|xhigh|max), the request + * carries `output_config: { effort }` (DTE series 2/5); out-of-range or unset + * efforts omit it so the API default applies. + * + * @param systemPrompt - The system prompt for the request. + * @param messages - The message history to send. + * @param metadata - Per-request metadata (carries the task-local effort override). + * @returns An async iterator of parsed Anthropic stream events. + */ async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ac0e321382..e448cb16bc 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1521,6 +1521,12 @@ export class Task extends EventEmitter implements TaskLike { * Updates the API configuration and rebuilds the API handler. * There is no tool-protocol switching or tool parser swapping. * + * DTE series 2/5: when a task-local thinking effort override is active + * (`setRuntimeThinkingEffort`), the incoming configuration's `reasoningEffort` + * becomes the new restore value and the override is re-applied on top of the + * fresh in-memory copy — clearing the override later restores the NEW profile's + * value, not a stale one. + * * @param newApiConfiguration - The new API configuration to use */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { @@ -2371,6 +2377,13 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Centralized task teardown: releases task resources and resets transient + * task-local state. + * + * DTE series 2/5: also clears the task-local thinking effort override (the + * `setRuntimeThinkingEffort` state) — the override never outlives the task. + */ public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) From fcc3cf453ada33bf08dbefd182e8ba46c7e58ae2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 14:04:53 +0800 Subject: [PATCH 06/19] feat(task): set_thinking_effort native tool Add the set_thinking_effort native tool (DTE series 3/5): the model adjusts its own per-turn thinking effort mid-task with no approval gate. - Guardrails: one-line chat notification (success or refusal), escalation cap (max 3 upward changes per task), A->B->A oscillation refusal, hard clamp to the model capability array (ties toward the lower level). - Gating: dynamicThinkingEffort experiment + model supportsReasoningEffort (non-empty array or true), evaluated at task start so the tool list stays stable within a task (prompt-cache safety). - Display: webview ChatRow one-line row (applied / oscillation / escalation refusal), i18n keys in all 17 locales; partial streaming updates the same line. - Tests: executor (clamp/cap/oscillation/no-op/no-approval/display), parser (partial + complete), dispatch, gating matrix, schema wiring, ChatRow display. Stacked on DTE PR-1 (experiment flag) and PR-2 (task-local runtime effort state). Closes Zoo-Code-Org/Zoo-Code#1330. --- packages/types/src/tool.ts | 1 + packages/types/src/vscode-extension-host.ts | 4 + .../assistant-message/NativeToolCallParser.ts | 18 + ...veToolCallParser.setThinkingEffort.spec.ts | 89 +++++ ...AssistantMessage-setThinkingEffort.spec.ts | 204 ++++++++++ .../presentAssistantMessage.ts | 12 + .../__tests__/filter-thinking-effort.spec.ts | 137 +++++++ .../prompts/tools/filter-tools-for-mode.ts | 38 ++ src/core/prompts/tools/native-tools/index.ts | 2 + .../tools/native-tools/set_thinking_effort.ts | 49 +++ src/core/tools/SetThinkingEffortTool.ts | 278 +++++++++++++ .../__tests__/setThinkingEffortTool.spec.ts | 378 ++++++++++++++++++ src/shared/tools.ts | 4 + webview-ui/src/components/chat/ChatRow.tsx | 26 ++ .../ChatRow.thinking-effort.spec.tsx | 110 +++++ webview-ui/src/i18n/locales/ca/chat.json | 5 + webview-ui/src/i18n/locales/de/chat.json | 5 + webview-ui/src/i18n/locales/en/chat.json | 5 + webview-ui/src/i18n/locales/es/chat.json | 5 + webview-ui/src/i18n/locales/fr/chat.json | 5 + webview-ui/src/i18n/locales/hi/chat.json | 5 + webview-ui/src/i18n/locales/id/chat.json | 5 + webview-ui/src/i18n/locales/it/chat.json | 5 + webview-ui/src/i18n/locales/ja/chat.json | 5 + webview-ui/src/i18n/locales/ko/chat.json | 5 + webview-ui/src/i18n/locales/nl/chat.json | 5 + webview-ui/src/i18n/locales/pl/chat.json | 5 + webview-ui/src/i18n/locales/pt-BR/chat.json | 5 + webview-ui/src/i18n/locales/ru/chat.json | 5 + webview-ui/src/i18n/locales/tr/chat.json | 5 + webview-ui/src/i18n/locales/vi/chat.json | 5 + webview-ui/src/i18n/locales/zh-CN/chat.json | 5 + webview-ui/src/i18n/locales/zh-TW/chat.json | 5 + 33 files changed, 1440 insertions(+) create mode 100644 src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts create mode 100644 src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts create mode 100644 src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts create mode 100644 src/core/prompts/tools/native-tools/set_thinking_effort.ts create mode 100644 src/core/tools/SetThinkingEffortTool.ts create mode 100644 src/core/tools/__tests__/setThinkingEffortTool.spec.ts create mode 100644 webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index d89a8107c1..712dc8adf4 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -45,6 +45,7 @@ export const toolNames = [ "run_slash_command", "skill", "generate_image", + "set_thinking_effort", "custom_tool", "invalid_tool_call", ] as const diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 337ad22e2c..136f504216 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -835,6 +835,7 @@ export interface ClineSayTool { | "runSlashCommand" | "updateTodoList" | "skill" + | "thinkingEffort" path?: string // For readCommandOutput readStart?: number @@ -892,6 +893,9 @@ export interface ClineSayTool { description?: string // Properties for skill tool skill?: string + // Properties for thinkingEffort (DTE series 3/5) + effort?: string + refusal?: string } export interface ClineAskUseMcpServer { diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 9639ae1baa..c3e74c2c3b 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -510,6 +510,15 @@ export class NativeToolCallParser { } break + case "set_thinking_effort": + if (partialArgs.effort !== undefined || partialArgs.reason !== undefined) { + nativeArgs = { + effort: partialArgs.effort, + reason: partialArgs.reason, + } + } + break + case "run_slash_command": if (partialArgs.command !== undefined) { nativeArgs = { @@ -852,6 +861,15 @@ export class NativeToolCallParser { } break + case "set_thinking_effort": + if (args.effort !== undefined && args.reason !== undefined) { + nativeArgs = { + effort: args.effort, + reason: args.reason, + } as NativeArgsFor + } + break + case "run_slash_command": if (args.command !== undefined) { nativeArgs = { diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts new file mode 100644 index 0000000000..dff4bdfbb0 --- /dev/null +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts @@ -0,0 +1,89 @@ +// npx vitest run src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts +// +// DTE series 3/5 — set_thinking_effort parsing in NativeToolCallParser: +// complete, partial-streaming, and finalize paths. + +import { NativeToolCallParser } from "../NativeToolCallParser" + +describe("NativeToolCallParser — set_thinking_effort", () => { + beforeEach(() => { + NativeToolCallParser.clearAllStreamingToolCalls() + NativeToolCallParser.clearRawChunkState() + }) + + describe("parseToolCall (complete)", () => { + it("parses effort and reason into nativeArgs", () => { + const toolCall = { + id: "toolu_dte_1", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + if (result?.type === "tool_use") { + expect(result.name).toBe("set_thinking_effort") + expect(result.nativeArgs).toEqual({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }) + expect(result.params).toEqual({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }) + } + }) + + it("returns null when the required reason is missing", () => { + const toolCall = { + id: "toolu_dte_2", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ effort: "high" }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + expect(result).toBeNull() + }) + }) + + describe("processStreamingChunk (partial)", () => { + it("emits a partial ToolUse carrying the streamed effort", () => { + const id = "toolu_dte_stream_1" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + const result = NativeToolCallParser.processStreamingChunk( + id, + JSON.stringify({ effort: "high", reason: "escalating" }), + ) + + expect(result).not.toBeNull() + const nativeArgs = result?.nativeArgs as { effort?: string; reason?: string } | undefined + expect(nativeArgs).toBeDefined() + expect(nativeArgs?.effort).toBe("high") + expect(nativeArgs?.reason).toBe("escalating") + }) + }) + + describe("finalizeStreamingToolCall", () => { + it("parses complete args on finalize", () => { + const id = "toolu_dte_final_1" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ effort: "low", reason: "mechanical step" })) + + const result = NativeToolCallParser.finalizeStreamingToolCall(id) + + expect(result).not.toBeNull() + if (result?.type === "tool_use") { + expect(result.nativeArgs).toEqual({ + effort: "low", + reason: "mechanical step", + }) + } + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts new file mode 100644 index 0000000000..b5df7e68f6 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts @@ -0,0 +1,204 @@ +// npx vitest run src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts +// +// DTE series 3/5 — set_thinking_effort dispatch in presentAssistantMessage: +// a completed native tool_use block is routed to SetThinkingEffortTool.handle +// with the standard callbacks (no approval gate). + +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import type { ModelInfo } from "@roo-code/types" + +import { presentAssistantMessage } from "../presentAssistantMessage" +import { setThinkingEffortTool } from "../../tools/SetThinkingEffortTool" +import type { Task } from "../../task/Task" + +// Mock dependencies +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn((toolName: string) => toolName === "set_thinking_effort"), +})) +// The mock handler mirrors the real tool: it pushes exactly one tool result +// through the callbacks (the pushToolResultToUserContent mock records it). +vi.mock("../../tools/SetThinkingEffortTool", () => ({ + setThinkingEffortTool: { + handle: vi.fn( + async (_task: unknown, _block: unknown, callbacks: { pushToolResult: (content: string) => void }) => { + callbacks.pushToolResult("Thinking effort applied") + }, + ), + }, +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +/** Structural double covering every Task surface this dispatch path touches. */ +interface PamTaskDouble { + taskId: string + instanceId: string + abort: boolean + presentAssistantMessageLocked: boolean + presentAssistantMessageHasPendingUpdates: boolean + currentStreamingContentIndex: number + assistantMessageContent: unknown[] + userMessageContent: unknown[] + didCompleteReadingStream: boolean + didRejectTool: boolean + didAlreadyUseTool: boolean + consecutiveMistakeCount: number + clineMessages: unknown[] + api: { getModel: () => { id: string; info: ModelInfo } } + recordToolUsage: Mock + recordToolError: Mock + toolRepetitionDetector: { check: Mock } + providerRef: { + deref: () => { + getState: () => Promise<{ mode: string; customModes: unknown[] }> + } + } + say: Mock + ask: Mock + pushToolResultToUserContent: Mock +} + +describe("presentAssistantMessage - set_thinking_effort dispatch", () => { + let mockTask: PamTaskDouble + + beforeEach(() => { + vi.clearAllMocks() + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + clineMessages: [], + api: { + getModel: () => ({ + id: "test-model", + info: { contextWindow: 1, supportsPromptCache: false }, + }), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + // Records tool results so the dispatched tool_result can be asserted. + pushToolResultToUserContent: vi.fn().mockImplementation((toolResult: unknown) => { + mockTask.userMessageContent.push(toolResult) + return true + }), + } + }) + + // The structural double covers every Task surface presentAssistantMessage + // touches for this dispatch path; a full Task is not needed here. + function asTask(): Task { + return mockTask as unknown as Task + } + + function toolCallId() { + return "tool_call_dte_dispatch_1" + } + + function makeBlock() { + const id = toolCallId() + return { + type: "tool_use" as const, + id, + name: "set_thinking_effort" as const, + params: { effort: "high", reason: "deep analysis ahead" }, + partial: false, + nativeArgs: { effort: "high", reason: "deep analysis ahead" }, + } + } + + function dispatchedToolResult(): unknown { + return mockTask.userMessageContent.find( + (item) => + typeof item === "object" && + item !== null && + (item as { type?: string; tool_use_id?: string }).type === "tool_result" && + (item as { type?: string; tool_use_id?: string }).tool_use_id === toolCallId(), + ) + } + + it("routes a completed set_thinking_effort block to the tool handler", async () => { + mockTask.assistantMessageContent = [makeBlock()] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).toHaveBeenCalledTimes(1) + const [taskArg, blockArg, callbacksArg] = handle.mock.calls[0] + expect(taskArg).toBe(mockTask) + expect(blockArg).toMatchObject({ + name: "set_thinking_effort", + nativeArgs: { effort: "high", reason: "deep analysis ahead" }, + }) + expect(callbacksArg).toEqual( + expect.objectContaining({ + askApproval: expect.any(Function), + handleError: expect.any(Function), + pushToolResult: expect.any(Function), + }), + ) + + // Usage is recorded under the real tool name (not a telemetry alias). + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("set_thinking_effort") + // The handler pushes a tool_result for the tool call id. + expect(dispatchedToolResult()).toBeDefined() + }) + + it("does not route other tools through the set_thinking_effort handler", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use" as const, + id: "tool_call_other_1", + name: "nonexistent_tool", + params: { some: "param" }, + partial: false, + }, + ] + + await presentAssistantMessage(asTask()) + }) + + it("describes a skipped set_thinking_effort block via the tool description when the task already rejected a tool", async () => { + mockTask.didRejectTool = true + mockTask.assistantMessageContent = [makeBlock()] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).not.toHaveBeenCalled() + const result = dispatchedToolResult() + expect(result).toBeDefined() + const content = (result as { content?: string }).content + expect(content).toContain("set_thinking_effort to 'high'") + expect(content).toContain("rejecting") + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7383a7a35a..cc23495250 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -34,6 +34,7 @@ import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" +import { setThinkingEffortTool } from "../tools/SetThinkingEffortTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" @@ -405,6 +406,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` + case "set_thinking_effort": + return `[${block.name} to '${block.params.effort ?? ""}']` default: return `[${block.name}]` } @@ -878,6 +881,15 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "set_thinking_effort": + // DTE series 3/5: model-driven thinking effort — no approval gate, + // no checkpoint (non-destructive, task-local, clamped). + await setThinkingEffortTool.handle(cline, block as ToolUse<"set_thinking_effort">, { + askApproval, + handleError, + pushToolResult, + }) + break default: { // Handle unknown/invalid tool names OR custom tools // This is critical for native tool calling where every tool_use MUST have a tool_result diff --git a/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts new file mode 100644 index 0000000000..2ffa95bc96 --- /dev/null +++ b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts @@ -0,0 +1,137 @@ +// npx vitest run src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts +// +// DTE series 3/5 — set_thinking_effort task-start gating: experiment flag +// AND model capability, stable tool list within a task. + +import { describe, it, expect } from "vitest" +import type OpenAI from "openai" +import type { ModelInfo } from "@roo-code/types" + +import { filterNativeToolsForMode, isSetThinkingEffortEnabled, isToolAllowedInMode } from "../filter-tools-for-mode" + +import { getNativeTools } from "../native-tools/index" + +function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { + return { + type: "function", + function: { + name, + description: name + " tool", + parameters: { type: "object", properties: {} }, + }, + } as OpenAI.Chat.ChatCompletionTool +} + +/** Minimal ModelInfo (contextWindow + supportsPromptCache are the only required fields). */ +function modelInfo(supportsReasoningEffort: ModelInfo["supportsReasoningEffort"]): ModelInfo { + return { contextWindow: 1, supportsPromptCache: false, supportsReasoningEffort } +} + +const TOOLS = [makeTool("execute_command"), makeTool("set_thinking_effort")] + +function toolNames(tools: OpenAI.Chat.ChatCompletionTool[]): string[] { + // The union also includes custom tools (no .function); only function tools carry names. + return tools.flatMap((t) => (t.type === "function" ? [t.function.name] : [])) +} + +describe("isSetThinkingEffortEnabled", () => { + it("is false when the experiment is off, even with capability", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: false }, modelInfo(["low", "high"]))).toBe(false) + expect(isSetThinkingEffortEnabled(undefined, modelInfo(["low", "high"]))).toBe(false) + }) + + it("is false when the model lacks per-request effort support", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, undefined)).toBe(false) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(false))).toBe(false) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo([]))).toBe(false) + }) + + it("is true for a capability array or boolean support", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(["low", "high"]))).toBe(true) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(true))).toBe(true) + }) +}) + +describe("filterNativeToolsForMode set_thinking_effort gate", () => { + it("removes the tool when the experiment is off", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: false }, undefined, { + modelInfo: modelInfo(["low", "high"]), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + expect(toolNames(result)).toContain("execute_command") + }) + + it("keeps the tool when experiment on and model supports effort", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(["low", "high"]), + }) + expect(toolNames(result)).toContain("set_thinking_effort") + }) + + it("removes the tool when the model does not support effort", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(false), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + }) + + it("keeps the tool list stable across repeated calls (prompt-cache safety)", () => { + const experiments = { dynamicThinkingEffort: true } + const settings = { modelInfo: modelInfo(["low", "high"]) } + const a = filterNativeToolsForMode(TOOLS, "code", undefined, experiments, undefined, settings) + const b = filterNativeToolsForMode(TOOLS, "code", undefined, experiments, undefined, settings) + expect(toolNames(a)).toEqual(toolNames(b)) + }) +}) + +describe("getNativeTools — set_thinking_effort schema", () => { + it("exposes the tool with strict effort + reason parameters", () => { + const schema = getNativeTools().find((t) => t.type === "function" && t.function.name === "set_thinking_effort") + if (!schema || schema.type !== "function") { + expect(schema).toBeDefined() + return + } + expect(schema.function.strict).toBe(true) + const parameters = schema.function.parameters as { + required?: string[] + properties?: Record + } + expect(parameters.required).toEqual(["effort", "reason"]) + expect(parameters.properties?.effort?.type).toBe("string") + expect(parameters.properties?.reason?.type).toBe("string") + expect(schema.function.description).toContain("no user approval") + }) +}) + +describe("isToolAllowedInMode — set_thinking_effort gate (prompt-side)", () => { + it("allows the tool only when the experiment is on and the model supports effort", () => { + const settings = { modelInfo: modelInfo(["low", "high"]) } + expect( + isToolAllowedInMode( + "set_thinking_effort", + "code", + undefined, + { dynamicThinkingEffort: true }, + undefined, + settings, + ), + ).toBe(true) + expect( + isToolAllowedInMode( + "set_thinking_effort", + "code", + undefined, + { dynamicThinkingEffort: false }, + undefined, + settings, + ), + ).toBe(false) + expect( + isToolAllowedInMode("set_thinking_effort", "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(false), + }), + ).toBe(false) + // Other always-available tools remain unconditional. + expect(isToolAllowedInMode("execute_command", "code", undefined, undefined, undefined, undefined)).toBe(true) + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..1756ce7800 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -6,6 +6,7 @@ import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" +import { EXPERIMENT_IDS } from "../../../shared/experiments" /** * Reverse lookup map - maps alias name to canonical tool name. @@ -295,6 +296,14 @@ export function filterNativeToolsForMode( allowedToolNames.delete("run_slash_command") } + // DTE series 3/5: conditionally exclude set_thinking_effort unless the + // dynamicThinkingEffort experiment is enabled AND the current model supports + // per-request reasoning effort. The gate is evaluated here at task start so + // the tool list stays stable within a task (prompt-cache safety). + if (!isSetThinkingEffortEnabled(experiments, settings?.modelInfo as ModelInfo | undefined)) { + allowedToolNames.delete("set_thinking_effort") + } + // Remove tools that are explicitly disabled via the disabledTools setting if (settings?.disabledTools?.length) { for (const toolName of settings.disabledTools) { @@ -354,6 +363,32 @@ function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean return servers.some((server) => server.resources && server.resources.length > 0) } +/** + * DTE series 3/5: whether the set_thinking_effort tool should be exposed. + * + * Requires both the dynamicThinkingEffort experiment to be enabled and the + * model to advertise per-request reasoning effort support (a non-empty + * `supportsReasoningEffort` capability array, or boolean/adaptive-class + * support). Evaluated at task start only (prompt-cache safety). + * + * @param experiments - Experiment flags from the current state + * @param modelInfo - Current model info (from apiConfiguration) + * @returns true when the tool should be included in the task tool list + */ +export function isSetThinkingEffortEnabled( + experiments: Record | undefined, + modelInfo: ModelInfo | undefined, +): boolean { + if (experiments?.[EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT] !== true) { + return false + } + const capability = modelInfo?.supportsReasoningEffort + if (Array.isArray(capability)) { + return capability.length > 0 + } + return capability === true +} + /** * Checks if a specific tool is allowed in the current mode. * This is useful for dynamically filtering system prompt content. @@ -396,6 +431,9 @@ export function isToolAllowedInMode( if (toolName === "run_slash_command") { return experiments?.runSlashCommand === true } + if (toolName === "set_thinking_effort") { + return isSetThinkingEffortEnabled(experiments, settings?.modelInfo as ModelInfo | undefined) + } return true } diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..28836a902a 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -13,6 +13,7 @@ import newTask from "./new_task" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" +import setThinkingEffort from "./set_thinking_effort" import skill from "./skill" import searchReplace from "./search_replace" import edit_file from "./edit_file" @@ -60,6 +61,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch readCommandOutput, createReadFileTool(readFileOptions), runSlashCommand, + setThinkingEffort, skill, searchReplace, edit_file, diff --git a/src/core/prompts/tools/native-tools/set_thinking_effort.ts b/src/core/prompts/tools/native-tools/set_thinking_effort.ts new file mode 100644 index 0000000000..029c8451e6 --- /dev/null +++ b/src/core/prompts/tools/native-tools/set_thinking_effort.ts @@ -0,0 +1,49 @@ +import type OpenAI from "openai" + +/** + * DTE series 3/5: native tool schema for model-driven per-turn thinking effort. + * + * The tool is only exposed when the dynamicThinkingEffort experiment is on and + * the current model supports per-request reasoning effort (see + * filter-tools-for-mode.ts). The gate is evaluated at task start only so the + * tool list stays stable within a task (prompt-cache safety). + */ +const SET_THINKING_EFFORT_DESCRIPTION = `Adjust your own thinking (reasoning) effort for the remainder of this task. Use it when the task complexity changes mid-task — for example, when a simple lookup turns into a deep multi-file refactor, or when a straightforward step follows a hard one. The change takes effect from the next model request and applies to the current task only; it is never written to persisted settings and requires no user approval. + +Parameters: +- effort: (required) The new thinking effort level. Must be one of the levels supported by the current model. +- reason: (required) A one-sentence explanation of why the effort is changing. It is shown to the user alongside the new level. + +Example: Escalating after a complex bug +{ "effort": "high", "reason": "The refactor spans 6 files with cross-cutting type changes; deeper reasoning is needed." } + +Example: De-escalating after a hard phase +{ "effort": "low", "reason": "Remaining work is mechanical test updates for already-verified behavior." }` + +const EFFORT_PARAMETER_DESCRIPTION = `The new thinking effort level (one of the levels supported by the current model)` + +const REASON_PARAMETER_DESCRIPTION = `A one-sentence explanation of why the effort is changing; shown to the user` + +export default { + type: "function", + function: { + name: "set_thinking_effort", + description: SET_THINKING_EFFORT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + effort: { + type: "string", + description: EFFORT_PARAMETER_DESCRIPTION, + }, + reason: { + type: "string", + description: REASON_PARAMETER_DESCRIPTION, + }, + }, + required: ["effort", "reason"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/SetThinkingEffortTool.ts b/src/core/tools/SetThinkingEffortTool.ts new file mode 100644 index 0000000000..2710717c2b --- /dev/null +++ b/src/core/tools/SetThinkingEffortTool.ts @@ -0,0 +1,278 @@ +import { type ClineSayTool, type ModelInfo } from "@roo-code/types" + +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import type { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { Task } from "../task/Task" +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** + * DTE series 3/5: model-driven per-turn thinking effort. + * + * The model calls this tool to adjust its own thinking effort mid-task. + * There is NO approval gate (non-destructive, clamped to the model + * capability, instantly undoable); guardrails replace approval: + * - always a one-line chat notification (success or refusal) + * - escalation cap: max 3 upward changes per task + * - oscillation detection: A -> B -> A ping-pong within a task is refused + * - hard clamp to the model capability array + * + * The tool is only exposed when the dynamicThinkingEffort experiment is on + * and the model supports per-request effort (see filter-tools-for-mode.ts); + * the checks below are defense in depth for stale or direct invocations. + */ + +interface SetThinkingEffortParams { + effort: string + reason: string +} + +/** + * Canonical effort ordering used to detect upward changes. "disable" ranks + * lowest: it is a UI/control value that can only appear as the + * settings-derived baseline, never as a value this tool may set. + */ +export const EFFORT_RANK: Record = { + disable: 0, + none: 1, + minimal: 2, + low: 3, + medium: 4, + high: 5, + xhigh: 6, + max: 7, +} + +/** Effort levels this tool may set (disable excluded — see above). */ +export const SETTABLE_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const + +type SettableEffort = (typeof SETTABLE_EFFORTS)[number] + +/** Max upward (escalating) changes per task before the tool refuses. */ +export const MAX_UPWARD_CHANGES = 3 + +/** Per-task guardrail state (scoped per Task; see guardState WeakMap). */ +interface EffortGuardState { + upwardChanges: number + /** Model-driven applied efforts, most recent last. */ + history: string[] +} + +function effortRank(level: string | undefined): number { + return level === undefined ? EFFORT_RANK.disable : (EFFORT_RANK[level] ?? EFFORT_RANK.disable) +} + +/** + * Hard clamp to the model capability array: an in-array request passes + * through unchanged; any other valid level is mapped to the nearest + * supported level (ties resolved toward the lower level). + */ +function clampToCapability( + requested: SettableEffort, + capability: ModelInfo["supportsReasoningEffort"], +): SettableEffort | "disable" { + if (!Array.isArray(capability) || capability.length === 0) { + return requested + } + const supported = capability + if (supported.includes(requested)) { + return requested + } + const requestedRank = effortRank(requested) + let best = supported[0] + let bestDistance = Number.POSITIVE_INFINITY + for (const level of supported) { + const distance = Math.abs(effortRank(level) - requestedRank) + // Ties resolve toward the lower effort level. + if (distance < bestDistance || (distance === bestDistance && effortRank(level) < effortRank(best))) { + best = level + bestDistance = distance + } + } + return best +} + +export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { + readonly name = "set_thinking_effort" as const + + /** + * Guardrail state is per-task. The tool instance is a module singleton, + * so state is keyed by Task instance in a WeakMap: each task starts + * fresh and state is garbage-collected with the task. + */ + private guardState = new WeakMap() + + private getGuardState(task: Task): EffortGuardState { + let state = this.guardState.get(task) + if (!state) { + state = { upwardChanges: 0, history: [] } + this.guardState.set(task, state) + } + return state + } + + async execute(params: SetThinkingEffortParams, task: Task, callbacks: ToolCallbacks): Promise { + const { effort, reason } = params + const { handleError, pushToolResult } = callbacks + + if (!effort) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + pushToolResult(await task.sayAndCreateMissingParamError("set_thinking_effort", "effort")) + return + } + + if (!reason) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + pushToolResult(await task.sayAndCreateMissingParamError("set_thinking_effort", "reason")) + return + } + + try { + // Defense in depth: the tool is only exposed when the experiment is + // on and the model supports per-request effort (task-start gate in + // filter-tools-for-mode.ts), but stale or direct calls can reach here. + const provider = task.providerRef.deref() + const state = await provider?.getState() + if (!experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT)) { + pushToolResult( + formatResponse.toolError( + "set_thinking_effort is unavailable: the dynamic thinking effort experiment is not enabled.", + ), + ) + return + } + + const capability = task.api.getModel().info.supportsReasoningEffort + const hasCapability = capability === true || (Array.isArray(capability) && capability.length > 0) + if (!hasCapability) { + pushToolResult( + formatResponse.toolError("The current model does not support per-request thinking effort."), + ) + return + } + + if (!(SETTABLE_EFFORTS as readonly string[]).includes(effort)) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + "Invalid thinking effort '" + effort + "'. Valid levels: " + SETTABLE_EFFORTS.join(", ") + ".", + ), + ) + return + } + // Validated above: `effort` is one of the settable literal levels. + const requested = effort as SettableEffort + + // Hard clamp to the model capability array. + const clamped = clampToCapability(requested, capability) + if (clamped === "disable") { + // The clamp landed on "disable", which this tool cannot set (the + // task-local API takes an effort level, not a UI off-switch). + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + const supported = Array.isArray(capability) + ? capability.filter((l) => l !== "disable").join(", ") + : "none" + pushToolResult( + formatResponse.toolError( + "'" + effort + "' is not supported by the current model. Supported levels: " + supported + ".", + ), + ) + return + } + + const guard = this.getGuardState(task) + const current = task.getRuntimeThinkingEffort().effort ?? task.apiConfiguration.reasoningEffort + + // No-op: already at the requested level — confirm without churn. + if (clamped === current) { + pushToolResult("Thinking effort is already '" + clamped + "'.") + return + } + + // Oscillation: A -> B -> A ping-pong within the task is refused. + const last = guard.history[guard.history.length - 1] + const secondLast = guard.history[guard.history.length - 2] + if (secondLast !== undefined && secondLast === clamped && last !== clamped) { + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", refusal: "oscillation" } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult( + formatResponse.toolError( + "Thinking effort change refused: oscillation between '" + + secondLast + + "' and '" + + last + + "' detected. Keep the current effort.", + ), + ) + return + } + + const isUpward = effortRank(clamped) > effortRank(current) + if (isUpward && guard.upwardChanges >= MAX_UPWARD_CHANGES) { + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", refusal: "escalation_cap" } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult( + formatResponse.toolError( + "Thinking effort change refused: the escalation limit of " + + MAX_UPWARD_CHANGES + + " upward changes per task has been reached.", + ), + ) + return + } + + // Apply (no approval gate) and notify with a single chat line. + task.consecutiveMistakeCount = 0 + task.setRuntimeThinkingEffort(clamped, "model") + if (isUpward) { + guard.upwardChanges++ + } + guard.history.push(clamped) + + const clampNote = + clamped === effort + ? "" + : " Requested '" + effort + "' was clamped to '" + clamped + "' (model capability)." + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: clamped, reason } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult("Thinking effort is now '" + clamped + "'." + clampNote + " (Reason: " + reason + ")") + } catch (error) { + await handleError("setting thinking effort", error as Error) + } + } + + override async handlePartial(task: Task, block: ToolUse<"set_thinking_effort">): Promise { + const effort: string | undefined = block.params.effort + const reason: string | undefined = block.params.reason + if (!effort && !reason) { + return + } + const message = JSON.stringify({ + tool: "thinkingEffort", + effort: effort ?? "", + reason: reason ?? "", + } satisfies ClineSayTool) + // Partial say: updates the same one-line display as it streams in. + await task.say("tool", message, undefined, true).catch(() => {}) + } +} + +export const setThinkingEffortTool = new SetThinkingEffortTool() diff --git a/src/core/tools/__tests__/setThinkingEffortTool.spec.ts b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts new file mode 100644 index 0000000000..c869ed64d7 --- /dev/null +++ b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts @@ -0,0 +1,378 @@ +// npx vitest run src/core/tools/__tests__/setThinkingEffortTool.spec.ts +// +// DTE series 3/5 — set_thinking_effort executor: clamp, escalation cap, +// oscillation, no-op, no-approval, and one-line chat display. + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest" + +import { setThinkingEffortTool, MAX_UPWARD_CHANGES } from "../SetThinkingEffortTool" +import { Task } from "../../task/Task" +import type { ToolUse } from "../../../shared/tools" + +type Capability = string[] | true | false | undefined + +/** Structural double covering every Task surface this tool touches. */ +interface TaskDouble { + taskId: string + consecutiveMistakeCount: number + didToolFailInCurrentTurn: boolean + recordToolError: Mock + sayAndCreateMissingParamError: Mock + say: Mock + setRuntimeThinkingEffort: Mock + getRuntimeThinkingEffort: Mock + apiConfiguration: { reasoningEffort?: string } + api: { getModel: () => { id: string; info: { supportsReasoningEffort: Capability } } } + providerRef: { + deref: () => { + getState: () => Promise<{ experiments: Record }> + } + } +} + +interface CallbackDoubles { + askApproval: Mock + handleError: Mock + pushToolResult: Mock +} + +function makeTask( + overrides: { capability?: Capability; experimentsOn?: boolean; settingsEffort?: string } = {}, +): TaskDouble { + const { capability = ["low", "medium", "high", "max"], experimentsOn = true, settingsEffort } = overrides + // Mirrors the real Task API: getRuntimeThinkingEffort() reflects only the + // task-local override (undefined until setRuntimeThinkingEffort is called); + // the settings baseline is read separately from apiConfiguration. + let override: string | undefined = undefined + return { + taskId: "task-1", + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + recordToolError: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing parameter error"), + say: vi.fn().mockResolvedValue(undefined), + setRuntimeThinkingEffort: vi.fn((effort: string | undefined) => { + override = effort + }), + getRuntimeThinkingEffort: vi.fn().mockImplementation(() => ({ + effort: override, + source: override === undefined ? undefined : "model", + })), + apiConfiguration: { reasoningEffort: settingsEffort }, + api: { getModel: () => ({ id: "test-model", info: { supportsReasoningEffort: capability } }) }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { dynamicThinkingEffort: experimentsOn }, + }), + }), + }, + } +} + +function sayPayloads(double: TaskDouble): unknown[] { + return double.say.mock.calls.filter((call) => call[0] === "tool").map((call) => JSON.parse(call[1] as string)) +} + +describe("setThinkingEffortTool", () => { + let double: TaskDouble + let task: Task + let callbacks: CallbackDoubles + + // Rebuild the double and bind it to the Task-typed reference the tool + // expects. The structural double covers every Task surface this unit + // exercises, so a full Task construction is unnecessary here. + function use(overrides?: { capability?: Capability; experimentsOn?: boolean; settingsEffort?: string }) { + double = makeTask(overrides) + task = double as unknown as Task + } + + beforeEach(() => { + vi.clearAllMocks() + use() + callbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn().mockResolvedValue(undefined), + pushToolResult: vi.fn(), + } + }) + + describe("parameter validation", () => { + it("reports a missing effort parameter and records a tool error", async () => { + await setThinkingEffortTool.execute({ effort: "", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.sayAndCreateMissingParamError).toHaveBeenCalledWith("set_thinking_effort", "effort") + expect(callbacks.pushToolResult).toHaveBeenCalledWith("missing parameter error") + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + }) + + it("reports a missing reason parameter and records a tool error", async () => { + await setThinkingEffortTool.execute({ effort: "high", reason: "" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.sayAndCreateMissingParamError).toHaveBeenCalledWith("set_thinking_effort", "reason") + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + }) + + describe("defense-in-depth gating", () => { + it("rejects when the experiment is off", async () => { + use({ experimentsOn: false }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("experiment") + expect(result).toContain("error") + }) + + it("rejects when the model does not support per-request effort", async () => { + use({ capability: false }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("does not support") + }) + + it("rejects an empty capability array", async () => { + use({ capability: [] }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("does not support") + }) + }) + + describe("clamp to model capability", () => { + it("rejects an unknown effort level", async () => { + await setThinkingEffortTool.execute({ effort: "ultra", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("Invalid thinking effort") + expect(result).toContain("ultra") + }) + + it("rejects 'disable' (a UI off-switch the tool cannot set)", async () => { + await setThinkingEffortTool.execute({ effort: "disable", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("Invalid thinking effort") + }) + + it("clamps an out-of-array request to the nearest supported level", async () => { + use({ capability: ["low", "medium", "high"], settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "max", reason: "deeper reasoning" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "high", reason: "deeper reasoning" }) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'high'") + expect(result).toContain("deeper reasoning") + }) + + it("resolves nearest-level ties toward the lower level", async () => { + use({ capability: ["high", "low"], settingsEffort: "high" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "tie-break" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "low", reason: "tie-break" }) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'low'") + }) + + it("rejects a request that clamps to 'disable' (capability without settable levels)", async () => { + use({ capability: ["disable"], settingsEffort: "disable" }) + await setThinkingEffortTool.execute({ effort: "low", reason: "some reasoning" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("not supported by the current model") + }) + }) + + describe("successful application (no approval gate)", () => { + it("applies the effort, notifies with a one-line say, and never asks for approval", async () => { + use({ settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "high", reason: "deep analysis" }, task, callbacks) + + expect(callbacks.askApproval).not.toHaveBeenCalled() + expect(double.consecutiveMistakeCount).toBe(0) + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "high", reason: "deep analysis" }) + expect(double.say).toHaveBeenCalledWith("tool", JSON.stringify(display), undefined, false) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("high") + expect(result).toContain("deep analysis") + }) + + it("passes through unchanged for a boolean-capability model (all levels supported)", async () => { + use({ capability: true, settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "xhigh", reason: "all levels" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("xhigh", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("xhigh") + expect(result).not.toContain("clamped") + }) + it("is a no-op (without a chat line) when already at the requested level", async () => { + use({ settingsEffort: "medium" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "confirm" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("already") + }) + }) + + describe("escalation cap", () => { + it("allows up to MAX_UPWARD_CHANGES upward changes and refuses the next", async () => { + use({ capability: ["low", "medium", "high", "xhigh", "max"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "up" }, task, callbacks) + + await step("medium") + await step("high") + await step("xhigh") + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(MAX_UPWARD_CHANGES) + + await step("max") // 4th upward change: refused + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(MAX_UPWARD_CHANGES) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "escalation_cap" }) + const result = callbacks.pushToolResult.mock.calls.at(-1)?.[0] as string + expect(result).toContain("escalation limit") + }) + + it("does not count downward changes toward the cap", async () => { + use({ capability: ["low", "medium", "high", "xhigh", "max"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("medium") // upward 1 + await step("low") // downward: not counted + await step("high") // upward 2 + await step("medium") // downward: not counted + await step("xhigh") // upward 3 + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(5) + + await step("max") // 4th upward change: refused + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(5) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "escalation_cap" }) + }) + }) + + describe("oscillation detection", () => { + it("refuses an A -> B -> A ping-pong within the task", async () => { + use({ capability: ["low", "medium", "high"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("medium") + await step("low") // downward, allowed + await step("medium") // ping-pong: refused + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(2) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "oscillation" }) + const result = callbacks.pushToolResult.mock.calls.at(-1)?.[0] as string + expect(result).toContain("oscillation") + expect(result).toContain("'medium'") + expect(result).toContain("'low'") + }) + + it("does not refuse the same level twice in a row (no-op path instead)", async () => { + use({ settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("medium") + await step("medium") // identical level: no-op, not oscillation + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(sayPayloads(double).some((p) => (p as Record).refusal !== undefined)).toBe(false) + }) + }) + + describe("error handling", () => { + it("routes unexpected errors to handleError", async () => { + use({ settingsEffort: "low" }) + double.setRuntimeThinkingEffort = vi.fn().mockImplementation(() => { + throw new Error("boom") + }) + + await setThinkingEffortTool.execute({ effort: "high", reason: "x" }, task, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith("setting thinking effort", expect.any(Error)) + }) + }) + + describe("handle() entry point", () => { + it("emits a partial say with the streamed effort and reason", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: { effort: "high", reason: "deep" }, + partial: true, + nativeArgs: { effort: "high", reason: "deep" }, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).toHaveBeenCalledWith( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: "high", reason: "deep" }), + undefined, + true, + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("ignores a partial block with no args yet", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: {}, + partial: true, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).not.toHaveBeenCalled() + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("reports a parse error when a complete block carries no native args", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: {}, + partial: false, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith( + "parsing set_thinking_effort args", + expect.objectContaining({ message: expect.stringContaining("missing native arguments") }), + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..6a0c76ac2b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -66,6 +66,7 @@ export const toolParamNames = [ "new_string", // search_replace and edit_file parameter "replace_all", // edit tool parameter for replacing all occurrences "expected_replacements", // edit_file parameter for multiple occurrences + "effort", // set_thinking_effort parameter "timeout", // execute_command parameter "artifact_id", // read_command_output parameter "search", // read_command_output parameter for grep-like search @@ -109,6 +110,7 @@ export type NativeToolArgs = { } codebase_search: { query: string; path?: string } generate_image: GenerateImageParams + set_thinking_effort: { effort: string; reason: string } run_slash_command: { command: string; args?: string } skill: { skill: string; args?: string } search_files: { path: string; regex: string; file_pattern?: string | null } @@ -289,6 +291,7 @@ export const TOOL_DISPLAY_NAMES: Record = { run_slash_command: "run slash command", skill: "load skill", generate_image: "generate images", + set_thinking_effort: "set thinking effort", custom_tool: "use custom tools", invalid_tool_call: "invalid tool call", } as const @@ -323,6 +326,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "update_todo_list", "run_slash_command", "skill", + "set_thinking_effort", ] as const /** diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 3c48b2fdd1..9f260c95d8 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -65,6 +65,7 @@ import { SquareArrowOutUpRight, FileCode2, PocketKnife, + Brain, FolderTree, SquareTerminal, MessageCircle, @@ -1549,6 +1550,31 @@ export const ChatRowContent = ({ ) } + case "thinkingEffort": { + const info = sayTool + return ( +
+ + + {info.refusal ? ( + info.refusal === "oscillation" ? ( + t("chat:thinkingEffort.oscillationRefused") + ) : ( + t("chat:thinkingEffort.escalationCapRefused") + ) + ) : ( + {info.effort}, + }} + values={{ effort: info.effort, reason: info.reason }} + /> + )} + +
+ ) + } default: return null } diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx new file mode 100644 index 0000000000..5fb0a54aab --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx @@ -0,0 +1,110 @@ +import React from "react" +import { render, screen } from "@/utils/test-utils" +import { ChatRowContent } from "../ChatRow" +import type { ClineMessage } from "@roo-code/types" + +// Mock vscode API +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (msg: unknown) => mockPostMessage(msg), + }, +})) + +// Mock i18n (value-substituting Trans for the one-line display) +const tMap: Record = { + "chat:thinkingEffort.applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "chat:thinkingEffort.escalationCapRefused": + "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "chat:thinkingEffort.oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected", +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => tMap[key] ?? key, + i18n: { exists: () => true }, + }), + Trans: ({ i18nKey, values }: { i18nKey?: string; values?: Record }) => { + const raw = (i18nKey && (tMap[i18nKey] ?? i18nKey)) || "" + return <>{String(raw).replace(/{{(\w+)}}/g, (_, k: string) => String(values?.[k] ?? ""))} + }, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock extension state context +let mockClineMessages: ClineMessage[] = [] +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpServers: [], + alwaysAllowMcp: false, + currentCheckpoint: null, + mode: "code", + apiConfiguration: {}, + clineMessages: mockClineMessages, + currentTaskItem: undefined, + }), +})) + +// Mock useSelectedModel hook +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ info: { supportsImages: true } }), +})) + +function renderChatRow(message: any) { + mockClineMessages = [message] + return render( + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + />, + ) +} + +function sayToolMessage(text: object): any { + return { + ts: Date.now(), + type: "say" as const, + say: "tool" as const, + text: JSON.stringify(text), + } +} + +describe("ChatRow - thinkingEffort display (DTE series 3/5)", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("renders the one-line applied display with effort and reason", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", reason: "deep analysis ahead" })) + + expect(screen.getByText("🧠 Thinking effort: high (Zoo) — deep analysis ahead")).toBeInTheDocument() + }) + + it("renders the oscillation refusal line", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", refusal: "oscillation" })) + + expect( + screen.getByText("🧠 Thinking effort unchanged: oscillation between levels detected"), + ).toBeInTheDocument() + }) + + it("renders the escalation-cap refusal line", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", refusal: "escalation_cap" })) + + expect( + screen.getByText("🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached"), + ).toBeInTheDocument() + }) + + it("renders nothing for unknown say-tool payloads", () => { + const { container } = renderChatRow(sayToolMessage({ tool: "someOtherTool" })) + + expect(container.textContent).toBe("") + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index d5b63ea886..d48c70d2c5 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -466,6 +466,11 @@ "wantsToRun": "Zoo vol executar una comanda slash", "didRun": "Zoo ha executat una comanda slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "contextMenu": { "noResults": "Sense resultats", "problems": "Problemes", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index ad94856234..25f5d31fc8 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo möchte einen Slash-Befehl ausführen", "didRun": "Zoo hat einen Slash-Befehl ausgeführt" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} von {{total}} To-Dos erledigt", "complete": "{{total}} To-Dos erledigt", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 89f6c2f488..9155909f26 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -450,6 +450,11 @@ "wantsToRun": "Zoo wants to run a slash command", "didRun": "Zoo ran a slash command" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "queuedMessages": { "title": "Queued Messages", "clickToEdit": "Click to edit message" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 7876845932..13f0ce5a21 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo quiere ejecutar un comando slash", "didRun": "Zoo ejecutó un comando slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} de {{total}} tareas pendientes realizadas", "complete": "{{total}} tareas pendientes realizadas", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 032d43ce27..e02f2bc95c 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo veut exécuter une commande slash", "didRun": "Zoo a exécuté une commande slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} sur {{total}} tâches terminées", "complete": "{{total}} tâches terminées", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 94a805f328..6503af242c 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo एक स्लैश कमांड चलाना चाहता है", "didRun": "Zoo ने एक स्लैश कमांड चलाया" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}} में से {{completed}} टू-डू हो गए", "complete": "{{total}} टू-डू हो गए", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index d58d80db00..8bd53802cc 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -478,6 +478,11 @@ "wantsToRun": "Zoo ingin menjalankan perintah slash", "didRun": "Zoo telah menjalankan perintah slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} dari {{total}} to-do selesai", "complete": "{{total}} to-do selesai", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 7c4c657b35..2941e9d9db 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo vuole eseguire un comando slash", "didRun": "Zoo ha eseguito un comando slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} di {{total}} cose da fare completate", "complete": "{{total}} cose da fare completate", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index f2f17b3fa5..3233fa0e11 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zooはスラッシュコマンドを実行したい", "didRun": "Zooはスラッシュコマンドを実行しました" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}}件中{{completed}}件のTo-Doが完了", "complete": "{{total}}件のTo-Doが完了", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 090bb1a706..cc971291cb 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo가 슬래시 명령어를 실행하려고 합니다", "didRun": "Zoo가 슬래시 명령어를 실행했습니다" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}}개의 할 일 중 {{completed}}개 완료", "complete": "{{total}}개의 할 일 완료", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 0f1ce14084..76360d37a6 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo wil een slash commando uitvoeren", "didRun": "Zoo heeft een slash commando uitgevoerd" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} van {{total}} to-do's voltooid", "complete": "{{total}} to-do's voltooid", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 53ae2013e1..e3d29238ab 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo chce uruchomić komendę slash", "didRun": "Zoo uruchomił komendę slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "Ukończono {{completed}} z {{total}} zadań do wykonania", "complete": "Ukończono {{total}} zadań do wykonania", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 4769341a7b..8071dbe5c7 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -472,6 +472,11 @@ "wantsToRun": "Zoo quer executar um comando slash", "didRun": "Zoo executou um comando slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} de {{total}} tarefas concluídas", "complete": "{{total}} tarefas concluídas", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index de34a0e0b8..ec49a053b9 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo хочет выполнить слеш-команду", "didRun": "Zoo выполнил слеш-команду" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} из {{total}} задач выполнено", "complete": "{{total}} задач выполнено", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 752e5bff9f..3448e5cd67 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo bir slash komutu çalıştırmak istiyor", "didRun": "Zoo bir slash komutu çalıştırdı" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}} yapılacaklar listesinden {{completed}} tanesi tamamlandı", "complete": "{{total}} yapılacaklar listesi tamamlandı", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 3caa0e8d3d..b257eb6519 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo muốn chạy lệnh slash", "didRun": "Zoo đã chạy lệnh slash" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} trong tổng số {{total}} công việc đã hoàn thành", "complete": "{{total}} công việc đã hoàn thành", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 76989eb473..82d2f3c5d8 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -473,6 +473,11 @@ "wantsToRun": "Zoo 想要运行斜杠命令", "didRun": "Zoo 运行了斜杠命令" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "已完成 {{completed}} / {{total}} 个待办事项", "complete": "已完成 {{total}} 个待办事项", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index e096c27af4..5fffb203a2 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -453,6 +453,11 @@ "wantsToRun": "Zoo 想要執行斜線指令", "didRun": "Zoo 執行了斜線指令" }, + "thinkingEffort": { + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "queuedMessages": { "title": "佇列中的訊息", "clickToEdit": "點選以編輯訊息" From 19954d398e88b91e7e78a4e075d40c7202b456e4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 19:19:24 +0800 Subject: [PATCH 07/19] 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 e83af72c13841d2e8351a913031e75a8dd7cc135 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 10:27:03 +0800 Subject: [PATCH 08/19] 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 42b423dda949da35415516461bf71d899d0e3a8f Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 13:01:42 +0800 Subject: [PATCH 09/19] 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 10/19] 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 11/19] 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 12/19] 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 13/19] 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 14/19] 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 15/19] feat(i18n): translate dynamic thinking effort setting (5 more locales) Complete the CodeRabbit i18n finding beyond the initially reported 11 locales: translate DYNAMIC_THINKING_EFFORT name/description in es, fr, hi, id and it as well (the key was English-identical in every locale except en and zh-TW). Terminology follows each locales chat.json thinkingEffort strings; find-missing-translations --area=webview is clean. --- webview-ui/src/i18n/locales/es/settings.json | 4 ++-- webview-ui/src/i18n/locales/fr/settings.json | 4 ++-- webview-ui/src/i18n/locales/hi/settings.json | 4 ++-- webview-ui/src/i18n/locales/id/settings.json | 4 ++-- webview-ui/src/i18n/locales/it/settings.json | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 72b4780329..ec903b3665 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -975,8 +975,8 @@ "toolParameters": "Parámetros" }, "DYNAMIC_THINKING_EFFORT": { - "name": "Dynamic thinking effort", - "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" + "name": "Esfuerzo de pensamiento dinámico", + "description": "Permite que el modelo decida su esfuerzo de pensamiento por paso y que tú lo ajustes en el chat. (experimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 15b5f35c2d..b3a11ed767 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -975,8 +975,8 @@ "toolParameters": "Paramètres" }, "DYNAMIC_THINKING_EFFORT": { - "name": "Dynamic thinking effort", - "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" + "name": "Effort de réflexion dynamique", + "description": "Permet au modèle de décider de son effort de réflexion à chaque étape et de l'ajuster dans le chat. (expérimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 149f7bc4f6..d6d89b9245 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -975,8 +975,8 @@ "toolParameters": "पैरामीटर्स" }, "DYNAMIC_THINKING_EFFORT": { - "name": "Dynamic thinking effort", - "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" + "name": "डायनामिक चिंतन प्रयास", + "description": "मॉडल को हर चरण में अपना चिंतन प्रयास स्वयं तय करने दें, और आप चैट में इसे समायोजित कर सकते हैं। (प्रयोगात्मक)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index ce33b6a018..4af275ca75 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -975,8 +975,8 @@ "toolParameters": "Parameter" }, "DYNAMIC_THINKING_EFFORT": { - "name": "Dynamic thinking effort", - "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" + "name": "Usaha berpikir dinamis", + "description": "Biarkan model memutuskan usaha berpikirnya per langkah, dan biarkan Anda menyesuaikannya di obrolan. (eksperimental)" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4d4d80d61c..dc590dfa90 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -975,8 +975,8 @@ "toolParameters": "Parametri" }, "DYNAMIC_THINKING_EFFORT": { - "name": "Dynamic thinking effort", - "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)" + "name": "Sforzo di pensiero dinamico", + "description": "Consente al modello di decidere lo sforzo di pensiero per ogni passaggio e di regolarlo nella chat. (sperimentale)" } }, "promptCaching": { From f7057f0cfd83f5823bf0ef94a55ad7f988d4cde2 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 18:20:59 +0800 Subject: [PATCH 16/19] 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 37c80401725d103ec2510b90fd224e7b02cdf36c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 02:11:30 +0800 Subject: [PATCH 17/19] 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 18/19] 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 19/19] 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.