From 6ea45b36a2bf0cf7787fca11ebd1969da567e611 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 06:31:23 +0800 Subject: [PATCH 01/12] feat(task): task-local thinking effort state, per-request override, and adaptive effort envelope DTE series 2/5 (part of #1329). - ApiHandlerCreateMessageMetadata.reasoningEffort: per-request override channel - resolveEffectiveReasoningEffort: single shared resolution point (override > settings > model default) - AnthropicHandler: adaptive output_config.effort envelope in both requestParams branches (in-range only) - Task: setRuntimeThinkingEffort/getRuntimeThinkingEffort with in-memory apiConfiguration merge/restore, per-request metadata at all four createMessage sites, dispose() reset; never persisted --- src/api/index.ts | 9 + .../anthropic-adaptive-effort.spec.ts | 297 ++++++++++++++++++ src/api/providers/anthropic.ts | 29 +- .../dte-effective-reasoning-effort.spec.ts | 58 ++++ src/api/transform/reasoning.ts | 45 +++ src/core/task/Task.ts | 82 +++++ .../Task.runtime-thinking-effort.test.ts | 249 +++++++++++++++ 7 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts create mode 100644 src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts create mode 100644 src/core/task/__tests__/Task.runtime-thinking-effort.test.ts diff --git a/src/api/index.ts b/src/api/index.ts index 8e7f20d66f..d6a88971ba 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,6 +7,7 @@ import { retiredProviderIdentifiers, type ProviderSettings, type ModelInfo, + type ReasoningEffortExtended, } from "@roo-code/types" import { getRouterRemovalMessage } from "../core/config/routerRemoval" @@ -115,6 +116,14 @@ export interface ApiHandlerCreateMessageMetadata { * when the user clicks stop, preventing wasted API tokens/compute on the provider side. */ abortSignal?: AbortSignal + /** + * Per-request thinking effort override (DTE series 2/5). + * When defined, takes precedence over the settings-derived `reasoningEffort` + * wherever the effective effort is resolved (see `resolveEffectiveReasoningEffort`). + * Task-scoped and transient: it applies to this request only (the next request + * after being set — no mid-stream effect) and is never persisted to settings. + */ + reasoningEffort?: ReasoningEffortExtended } export interface ApiHandler { diff --git a/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts b/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts new file mode 100644 index 0000000000..7f37b8dcc9 --- /dev/null +++ b/src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts @@ -0,0 +1,297 @@ +// npx vitest run src/api/providers/__tests__/anthropic-adaptive-effort.spec.ts +// +// DTE series 2/5 — per-request adaptive thinking effort envelope +// (output_config.effort) on the main Anthropic handler. +// +// Kept in a dedicated file (rather than anthropic.spec.ts) so the DTE series PRs +// stay mergeable while other series PRs extend the shared spec file. + +import { AnthropicHandler } from "../anthropic" +import type { ApiHandlerOptions } from "../../../shared/api" +import type { ReasoningEffortExtended } from "@roo-code/types" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" +import type { ApiHandlerCreateMessageMetadata } from "../../../api" + +// Mock TelemetryService +vitest.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vitest.fn(), + }, + }, +})) + +const mockCreate = vitest.fn() + +// Same SDK mock pattern as anthropic.spec.ts: createMessage resolves to a short +// finite stream so the handler's for-await loop terminates cleanly. +vitest.mock("@anthropic-ai/sdk", () => { + const mockAnthropicConstructor = vitest.fn().mockImplementation(function () { + return { + messages: { + create: mockCreate.mockImplementation(async (options: { stream?: boolean; model?: string }) => { + if (!options.stream) { + return { + id: "test-completion", + content: [{ type: "text", text: "Test response" }], + role: "assistant", + model: options.model, + usage: { input_tokens: 10, output_tokens: 5 }, + } + } + return asyncStreamFrom([ + { + type: "message_start", + message: { + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10, + }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "Hello" }, + }, + { + type: "content_block_delta", + delta: { type: "text_delta", text: " world" }, + }, + ]) + }), + }, + } + }) + + return { + Anthropic: mockAnthropicConstructor, + } +}) + +const userMessage = { + role: "user" as const, + content: [{ type: "text" as const, text: "Hi" }], +} + +/** Runs createMessage to completion and returns the request params sent to the SDK. */ +async function sentRequestParams( + handler: AnthropicHandler, + metadata?: ApiHandlerCreateMessageMetadata, +): Promise> { + const stream = handler.createMessage("system prompt", [userMessage], metadata) + await collectStream(stream) + const call = mockCreate.mock.calls.at(-1) + if (!call) { + throw new Error("Expected the SDK messages.create to have been called") + } + return call[0] as Record +} + +function makeHandler(options: { + apiModelId?: string + enableReasoningEffort?: boolean + reasoningEffort?: ApiHandlerOptions["reasoningEffort"] +}): AnthropicHandler { + return new AnthropicHandler({ + apiKey: "test-api-key", + apiModelId: options.apiModelId ?? "claude-opus-4-7", + enableReasoningEffort: options.enableReasoningEffort, + reasoningEffort: options.reasoningEffort, + }) +} + +describe("AnthropicHandler adaptive effort envelope (DTE series 2/5)", () => { + beforeEach(() => { + clearAllMocks() + }) + + describe("output_config.effort on adaptive-thinking requests", () => { + const inRangeEfforts: ReasoningEffortExtended[] = ["low", "medium", "high", "xhigh", "max"] + + it.each(inRangeEfforts)( + "sends the settings effort %s as output_config.effort for an adaptive model", + async (effort) => { + const handler = makeHandler({ enableReasoningEffort: true, reasoningEffort: effort }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort }) + }, + ) + + it("sends the envelope from the first (cache-control) requestParams branch", async () => { + // claude-opus-4-8 takes the first (cache-control) requestParams branch; + // the default branch is covered below via an unknown model id. + const handler = makeHandler({ + apiModelId: "claude-opus-4-8", + enableReasoningEffort: true, + reasoningEffort: "xhigh", + }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort: "xhigh" }) + }) + + it("sends the envelope from the default requestParams branch", async () => { + // Unknown model id -> falls through to the default switch branch, while the + // guessed model info (claude-opus-4-7 substring) is adaptive-capable. + const handler = makeHandler({ + apiModelId: "claude-opus-4-7-custom", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + const params = await sentRequestParams(handler) + + expect(params.model).toBe("claude-opus-4-7-custom") + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params.output_config).toEqual({ effort: "high" }) + }) + }) + + describe("envelope omission (out-of-range or non-adaptive)", () => { + const settingsEfforts: ApiHandlerOptions["reasoningEffort"][] = ["none", "minimal", "disable"] + + it.each(settingsEfforts)( + "omits output_config when the settings effort is %s on an adaptive model", + async (effort) => { + const handler = makeHandler({ enableReasoningEffort: true, reasoningEffort: effort }) + + const params = await sentRequestParams(handler) + + // Adaptive thinking is still requested, but no envelope is sent so the + // API applies its own default effort. + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }, + ) + + it("omits output_config when no effort is set anywhere on an adaptive model", async () => { + const handler = makeHandler({ enableReasoningEffort: true }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }) + + it("omits output_config for a non-adaptive model even with an in-range effort", async () => { + // Budget-based extended thinking (type: "enabled") never carries the + // adaptive envelope. + const handler = makeHandler({ + apiModelId: "claude-sonnet-4-5", + enableReasoningEffort: true, + reasoningEffort: "xhigh", + }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toMatchObject({ type: "enabled" }) + expect(params).not.toHaveProperty("output_config") + }) + + it("omits output_config when adaptive thinking itself is not requested", async () => { + // enableReasoningEffort=false -> thinking is undefined -> no envelope even + // with an in-range settings effort. + const handler = makeHandler({ enableReasoningEffort: false, reasoningEffort: "xhigh" }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toBeUndefined() + expect(params).not.toHaveProperty("output_config") + }) + + it("keeps the pre-DTE request shape for a plain model with no reasoning settings", async () => { + // Guard: no reasoning settings and no metadata -> no output_config. + const handler = makeHandler({ apiModelId: "claude-3-5-haiku-20241022" }) + + const params = await sentRequestParams(handler) + + expect(params.thinking).toBeUndefined() + expect(params).not.toHaveProperty("output_config") + }) + }) + + describe("per-request override (metadata.reasoningEffort) precedence", () => { + const baseOptions: { + apiModelId?: string + enableReasoningEffort?: boolean + reasoningEffort?: ApiHandlerOptions["reasoningEffort"] + } = { + apiModelId: "claude-opus-4-7", + enableReasoningEffort: true, + } + + it("lets metadata.reasoningEffort override the settings value", async () => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "low" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "xhigh", + }) + + expect(params.output_config).toEqual({ effort: "xhigh" }) + }) + + it("suppresses the envelope when the metadata override is out-of-range", async () => { + // Settings would send "high"; the override wins and is out-of-range, so + // the envelope is omitted entirely. + const handler = makeHandler({ ...baseOptions, reasoningEffort: "high" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "minimal", + }) + + expect(params.thinking).toEqual({ type: "adaptive" }) + expect(params).not.toHaveProperty("output_config") + }) + + const overrideEfforts: ReasoningEffortExtended[] = ["none", "minimal"] + + it.each(overrideEfforts)( + "suppresses the envelope for metadata override %s even with an in-range settings value", + async (effort) => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "max" }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: effort, + }) + + expect(params).not.toHaveProperty("output_config") + }, + ) + + it("applies the settings value when metadata carries no override", async () => { + const handler = makeHandler({ ...baseOptions, reasoningEffort: "medium" }) + + const params = await sentRequestParams(handler, { taskId: "task-1" }) + + expect(params.output_config).toEqual({ effort: "medium" }) + }) + + it("keeps non-adaptive requests envelope-free even with a metadata override", async () => { + const handler = makeHandler({ + apiModelId: "claude-sonnet-4-5", + enableReasoningEffort: true, + reasoningEffort: "low", + }) + + const params = await sentRequestParams(handler, { + taskId: "task-1", + reasoningEffort: "xhigh", + }) + + expect(params.thinking).toMatchObject({ type: "enabled" }) + expect(params).not.toHaveProperty("output_config") + }) + }) +}) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index b55c8b3089..2e9555cc8e 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -18,7 +18,11 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" -import { getAnthropicProviderReasoning } from "../transform/reasoning" +import { + ADAPTIVE_OUTPUT_CONFIG_EFFORTS, + getAnthropicProviderReasoning, + resolveEffectiveReasoningEffort, +} from "../transform/reasoning" import { handleProviderError } from "./utils/error-handler" import { BaseProvider } from "./base-provider" @@ -79,6 +83,25 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa settings: this.options, }) + // DTE series 2/5: per-request adaptive effort envelope (output_config.effort). + // The task-local per-request override (metadata.reasoningEffort) takes + // precedence over the settings-derived value (shared resolution in + // resolveEffectiveReasoningEffort). Only adaptive-thinking requests whose + // effective effort is in-range get the envelope; everything else (unset, + // "disable", "none", "minimal") omits it and lets the API apply its default. + const effectiveReasoningEffort = resolveEffectiveReasoningEffort({ + override: metadata?.reasoningEffort, + settingsReasoningEffort: this.options.reasoningEffort, + modelDefaultEffort: info.reasoningEffort, + }) + const adaptiveEffort = + thinking?.type === "adaptive" && + effectiveReasoningEffort !== undefined && + effectiveReasoningEffort !== "disable" && + ADAPTIVE_OUTPUT_CONFIG_EFFORTS.includes(effectiveReasoningEffort) + ? effectiveReasoningEffort + : undefined + // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API const sanitizedMessages = filterNonAnthropicBlocks(messages) @@ -141,6 +164,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), // Setting cache breakpoint for system prompt so new tasks can reuse it. system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], messages: sanitizedMessages.map((message, index) => { @@ -216,6 +241,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), system: [{ text: systemPrompt, type: "text" }], messages: sanitizedMessages, stream: true, diff --git a/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts new file mode 100644 index 0000000000..f126cae97c --- /dev/null +++ b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts @@ -0,0 +1,58 @@ +// npx vitest run src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts + +import { ADAPTIVE_OUTPUT_CONFIG_EFFORTS, resolveEffectiveReasoningEffort } from "../reasoning" + +describe("DTE series 2/5 — resolveEffectiveReasoningEffort", () => { + const settingsEffort = "high" + const modelDefault = "medium" + + it("returns the per-request override when present (strongest precedence)", () => { + expect( + resolveEffectiveReasoningEffort({ + override: "xhigh", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("xhigh") + }) + + it("lets the override win even when it is out-of-range for the adaptive envelope", () => { + // "minimal" is a valid override value but outside the adaptive envelope set; + // resolution still returns it — envelope gating is the caller's concern. + expect( + resolveEffectiveReasoningEffort({ + override: "minimal", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("minimal") + }) + + it("falls back to the settings value when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "low", modelDefaultEffort: modelDefault }), + ).toBe("low") + }) + + it("preserves the settings 'disable' sentinel when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "disable", modelDefaultEffort: modelDefault }), + ).toBe("disable") + }) + + it("an explicit override wins over a settings 'disable' sentinel", () => { + expect(resolveEffectiveReasoningEffort({ override: "low", settingsReasoningEffort: "disable" })).toBe("low") + }) + + it("falls back to the model default when neither override nor settings is set", () => { + expect(resolveEffectiveReasoningEffort({ modelDefaultEffort: "low" })).toBe("low") + }) + + it("returns undefined when nothing is set", () => { + expect(resolveEffectiveReasoningEffort({})).toBeUndefined() + }) + + it("exposes exactly the in-range adaptive envelope efforts", () => { + expect([...ADAPTIVE_OUTPUT_CONFIG_EFFORTS]).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) +}) diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index c51111125a..14bdaba889 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -22,6 +22,51 @@ export type AnthropicProviderReasoningParams = AnthropicReasoningParams | { type export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] } +/** + * DTE series 2/5 — effort levels accepted by the Claude 4.7+ adaptive-thinking + * `output_config.effort` envelope. Efforts outside this set (e.g. "none", + * "minimal", "disable") omit the envelope so the API applies its own default. + */ +export const ADAPTIVE_OUTPUT_CONFIG_EFFORTS: readonly ReasoningEffortExtended[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +] + +/** + * DTE series 2/5 — resolves the effective thinking effort for a single request. + * + * Resolution order (strongest first): + * 1. `override` — the per-request task-local effort + * (`ApiHandlerCreateMessageMetadata.reasoningEffort`), + * 2. `settingsReasoningEffort` — the settings-derived value, + * 3. `modelDefaultEffort` — the model's default effort. + * + * This is the single shared resolution point for the per-request override: + * providers that resolve the effective effort through it inherit the override + * without duplicating precedence logic. The override is transient (next request + * only) and never persisted to settings. + */ +export const resolveEffectiveReasoningEffort = ({ + override, + settingsReasoningEffort, + modelDefaultEffort, +}: { + override?: ReasoningEffortExtended + settingsReasoningEffort?: ReasoningEffortExtended | "disable" + modelDefaultEffort?: ReasoningEffortExtended +}): ReasoningEffortExtended | "disable" | undefined => { + if (override !== undefined) { + return override + } + if (settingsReasoningEffort !== undefined) { + return settingsReasoningEffort + } + return modelDefaultEffort +} + // Valid Gemini thinking levels for effort-based reasoning const GEMINI_THINKING_LEVELS = ["minimal", "low", "medium", "high"] as const diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4be087394e..2a139923c1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -22,6 +22,7 @@ import { type TaskMetadata, type TaskEvents, type ProviderSettings, + type ReasoningEffortExtended, type TokenUsage, type ToolUsage, type ToolName, @@ -289,6 +290,13 @@ export class Task extends EventEmitter implements TaskLike { // API apiConfiguration: ProviderSettings api: ApiHandler + // DTE series 2/5: task-local thinking effort override. Transient per-task state — + // never persisted to settings; cleared on dispose (see dispose()). + private runtimeThinkingEffort?: ReasoningEffortExtended + private runtimeThinkingEffortSource?: string + // Settings-derived effort captured when the override activates, so clearing + // (undefined) restores it in the in-memory apiConfiguration copy. + private preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -1521,6 +1529,66 @@ export class Task extends EventEmitter implements TaskLike { this.api = buildApiHandler(this.apiConfiguration) } + /** + * DTE series 2/5: sets — or clears with `undefined` — the task-local thinking + * effort override. + * + * Resolution order for the affected requests (strongest first): this + * task-local override → settings `reasoningEffort` → model default. The + * override applies to the NEXT API request only (no mid-stream effect): it is + * passed per request as `metadata.reasoningEffort` and, while active, is + * merged into the in-memory `apiConfiguration` copy (profile-switch / + * `updateApiConfiguration` precedent) so the rebuilt handler reflects it too. + * `undefined` clears the override and restores the settings-derived value in + * the copy. Nothing is ever written to persisted settings. + * + * @param effort - The task-local effort, or `undefined` to clear. + * @param source - Optional provenance label (UI wiring lands in a later PR). + */ + public setRuntimeThinkingEffort(effort: ReasoningEffortExtended | undefined, source?: string): void { + const wasActive = this.runtimeThinkingEffort !== undefined + this.runtimeThinkingEffort = effort + this.runtimeThinkingEffortSource = effort === undefined ? undefined : source + + if (effort !== undefined) { + // Capture the settings-derived value once so clearing can restore it. + if (!wasActive) { + this.preOverrideReasoningEffort = this.apiConfiguration.reasoningEffort + } + // Merge into the in-memory copy (never the persisted settings object). + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: effort } + } else if (wasActive) { + // Restore the settings-derived value captured when the override activated. + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: this.preOverrideReasoningEffort } + this.preOverrideReasoningEffort = undefined + } else { + // Already inactive: nothing to clear. + return + } + + // Rebuild the handler from the updated copy so the next request uses it. + this.api = buildApiHandler(this.apiConfiguration) + } + + /** + * DTE series 2/5: reads the current task-local thinking effort override. + */ + public getRuntimeThinkingEffort(): { effort?: ReasoningEffortExtended; source?: string } { + return { + effort: this.runtimeThinkingEffort, + source: this.runtimeThinkingEffortSource, + } + } + + /** + * DTE series 2/5: metadata fragment carrying the active task-local effort + * override on a single request. Empty when no override is active, so the + * existing settings resolution applies unchanged. + */ + private getRuntimeThinkingEffortMetadata(): Pick { + return this.runtimeThinkingEffort !== undefined ? { reasoningEffort: this.runtimeThinkingEffort } : {} + } + public async submitUserMessage( text: string, images?: string[], @@ -1637,6 +1705,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Generate environment details to include in the condensed summary const environmentDetails = await getEnvironmentDetails(this, true) @@ -2295,6 +2365,12 @@ export class Task extends EventEmitter implements TaskLike { public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // DTE series 2/5: the task-local effort override is transient — clear it on + // task end so a disposed task never carries it forward. + this.runtimeThinkingEffort = undefined + this.runtimeThinkingEffortSource = undefined + this.preOverrideReasoningEffort = undefined + // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. @@ -3955,6 +4031,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } try { @@ -4181,6 +4259,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Only generate environment details when context management will actually run. @@ -4346,6 +4426,8 @@ export class Task extends EventEmitter implements TaskLike { taskId: this.taskId, suppressPreviousResponseId: this.skipPrevResponseIdOnce, abortSignal, + // DTE series 2/5: carry the active task-local effort override for this request. + ...this.getRuntimeThinkingEffortMetadata(), // Include tools whenever they are present. ...(shouldIncludeTools ? { diff --git a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts new file mode 100644 index 0000000000..2ff7e046f8 --- /dev/null +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -0,0 +1,249 @@ +// npx vitest run src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +// +// DTE series 2/5 — task-local thinking effort state on Task: +// setRuntimeThinkingEffort / getRuntimeThinkingEffort, the in-memory +// apiConfiguration merge + restore, and the task-end reset in dispose(). + +import { ProviderSettings, type ReasoningEffortExtended } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { buildApiHandler } from "../../../api" + +// Mock dependencies (same lightweight set as Task.throttle.test.ts) +vi.mock("../../webview/ClineProvider") +vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") +vi.mock("../../context-tracking/FileContextTracker") +vi.mock("../../../integrations/editor/DiffViewProvider") +vi.mock("../../tools/ToolRepetitionDetector") +vi.mock("../../../api", () => ({ + // Returns a fresh handler object per call so tests can assert on the exact + // configuration each rebuild received (via vi.mocked(buildApiHandler).mock.calls). + buildApiHandler: vi.fn((configuration: { apiModelId?: string }) => ({ + getModel: () => ({ info: {}, id: configuration.apiModelId ?? "test-model" }), + })), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + }, + }, +})) + +// Mock task persistence to avoid disk writes +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + readTaskMessages: vi.fn().mockResolvedValue([]), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + taskMetadata: vi.fn().mockResolvedValue({ + historyItem: { + id: "test-task-id", + number: 1, + task: "Test task", + ts: Date.now(), + totalCost: 0.01, + tokensIn: 100, + tokensOut: 50, + }, + tokenUsage: { + totalTokensIn: 100, + totalTokensOut: 50, + totalCost: 0.01, + contextTokens: 150, + totalCacheWrites: 0, + totalCacheReads: 0, + }, + }), +})) + +// Typed access to the intentionally-private DTE state, mirroring the +// getTaskTestAccess pattern in Task.spec.ts (single double assertion, documented). +type RuntimeThinkingEffortAccess = { + runtimeThinkingEffort?: ReasoningEffortExtended + runtimeThinkingEffortSource?: string + preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] + getRuntimeThinkingEffortMetadata: () => { reasoningEffort?: ReasoningEffortExtended } +} + +function getPrivateAccess(task: Task): RuntimeThinkingEffortAccess { + return task as unknown as RuntimeThinkingEffortAccess +} + +const SETTINGS_EFFORT: ReasoningEffortExtended = "low" + +describe("Task runtime thinking effort (DTE series 2/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/path" }, + }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + } as ProviderSettings + + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + startTask: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("setRuntimeThinkingEffort", () => { + it("stores effort + source, merges into the in-memory apiConfiguration, and rebuilds the handler", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + expect(getPrivateAccess(task).runtimeThinkingEffort).toBe("xhigh") + expect(getPrivateAccess(task).runtimeThinkingEffortSource).toBe("test-source") + + // The in-memory copy carries the override... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + reasoningEffort: "xhigh", + }), + ) + // ...without mutating the settings object the provider handed in. + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + reasoningEffort: SETTINGS_EFFORT, + }), + ) + // The handler is rebuilt from the merged copy (last build call). + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "xhigh" })) + // The merged copy is a fresh object, not the settings object. + expect(lastCall?.[0]).not.toBe(mockApiConfiguration) + }) + + it("does not re-capture the settings value when re-set while active", () => { + task.setRuntimeThinkingEffort("high", "first") + task.setRuntimeThinkingEffort("medium", "second") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "medium", source: "second" }) + // The settings-derived value captured at first activation is preserved. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe(SETTINGS_EFFORT) + + // Clearing restores the original settings value, not the intermediate one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + }) + + it("restores the settings-derived effort when cleared with undefined", () => { + task.setRuntimeThinkingEffort("max") + expect(task.apiConfiguration.reasoningEffort).toBe("max") + + task.setRuntimeThinkingEffort(undefined) + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + // The rest of the configuration is preserved through the restore. + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + }), + ) + // The handler is rebuilt from the restored copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: SETTINGS_EFFORT })) + }) + + it("is a no-op when cleared while inactive (no handler rebuild)", () => { + const callsBefore = vi.mocked(buildApiHandler).mock.calls.length + + task.setRuntimeThinkingEffort(undefined) + + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(callsBefore) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration).toBe(mockApiConfiguration) + }) + + it("never writes to the provider or persisted settings", () => { + task.setRuntimeThinkingEffort("xhigh") + task.setRuntimeThinkingEffort(undefined) + + // Nothing is posted to the webview and the handed-in settings object is intact. + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + }), + ) + }) + }) + + describe("request metadata fragment", () => { + it("is empty while inactive and carries the override while active", () => { + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + + task.setRuntimeThinkingEffort("high") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "high" }) + + task.setRuntimeThinkingEffort("low") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "low" }) + + task.setRuntimeThinkingEffort(undefined) + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + }) + }) + + describe("dispose", () => { + it("clears the task-local override at task end", () => { + task.setRuntimeThinkingEffort("xhigh", "source") + task.dispose() + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + const access = getPrivateAccess(task) + expect(access.runtimeThinkingEffort).toBeUndefined() + expect(access.runtimeThinkingEffortSource).toBeUndefined() + expect(access.preOverrideReasoningEffort).toBeUndefined() + }) + }) +}) From 14d1f35a8e1ec1f9d15567ed3c483b66477ddb61 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 08:38:27 +0800 Subject: [PATCH 02/12] fix(task): keep override restore value current across profile switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTE series 2/5 — addresses the CodeRabbit review finding on #1338: when a task-local thinking-effort override is active, updateApiConfiguration() now re-captures the incoming profile's reasoningEffort as the restore value and re-applies the override on top of the new in-memory copy, so clearing the override restores the NEW profile value instead of the stale one. Additive: activation and clearing semantics are otherwise unchanged. Adds two regression tests (override active + profile switch restores new value; inactive updateApiConfiguration unchanged behavior). --- src/core/task/Task.ts | 11 +++- .../Task.runtime-thinking-effort.test.ts | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2a139923c1..ac0e321382 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1525,7 +1525,16 @@ export class Task extends EventEmitter implements TaskLike { */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { // Update the configuration and rebuild the API handler - this.apiConfiguration = newApiConfiguration + if (this.runtimeThinkingEffort !== undefined) { + // DTE series 2/5: a task-local override is active, so re-capture the + // incoming profile's value as the restore value and re-apply the + // override on top of the new in-memory copy — clearing the override + // must restore the NEW profile's value, not the stale one. + this.preOverrideReasoningEffort = newApiConfiguration.reasoningEffort + this.apiConfiguration = { ...newApiConfiguration, reasoningEffort: this.runtimeThinkingEffort } + } else { + this.apiConfiguration = newApiConfiguration + } this.api = buildApiHandler(this.apiConfiguration) } diff --git a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts index 2ff7e046f8..4fce91b475 100644 --- a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -219,6 +219,68 @@ describe("Task runtime thinking effort (DTE series 2/5)", () => { }) }) + describe("updateApiConfiguration while an override is active", () => { + it("re-captures the incoming profile's effort as the restore value and keeps the override applied", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + // A profile switch lands a different settings-derived effort while the override is active. + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + task.updateApiConfiguration(newConfig) + + // The override still wins in the in-memory copy... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "xhigh", + }), + ) + // ...the override remains active... + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + // ...and the NEW profile's value is now the restore target. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe("medium") + // The handler was rebuilt from the merged new copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual( + expect.objectContaining({ apiModelId: "claude-opus-4-8", reasoningEffort: "xhigh" }), + ) + expect(lastCall?.[0]).not.toBe(newConfig) + + // Clearing restores the NEW profile's effort, not the stale original one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + }), + ) + const lastCallAfterClear = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCallAfterClear?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "medium" })) + }) + + it("replaces the configuration as usual while inactive", () => { + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + + task.updateApiConfiguration(newConfig) + + expect(task.apiConfiguration).toBe(newConfig) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toBe(newConfig) + }) + }) + describe("request metadata fragment", () => { it("is empty while inactive and carries the override while active", () => { expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) From 90b47b05399b2dabe299937946be20eb92f5dc9a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 09:38:00 +0800 Subject: [PATCH 03/12] docs(task): JSDoc for diff-touched functions flagged by CodeRabbit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTE series 2/5 — addresses the CodeRabbit docstring-coverage warning on #1338 (33.33% < 80% across the functions touched by the diff): - AnthropicHandler.createMessage: documents the shared effective-effort resolution and the adaptive output_config.effort envelope (in-range only). - Task.dispose: documents centralized teardown incl. the transient task-local override reset. - Task.updateApiConfiguration: documents the override-preservation behavior (re-captured restore value + re-applied override on the new in-memory copy). Comment-only change: 30/30 patch lines and 10/10 branches unchanged; 317/317 tests and tsc --noEmit re-verified green. --- src/api/providers/anthropic.ts | 15 +++++++++++++++ src/core/task/Task.ts | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 2e9555cc8e..c0843d29a8 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -62,6 +62,21 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa }) } + /** + * Creates a streaming Anthropic message for the current model. + * + * Resolves the effective thinking effort for this request through the shared + * `resolveEffectiveReasoningEffort` point (per-request override → settings → + * model default). For adaptive-thinking models, when the resolved effort is one + * of `ADAPTIVE_OUTPUT_CONFIG_EFFORTS` (low|medium|high|xhigh|max), the request + * carries `output_config: { effort }` (DTE series 2/5); out-of-range or unset + * efforts omit it so the API default applies. + * + * @param systemPrompt - The system prompt for the request. + * @param messages - The message history to send. + * @param metadata - Per-request metadata (carries the task-local effort override). + * @returns An async iterator of parsed Anthropic stream events. + */ async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ac0e321382..e448cb16bc 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1521,6 +1521,12 @@ export class Task extends EventEmitter implements TaskLike { * Updates the API configuration and rebuilds the API handler. * There is no tool-protocol switching or tool parser swapping. * + * DTE series 2/5: when a task-local thinking effort override is active + * (`setRuntimeThinkingEffort`), the incoming configuration's `reasoningEffort` + * becomes the new restore value and the override is re-applied on top of the + * fresh in-memory copy — clearing the override later restores the NEW profile's + * value, not a stale one. + * * @param newApiConfiguration - The new API configuration to use */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { @@ -2371,6 +2377,13 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Centralized task teardown: releases task resources and resets transient + * task-local state. + * + * DTE series 2/5: also clears the task-local thinking effort override (the + * `setRuntimeThinkingEffort` state) — the override never outlives the task. + */ public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) From 146c5c826a070c7cb51ca006963151a7d74f58b4 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sun, 23 Aug 2026 15:22:21 +0800 Subject: [PATCH 04/12] feat(task): orchestrator new_task thinking_effort --- packages/types/src/vscode-extension-host.ts | 9 +- src/__tests__/new-task-delegation.spec.ts | 6 + src/__tests__/provider-delegation.spec.ts | 77 +++++ .../prompts/tools/native-tools/new_task.ts | 6 + src/core/task/Task.ts | 58 +++- .../__tests__/Task.new-task-effort.spec.ts | 194 +++++++++++ src/core/tools/NewTaskTool.ts | 64 +++- .../__tests__/newTaskThinkingEffort.spec.ts | 314 ++++++++++++++++++ src/core/tools/__tests__/newTaskTool.spec.ts | 12 + src/core/webview/ClineProvider.ts | 13 +- .../__tests__/webviewMessageHandler.spec.ts | 39 ++- src/core/webview/webviewMessageHandler.ts | 9 +- src/shared/tools.ts | 5 +- webview-ui/src/components/chat/ChatView.tsx | 80 ++++- .../chat/__tests__/ChatView.spec.tsx | 171 ++++++++++ 15 files changed, 1041 insertions(+), 16 deletions(-) create mode 100644 src/core/task/__tests__/Task.new-task-effort.spec.ts create mode 100644 src/core/tools/__tests__/newTaskThinkingEffort.spec.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 337ad22e2c..afc79e0970 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -12,7 +12,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" -import { RouterModelsMessageType, type ModelRecord, type RouterModels } from "./model.js" +import { RouterModelsMessageType, type ModelRecord, type RouterModels, type ReasoningEffortExtended } from "./model.js" import { LmStudioModelsMessageType } from "./providers/lm-studio.js" import { OllamaModelsMessageType } from "./providers/ollama.js" import { OpenAiModelsMessageType } from "./providers/openai.js" @@ -643,6 +643,9 @@ export interface WebviewMessage { | "openRulesDirectory" text?: string taskId?: string + // DTE series 5/5: thinking effort chosen in the pending new_task ask block + // (sent with the ask response, see Task.handleWebviewAskResponse). + thinkingEffort?: ReasoningEffortExtended editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean @@ -892,6 +895,10 @@ export interface ClineSayTool { description?: string // Properties for skill tool skill?: string + // DTE series 5/5: new_task thinking-effort prefill for the ask block and the + // effort levels the target model supports (see NewTaskTool). + thinkingEffort?: ReasoningEffortExtended + supportedThinkingEfforts?: ReasoningEffortExtended[] } export interface ClineAskUseMcpServer { diff --git a/src/__tests__/new-task-delegation.spec.ts b/src/__tests__/new-task-delegation.spec.ts index b6f6d4d36c..1090b00f30 100644 --- a/src/__tests__/new-task-delegation.spec.ts +++ b/src/__tests__/new-task-delegation.spec.ts @@ -20,14 +20,20 @@ describe("Task.startSubtask() metadata-driven delegation", () => { ;(parent as any).taskId = "parent-1" ;(parent as any).providerRef = { deref: () => provider } ;(parent as any).emit = vi.fn() + // DTE series 5/5: startSubtask now passes the parent's effective effort to the + // child's init; this Object.create double bypasses the constructor, so shadow + // the public resolver with the value under test. + parent.resolveNewTaskEffectiveEffort = () => undefined const child = await (Task.prototype as any).startSubtask.call(parent, "Do something", [], "code") + // DTE series 5/5: thinkingEffort is always present (undefined = inherit parent effective). expect(provider.delegateParentAndOpenChild).toHaveBeenCalledWith({ parentTaskId: "parent-1", message: "Do something", initialTodos: [], mode: "code", + thinkingEffort: undefined, }) expect(child.taskId).toBe("child-1") diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0154027753..3e86a234e2 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -386,4 +386,81 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) + + it("applies the parent-supplied starting effort to the child at init (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + const child = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "high", + }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + + expect(child.taskId).toBe("child-1") + // Applied as a task-local override with provenance "parent" before the child's + // first request (the child header shows it from the start). + expect(setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "parent") + }) + + it("leaves the child's effort untouched when no starting effort is supplied (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() + + expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) }) diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f8e29e549d..cc6fb8e374 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos` +const THINKING_EFFORT_PARAMETER_DESCRIPTION = `Optional thinking effort the new task starts with (e.g., "low", "medium", "high"). Must be a level the target model supports. When omitted, the new task starts with the current task's effective effort. The user can still change it before entering the new task.` + export default { type: "function", function: { @@ -31,6 +33,10 @@ export default { type: ["string", "null"], description: TODOS_PARAMETER_DESCRIPTION, }, + thinking_effort: { + type: "string", + description: THINKING_EFFORT_PARAMETER_DESCRIPTION, + }, }, required: ["mode", "message", "todos"], additionalProperties: false, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e448cb16bc..cffe9328fd 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -58,6 +58,7 @@ import { providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { resolveEffectiveReasoningEffort } from "../../api/transform/reasoning" import { CloudService } from "@roo-code/cloud" // api @@ -297,6 +298,9 @@ export class Task extends EventEmitter implements TaskLike { // Settings-derived effort captured when the override activates, so clearing // (undefined) restores it in the in-memory apiConfiguration copy. private preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] + // DTE series 5/5: thinking effort chosen in the webview new_task ask block; carried + // by the ask response (handleWebviewAskResponse) and consumed once by NewTaskTool. + private newTaskAskThinkingEffort?: ReasoningEffortExtended private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -1443,7 +1447,16 @@ export class Task extends EventEmitter implements TaskLike { return result } - handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + /** + * DTE series 5/5: the optional `thinkingEffort` is the user's new_task ask-block + * selection (webview `WebviewMessage.thinkingEffort`), consumed by NewTaskTool. + */ + handleWebviewAskResponse( + askResponse: ClineAskResponse, + text?: string, + images?: string[], + thinkingEffort?: ReasoningEffortExtended, + ) { // Clear any pending auto-approval timeout when user responds this.cancelAutoApprovalTimeout() @@ -1451,6 +1464,10 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = text this.askResponseImages = images + if (thinkingEffort !== undefined) { + this.newTaskAskThinkingEffort = thinkingEffort + } + // Create a checkpoint whenever the user sends a message. // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. @@ -1604,6 +1621,38 @@ export class Task extends EventEmitter implements TaskLike { return this.runtimeThinkingEffort !== undefined ? { reasoningEffort: this.runtimeThinkingEffort } : {} } + /** + * DTE series 5/5: resolves this task's current effective thinking effort — used to + * pre-fill the new_task ask block and to inherit the effort into a child task when + * neither the model nor the user specifies one. + * + * Resolution reuses the PR-2 point (task-local override → settings + * `reasoningEffort` → model default). The settings "disable" sentinel is excluded: + * it is a UI off-switch, not a level a child task can start with. + */ + public resolveNewTaskEffectiveEffort(): ReasoningEffortExtended | undefined { + const { effort: runtimeEffort } = this.getRuntimeThinkingEffort() + if (runtimeEffort !== undefined) { + return runtimeEffort + } + const resolved = resolveEffectiveReasoningEffort({ + settingsReasoningEffort: this.apiConfiguration?.reasoningEffort, + modelDefaultEffort: this.api.getModel().info.reasoningEffort, + }) + return resolved === "disable" ? undefined : resolved + } + + /** + * DTE series 5/5: reads and clears the thinking effort the user chose in the + * pending new_task ask block (set from the webview ask response). The value is + * consumed once by NewTaskTool so a later, different ask cannot reuse it. + */ + public takeNewTaskAskThinkingEffort(): ReasoningEffortExtended | undefined { + const effort = this.newTaskAskThinkingEffort + this.newTaskAskThinkingEffort = undefined + return effort + } + public async submitUserMessage( text: string, images?: string[], @@ -2388,10 +2437,12 @@ export class Task extends EventEmitter implements TaskLike { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) // DTE series 2/5: the task-local effort override is transient — clear it on - // task end so a disposed task never carries it forward. + // task end so a disposed task never carries it forward. DTE series 5/5: the + // pending new_task ask-block selection is consumed or discarded the same way. this.runtimeThinkingEffort = undefined this.runtimeThinkingEffortSource = undefined this.preOverrideReasoningEffort = undefined + this.newTaskAskThinkingEffort = undefined // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task @@ -2489,6 +2540,9 @@ export class Task extends EventEmitter implements TaskLike { message, initialTodos, mode, + // DTE series 5/5: the child starts with the parent's current effective + // effort (source "parent") so its header shows it from the first request. + thinkingEffort: this.resolveNewTaskEffectiveEffort(), }) return child } diff --git a/src/core/task/__tests__/Task.new-task-effort.spec.ts b/src/core/task/__tests__/Task.new-task-effort.spec.ts new file mode 100644 index 0000000000..04d7a88948 --- /dev/null +++ b/src/core/task/__tests__/Task.new-task-effort.spec.ts @@ -0,0 +1,194 @@ +// npx vitest run src/core/task/__tests__/Task.new-task-effort.spec.ts +// +// DTE series 5/5 — new_task thinking effort plumbing on Task: +// resolveNewTaskEffectiveEffort (task-local override → settings reasoningEffort +// → model default, with the settings "disable" sentinel mapped to undefined), +// the single-consume takeNewTaskAskThinkingEffort, the ask-response capture in +// handleWebviewAskResponse, and the dispose() discard. + +import { ProviderSettings } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +// Mock dependencies (same lightweight set as Task.runtime-thinking-effort.test.ts) +vi.mock("../../webview/ClineProvider") +vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") +vi.mock("../../context-tracking/FileContextTracker") +vi.mock("../../../integrations/editor/DiffViewProvider") +vi.mock("../../tools/ToolRepetitionDetector") + +// The model info object the mocked API handler reports; tests mutate it to steer +// the model-default branch of resolveNewTaskEffectiveEffort. +const { modelInfo } = vi.hoisted(() => ({ + modelInfo: {} as { reasoningEffort?: string }, +})) + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn(() => ({ + getModel: () => ({ info: modelInfo, id: "test-model" }), + })), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + }, + }, +})) + +// Mock task persistence to avoid disk writes +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + readTaskMessages: vi.fn().mockResolvedValue([]), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + taskMetadata: vi.fn().mockResolvedValue({ + historyItem: { + id: "test-task-id", + number: 1, + task: "Test task", + ts: Date.now(), + totalCost: 0.01, + tokensIn: 100, + tokensOut: 50, + }, + tokenUsage: { + totalTokensIn: 100, + totalTokensOut: 50, + totalCost: 0.01, + contextTokens: 150, + totalCacheWrites: 0, + totalCacheReads: 0, + }, + }), +})) + +describe("Task new_task thinking effort (DTE series 5/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + const makeTask = (apiConfiguration: ProviderSettings) => + new Task({ + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + provider: mockProvider as unknown as ClineProvider, + apiConfiguration, + startTask: false, + }) + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + modelInfo.reasoningEffort = undefined + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/path" }, + }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: "low", + } as ProviderSettings + + task = makeTask(mockApiConfiguration) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("resolveNewTaskEffectiveEffort", () => { + it("prefers the task-local runtime override", () => { + task.setRuntimeThinkingEffort("xhigh", "source") + + expect(task.resolveNewTaskEffectiveEffort()).toBe("xhigh") + }) + + it("falls back to the settings reasoningEffort without an override", () => { + expect(task.resolveNewTaskEffectiveEffort()).toBe("low") + }) + + it("falls back to the model default when settings carries no effort", () => { + modelInfo.reasoningEffort = "high" + const noSettingsTask = makeTask({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + } as ProviderSettings) + + expect(noSettingsTask.resolveNewTaskEffectiveEffort()).toBe("high") + noSettingsTask.dispose() + }) + + it("maps the settings 'disable' sentinel to undefined", () => { + const disableTask = makeTask({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: "disable", + } as ProviderSettings) + + expect(disableTask.resolveNewTaskEffectiveEffort()).toBeUndefined() + disableTask.dispose() + }) + }) + + describe("takeNewTaskAskThinkingEffort", () => { + it("is empty until the ask response carries a selection", () => { + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + + it("stores the selection from handleWebviewAskResponse and consumes it once", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "high") + + expect(task.takeNewTaskAskThinkingEffort()).toBe("high") + // Consumed: a second read (or a later, different ask) cannot reuse it. + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + + it("leaves a stored selection untouched when a later response carries none", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "medium") + // A non-new_task response never carries the field, so the stored value + // survives until the new_task approval consumes it. + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined) + + expect(task.takeNewTaskAskThinkingEffort()).toBe("medium") + }) + }) + + describe("dispose", () => { + it("discards the pending ask-block selection at task end", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "max") + task.dispose() + + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + }) +}) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e37..62c5417c76 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" -import { TodoItem } from "@roo-code/types" +import { TodoItem, type ReasoningEffortExtended } from "@roo-code/types" import { Task } from "../task/Task" import { getModeBySlug } from "../../shared/modes" @@ -15,13 +15,32 @@ interface NewTaskParams { mode: string message: string todos?: string + // DTE series 5/5: optional subtask start effort (validated against the target model). + thinking_effort?: string } +// DTE series 5/5: the effort levels a new task can start with. "disable" is a settings +// off-switch, not a start level, so it is excluded from this list. +const NEW_TASK_EFFORT_LEVELS: readonly ReasoningEffortExtended[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] + +// Narrows a raw tool argument to a reasoning-effort level (single documented cast: +// the literal list above is exactly the value set of ReasoningEffortExtended). +const isNewTaskEffortLevel = (value: string): value is ReasoningEffortExtended => + (NEW_TASK_EFFORT_LEVELS as readonly string[]).includes(value) + export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { - const { mode, message, todos } = params + const { mode, message, todos, thinking_effort } = params const { askApproval, handleError, pushToolResult } = callbacks try { @@ -42,6 +61,27 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the child task is created with the parent's API configuration, + // so the child model is the parent's current model. Validate the optional start + // effort against that model's capability array before asking for approval. + const modelCapabilities = task.api.getModel().info.supportsReasoningEffort + let validatedEffort: ReasoningEffortExtended | undefined + if (thinking_effort !== undefined && thinking_effort !== "") { + const supportedLevels = Array.isArray(modelCapabilities) ? modelCapabilities : [] + if (!isNewTaskEffortLevel(thinking_effort) || !supportedLevels.includes(thinking_effort)) { + const reason = !isNewTaskEffortLevel(thinking_effort) + ? `must be one of: ${NEW_TASK_EFFORT_LEVELS.join(", ")}` + : supportedLevels.length > 0 + ? `the target model only supports: ${ + supportedLevels.filter((level) => level !== "disable").join(", ") || "none" + }` + : "the target model does not support thinking_effort" + pushToolResult(formatResponse.toolError(`Invalid thinking_effort '${thinking_effort}'. ${reason}`)) + return + } + validatedEffort = thinking_effort + } + // Get the VSCode setting for requiring todos. const provider = task.providerRef.deref() @@ -96,11 +136,19 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the ask payload pre-fills the webview effort selector with + // the validated model effort (falling back to the parent's current effective + // effort) and lists the levels the target model supports ("disable" is a + // settings off-switch, not a level a child task can start with). const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, content: message, todos: todoItems, + thinkingEffort: validatedEffort ?? task.resolveNewTaskEffectiveEffort(), + supportedThinkingEfforts: Array.isArray(modelCapabilities) + ? modelCapabilities.filter((level): level is ReasoningEffortExtended => level !== "disable") + : undefined, }) const didApprove = await askApproval("tool", toolMessage) @@ -109,12 +157,24 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the user may have switched the effort in the ask block — + // the ask response carries it (consumed once from Task) and wins over the + // model-specified value, which wins over the parent's effective effort. An + // ask selection the target model does not support falls back the same way. + const askEffort = task.takeNewTaskAskThinkingEffort() + const askEffortSupported = + askEffort !== undefined && Array.isArray(modelCapabilities) && modelCapabilities.includes(askEffort) + const childThinkingEffort = askEffortSupported + ? askEffort + : (validatedEffort ?? task.resolveNewTaskEffectiveEffort()) + // Delegate parent and open child as sole active task const child = await (provider as any).delegateParentAndOpenChild({ parentTaskId: task.taskId, message: unescapedMessage, initialTodos: todoItems, mode, + thinkingEffort: childThinkingEffort, }) // Reflect delegation in tool result (no pause/unpause, no wait) diff --git a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts new file mode 100644 index 0000000000..8db2b0eb36 --- /dev/null +++ b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts @@ -0,0 +1,314 @@ +// npx vitest core/tools/__tests__/newTaskThinkingEffort.spec.ts +// +// DTE series 5/5 — orchestrator new_task thinking_effort: +// - the tool schema exposes the optional thinking_effort param +// - a model-specified effort is validated against the target model's +// capability array (the child starts with the parent's model) +// - the ask payload pre-fills the effort and lists the supported levels +// ("disable" is a settings off-switch, never a start level) +// - the ask-block selection (carried by the ask response) wins over the +// model-specified value, which wins over the parent's effective effort + +import type { AskApproval, HandleError, NativeToolArgs, PushToolResult, ToolUse } from "../../../shared/tools" + +// Mock the vscode module +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => false), + })), + }, +})) + +// Mock Package module +vi.mock("../../../shared/package", () => ({ + Package: { + name: "zoo-code", + publisher: "ZooCodeOrganization", + version: "1.0.0", + outputChannel: "Zoo-Code", + }, +})) + +vi.mock("../../../shared/modes", () => ({ + getModeBySlug: vi.fn(), + defaultModeSlug: "ask", +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Tool Error: ${msg}`), + }, +})) + +vi.mock("../updateTodoListTool", () => ({ + parseMarkdownChecklist: vi.fn().mockReturnValue([]), +})) + +import { newTaskTool } from "../NewTaskTool" +import { getModeBySlug } from "../../../shared/modes" +import newTaskSchema from "../../prompts/tools/native-tools/new_task" +import type { Task } from "../../task/Task" + +interface RunOptions { + /** Target model capability array (boolean/undefined = no known levels). */ + supportsReasoningEffort?: boolean | string[] + /** Effort the user chose in the ask block (carried by the ask response). */ + askEffort?: string + /** Parent's current effective effort (Task.resolveNewTaskEffectiveEffort). */ + parentEffort?: string +} + +/** + * Task double with the members new_task reads: the API handler (target model + * lookup), the PR-2/5/5 Task effort methods, and the provider delegation hook. + */ +function makeTask(options: RunOptions = {}) { + const delegateParentAndOpenChild = vi.fn().mockResolvedValue({ taskId: "child-1" }) + const resolveNewTaskEffectiveEffort = vi.fn().mockReturnValue(options.parentEffort) + const takeNewTaskAskThinkingEffort = vi.fn().mockReturnValue(options.askEffort) + // Structural double; the cast documents that handle() expects a real Task. + const task = { + taskId: "parent-1", + ask: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing param error"), + emit: vi.fn(), + recordToolError: vi.fn(), + consecutiveMistakeCount: 0, + isPaused: false, + pausedModeSlug: "ask", + enableCheckpoints: false, + checkpointSave: vi.fn(), + startSubtask: vi.fn(), + api: { + getModel: () => ({ + id: "test-model", + info: { + supportsReasoningEffort: options.supportsReasoningEffort, + reasoningEffort: undefined, + }, + }), + }, + resolveNewTaskEffectiveEffort, + takeNewTaskAskThinkingEffort, + providerRef: { + deref: vi.fn(() => ({ + getState: vi.fn().mockResolvedValue({ mode: "ask", customModes: [], experiments: {} }), + delegateParentAndOpenChild, + })), + }, + } as unknown as Task + + return { + task, + delegateParentAndOpenChild, + resolveNewTaskEffectiveEffort, + takeNewTaskAskThinkingEffort, + } +} + +const makeCallbacks = () => ({ + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), +}) + +const runNewTask = async ( + task: Task, + params: { mode?: string; message?: string; todos?: string; thinking_effort?: string }, + callbacks: ReturnType, +) => { + const args = { + mode: params.mode ?? "code", + message: params.message ?? "Do the delegated work", + todos: params.todos, + thinking_effort: params.thinking_effort, + } + // Native tool calling: nativeArgs is the source of truth for execution; the + // resolved defaults land on both surfaces so missing mode/message fall back + // identically instead of tripping the missing-param guard. + const block: ToolUse<"new_task"> = { + type: "tool_use", + name: "new_task", + params: { + mode: args.mode, + message: args.message, + todos: args.todos, + thinking_effort: args.thinking_effort, + }, + partial: false, + nativeArgs: { + mode: args.mode, + message: args.message, + todos: args.todos, + thinking_effort: args.thinking_effort, + } as unknown as NativeToolArgs["new_task"], + } + await newTaskTool.handle(task, block, callbacks) +} + +describe("new_task thinking_effort schema (DTE series 5/5)", () => { + it("exposes an optional thinking_effort string parameter", () => { + const parameters = newTaskSchema.function.parameters + + expect(parameters.properties.thinking_effort).toEqual({ + type: "string", + description: expect.stringContaining("thinking effort"), + }) + // Optional: omitting it makes the child start with the parent's current + // effective effort. additionalProperties stays closed. + expect(parameters.required).toEqual(["mode", "message", "todos"]) + expect(parameters.additionalProperties).toBe(false) + }) +}) + +describe("new_task thinking_effort validation (DTE series 5/5)", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getModeBySlug).mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "Test role definition", + groups: ["command", "read", "edit"], + }) + }) + + it("delegates with the model-specified effort when the target model supports it", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium", "high"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "medium" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do the delegated work", + initialTodos: [], + mode: "code", + thinkingEffort: "medium", + }) + }) + + it("rejects a value that is not a reasoning effort level", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "ultra" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Invalid thinking_effort 'ultra'"), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("must be one of")) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + expect(callbacks.askApproval).not.toHaveBeenCalled() + }) + + it("rejects a level the target model does not support", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "high" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("the target model only supports: low"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("rejects an effort when the target model exposes no capability array", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("pre-fills the ask payload with the effort and the supported levels, filtering 'disable'", async () => { + const { task } = makeTask({ + supportsReasoningEffort: ["disable", "low", "medium"], + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.askApproval).toHaveBeenCalledTimes(1) + const [askType, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + expect(askType).toBe("tool") + const payload = JSON.parse(toolMessage as string) as { + tool: string + thinkingEffort?: string + supportedThinkingEfforts?: string[] + } + expect(payload.tool).toBe("newTask") + expect(payload.thinkingEffort).toBe("low") + expect(payload.supportedThinkingEfforts).toEqual(["low", "medium"]) + }) + + it("falls back to the parent's effective effort when no effort is specified", async () => { + const { task, delegateParentAndOpenChild, resolveNewTaskEffectiveEffort } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + parentEffort: "medium", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(resolveNewTaskEffectiveEffort).toHaveBeenCalled() + expect(delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do the delegated work", + initialTodos: [], + mode: "code", + thinkingEffort: "medium", + }) + }) + + it("prefers the ask-block selection over the model-specified effort", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium", "high"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "high" })) + }) + + it("ignores an ask-block selection the target model does not support", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) + + it("falls back to the parent's effective effort when the ask selection is unsupported and no model effort was given", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + askEffort: "high", + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) +}) diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 9e61bc7fab..5789fa50ef 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -97,6 +97,11 @@ const mockCline = { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: mockStartSubtask, + // DTE series 5/5: new_task resolves the target model's capability from the + // task's API handler and consults the pending new_task ask effort on Task. + api: { getModel: () => ({ id: "test-model", info: {} }) }, + resolveNewTaskEffectiveEffort: vi.fn().mockReturnValue(undefined), + takeNewTaskAskThinkingEffort: vi.fn().mockReturnValue(undefined), providerRef: { deref: vi.fn(() => ({ getState: vi.fn(() => ({ customModes: [], mode: "ask" })), @@ -635,6 +640,10 @@ describe("newTaskTool delegation flow", () => { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: localStartSubtask, + // DTE series 5/5: target model lookup + ask-block effort plumbing. + api: { getModel: () => ({ id: "test-model", info: {} }) }, + resolveNewTaskEffectiveEffort: vi.fn().mockReturnValue(undefined), + takeNewTaskAskThinkingEffort: vi.fn().mockReturnValue(undefined), providerRef: { deref: vi.fn(() => providerSpy), }, @@ -659,11 +668,14 @@ describe("newTaskTool delegation flow", () => { }) // Assert: provider method called with correct params + // DTE series 5/5: thinkingEffort is always present; undefined here because the + // tool, the ask block, and the parent's effective resolution all yield none. expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({ parentTaskId: "mock-parent-task-id", message: "Do something", initialTodos: [], mode: "code", + thinkingEffort: undefined, }) // Assert: legacy path not used diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2d00f25107..05558142e0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -35,6 +35,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type ReasoningEffortExtended, type ExtensionMessage, type ExtensionState, type MarketplaceInstalledMetadata, @@ -3714,8 +3715,11 @@ export class ClineProvider message: string initialTodos: TodoItem[] mode: string + // DTE series 5/5: the subtask start effort (model-specified or the parent's + // current effective effort); applied to the child at init below. + thinkingEffort?: ReasoningEffortExtended }): Promise { - const { parentTaskId, message, initialTodos, mode } = params + const { parentTaskId, message, initialTodos, mode, thinkingEffort } = params // Metadata-driven delegation is always enabled @@ -3808,6 +3812,13 @@ export class ClineProvider startTask: false, }) + // DTE series 5/5: apply the subtask start effort as a task-local override before + // the child's first request so the child header shows it from the start. + // Source "parent" — set by the orchestrator, not the child's own settings. + if (thinkingEffort !== undefined) { + child.setRuntimeThinkingEffort(thinkingEffort, "parent") + } + // 5) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a // single lock acquisition — no concurrent writer can slip between the read and diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 815eb08683..e62781cc8d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -73,6 +73,7 @@ import type { ModelRecord } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" +import type { Task } from "../../task/Task" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio" import { getCommands } from "../../../services/command/commands" @@ -311,9 +312,41 @@ describe("webviewMessageHandler - image mentions", () => { }) expect(vi.mocked(resolveImageMentions)).toHaveBeenCalled() - expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "See @/img.png", [ - "data:image/png;base64,from-mention", - ]) + // DTE series 5/5: the handler always forwards the ask-block effort as the 4th + // argument (undefined for responses without a selection). + expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith( + "messageResponse", + "See @/img.png", + ["data:image/png;base64,from-mention"], + undefined, + ) + }) + + it("forwards the new_task ask-block thinking effort to the task (DTE series 5/5)", async () => { + const mockHandleWebviewAskResponse = vi.fn() + // Structural double: the askResponse case only dereferences the current task + // to forward the response (single documented double assertion, last resort). + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ + cwd: "/mock/workspace", + rooIgnoreController: undefined, + handleWebviewAskResponse: mockHandleWebviewAskResponse, + } as unknown as Task) + + await webviewMessageHandler(mockClineProvider, { + type: "askResponse", + askResponse: "yesButtonClicked", + text: "", + thinkingEffort: "high", + }) + + // The ask-block selection is forwarded as the 4th argument; every other ask + // type omits the field, so the task only stores it for new_task approvals. + expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith( + "yesButtonClicked", + "", + ["data:image/png;base64,from-mention"], + "high", + ) }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 8bf1c64777..4f98e3505b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -716,7 +716,14 @@ export const webviewMessageHandler = async ( const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) provider .getCurrentTask() - ?.handleWebviewAskResponse(message.askResponse!, resolved.text, resolved.images) + // DTE series 5/5: forward the new_task ask-block effort selection (undefined + // for all other ask responses). + ?.handleWebviewAskResponse( + message.askResponse!, + resolved.text, + resolved.images, + message.thinkingEffort, + ) } break diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..7225e5ef73 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -81,6 +81,7 @@ export const toolParamNames = [ // read_file legacy format parameter (backward compatibility) "files", "line_ranges", + "thinking_effort", // new_task parameter: optional subtask start effort (DTE series 5/5) ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -102,7 +103,7 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } - new_task: { mode: string; message: string; todos?: string } + new_task: { mode: string; message: string; todos?: string; thinking_effort?: string } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> @@ -240,7 +241,7 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" - params: Partial, "mode" | "message" | "todos">> + params: Partial, "mode" | "message" | "todos" | "thinking_effort">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 25aec24cfc..0aef1e369a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -20,7 +20,15 @@ import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" import { batchNearby } from "@src/utils/batchNearby" import { isBoundary, isIgnorableBetweenTargets } from "@src/utils/chatBatchingPredicates" -import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types" +import type { + ClineAsk, + ClineSayTool, + ClineMessage, + ExtensionMessage, + AudioType, + SuggestionItem, + ReasoningEffortExtended, +} from "@roo-code/types" import { getCompletionCheckpoint, getSuggestionMode, isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" @@ -182,6 +190,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [primaryButtonText, setPrimaryButtonText] = useState(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) + // DTE series 5/5: the effort chosen in the pending new_task ask block (pre-filled + // from the tool payload) and the levels the target model supports for it. + const [newTaskAskEffort, setNewTaskAskEffort] = useState(undefined) + const [newTaskAskSupportedEfforts, setNewTaskAskSupportedEfforts] = useState( + undefined, + ) const [_didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) @@ -305,6 +319,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked", text: trimmedInput, images: images, + thinkingEffort: newTaskAskEffort, }) // Clear input state after sending setInputValue("") setSelectedImages([]) } else { - vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: newTaskAskEffort, + }) } break case "resume_task": @@ -849,7 +885,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : ( <> + {/* DTE series 5/5: the new_task ask effort selector — pre-filled from the + tool payload, switchable before entering the subtask. Rich surfaces are PR-4. */} + {clineAsk === "tool" && + newTaskAskSupportedEfforts && + newTaskAskSupportedEfforts.length > 0 && ( + + )} {primaryButtonText && ( { ) }) }) + +describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => { + // Posts a fresh state snapshot whose last message is the given tool ask. + const postToolAsk = (toolPayload: Record) => + mockPostMessage({ + clineMessages: [ + { type: "say", say: "task", ts: 1, text: "Parent task" }, + { type: "ask", ask: "tool", ts: 2, text: JSON.stringify(toolPayload) }, + ], + }) + + const NEW_TASK_ASK: Record = { + tool: "newTask", + mode: "Code Mode", + content: "Do the delegated work", + todos: [], + thinkingEffort: "low", + supportedThinkingEfforts: ["low", "medium", "high"], + } + + beforeEach(() => { + vi.clearAllMocks() + mockTaskHeaderState.renders.length = 0 + }) + + it("renders the effort selector pre-filled from the newTask ask payload", async () => { + const { getByLabelText } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + // Pre-filled with the effort the extension resolved for the new task... + expect(select).toHaveValue("low") + // ...and offers exactly the levels the target model supports. + expect(Array.from(select.options).map((option) => option.value)).toEqual(["low", "medium", "high"]) + }) + + it("falls back to the first supported level when the pre-fill is not supported", async () => { + const { getByLabelText } = renderChatView() + + await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) + + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + expect(select).toHaveValue("low") + }) + + it("hides the selector for non-newTask tool asks (the ask effect resets the state)", async () => { + const { getByLabelText, queryByLabelText } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + await waitFor(() => { + expect(getByLabelText("Thinking effort")).toBeInTheDocument() + }) + + // A subsequent readFile ask must drop the selector: the effort state is + // cleared for every unanswered ask and only re-set for newTask asks. + mockPostMessage({ + clineMessages: [ + { type: "say", say: "task", ts: 1, text: "Parent task" }, + { type: "ask", ask: "tool", ts: 3, text: JSON.stringify({ tool: "readFile", path: "a.ts" }) }, + ], + }) + + await waitFor(() => { + expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + }) + }) + + it("hides the selector when the payload carries no supported efforts", async () => { + const { getByRole, queryByLabelText } = renderChatView() + + await postToolAsk({ tool: "newTask", mode: "Code Mode", content: "Do the work", todos: [] }) + + // Wait for the ask UI to settle (approve button rendered) before asserting absence. + await waitFor(() => { + expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() + }) + expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + }) + + it("posts the selected effort when the user approves the newTask ask", async () => { + const { getByLabelText, getByRole } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + + // The user switches the effort before entering the subtask... + await act(async () => { + fireEvent.change(select, { target: { value: "high" } }) + }) + + // ...and approves without typing feedback (bare yesButtonClicked branch). + await act(async () => { + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: "high", + }) + }) + + it("posts the selected effort along with feedback text on approval", async () => { + const { getByLabelText, getByRole, getByTestId } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + await act(async () => { + fireEvent.change(select, { target: { value: "medium" } }) + }) + + const input = getByTestId("chat-textarea").querySelector("input")! as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { value: "focus on tests" } }) + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + text: "focus on tests", + images: [], + thinkingEffort: "medium", + }) + }) + + it("posts undefined effort when approving a non-newTask tool ask", async () => { + const { getByRole } = renderChatView() + + await postToolAsk({ tool: "readFile", path: "a.ts" }) + await waitFor(() => { + expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: undefined, + }) + }) + + it("posts the effort when a message is sent during the pending newTask ask", async () => { + const { getByLabelText, getByTestId } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + await act(async () => { + fireEvent.change(select, { target: { value: "high" } }) + }) + + vscodePostMessageMock.cleanup() + const input = getByTestId("chat-textarea").querySelector("input")! as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { value: "please hurry" } }) + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "messageResponse", + text: "please hurry", + images: [], + thinkingEffort: "high", + }) + }) +}) From 6ad8d863af03d1cf755c5bf299f7980cbace411a Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 01:50:16 +0800 Subject: [PATCH 05/12] test(e2e): new_task thinking_effort pass-through (DTE addendum) --- apps/vscode-e2e/src/fixtures/subtasks.ts | 189 +++++++ apps/vscode-e2e/src/runTest.ts | 3 +- .../suite/new-task-thinking-effort.test.ts | 482 ++++++++++++++++++ 3 files changed, 673 insertions(+), 1 deletion(-) create mode 100644 apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 30d3852b0a..5f06fb32e3 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -627,3 +627,192 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) } + +// --------------------------------------------------------------------------- +// DTE series 5/5 — new_task thinking_effort pass-through (e2e). +// +// Three scenarios with unique, stable markers (no timestamps, no environment +// details): +// - INHERIT: the parent's new_task call carries NO thinking_effort — the child +// starts with the parent's current effective effort (PR-2 resolution) and the +// child's real request must carry it. +// - EXPLICIT: the parent's new_task call carries thinking_effort "high" on a +// model with a capability array — validation passes and the child subtask +// runs to completion on the real host. +// - NEGATIVE: the parent's new_task call carries thinking_effort on a model +// without a capability array — the tool rejects before the approval ask, no +// child is created, and the error is visible to the model. +export const DTE_NT_INHERIT_PARENT_MARKER = "DTE_E2E_NT_INHERIT_PARENT" +export const DTE_NT_INHERIT_CHILD_MARKER = "DTE_E2E_NT_INHERIT_CHILD" +const DTE_NT_INHERIT_CHILD_PROMPT = `${DTE_NT_INHERIT_CHILD_MARKER}: Complete immediately with the exact result "DTE inherit child completed".` +export const DTE_NT_INHERIT_PARENT_PROMPT = `${DTE_NT_INHERIT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${DTE_NT_INHERIT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "DTE inherit parent resumed".` +export const DTE_NT_INHERIT_CHILD_RESULT = "DTE inherit child completed" +export const DTE_NT_INHERIT_PARENT_RESULT = "DTE inherit parent resumed" + +const DTE_NT_EXPLICIT_PARENT_MARKER = "DTE_E2E_NT_EXPLICIT_PARENT" +const DTE_NT_EXPLICIT_CHILD_MARKER = "DTE_E2E_NT_EXPLICIT_CHILD" +const DTE_NT_EXPLICIT_CHILD_PROMPT = `${DTE_NT_EXPLICIT_CHILD_MARKER}: Complete immediately with the exact result "DTE explicit child completed".` +export const DTE_NT_EXPLICIT_PARENT_PROMPT = `${DTE_NT_EXPLICIT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${DTE_NT_EXPLICIT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "DTE explicit parent resumed".` +export const DTE_NT_EXPLICIT_CHILD_RESULT = "DTE explicit child completed" +export const DTE_NT_EXPLICIT_PARENT_RESULT = "DTE explicit parent resumed" + +const DTE_NT_NEGATIVE_PARENT_MARKER = "DTE_E2E_NT_NEGATIVE_PARENT" +const DTE_NT_NEGATIVE_CHILD_MARKER = "DTE_E2E_NT_NEGATIVE_CHILD" +const DTE_NT_NEGATIVE_CHILD_PROMPT = `${DTE_NT_NEGATIVE_CHILD_MARKER}: Complete immediately with the exact result "DTE negative child completed".` +export const DTE_NT_NEGATIVE_PARENT_PROMPT = `${DTE_NT_NEGATIVE_PARENT_MARKER}: Use the new_task tool exactly once, with thinking_effort set to "high". Create an ask-mode subtask with this exact message: "${DTE_NT_NEGATIVE_CHILD_PROMPT}" Do not answer directly. If the tool call is rejected, complete with the exact result "DTE negative parent completed".` +export const DTE_NT_NEGATIVE_PARENT_RESULT = "DTE negative parent completed" + +export function addDteNewTaskEffortFixtures(mock: InstanceType) { + // INHERIT: parent turn -> new_task without an explicit effort. + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_INHERIT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_INHERIT_CHILD_PROMPT, + }), + id: "call_dte_nt_inherit_new_task_001", + }, + ], + }, + }) + + // Child turn: the child prompt is embedded verbatim in the parent prompt, so the + // parent-marker exclusion keeps parent turns out of this fixture (same collision + // class as the fast-child fixture above). + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, DTE_NT_INHERIT_CHILD_MARKER) && + !requestContains(req, [DTE_NT_INHERIT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_INHERIT_CHILD_RESULT }), + id: "call_dte_nt_inherit_child_completion_002", + }, + ], + }, + }) + + // Parent resume turn: guarded on the child-result injection (not the child result + // text, which the parent prompt embeds verbatim). + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_INHERIT_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_INHERIT_PARENT_RESULT }), + id: "call_dte_nt_inherit_parent_completion_003", + }, + ], + }, + }) + + // EXPLICIT: parent turn -> new_task with thinking_effort "high" (valid on models + // whose capability array accepts it, e.g. deepseek-v4-pro). + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_EXPLICIT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_EXPLICIT_CHILD_PROMPT, + thinking_effort: "high", + }), + id: "call_dte_nt_explicit_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, DTE_NT_EXPLICIT_CHILD_MARKER) && + !requestContains(req, [DTE_NT_EXPLICIT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_EXPLICIT_CHILD_RESULT }), + id: "call_dte_nt_explicit_child_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_EXPLICIT_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_EXPLICIT_PARENT_RESULT }), + id: "call_dte_nt_explicit_parent_completion_003", + }, + ], + }, + }) + + // NEGATIVE: parent turn -> new_task with thinking_effort "high" on a model without a + // capability array. The tool rejects before the approval ask, so the next parent + // turn is the error-recovery completion (matched on the tool-error text, which only + // appears in a request after the rejected call). + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_NEGATIVE_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_NEGATIVE_CHILD_PROMPT, + thinking_effort: "high", + }), + id: "call_dte_nt_negative_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_NEGATIVE_PARENT_MARKER, "Invalid thinking_effort"]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_NEGATIVE_PARENT_RESULT }), + id: "call_dte_nt_negative_parent_completion_002", + }, + ], + }, + }) +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index 8162f34068..7fd4d1e22c 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -18,7 +18,7 @@ import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile" import { addListFilesResultFixtures } from "./fixtures/list-files" import { addReadFileResultFixtures } from "./fixtures/read-file" import { addSearchFilesResultFixtures } from "./fixtures/search-files" -import { addSubtaskFixtures } from "./fixtures/subtasks" +import { addDteNewTaskEffortFixtures, addSubtaskFixtures } from "./fixtures/subtasks" import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool" import { addWriteToFileResultFixtures } from "./fixtures/write-to-file" import { createScenarioWorkspace, removeScenarioWorkspace } from "./restart/scenarioWorkspace" @@ -140,6 +140,7 @@ async function main() { addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) addSubtaskFixtures(mock) + addDteNewTaskEffortFixtures(mock) addUseMcpToolResultFixtures(mock) addWriteToFileResultFixtures(mock) addDeepSeekV4Fixtures(mock) diff --git a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts new file mode 100644 index 0000000000..d5c68d65e4 --- /dev/null +++ b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts @@ -0,0 +1,482 @@ +import * as assert from "assert" +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { + DTE_NT_EXPLICIT_CHILD_RESULT, + DTE_NT_EXPLICIT_PARENT_PROMPT, + DTE_NT_EXPLICIT_PARENT_RESULT, + DTE_NT_INHERIT_CHILD_MARKER, + DTE_NT_INHERIT_CHILD_RESULT, + DTE_NT_INHERIT_PARENT_MARKER, + DTE_NT_INHERIT_PARENT_PROMPT, + DTE_NT_INHERIT_PARENT_RESULT, + DTE_NT_NEGATIVE_PARENT_PROMPT, + DTE_NT_NEGATIVE_PARENT_RESULT, +} from "../fixtures/subtasks" +import { setDefaultSuiteTimeout } from "./test-utils" +import { sleep, waitFor, waitUntilCompleted } from "./utils" + +// Wire-boundary capture (modeled on anthropic-opus-4-7.test.ts): a local 127.0.0.1 +// proxy in front of the Anthropic base URL records every /v1/messages request body +// before forwarding it to the upstream (the aimock server in mock mode). Assertions +// below therefore run against the real request the extension host actually sent. +type CapturedEffortRequest = { + model?: string + thinkingType?: string + outputConfigEffort?: string + lastUserMessage: string +} + +const ANTHROPIC_MESSAGES_PATH = "/v1/messages" +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "host", + "content-length", +]) + +function isMessagesUrl(rawUrl: string): boolean { + try { + return new URL(rawUrl).pathname.endsWith(ANTHROPIC_MESSAGES_PATH) + } catch { + return false + } +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +function writeResponseHeaders(target: ServerResponse, source: Response) { + const headers: Record = {} + source.headers.forEach((value, key) => { + const lower = key.toLowerCase() + // fetch() automatically decompresses the body, so strip content-encoding to + // prevent the SDK from attempting a second decompression (zlib "incorrect + // header check"). Also strip content-length since the decoded body length + // differs from the compressed length. + if (lower !== "content-length" && lower !== "content-encoding") { + headers[key] = value + } + }) + target.writeHead(source.status, headers) +} + +async function pipeFetchResponse(target: ServerResponse, source: Response) { + writeResponseHeaders(target, source) + + if (!source.body) { + target.end() + return + } + + const reader = source.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + target.write(value) + } + + target.end() +} + +function resolveAllowedUpstreamUrl(baseUrl: string): URL { + const upstreamBase = new URL(baseUrl) + const isLocalProxy = upstreamBase.hostname === "127.0.0.1" || upstreamBase.hostname === "localhost" + + if (!isLocalProxy || (upstreamBase.protocol !== "http:" && baseUrl !== "https://api.anthropic.com")) { + throw new Error("Unexpected Anthropic proxy target: " + upstreamBase.origin) + } + + return new URL(ANTHROPIC_MESSAGES_PATH, upstreamBase) +} + +async function withEffortProxy( + baseUrl: string, + run: (args: { proxyUrl: string; requests: CapturedEffortRequest[] }) => Promise, +): Promise { + const requests: CapturedEffortRequest[] = [] + let proxyError: Error | undefined + const server = createServer(async (req, res) => { + try { + const requestUrl = req.url ?? "/" + + if (!isMessagesUrl("http://127.0.0.1" + requestUrl)) { + res.writeHead(404) + res.end("Not found") + return + } + + const bodyText = await readRequestBody(req) + const body = JSON.parse(bodyText) as { + model?: string + thinking?: { type?: string } + output_config?: { effort?: string } + messages?: Array<{ role?: string; content?: unknown }> + } + + const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user") + const lastUserMessage = + typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "") + + requests.push({ + model: body.model, + thinkingType: body.thinking?.type, + outputConfigEffort: body.output_config?.effort, + lastUserMessage, + }) + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && typeof value === "string") { + forwardHeaders[key] = value + } + } + + const upstreamUrl = resolveAllowedUpstreamUrl(baseUrl) + const upstream = await fetch(upstreamUrl, { + method: req.method, + headers: forwardHeaders, + body: bodyText, + }) + + await pipeFetchResponse(res, upstream) + } catch (error) { + proxyError = error instanceof Error ? error : new Error(String(error)) + console.error("Effort proxy request failed:", proxyError) + res.writeHead(500) + res.end("Effort proxy request failed") + } + }) + + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) + const address = server.address() + if (!address || typeof address === "string") { + server.close() + throw new Error("Failed to start effort proxy server") + } + + const proxyUrl = "http://127.0.0.1:" + address.port + + try { + const result = await run({ proxyUrl, requests }) + if (proxyError) { + throw proxyError + } + return result + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} + +// Restore the OpenRouter default config after this suite so other suites are unaffected. +const restoreOpenRouterConfig = async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: aimockUrl + "/v1" }), + }) +} + +suite("new_task thinking effort (DTE series 5/5)", function () { + setDefaultSuiteTimeout(this) + + suiteTeardown(restoreOpenRouterConfig) + + // (b) Inheritance: a new_task call without thinking_effort starts the child with the + // parent's current effective effort (PR-2 resolution: no task-local override is + // reachable in e2e before DTE series 3/5, so the settings value "medium" is the + // strongest source). The child's real /v1/messages request must carry that effort. + test("child started without explicit effort carries the parent's effective effort", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) { + this.skip() + } + + await withEffortProxy(aimockUrl || "https://api.anthropic.com", async ({ proxyUrl, requests }) => { + await api.setConfiguration({ + apiProvider: "anthropic" as const, + apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!, + apiModelId: "claude-opus-4-7", + enableReasoningEffort: true, + reasoningEffort: "medium", + anthropicBaseUrl: proxyUrl, + }) + + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_INHERIT_PARENT_PROMPT, + }) + + // Wait for the child's real request to reach the proxy: an immediate child is + // only observable while its first request is in flight (the parent instance is + // disposed on delegation and re-instantiated on resume, so the UI task stack is + // not a reliable child-liveness signal here). + await waitFor( + () => requests.some((request) => request.lastUserMessage.includes(DTE_NT_INHERIT_CHILD_MARKER)), + { timeout: 45_000 }, + ) + + // The parent's completion is the terminal event of the whole flow. + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 60_000 }) + + assert.ok( + Object.entries(says).some( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === DTE_NT_INHERIT_CHILD_RESULT, + ), + ), + "Immediately-completing child should emit its expected result", + ) + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_INHERIT_PARENT_RESULT, + "Parent should resume after the child completes", + ) + + // Wire assertion: the child's real request (identified by the child prompt + // marker in its last user message) carries the parent's effective effort. + const childRequests = requests.filter((request) => + request.lastUserMessage.includes(DTE_NT_INHERIT_CHILD_MARKER), + ) + assert.ok(childRequests.length > 0, "The child subtask should issue a real API request") + const firstChildRequest = childRequests[0] + assert.ok(firstChildRequest, "Child request should be captured by the proxy") + assert.strictEqual(firstChildRequest.model, "claude-opus-4-7") + assert.strictEqual( + firstChildRequest.thinkingType, + "adaptive", + "The child request should be an adaptive-thinking request", + ) + assert.strictEqual( + firstChildRequest.outputConfigEffort, + "medium", + "The child's request should carry the parent's current effective effort (DTE series 5/5 inheritance via PR-2 resolution)", + ) + + // Control: the parent's own first request carries the same settings-derived + // baseline, confirming the envelope is resolved identically on both sides. + const parentRequests = requests.filter((request) => + request.lastUserMessage.includes(DTE_NT_INHERIT_PARENT_MARKER), + ) + assert.ok(parentRequests.length > 0, "The parent should issue a real API request") + const firstParentRequest = parentRequests[0] + assert.ok(firstParentRequest, "Parent request should be captured by the proxy") + assert.strictEqual(firstParentRequest.outputConfigEffort, "medium") + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) + }) + + // (a) Explicit effort: a new_task call with thinking_effort "high" on a model whose + // capability array accepts it (deepseek-v4-pro: ["disable","low","high","max"]). The + // parameter round-trips schema -> validation -> approval -> delegation and the child + // subtask runs to completion on the real host. + // + // No wire assertion here: the DeepSeek handler resolves the request effort from + // settings only and does not consume the per-request override — and the only handler + // that does consume it (Anthropic) serves catalog models without a capability array, + // so no model today both passes the DTE 5/5 validation and propagates an explicit + // effort to the wire. Documented in the PR body. + test("explicit thinking_effort delegates a child subtask that completes", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.DEEPSEEK_API_KEY) { + this.skip() + } + + await api.setConfiguration({ + apiProvider: "deepseek" as const, + deepSeekApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.DEEPSEEK_API_KEY!, + ...(aimockUrl && { deepSeekBaseUrl: aimockUrl + "/v1" }), + apiModelId: "deepseek-v4-pro", + // Reasoning off for this probe: the test is about the subtask flow carrying + // the explicit effort parameter, not about the reasoning envelope. + enableReasoningEffort: false, + }) + + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_EXPLICIT_PARENT_PROMPT, + }) + + // The parent's completion is the terminal event of the whole flow (the child + // completes on its first response, so its own lifecycle is covered by the + // completion_result assertions below — same pattern as the fast-child test). + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 75_000 }) + + assert.ok( + Object.entries(says).some( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === DTE_NT_EXPLICIT_CHILD_RESULT, + ), + ), + "Explicit-effort child should emit its expected result", + ) + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_EXPLICIT_PARENT_RESULT, + "Parent should resume after the explicit-effort child completes", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) + + // (a) Negative guard: an explicit effort on a model without a capability array is + // rejected by the tool before the approval ask — no child is created and the model + // sees the tool error. claude-opus-4-7 has supportsReasoningBinary (adaptive + // thinking) but no effort capability array, so "high" must be refused. + test("explicit thinking_effort on a capability-less model is rejected without creating a child", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) { + this.skip() + } + + // aimock serves the Anthropic /v1/messages endpoint directly, so the negative + // flow does not need the capturing proxy — just point the base URL at the mock. + await api.setConfiguration({ + apiProvider: "anthropic" as const, + apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!, + apiModelId: "claude-opus-4-7", + ...(aimockUrl && { anthropicBaseUrl: aimockUrl }), + }) + + const says: Record = {} + const seenTaskIds = new Set() + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + seenTaskIds.add(taskId) + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_NEGATIVE_PARENT_PROMPT, + }) + + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 60_000 }) + + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_NEGATIVE_PARENT_RESULT, + "Parent should complete after the rejected tool call", + ) + assert.strictEqual( + seenTaskIds.size, + 1, + "No child subtask should be created for a rejected thinking_effort (task ids: " + + [...seenTaskIds].join(", ") + + ")", + ) + assert.ok( + Object.values(says) + .flat() + .some(({ text }) => (text ?? "").includes("Invalid thinking_effort")), + "The tool error should be visible to the model", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) +}) From 4eb13a99903e133f88dfc53d4f5f796c76063191 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 04:04:01 +0800 Subject: [PATCH 06/12] fix(task): new_task thinking_effort review fixes (boolean capability, post-mode-switch revalidation, ask prefill normalization) --- src/__tests__/provider-delegation.spec.ts | 95 +++++++++++++++++++ src/core/tools/NewTaskTool.ts | 27 ++++-- .../__tests__/newTaskThinkingEffort.spec.ts | 34 ++++++- src/core/webview/ClineProvider.ts | 26 ++++- webview-ui/src/components/chat/ChatView.tsx | 24 +++-- .../chat/__tests__/ChatView.spec.tsx | 23 +++++ 6 files changed, 209 insertions(+), 20 deletions(-) diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 3e86a234e2..a4181e5c69 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -396,6 +396,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { start: vi.fn(), run: childRun, setRuntimeThinkingEffort, + // The child's resolved model (post mode switch) supports the requested level. + api: { + getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "medium", "high"] } }), + }, }) const taskHistoryStore = makeStoreStub() @@ -463,4 +467,95 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() }) + + it("falls back with an observable say when the child model (post mode switch) does not support the effort (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const say = vi.fn().mockResolvedValue(undefined) + const childRun = vi.fn().mockResolvedValue(undefined) + // The mode switch resolved a DIFFERENT model than the parent's: it only + // supports low/high, so the parent-validated "xhigh" must not be applied. + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + say, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "high"] } }) }, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + // No task-local override: the child runs with the settings-derived effort. + expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() + // Observable on the child task: the fallback is announced, not silent. + expect(say).toHaveBeenCalledTimes(1) + const [sayType, sayText] = say.mock.calls[0] + expect(sayType).toBe("error") + expect(sayText).toContain("xhigh") + expect(sayText).toContain("child-model") + // Delegation itself still proceeds: the child runs. + expect(childRun).toHaveBeenCalledTimes(1) + }) + + it("applies the effort when the child model (post mode switch) has a boolean-true capability (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + // Boolean-true capability: the child model supports every level. + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: true } }) }, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + expect(setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(setRuntimeThinkingEffort).toHaveBeenCalledWith("xhigh", "parent") + }) }) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index 62c5417c76..7b8e7bd074 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -63,11 +63,24 @@ export class NewTaskTool extends BaseTool<"new_task"> { // DTE series 5/5: the child task is created with the parent's API configuration, // so the child model is the parent's current model. Validate the optional start - // effort against that model's capability array before asking for approval. + // effort against that model's capability before asking for approval. + // + // ModelInfo.supportsReasoningEffort is `boolean | string[] | undefined`: the bare + // `true` means the model supports reasoning effort without an explicit allow-list, + // so normalize it to the full level set. `false`/`undefined` stay unsupported + // (argument rejected below). The normalized array is the single source of truth + // for the argument validation, the ask payload, and the ask-selection check. const modelCapabilities = task.api.getModel().info.supportsReasoningEffort + // "disable" stays in the element type: capability arrays may carry it (it is a + // settings off-switch, not a start level) and is filtered where levels are listed. + const supportedLevels: readonly (ReasoningEffortExtended | "disable")[] = + modelCapabilities === true + ? NEW_TASK_EFFORT_LEVELS + : Array.isArray(modelCapabilities) + ? modelCapabilities + : [] let validatedEffort: ReasoningEffortExtended | undefined if (thinking_effort !== undefined && thinking_effort !== "") { - const supportedLevels = Array.isArray(modelCapabilities) ? modelCapabilities : [] if (!isNewTaskEffortLevel(thinking_effort) || !supportedLevels.includes(thinking_effort)) { const reason = !isNewTaskEffortLevel(thinking_effort) ? `must be one of: ${NEW_TASK_EFFORT_LEVELS.join(", ")}` @@ -146,9 +159,10 @@ export class NewTaskTool extends BaseTool<"new_task"> { content: message, todos: todoItems, thinkingEffort: validatedEffort ?? task.resolveNewTaskEffectiveEffort(), - supportedThinkingEfforts: Array.isArray(modelCapabilities) - ? modelCapabilities.filter((level): level is ReasoningEffortExtended => level !== "disable") - : undefined, + supportedThinkingEfforts: + supportedLevels.length > 0 + ? supportedLevels.filter((level): level is ReasoningEffortExtended => level !== "disable") + : undefined, }) const didApprove = await askApproval("tool", toolMessage) @@ -162,8 +176,7 @@ export class NewTaskTool extends BaseTool<"new_task"> { // model-specified value, which wins over the parent's effective effort. An // ask selection the target model does not support falls back the same way. const askEffort = task.takeNewTaskAskThinkingEffort() - const askEffortSupported = - askEffort !== undefined && Array.isArray(modelCapabilities) && modelCapabilities.includes(askEffort) + const askEffortSupported = askEffort !== undefined && supportedLevels.includes(askEffort) const childThinkingEffort = askEffortSupported ? askEffort : (validatedEffort ?? task.resolveNewTaskEffectiveEffort()) diff --git a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts index 8db2b0eb36..4a3e1dc741 100644 --- a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts +++ b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts @@ -51,7 +51,7 @@ import newTaskSchema from "../../prompts/tools/native-tools/new_task" import type { Task } from "../../task/Task" interface RunOptions { - /** Target model capability array (boolean/undefined = no known levels). */ + /** Target model capability: array = allow-list; true = full level set; false/undefined = unsupported. */ supportsReasoningEffort?: boolean | string[] /** Effort the user chose in the ask block (carried by the ask response). */ askEffort?: string @@ -311,4 +311,36 @@ describe("new_task thinking_effort validation (DTE series 5/5)", () => { expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) }) + + it("accepts a valid level when the capability is boolean true (full level set)", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: true, + }) + const callbacks = makeCallbacks() + + // xhigh is a valid level but is not in any provider allow-list today: only the + // boolean-true normalization (full level set) accepts it. + await runNewTask(task, { thinking_effort: "xhigh" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "xhigh" })) + + // The ask payload lists the full level set for a boolean-true capability. + const [, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + const payload = JSON.parse(toolMessage as string) as { supportedThinkingEfforts?: string[] } + expect(payload.supportedThinkingEfforts).toEqual(["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + }) + + it("rejects an effort when the capability is boolean false", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: false, + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3fc4aa4de5..16ee7b3ece 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -3867,11 +3867,29 @@ export class ClineProvider startTask: false, }) - // DTE series 5/5: apply the subtask start effort as a task-local override before - // the child's first request so the child header shows it from the start. - // Source "parent" — set by the orchestrator, not the child's own settings. + // DTE series 5/5: the mode switch above can change the provider profile and + // therefore the model the child actually runs on (mode-specific provider + // profiles), so a level validated against the parent model can be invalid for + // the child's. Re-validate against the child's resolved model immediately before + // applying; when the child model does not support the level, fall back to no + // task-local override (the settings-derived effort applies) with an observable + // say on the child instead of failing the whole delegation. if (thinkingEffort !== undefined) { - child.setRuntimeThinkingEffort(thinkingEffort, "parent") + const childModel = child.api.getModel() + const childCapability = childModel.info.supportsReasoningEffort + const childSupportsEffort = + childCapability === true || (Array.isArray(childCapability) && childCapability.includes(thinkingEffort)) + if (childSupportsEffort) { + // Applied as a task-local override before the child's first request so the + // child header shows it from the start. Source "parent" — set by the + // orchestrator, not the child's own settings. + child.setRuntimeThinkingEffort(thinkingEffort, "parent") + } else { + await child.say( + "error", + `new_task thinking_effort '${thinkingEffort}' is not supported by the child model (${childModel.id}); the child starts without the effort override.`, + ) + } } // 5) Persist parent delegation metadata BEFORE the child starts writing. diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index e45cba5af4..3ec287d973 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -362,9 +362,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 + ? tool.thinkingEffort && supported.includes(tool.thinkingEffort) + ? tool.thinkingEffort + : supported[0] + : tool.thinkingEffort, + ) } switch (tool.tool) { case "editedExistingFile": @@ -1802,12 +1813,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 && ( {newTaskAskSupportedEfforts.map((effort) => ( ))} diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 1b062bd1e5..8198ce62bd 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -1561,11 +1561,20 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) // Pre-filled with the effort the extension resolved for the new task... expect(select).toHaveValue("low") // ...and offers exactly the levels the target model supports. expect(Array.from(select.options).map((option) => option.value)).toEqual(["low", "medium", "high"]) + // Option labels are bound to the translated level keys (settings:providers.reasoningEffort.*); + // in this test the effective t() is the identity function, so the raw keys render verbatim. + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + "settings:providers.reasoningEffort.low", + "settings:providers.reasoningEffort.medium", + "settings:providers.reasoningEffort.high", + ]) }) it("falls back to the first supported level when the pre-fill is not supported", async () => { @@ -1573,7 +1582,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) expect(select).toHaveValue("low") }) @@ -1586,7 +1597,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => // not the raw payload value the user never saw. await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) expect(select).toHaveValue("low") await act(async () => { @@ -1605,7 +1618,7 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await postToolAsk(NEW_TASK_ASK) await waitFor(() => { - expect(getByLabelText("Thinking effort")).toBeInTheDocument() + expect(getByLabelText("settings:providers.reasoningEffort.label")).toBeInTheDocument() }) // A subsequent readFile ask must drop the selector: the effort state is @@ -1618,7 +1631,7 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => }) await waitFor(() => { - expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + expect(queryByLabelText("settings:providers.reasoningEffort.label")).not.toBeInTheDocument() }) }) @@ -1631,14 +1644,16 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => await waitFor(() => { expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() }) - expect(queryByLabelText("Thinking effort")).not.toBeInTheDocument() + expect(queryByLabelText("settings:providers.reasoningEffort.label")).not.toBeInTheDocument() }) it("posts the selected effort when the user approves the newTask ask", async () => { const { getByLabelText, getByRole } = renderChatView() await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) // The user switches the effort before entering the subtask... await act(async () => { @@ -1661,7 +1676,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => const { getByLabelText, getByRole, getByTestId } = renderChatView() await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) await act(async () => { fireEvent.change(select, { target: { value: "medium" } }) }) @@ -1704,7 +1721,9 @@ describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => const { getByLabelText, getByTestId } = renderChatView() await postToolAsk(NEW_TASK_ASK) - const select = await waitFor(() => getByLabelText("Thinking effort") as HTMLSelectElement) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) await act(async () => { fireEvent.change(select, { target: { value: "high" } }) }) From 4f88bce6b7b3e9e721a26c876883ae9d44f34e70 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Mon, 24 Aug 2026 07:55:45 +0800 Subject: [PATCH 12/12] test(e2e): allow the live Anthropic upstream in the effort proxy guard --- apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts index ebf8322133..fdc3573318 100644 --- a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts +++ b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts @@ -104,8 +104,10 @@ async function pipeFetchResponse(target: ServerResponse, source: Response) { function resolveAllowedUpstreamUrl(baseUrl: string): URL { const upstreamBase = new URL(baseUrl) const isLocalProxy = upstreamBase.hostname === "127.0.0.1" || upstreamBase.hostname === "localhost" + const isLocalHttp = isLocalProxy && upstreamBase.protocol === "http:" + const isAnthropicUpstream = upstreamBase.origin === "https://api.anthropic.com" - if (!isLocalProxy || (upstreamBase.protocol !== "http:" && baseUrl !== "https://api.anthropic.com")) { + if (!isLocalHttp && !isAnthropicUpstream) { throw new Error("Unexpected Anthropic proxy target: " + upstreamBase.origin) }