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/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 5f6b579779..16693993c2 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -336,6 +336,12 @@ export type ExtensionState = Pick< clineMessages: ClineMessage[] currentTaskId?: string currentTaskItem?: HistoryItem + // DTE series 4/5: task-local thinking effort override for the current task. + // Present only while a task-local override is active (set by the composer + // toggle, the set_thinking_effort tool, or a parent orchestrator); undefined + // otherwise, in which case the webview derives the display from settings -> + // model default. Authoritative state stays extension-side. + taskThinkingEffort?: { effort: string; source: string } currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings uriScheme?: string @@ -501,6 +507,7 @@ export interface WebviewMessage { | "openMention" | "cancelTask" | "cancelAutoApproval" + | "setTaskThinkingEffort" | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" @@ -648,6 +655,11 @@ export interface WebviewMessage { | "themeFixtureProbeResponse" text?: string taskId?: string + // DTE series 4/5: task-local thinking effort set from the composer toggle + // (message type "setTaskThinkingEffort"). Task-local only; persisted settings + // are never touched. + effort?: string + reason?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean @@ -847,6 +859,7 @@ export interface ClineSayTool { | "runSlashCommand" | "updateTodoList" | "skill" + | "thinkingEffort" path?: string // For readCommandOutput readStart?: number @@ -904,6 +917,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/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..c0843d29a8 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" @@ -58,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[], @@ -79,6 +98,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 +179,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 +256,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/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/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..5b1b410811 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 @@ -1517,14 +1525,89 @@ 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 { // 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) + } + + /** + * 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[], @@ -1641,6 +1724,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) @@ -2305,9 +2390,22 @@ 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}`) + // 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. @@ -3968,6 +4066,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } try { @@ -4194,6 +4294,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. @@ -4359,6 +4461,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..4fce91b475 --- /dev/null +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -0,0 +1,311 @@ +// 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("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({}) + + 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() + }) + }) +}) 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/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4621cb3fc4..8f9518d82e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -17,6 +17,7 @@ import { type ProviderName, type ProviderSettings, type RooCodeSettings, + type ReasoningEffortExtended, type ProviderSettingsEntry, type StaticAppProperties, type DynamicAppProperties, @@ -35,6 +36,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type ClineSayTool, type ExtensionMessage, type ExtensionState, type WebviewThemeFixture, @@ -201,6 +203,10 @@ export class ClineProvider private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void + // DTE: thinking effort selected in the composer while no task was open. + // Parked here and applied to the next top-level task in createTask + // (non-persisted, like task-local overrides). + private pendingTaskThinkingEffort: { effort: ReasoningEffortExtended; source: string } | undefined private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false @@ -2649,6 +2655,11 @@ export class ClineProvider const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd const currentTask = this.getCurrentTask() + // DTE series 4/5: task-local thinking effort override for the current task + // (undefined while no override is active — the webview then derives the + // display from settings -> model default). The optional call keeps this + // tolerant of partial task doubles in extension tests. + const currentTaskRuntimeEffort = currentTask?.getRuntimeThinkingEffort?.() let zooCodeState: { zooCodeIsAuthenticated: boolean zooCodeUserName: string | undefined @@ -2706,6 +2717,9 @@ export class ClineProvider currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, clineMessages: currentTask?.clineMessages || [], currentTaskTodos: currentTask?.todoList || [], + taskThinkingEffort: currentTaskRuntimeEffort?.effort + ? { effort: currentTaskRuntimeEffort.effort, source: currentTaskRuntimeEffort.source ?? "default" } + : this.pendingTaskThinkingEffort, messageQueue: currentTask?.messageQueueService?.messages, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task) @@ -3357,6 +3371,16 @@ export class ClineProvider // from the stack and the caller is resumed in this way we can have a chain // of tasks, each one being a sub task of the previous one until the main // task is finished. + // DTE: park a composer-selected thinking effort for the next top-level + // task (no task open yet). createTask consumes and validates it. + public setPendingTaskThinkingEffort(effort: ReasoningEffortExtended): void { + this.pendingTaskThinkingEffort = { effort, source: "you" } + } + + public getPendingTaskThinkingEffort(): { effort: ReasoningEffortExtended; source: string } | undefined { + return this.pendingTaskThinkingEffort + } + public async createTask( text?: string, images?: string[], @@ -3447,6 +3471,33 @@ export class ClineProvider }) await this.addClineToStack(task) + + // DTE: apply the composer-parked thinking effort (selected while no task + // was open) to the new top-level task when its model supports the level. + // Consumed regardless of support so a stale selection never leaks into a + // later task. + if (!parentTask && this.pendingTaskThinkingEffort) { + const pendingEffort = this.pendingTaskThinkingEffort + this.pendingTaskThinkingEffort = undefined + const pendingCapability = task.api.getModel().info.supportsReasoningEffort + const pendingSupported = Array.isArray(pendingCapability) + ? (pendingCapability as string[]).includes(pendingEffort.effort) + : pendingCapability === true + if (pendingSupported) { + task.setRuntimeThinkingEffort(pendingEffort.effort, pendingEffort.source) + await task.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: pendingEffort.effort, + source: pendingEffort.source, + } satisfies ClineSayTool), + undefined, + false, + ) + } + } + if (options.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTask") } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 731124cccc..678ba922f1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -9,6 +9,7 @@ import axios from "axios" import { type ProviderSettingsEntry, + type RooCodeSettings, type ClineMessage, type ExtensionMessage, type ExtensionState, @@ -263,8 +264,31 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { vi.mock("../../task/Task", () => ({ Task: vi.fn().mockImplementation(function (options: any) { + // DTE: per-instance runtime-effort state and a capability-advertising + // api model (mirrors the deepseek catalog levels) so createTask's + // pending-effort consumption path can be exercised in these unit tests. + let runtimeEffort: string | undefined + let runtimeSource: string | undefined + const messages: Array> = [] return { - api: undefined, + api: { + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + info: { + supportsReasoningEffort: ["disable", "low", "high", "max"], + }, + }), + }, + setRuntimeThinkingEffort: vi.fn((effort: string, source?: string) => { + runtimeEffort = effort + runtimeSource = source + }), + getRuntimeThinkingEffort: vi.fn(() => + runtimeEffort !== undefined ? { effort: runtimeEffort, source: runtimeSource } : {}, + ), + say: vi.fn(async (type: string, text?: string) => { + messages.push({ type: "say", say: type, text, ts: Date.now() }) + }), abortTask: vi.fn(), handleWebviewAskResponse: vi.fn(), clineMessages: [], @@ -410,11 +434,34 @@ afterAll(() => { describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { + // DTE: per-instance runtime-effort state and a capability-advertising + // api model (mirrors the deepseek catalog levels) so createTask's + // pending-effort consumption path can be exercised in these unit tests. + let runtimeEffort: string | undefined + let runtimeSource: string | undefined + const messages: Array> = [] const task: any = { - api: undefined, + api: { + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + info: { + supportsReasoningEffort: ["disable", "low", "high", "max"], + }, + }), + }, + setRuntimeThinkingEffort: vi.fn((effort: string, source?: string) => { + runtimeEffort = effort + runtimeSource = source + }), + getRuntimeThinkingEffort: vi.fn(() => + runtimeEffort !== undefined ? { effort: runtimeEffort, source: runtimeSource } : {}, + ), + say: vi.fn(async (type: string, text?: string) => { + messages.push({ type: "say", say: type, text, ts: Date.now() }) + }), abortTask: vi.fn(), handleWebviewAskResponse: vi.fn(), - clineMessages: [], + clineMessages: messages, apiConversationHistory: [], overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), @@ -936,6 +983,81 @@ describe("ClineProvider", () => { expect(state.taskHistory).toEqual([historyItem]) }) + test("getStateToPostToWebview surfaces the task-local thinking effort override (DTE 4/5)", async () => { + // Models the real Task contract: getRuntimeThinkingEffort always returns an + // object; the no-override state is the empty object (effort undefined). + // The double is partial on purpose — the spy only needs the method under test; + // Task has many constructor-dependent required members, hence the cast. + const task = (runtime: { effort?: string; source?: string }) => + ({ + taskId: "effort-task", + clineMessages: [], + todoList: [], + getRuntimeThinkingEffort: () => runtime, + }) as unknown as Task + vi.spyOn(provider.taskHistoryStore, "getAll").mockReturnValue([]) + const getCurrentTaskSpy = vi.spyOn(provider, "getCurrentTask") + + getCurrentTaskSpy.mockReturnValue(task({ effort: "high", source: "you" })) + let state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) + + // A source-less runtime override is reported as the default source. + getCurrentTaskSpy.mockReturnValue(task({ effort: "medium" })) + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "medium", source: "default" }) + + // Without an active override (the real empty-object shape) the field is omitted. + getCurrentTaskSpy.mockReturnValue(task({})) + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toBeUndefined() + + // A parked composer effort (selected while no task was open) surfaces as + // the webview state fallback until the next task consumes it. + provider.setPendingTaskThinkingEffort("high") + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) + // Reset the private field for the other tests (no public clear needed — + // createTask consumes it; the narrow cast documents the intent). + ;(provider as unknown as { pendingTaskThinkingEffort?: unknown }).pendingTaskThinkingEffort = undefined + + getCurrentTaskSpy.mockRestore() + }) + + test("createTask applies a parked composer effort to a new top-level task when the model supports it (DTE)", async () => { + await provider.setValues({ + apiConfiguration: { + apiProvider: providerIdentifiers.deepseek, + apiModelId: "deepseek-v4-flash", + }, + } as unknown as RooCodeSettings) + + provider.setPendingTaskThinkingEffort("max") + + const task = await provider.createTask("pending effort task", undefined, undefined, { startTask: false }) + + expect(task.api.getModel().info.supportsReasoningEffort).toEqual(["disable", "low", "high", "max"]) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "max", source: "you" }) + expect(provider.getPendingTaskThinkingEffort()).toBeUndefined() + const sayLines = task.clineMessages.filter((m) => m.type === "say" && m.say === "tool") + expect(sayLines.length).toBeGreaterThan(0) + expect(JSON.parse(sayLines[sayLines.length - 1].text ?? ("{}" as string))).toEqual({ + tool: "thinkingEffort", + effort: "max", + source: "you", + }) + }) + + test("createTask consumes a parked effort the new task's model does not support (DTE)", async () => { + // deepseek-v4-flash does not advertise the "xhigh" level. + provider.setPendingTaskThinkingEffort("xhigh") + + const task = await provider.createTask("stale effort task", undefined, undefined, { startTask: false }) + + expect(task.getRuntimeThinkingEffort()).toEqual({}) + expect(provider.getPendingTaskThinkingEffort()).toBeUndefined() + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts new file mode 100644 index 0000000000..707cbf9073 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from "vitest" + +import { webviewMessageHandler } from "../webviewMessageHandler" + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), + changeLanguage: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + workspace: { + workspaceFolders: undefined, + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + update: vi.fn(), + })), + }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, + Uri: { + parse: vi.fn((str) => ({ toString: () => str })), + file: vi.fn((path) => ({ fsPath: path })), + }, +})) + +describe("webviewMessageHandler setTaskThinkingEffort (DTE series 4/5)", () => { + const makeTask = (supportsReasoningEffort: unknown) => { + const say = vi.fn(async (_say: string, _text?: string) => {}) + return { + taskId: "test-task-id", + api: { getModel: () => ({ id: "test-model", info: { supportsReasoningEffort } }) }, + setRuntimeThinkingEffort: vi.fn(), + say, + } + } + + const makeProvider = (task: unknown) => ({ + getCurrentTask: vi.fn(() => task), + postStateToWebviewWithoutTaskHistory: vi.fn(async () => {}), + setPendingTaskThinkingEffort: vi.fn(), + }) + + const apply = (provider: ReturnType, message: Record) => + webviewMessageHandler(provider as never, message as never) + + it("applies a task-local effort for a supported level, records the chat line, and pushes state", async () => { + const task = makeTask(["low", "medium", "high"]) + const provider = makeProvider(task) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + + expect(task.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "you") + const [say, text] = task.say.mock.calls[0] + expect(say).toBe("tool") + expect(text).toBe(JSON.stringify({ tool: "thinkingEffort", effort: "high", source: "you" })) + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it("accepts boolean/adaptive-class capability", async () => { + const provider = makeProvider(makeTask(true)) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "medium" }) + + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it.each([ + ["an unsupported level", ["low", "medium", "high"], { effort: "max" }], + ["an effort outside the canonical enum", ["low", "medium", "high"], { effort: "bogus" }], + ["a missing effort", ["low", "medium", "high"], {}], + ["a model without effort support", false, { effort: "high" }], + ])("ignores %s", async (_name, capability, message) => { + const task = makeTask(capability) + const provider = makeProvider(task) + + await apply(provider, { type: "setTaskThinkingEffort", ...message }) + + expect(task.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) + + it("parks a pending effort for the next task when there is no current task", async () => { + const provider = makeProvider(undefined) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + + expect(provider.setPendingTaskThinkingEffort).toHaveBeenCalledWith("high") + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it.each([ + ["an effort outside the canonical enum", { effort: "bogus" }], + ["a missing effort", {}], + ])("does not park %s when there is no current task", async (_name, message) => { + const provider = makeProvider(undefined) + + await apply(provider, { type: "setTaskThinkingEffort", ...message }) + + expect(provider.setPendingTaskThinkingEffort).not.toHaveBeenCalled() + expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..31809e4f40 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -16,6 +16,8 @@ import { type Command as SlashCommand, type WebviewMessage, type EditQueuedMessagePayload, + type ClineSayTool, + reasoningEffortExtendedSchema, TelemetryEventName, RooCodeSettings, ExperimentId, @@ -1655,6 +1657,49 @@ export const webviewMessageHandler = async ( // Cancel any pending auto-approval timeout for the current task provider.getCurrentTask()?.cancelAutoApprovalTimeout() break + case "setTaskThinkingEffort": { + // DTE series 4/5: task-local thinking effort set from the composer + // toggle. Task-local only — persisted settings are never touched. + // Defense in depth: the composer menu only offers model-supported + // levels, but the webview is never trusted blindly. + const setEffortTask = provider.getCurrentTask() + // Validate the webview-supplied effort against the canonical enum. + const setEffortParsed = reasoningEffortExtendedSchema.safeParse(message.effort) + if (setEffortParsed.success) { + if (setEffortTask) { + const setEffortValue = setEffortParsed.data + const capability = setEffortTask.api.getModel().info.supportsReasoningEffort + const supported = Array.isArray(capability) + ? (capability as string[]).includes(setEffortValue) + : capability === true + if (supported) { + setEffortTask.setRuntimeThinkingEffort(setEffortValue, "you") + // Single in-chat line (same ChatRow case as model-initiated changes). + await setEffortTask.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: setEffortValue, + source: "you", + } satisfies ClineSayTool), + undefined, + false, + ) + // Push the authoritative display state to the webview. + await provider.postStateToWebviewWithoutTaskHistory() + } + } else { + // No open task: park the selection as the pending effort for the + // next top-level task (createTask applies it after validating the + // new task's model capability). The toggle keeps showing the + // selection because the webview state falls back to the pending + // value while no task is open. + provider.setPendingTaskThinkingEffort(setEffortParsed.data) + await provider.postStateToWebviewWithoutTaskHistory() + } + } + break + } case "allowedCommands": { // Validate and sanitize the commands array const commands = message.commands ?? [] diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index f2261a5c09..b6e8993df4 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,28 @@ 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) + }) + + it("returns false when explicitly disabled", () => { + expect(Experiments.isEnabled({ dynamicThinkingEffort: false }, "dynamicThinkingEffort")).toBe(false) + }) + }) }) 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/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 952322084f..a849c4b597 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,35 @@ 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/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 3b94fb6e74..eab4830443 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -28,6 +28,7 @@ import Thumbnails from "../common/Thumbnails" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" +import { ThinkingEffortToggle } from "./ThinkingEffortToggle" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" import { IndexingStatusBadge } from "./IndexingStatusBadge" @@ -1311,6 +1312,7 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..27406da0e1 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -8,6 +8,7 @@ import { ListChevronsDownUp, ArrowLeft, ArrowRight, + Brain, } from "lucide-react" import prettyBytes from "pretty-bytes" @@ -29,6 +30,8 @@ import { Mention } from "./Mention" import { TodoListDisplay } from "./TodoListDisplay" import { LucideIconButton } from "./LucideIconButton" +import { computeThinkingEffortDisplay } from "@/utils/thinkingEffort" + export interface TaskHeaderProps { task: ClineMessage tokensIn: number @@ -63,7 +66,7 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem } = useExtensionState() + const { apiConfiguration, currentTaskItem, taskThinkingEffort } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -86,6 +89,24 @@ const TaskHeader = ({ // vscode-lm reports maxTokens: -1 (unlimited); a negative reserve must not distort the window math. const reservedForOutput = maxTokens && maxTokens > 0 ? maxTokens : 0 + // DTE series 4/5: current effective thinking effort + source badge + // (task-local override → settings → model default/adaptive). + const thinkingEffortDisplay = useMemo( + () => + computeThinkingEffortDisplay({ + apiConfiguration, + model, + taskThinkingEffort, + }), + [apiConfiguration, model, taskThinkingEffort], + ) + const thinkingEffortSourceKey = + thinkingEffortDisplay?.source === "you" + ? "chat:thinkingEffort.sourceYou" + : thinkingEffortDisplay?.source === "auto" + ? "chat:thinkingEffort.sourceAuto" + : "chat:thinkingEffort.sourceDefault" + const condenseButton = (
e.stopPropagation()}> + {thinkingEffortDisplay && ( + + + + + {thinkingEffortDisplay.effort} + + {t(thinkingEffortSourceKey)} + + + )} + ))} + + + ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx new file mode 100644 index 0000000000..ff0361444e --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx @@ -0,0 +1,125 @@ +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.appliedByUser": "🧠 Thinking effort set to: {{effort}}", + "chat:thinkingEffort.escalationCapRefused": + "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "chat:thinkingEffort.oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected", +} +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() + }) + + // DTE series 4/5: user-set (composer toggle) changes reuse the same one-line + // display with a user-specific phrasing. + it("renders the applied display with user phrasing for source 'you'", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", source: "you" })) + + expect(screen.getByText("🧠 Thinking effort set to: high")).toBeInTheDocument() + }) + + it("keeps the model phrasing for non-user sources", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", source: "model", reason: "deep dive" })) + + expect(screen.getByText("🧠 Thinking effort: high (Zoo) — deep dive")).toBeInTheDocument() + }) + + it("renders nothing for unknown say-tool payloads", () => { + const { container } = renderChatRow(sayToolMessage({ tool: "someOtherTool" })) + + expect(container.textContent).toBe("") + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx new file mode 100644 index 0000000000..5797d262b3 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx @@ -0,0 +1,162 @@ +import React from "react" +import { renderWithExtensionState, screen } from "@/utils/test-utils" +import type { ProviderSettings } from "@roo-code/types" + +import TaskHeader, { TaskHeaderProps } from "../TaskHeader" + +// i18n: keys, with exact badge strings for the thinking-effort keys +const effortKeys: Record = { + "chat:thinkingEffort.sourceYou": "you", + "chat:thinkingEffort.sourceAuto": "Zoo (auto)", + "chat:thinkingEffort.sourceDefault": "default", + "chat:thinkingEffort.chipTooltip": "thinking-effort-chip", +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => effortKeys[key] ?? key, + }), + initReactI18next: { + type: "3rdParty", + init: vi.fn(), + }, +})) + +const { mockPostMessage } = vi.hoisted(() => ({ mockPostMessage: vi.fn() })) +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const mockState: { + apiConfiguration: ProviderSettings + currentTaskItem: { id: string } | null + clineMessages: any[] + taskHistory: any[] + experiments: Record + taskThinkingEffort: { effort: string; source: string } | undefined +} = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id" }, + clineMessages: [], + taskHistory: [], + experiments: { dynamicThinkingEffort: true }, + taskThinkingEffort: undefined, +} +vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, + useExtensionState: () => mockState, +})) + +vi.mock("@roo/array", () => ({ + findLastIndex: (array: any[], predicate: (item: any) => boolean) => array.map(predicate).findLastIndex(Boolean), +})) + +let mockModelInfo: any = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", +} +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ + provider: "anthropic", + id: "test-model", + info: mockModelInfo, + isLoading: false, + isError: false, + }), +})) + +let mockMaxOutputTokens = 0 +vi.mock("@roo/api", () => ({ + getModelMaxOutputTokens: () => mockMaxOutputTokens, +})) + +describe("TaskHeader - thinking effort chip (DTE series 4/5)", () => { + const defaultProps: TaskHeaderProps = { + task: { type: "say", ts: Date.now(), text: "Test task", images: [] }, + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + contextTokens: 200, + buttonsDisabled: false, + handleCondenseContext: vi.fn(), + } as TaskHeaderProps + + beforeEach(() => { + mockMaxOutputTokens = 0 + mockState.experiments = { dynamicThinkingEffort: true } + mockState.taskThinkingEffort = undefined + mockState.apiConfiguration = { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + } + }) + + const renderChip = () => renderWithExtensionState() + + it("shows the effective effort with a 'you' source badge for a task-local override", () => { + mockState.taskThinkingEffort = { effort: "high", source: "you" } + renderChip() + expect(screen.getByText("high")).toBeInTheDocument() + expect(screen.getByText("you")).toBeInTheDocument() + }) + + it("shows the 'Zoo (auto)' source badge for model/parent-sourced overrides", () => { + mockState.taskThinkingEffort = { effort: "low", source: "model" } + renderChip() + expect(screen.getByText("low")).toBeInTheDocument() + expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() + }) + + it("shows the settings-derived effort with a 'default' source badge", () => { + mockState.apiConfiguration = { apiProvider: "anthropic", reasoningEffort: "medium" } as ProviderSettings + renderChip() + expect(screen.getByText("medium")).toBeInTheDocument() + expect(screen.getByText("default")).toBeInTheDocument() + }) + + it("shows the adaptive soft-guidance level with 'Zoo (auto)' for boolean-class models", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + renderChip() + expect(screen.getByText("adaptive")).toBeInTheDocument() + expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() + }) + + it("shows the chip when the dynamic-thinking-effort experiment is disabled", () => { + mockState.experiments = { dynamicThinkingEffort: false } + renderChip() + // The chip is a normal feature: gated by model capability, not the experiment. + expect(screen.getByText("medium")).toBeInTheDocument() + expect(screen.getByText("default")).toBeInTheDocument() + }) + + it("hides the chip when the model does not advertise effort support", () => { + mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + renderChip() + expect(screen.queryByText("medium")).toBeNull() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx new file mode 100644 index 0000000000..d7e04f9c86 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx @@ -0,0 +1,238 @@ +import React from "react" +import { fireEvent, render, screen, within } from "@/utils/test-utils" +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { ThinkingEffortToggle } from "../ThinkingEffortToggle" + +const mockPostMessage = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@src/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +const mockState: { + experiments: Record + apiConfiguration: ProviderSettings + taskThinkingEffort: { effort: string; source: string } | undefined +} = { + experiments: { dynamicThinkingEffort: true }, + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + taskThinkingEffort: undefined, +} +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockState, +})) + +let mockModelInfo: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], +} +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ id: "test-model", info: mockModelInfo }), +})) + +// Faithful popover double: the trigger flips the open state; content mounts only while open. +const PopoverState = React.createContext<{ open: boolean; setOpen: (open: boolean) => void } | null>(null) +vi.mock("@src/components/ui", () => ({ + Popover: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( + onOpenChange?.(next) }}> + {children} + + ), + PopoverTrigger: (props: { + children?: React.ReactNode + disabled?: boolean + className?: string + "data-testid"?: string + }) => { + const state = React.useContext(PopoverState) + const { children, ...rest } = props + return ( + + ) + }, + PopoverContent: (props: { children?: React.ReactNode; "data-testid"?: string }) => { + const state = React.useContext(PopoverState) + if (!state?.open) { + return null + } + const { children, ...rest } = props + return
{children}
+ }, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +const renderToggle = (props: { disabled?: boolean } = {}) => render() + +const openMenu = () => { + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + return screen.getByTestId("thinking-effort-toggle-menu") +} + +const option = (level: string) => screen.getByTestId("thinking-effort-option-" + level) + +describe("ThinkingEffortToggle (DTE series 4/5)", () => { + beforeEach(() => { + mockPostMessage.mockClear() + mockState.experiments = { dynamicThinkingEffort: true } + mockState.apiConfiguration = { reasoningEffort: "low" } as ProviderSettings + mockState.taskThinkingEffort = undefined + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + }) + + it("renders when the dynamic-thinking-effort experiment is disabled", () => { + mockState.experiments = { dynamicThinkingEffort: false } + renderToggle() + // The manual toggle is a normal feature: gated by model capability, not the experiment. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toBeInTheDocument() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) + }) + + it("exposes the localized accessible name on the icon-only trigger", () => { + renderToggle() + // The mocked i18n returns keys, so the exact localized label is the raw key. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) + }) + + it("renders nothing when the model does not advertise effort support", () => { + mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + const { container } = renderToggle() + expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("renders nothing when the capability array only advertises the disable sentinel", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable"], + } + const { container } = renderToggle() + expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("lists only the model-supported levels (never the disable sentinel)", () => { + renderToggle() + const menu = openMenu() + expect(menu).toHaveTextContent("chat:thinkingEffort.toggleTitle") + for (const level of ["low", "medium", "high", "max"]) { + expect(within(menu).getByTestId("thinking-effort-option-" + level)).toBeInTheDocument() + } + expect(screen.queryByTestId("thinking-effort-option-disable")).toBeNull() + }) + + it("marks the currently effective level and follows the task-local override", () => { + const view = renderToggle() + openMenu() + expect(option("low").querySelector("svg")).not.toBeNull() + expect(option("high").querySelector("svg")).toBeNull() + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + + mockState.taskThinkingEffort = { effort: "high", source: "you" } + view.rerender() + openMenu() + expect(option("high").querySelector("svg")).not.toBeNull() + expect(option("low").querySelector("svg")).toBeNull() + }) + + it("posts a task-local set request when a level is selected and closes the menu", () => { + renderToggle() + openMenu() + fireEvent.click(option("max")) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "setTaskThinkingEffort", effort: "max" }) + expect(screen.queryByTestId("thinking-effort-toggle-menu")).toBeNull() + }) + + it("dims and disables the trigger when the disabled prop is set", () => { + renderToggle({ disabled: true }) + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveClass("opacity-50") + // The trigger button still mounts while disabled (Radix blocks the open). + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + expect(screen.queryByTestId("thinking-effort-toggle-menu")).toBeNull() + }) + + it("highlights the trigger icon for a user-sourced override", () => { + mockState.taskThinkingEffort = { effort: "medium", source: "you" } + renderToggle() + const icon = screen.getByTestId("thinking-effort-toggle-trigger").querySelector("svg") + expect(icon).toHaveClass("text-vscode-textLink-foreground") + }) + + it("shows the current effective effort value in the trigger", () => { + // Settings-derived default. + const view = renderToggle() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveTextContent("low") + // Follows the task-local override. + mockState.taskThinkingEffort = { effort: "high", source: "you" } + view.rerender() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveTextContent("high") + }) + + it("applies the sibling-selector hover treatment when enabled", () => { + renderToggle() + const trigger = screen.getByTestId("thinking-effort-toggle-trigger") + expect(trigger).toHaveClass("hover:border-vscode-focusBorder") + expect(trigger).toHaveClass("hover:bg-vscode-toolbar-hoverBackground") + }) + + it("drops the user highlight when the override lands on the resolved default", () => { + // settings effort is "low" — a user override to "low" displays as default. + mockState.taskThinkingEffort = { effort: "low", source: "you" } + renderToggle() + const icon = screen.getByTestId("thinking-effort-toggle-trigger").querySelector("svg") + expect(icon).not.toHaveClass("text-vscode-textLink-foreground") + }) + + it("shows the adaptive soft-guidance hint and a single adaptive level for boolean-class models", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + mockState.apiConfiguration = {} as ProviderSettings + renderToggle() + const menu = openMenu() + expect(menu).toHaveTextContent("chat:thinkingEffort.adaptiveHint") + expect(within(menu).getByTestId("thinking-effort-option-adaptive")).toBeInTheDocument() + fireEvent.click(within(menu).getByTestId("thinking-effort-option-adaptive")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "setTaskThinkingEffort", effort: "adaptive" }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx new file mode 100644 index 0000000000..bed1a7f4c5 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx @@ -0,0 +1,27 @@ +import React from "react" + +import { AppProviders } from "../../../../playwright/AppProviders" +import { ThinkingEffortToggle } from "../ThinkingEffortToggle" + +// DTE series 4/5: CT story for the composer thinking-effort toggle. Uses a real +// model (gpt-5.6-sol) that advertises a per-request effort array; the toggle +// renders for capable models regardless of the experiment flag. The experiment +// state is kept in the initial state so the story renders exactly the component +// state the baselines were generated with. +export function ThinkingEffortToggleStory() { + return ( + +
+ Composer bottom bar + + +
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx new file mode 100644 index 0000000000..d7cd8c8f58 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx @@ -0,0 +1,32 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" +import { ThinkingEffortToggleStory } from "./ThinkingEffortToggle.visual.fixture" + +// DTE series 4/5: the toggle only renders for models that advertise per-request +// effort support, so the story pins such a model (see the fixture). +for (const theme of visualThemes.filter((candidate) => candidate.name === "dark" || candidate.name === "light")) { + test(`renders the thinking effort toggle in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await applyVisualTheme(page, theme) + // The full provider bundle leaves a bare Zod reference after CT tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + const component = await mount() + const story = component.getByTestId("thinking-effort-toggle-story") + const trigger = story.getByTestId("thinking-effort-toggle-trigger") + await expect(trigger).toBeVisible() + await expect(story).toHaveScreenshot(`thinking-effort-toggle-resting-${theme.name}.png`) + + await trigger.click() + const menu = page.getByTestId("thinking-effort-toggle-menu") + await expect(menu).toBeVisible() + await expect(menu.getByTestId("thinking-effort-option-high")).toBeVisible() + // The menu is a portal sibling of the story box (opened below the + // trigger), so capture the menu element itself. Finish the portal + // entrance animations first so the capture is pixel-stable. + await page.evaluate(() => { + document.getAnimations().forEach((animation) => animation.finish()) + }) + await expect(menu).toHaveScreenshot(`thinking-effort-toggle-menu-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png new file mode 100644 index 0000000000..7a5f2fb3a1 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png new file mode 100644 index 0000000000..8f66209a70 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png new file mode 100644 index 0000000000..97331bce0d Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-light.png new file mode 100644 index 0000000000..df4b189197 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-light.png differ diff --git a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx index b31f87dc7e..3feddad1d4 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,78 @@ 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("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( + , + ) + + 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) + }) + + 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) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 203d54f6ae..865253b43a 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -466,6 +466,18 @@ "wantsToRun": "Zoo vol executar una comanda slash", "didRun": "Zoo ha executat una comanda slash" }, + "thinkingEffort": { + "appliedByUser": "Esforç de raonament establert a: {{effort}}", + "chipTooltip": "Esforç de raonament: {{effort}} ({{source}})", + "sourceDefault": "per defecte", + "sourceAuto": "Zoo (auto)", + "sourceYou": "tu", + "toggleTitle": "Esforç de raonament", + "adaptiveHint": "Aquest model decideix el seu esforç automàticament — la teva selecció és només una guia suau.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "contextMenu": { "noResults": "Sense resultats", "problems": "Problemes", diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 52805e74e8..fb11e4b91e 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 590914a9ee..6aead97a2d 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo möchte einen Slash-Befehl ausführen", "didRun": "Zoo hat einen Slash-Befehl ausgeführt" }, + "thinkingEffort": { + "appliedByUser": "Denkanstrengung festgelegt auf: {{effort}}", + "chipTooltip": "Denkanstrengung: {{effort}} ({{source}})", + "sourceDefault": "Standard", + "sourceAuto": "Zoo (auto)", + "sourceYou": "Sie", + "toggleTitle": "Denkanstrengung", + "adaptiveHint": "Dieses Modell bestimmt seine Denkanstrengung automatisch — Ihre Auswahl ist nur eine sanfte Vorgabe.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} von {{total}} To-Dos erledigt", "complete": "{{total}} To-Dos erledigt", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index b895717422..de64197cd2 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 1caacde55f..03cb12fa6f 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -450,6 +450,18 @@ "wantsToRun": "Zoo wants to run a slash command", "didRun": "Zoo ran a slash command" }, + "thinkingEffort": { + "appliedByUser": "Thinking effort set to: {{effort}}", + "chipTooltip": "Thinking effort: {{effort}} ({{source}})", + "sourceDefault": "default", + "sourceAuto": "Zoo (auto)", + "sourceYou": "you", + "toggleTitle": "Thinking effort", + "adaptiveHint": "This model decides its effort automatically — your selection is soft guidance only.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "queuedMessages": { "title": "Queued Messages", "clickToEdit": "Click to edit message" diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index eaa37b7034..4c7b59992f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1053,6 +1053,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/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 527d78aed5..8005ad420c 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo quiere ejecutar un comando slash", "didRun": "Zoo ejecutó un comando slash" }, + "thinkingEffort": { + "appliedByUser": "Esfuerzo de razonamiento establecido en: {{effort}}", + "chipTooltip": "Esfuerzo de razonamiento: {{effort}} ({{source}})", + "sourceDefault": "predeterminado", + "sourceAuto": "Zoo (auto)", + "sourceYou": "tú", + "toggleTitle": "Esfuerzo de razonamiento", + "adaptiveHint": "Este modelo decide su esfuerzo automáticamente — tu selección es solo una guía suave.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} de {{total}} tareas pendientes realizadas", "complete": "{{total}} tareas pendientes realizadas", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index abb8a60609..72b4780329 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 638b2c0227..5e1a28dbec 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo veut exécuter une commande slash", "didRun": "Zoo a exécuté une commande slash" }, + "thinkingEffort": { + "appliedByUser": "Effort de raisonnement défini sur : {{effort}}", + "chipTooltip": "Effort de raisonnement : {{effort}} ({{source}})", + "sourceDefault": "par défaut", + "sourceAuto": "Zoo (auto)", + "sourceYou": "vous", + "toggleTitle": "Effort de raisonnement", + "adaptiveHint": "Ce modèle détermine son effort automatiquement — votre choix n'est qu'un guide souple.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} sur {{total}} tâches terminées", "complete": "{{total}} tâches terminées", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 272f21a6ee..15b5f35c2d 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 31270bc937..db3bba27da 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo एक स्लैश कमांड चलाना चाहता है", "didRun": "Zoo ने एक स्लैश कमांड चलाया" }, + "thinkingEffort": { + "appliedByUser": "सोच प्रयास सेट किया गया: {{effort}}", + "chipTooltip": "सोच प्रयास: {{effort}} ({{source}})", + "sourceDefault": "डिफ़ॉल्ट", + "sourceAuto": "Zoo (ऑटो)", + "sourceYou": "आप", + "toggleTitle": "सोच प्रयास", + "adaptiveHint": "यह मॉडल अपने प्रयास को स्वतः निर्धारित करता है — आपका चयन केवल एक लचीली मार्गदर्शिका है।", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}} में से {{completed}} टू-डू हो गए", "complete": "{{total}} टू-डू हो गए", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 0a4152b17a..149f7bc4f6 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 3b11773652..89a4964046 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -478,6 +478,18 @@ "wantsToRun": "Zoo ingin menjalankan perintah slash", "didRun": "Zoo telah menjalankan perintah slash" }, + "thinkingEffort": { + "appliedByUser": "Upaya pemikiran diatur ke: {{effort}}", + "chipTooltip": "Upaya pemikiran: {{effort}} ({{source}})", + "sourceDefault": "bawaan", + "sourceAuto": "Zoo (otomatis)", + "sourceYou": "anda", + "toggleTitle": "Upaya pemikiran", + "adaptiveHint": "Model ini menentukan usahanya secara otomatis — pilihan Anda hanya panduan lunak.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} dari {{total}} to-do selesai", "complete": "{{total}} to-do selesai", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b8abe9ab25..ce33b6a018 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/it/chat.json index f473b9e454..f44c0d7422 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo vuole eseguire un comando slash", "didRun": "Zoo ha eseguito un comando slash" }, + "thinkingEffort": { + "appliedByUser": "Sforzo di ragionamento impostato su: {{effort}}", + "chipTooltip": "Sforzo di ragionamento: {{effort}} ({{source}})", + "sourceDefault": "predefinito", + "sourceAuto": "Zoo (auto)", + "sourceYou": "tu", + "toggleTitle": "Sforzo di ragionamento", + "adaptiveHint": "Questo modello decide il proprio sforzo automaticamente — la tua selezione è solo una guida flessibile.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} di {{total}} cose da fare completate", "complete": "{{total}} cose da fare completate", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index e49ede9cec..4d4d80d61c 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 470e66bd65..dba8df4088 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zooはスラッシュコマンドを実行したい", "didRun": "Zooはスラッシュコマンドを実行しました" }, + "thinkingEffort": { + "appliedByUser": "思考努力度を {{effort}} に設定しました", + "chipTooltip": "思考努力度: {{effort}} ({{source}})", + "sourceDefault": "デフォルト", + "sourceAuto": "Zoo (自動)", + "sourceYou": "ユーザー", + "toggleTitle": "思考努力度", + "adaptiveHint": "このモデルは自動的に努力度を決定します。選択は参考情報です。", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}}件中{{completed}}件のTo-Doが完了", "complete": "{{total}}件のTo-Doが完了", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index d58c86c95d..97f890fd6e 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index c988d469dd..c6af1eadd4 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo가 슬래시 명령어를 실행하려고 합니다", "didRun": "Zoo가 슬래시 명령어를 실행했습니다" }, + "thinkingEffort": { + "appliedByUser": "사고 노력이 {{effort}}(으)로 설정됨", + "chipTooltip": "사고 노력: {{effort}} ({{source}})", + "sourceDefault": "기본값", + "sourceAuto": "Zoo (자동)", + "sourceYou": "사용자", + "toggleTitle": "사고 노력", + "adaptiveHint": "이 모델은 자동으로 노력도를 결정합니다. 선택은 단순 참고용입니다.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}}개의 할 일 중 {{completed}}개 완료", "complete": "{{total}}개의 할 일 완료", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 68ce8b2523..bf7c219cc1 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index e6f388281e..7c7eb8c0d2 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo wil een slash commando uitvoeren", "didRun": "Zoo heeft een slash commando uitgevoerd" }, + "thinkingEffort": { + "appliedByUser": "Denkinspanning ingesteld op: {{effort}}", + "chipTooltip": "Denkinspanning: {{effort}} ({{source}})", + "sourceDefault": "standaard", + "sourceAuto": "Zoo (auto)", + "sourceYou": "jij", + "toggleTitle": "Denkinspanning", + "adaptiveHint": "Dit model bepaalt zijn inspanning automatisch — je keuze is slechts een zachte aanwijzing.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} van {{total}} to-do's voltooid", "complete": "{{total}} to-do's voltooid", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 8d90d7747e..3214bfe1e1 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 39c2d1c9cd..a413f57551 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo chce uruchomić komendę slash", "didRun": "Zoo uruchomił komendę slash" }, + "thinkingEffort": { + "appliedByUser": "Wysiłek myślowy ustawiony na: {{effort}}", + "chipTooltip": "Wysiłek myślowy: {{effort}} ({{source}})", + "sourceDefault": "domyślny", + "sourceAuto": "Zoo (auto)", + "sourceYou": "ty", + "toggleTitle": "Wysiłek myślowy", + "adaptiveHint": "Ten model samodzielnie decyduje o wysiłku — Twój wybór to tylko luźna wskazówka.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "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/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ffc1cdf1a4..86376c737b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 9dc67a627c..25a177e4a6 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo quer executar um comando slash", "didRun": "Zoo executou um comando slash" }, + "thinkingEffort": { + "appliedByUser": "Esforço de raciocínio definido para: {{effort}}", + "chipTooltip": "Esforço de raciocínio: {{effort}} ({{source}})", + "sourceDefault": "padrão", + "sourceAuto": "Zoo (auto)", + "sourceYou": "você", + "toggleTitle": "Esforço de raciocínio", + "adaptiveHint": "Este modelo decide seu esforço automaticamente — sua seleção é apenas um guia suave.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} de {{total}} tarefas concluídas", "complete": "{{total}} tarefas concluídas", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index cf92b76ac7..0b23c9b74c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 7eb863904f..1018f55c90 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo хочет выполнить слеш-команду", "didRun": "Zoo выполнил слеш-команду" }, + "thinkingEffort": { + "appliedByUser": "Усиление размышлений установлено: {{effort}}", + "chipTooltip": "Усиление размышлений: {{effort}} ({{source}})", + "sourceDefault": "по умолчанию", + "sourceAuto": "Zoo (авто)", + "sourceYou": "вы", + "toggleTitle": "Усиление размышлений", + "adaptiveHint": "Эта модель сама определяет усиление — ваш выбор носит рекомендательный характер.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{completed}} из {{total}} задач выполнено", "complete": "{{total}} задач выполнено", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 23ff32faa9..a8fd78565a 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index b6d43b4b12..ee993a7129 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo bir slash komutu çalıştırmak istiyor", "didRun": "Zoo bir slash komutu çalıştırdı" }, + "thinkingEffort": { + "appliedByUser": "Düşünme çabası şuna ayarlandı: {{effort}}", + "chipTooltip": "Düşünme çabası: {{effort}} ({{source}})", + "sourceDefault": "varsayılan", + "sourceAuto": "Zoo (otomatik)", + "sourceYou": "sen", + "toggleTitle": "Düşünme çabası", + "adaptiveHint": "Bu model çabasını otomatik olarak belirler — seçiminiz yalnızca yumuşak bir yönergedir.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "{{total}} yapılacaklar listesinden {{completed}} tanesi tamamlandı", "complete": "{{total}} yapılacaklar listesi tamamlandı", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index f674e116d2..152bb3c820 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index e6c7ded31a..ee9b4c3ee4 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo muốn chạy lệnh slash", "didRun": "Zoo đã chạy lệnh slash" }, + "thinkingEffort": { + "appliedByUser": "Đã đặt nỗ lực suy luận: {{effort}}", + "chipTooltip": "Nỗ lực suy luận: {{effort}} ({{source}})", + "sourceDefault": "mặc định", + "sourceAuto": "Zoo (tự động)", + "sourceYou": "bạn", + "toggleTitle": "Nỗ lực suy luận", + "adaptiveHint": "Mô hình này tự quyết định nỗ lực — lựa chọn của bạn chỉ là gợi ý mềm.", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "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/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4b908ca658..e103d6ea5b 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index bc8f5dba93..3f8078f288 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo 想要运行斜杠命令", "didRun": "Zoo 运行了斜杠命令" }, + "thinkingEffort": { + "appliedByUser": "思考强度已设为: {{effort}}", + "chipTooltip": "思考强度: {{effort}} ({{source}})", + "sourceDefault": "默认", + "sourceAuto": "Zoo(自动)", + "sourceYou": "你", + "toggleTitle": "思考强度", + "adaptiveHint": "此模型会自动决定思考强度,你的选择仅作为软性指引。", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "todo": { "partial": "已完成 {{completed}} / {{total}} 个待办事项", "complete": "已完成 {{total}} 个待办事项", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index d79edca302..a48ce6a39d 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -973,6 +973,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/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index d2fe37a774..4653a33c4d 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -453,6 +453,18 @@ "wantsToRun": "Zoo 想要執行斜線指令", "didRun": "Zoo 執行了斜線指令" }, + "thinkingEffort": { + "appliedByUser": "思考強度已設為: {{effort}}", + "chipTooltip": "思考強度: {{effort}} ({{source}})", + "sourceDefault": "預設", + "sourceAuto": "Zoo(自動)", + "sourceYou": "你", + "toggleTitle": "思考強度", + "adaptiveHint": "此模型會自動決定思考強度,你的選擇僅作為軟性指引。", + "applied": "Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "Thinking effort unchanged: oscillation between levels detected" + }, "queuedMessages": { "title": "佇列中的訊息", "clickToEdit": "點選以編輯訊息" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 250cc2111b..34eee9201b 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1000,6 +1000,10 @@ "refreshSuccess": "工具重新整理成功", "refreshError": "工具重新整理失敗", "toolParameters": "參數" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "動態思考強度", + "description": "讓模型自行決定每一步的思考強度,並讓您在對話中調整。(實驗性)" } }, "promptCaching": { diff --git a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts new file mode 100644 index 0000000000..4ae1190873 --- /dev/null +++ b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts @@ -0,0 +1,178 @@ +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { computeThinkingEffortDisplay, THINKING_EFFORT_ADAPTIVE_LEVEL } from "../thinkingEffort" + +describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { + const modelWithLevels: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + reasoningEffort: "medium", + } + + const modelAdaptive: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + + const modelNone: ModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + + it("resolves the display for capable models without the experiment flag", () => { + // The manual surfaces are normal features: resolution is gated only by + // model capability. Settings effort wins over the model default. + const settings = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + }) + expect(settings?.effort).toBe("low") + expect(settings?.source).toBe("default") + // Model default. + const modelDefault = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(modelDefault?.effort).toBe("medium") + expect(modelDefault?.source).toBe("default") + // Boolean/adaptive-class model. + const adaptive = computeThinkingEffortDisplay({ model: modelAdaptive }) + expect(adaptive?.effort).toBe(THINKING_EFFORT_ADAPTIVE_LEVEL) + expect(adaptive?.source).toBe("auto") + }) + + it("shows the task-local value with source 'you' when the experiment flag is absent", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "max", source: "you" }, + }) + expect(display?.effort).toBe("max") + expect(display?.source).toBe("you") + }) + + it("returns null when the model does not advertise effort support", () => { + expect(computeThinkingEffortDisplay({ model: modelNone })).toBeNull() + expect(computeThinkingEffortDisplay({ model: undefined })).toBeNull() + }) + + it("returns null when the capability array only advertises the disable sentinel", () => { + const disableOnly: ModelInfo = { + contextWindow: 1, + maxTokens: 1, + supportsPromptCache: false, + supportsReasoningEffort: ["disable"], + } + expect(computeThinkingEffortDisplay({ model: disableOnly })).toBeNull() + }) + + it("excludes the disable sentinel from the supported levels", () => { + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(display?.supportedLevels).toEqual(["low", "medium", "high", "max"]) + expect(display?.isAdaptiveClass).toBe(false) + }) + + it("resolves a task-local override with source 'you'", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + taskThinkingEffort: { effort: "max", source: "you" }, + }) + expect(display?.effort).toBe("max") + expect(display?.source).toBe("you") + }) + + it("resolves task-local overrides from model/parent sources as auto", () => { + for (const source of ["model", "parent"]) { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source }, + }) + expect(display?.effort).toBe("high") + expect(display?.source).toBe("auto") + } + }) + + it("shows a user-set task-local effort as default when it equals the model default", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "medium", source: "you" }, + }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("shows a user-set task-local effort as default when it equals the settings effort", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + taskThinkingEffort: { effort: "low", source: "you" }, + }) + expect(display?.effort).toBe("low") + expect(display?.source).toBe("default") + }) + + it("keeps the user badge when the task-local effort differs from the resolved default", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source: "you" }, + }) + expect(display?.effort).toBe("high") + expect(display?.source).toBe("you") + }) + + it("resolves an unrecognized task-local source as default", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source: "unknown-origin" }, + }) + expect(display?.source).toBe("default") + }) + + it("resolves the settings effort with source 'default' when no override is active", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + }) + expect(display?.effort).toBe("low") + expect(display?.source).toBe("default") + }) + + it("treats the settings 'disable' sentinel as unset and falls through", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, + model: modelWithLevels, + }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("falls back to the model default effort", () => { + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("returns null for a level-array model with no settings or model default", () => { + const noDefault: ModelInfo = { ...modelWithLevels, reasoningEffort: undefined } + expect(computeThinkingEffortDisplay({ model: noDefault })).toBeNull() + }) + + it("resolves boolean/adaptive-class models to the adaptive soft-guidance level", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, + model: modelAdaptive, + }) + expect(display?.effort).toBe(THINKING_EFFORT_ADAPTIVE_LEVEL) + expect(display?.source).toBe("auto") + expect(display?.supportedLevels).toEqual([THINKING_EFFORT_ADAPTIVE_LEVEL]) + expect(display?.isAdaptiveClass).toBe(true) + }) + + it("lets a task-local override win over the adaptive fallback", () => { + const display = computeThinkingEffortDisplay({ + model: modelAdaptive, + taskThinkingEffort: { effort: "adaptive", source: "you" }, + }) + expect(display?.effort).toBe("adaptive") + expect(display?.source).toBe("you") + }) +}) diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts new file mode 100644 index 0000000000..516c68f874 --- /dev/null +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -0,0 +1,82 @@ +import type { ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" + +export type ThinkingEffortSource = "default" | "auto" | "you" + +export interface ThinkingEffortDisplay { + effort: string + source: ThinkingEffortSource + /** Levels the model advertises (menu entries); "adaptive" for boolean-class models. */ + supportedLevels: string[] + /** True for boolean/adaptive-class models (soft guidance). */ + isAdaptiveClass: boolean +} + +export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" + +/** + * DTE series 4/5: webview-side computation of the current effective thinking + * effort and its source, shared by the TaskHeader chip and the composer + * bottom-bar toggle. + * + * Resolution (strongest first): task-local override (authoritative + * extension-side push via `taskThinkingEffort`) → settings `reasoningEffort` + * (provider profile) → model default (`model.reasoningEffort`); boolean/ + * adaptive-class models fall back to the "adaptive" soft-guidance display. + * Returns `null` when the model does not advertise per-request effort support. + * + * Source-label rule: a task-local override that lands exactly on the resolved + * default (settings effort, else model default) is displayed with source + * "default" — the "you" badge only marks a non-default choice. + */ +export function computeThinkingEffortDisplay(args: { + apiConfiguration?: ProviderSettings + model?: ModelInfo + taskThinkingEffort?: { effort: string; source: string } +}): ThinkingEffortDisplay | null { + const { apiConfiguration, model, taskThinkingEffort } = args + + const capability = model?.supportsReasoningEffort + const isAdaptiveClass = capability === true + // The "disable" sentinel is a UI off-switch (settings value), not a level a + // task can be set to — keep it out of the menu even when a model advertises it. + const supportedLevels = Array.isArray(capability) + ? capability.filter((level) => level !== "disable") + : isAdaptiveClass + ? [THINKING_EFFORT_ADAPTIVE_LEVEL] + : [] + if (supportedLevels.length === 0) { + return null + } + + // Settings-derived effort (provider profile). The "disable" sentinel means + // "no effort" for the per-request envelope resolution. Resolved before the + // task-local branch so the default-source rule can compare against it. + const settingsEffort = apiConfiguration?.reasoningEffort as ReasoningEffortExtended | "disable" | undefined + const resolvedDefault = settingsEffort && settingsEffort !== "disable" ? settingsEffort : model?.reasoningEffort + + // 1. Task-local override (authoritative extension push). + if (taskThinkingEffort?.effort) { + const isAtResolvedDefault = resolvedDefault !== undefined && taskThinkingEffort.effort === resolvedDefault + const source: ThinkingEffortSource = + taskThinkingEffort.source === "you" && !isAtResolvedDefault + ? "you" + : taskThinkingEffort.source === "model" || taskThinkingEffort.source === "parent" + ? "auto" + : "default" + return { effort: taskThinkingEffort.effort, source, supportedLevels, isAdaptiveClass } + } + + // 2. Settings-derived effort (provider profile). + if (settingsEffort && settingsEffort !== "disable") { + return { effort: settingsEffort, source: "default", supportedLevels, isAdaptiveClass } + } + + // 3. Model default / adaptive soft guidance. + if (isAdaptiveClass) { + return { effort: THINKING_EFFORT_ADAPTIVE_LEVEL, source: "auto", supportedLevels, isAdaptiveClass } + } + if (model?.reasoningEffort) { + return { effort: model.reasoningEffort, source: "default", supportedLevels, isAdaptiveClass } + } + return null +}