From 6ea45b36a2bf0cf7787fca11ebd1969da567e611 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Sat, 22 Aug 2026 06:31:23 +0800 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 9d58ba2965c4ebe496b32cdc8e68522c6597ade8 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 23:03:49 +0800 Subject: [PATCH 4/8] fix(task): persist task-local thinking effort on history items A task reopened from history constructed a fresh Task with no runtime thinking effort, so the displayed and effective effort silently fell back to the settings value even when the task had a per-task override. - historyItemSchema: optional thinkingEffort + thinkingEffortSource - taskMetadata: accepts and spreads both (only when active) - Task.saveClineMessages: writes the active override via getRuntimeThinkingEffort() - Task ctor (historyItem branch): restores it via setRuntimeThinkingEffort, which also merges into the in-memory apiConfiguration copy and rebuilds the handler - spec: 4 new persistence round-trip cases (restore, no-op without effort, save writes effort, save omits effort while inactive) --- packages/types/src/history.ts | 8 +++ src/core/task-persistence/taskMetadata.ts | 12 +++- src/core/task/Task.ts | 10 +++ .../Task.runtime-thinking-effort.test.ts | 62 ++++++++++++++++++- 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 5b173c6a6b..e9acd320e7 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { reasoningEffortExtendedSchema } from "./model" + /** * HistoryItem */ @@ -26,6 +28,12 @@ export const historyItemSchema = z.object({ awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated) completedByChildId: z.string().optional(), // Child that completed and resumed this parent completionResultSummary: z.string().optional(), // Summary from completed child + // DTE series 2/5: task-local thinking effort override persisted with the history + // item so a task reopened from history keeps the effort it had (user-set or + // model/parent-chosen) instead of falling back to the settings value. + thinkingEffort: reasoningEffortExtendedSchema.optional(), + // Provenance of the persisted effort (e.g. "you", "model", "parent"). + thinkingEffortSource: z.string().optional(), }) export type HistoryItem = z.infer diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index ec2e6cceeb..de3b6bb65f 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -1,7 +1,7 @@ import NodeCache from "node-cache" import getFolderSize from "get-folder-size" -import type { ClineMessage, HistoryItem } from "@roo-code/types" +import type { ClineMessage, HistoryItem, ReasoningEffortExtended } from "@roo-code/types" import { combineApiRequests } from "../../shared/combineApiRequests" import { combineCommandSequences } from "../../shared/combineCommandSequences" @@ -25,6 +25,10 @@ export type TaskMetadataOptions = { apiConfigName?: string /** Initial status for the task (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" | "interrupted" + /** DTE series 2/5: active task-local thinking effort override to persist on the history item. */ + thinkingEffort?: ReasoningEffortExtended + /** DTE series 2/5: provenance of the persisted effort (e.g. "you", "model", "parent"). */ + thinkingEffortSource?: string } export async function taskMetadata({ @@ -38,6 +42,8 @@ export async function taskMetadata({ mode, apiConfigName, initialStatus, + thinkingEffort, + thinkingEffortSource, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -112,6 +118,10 @@ export async function taskMetadata({ mode, ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), ...(initialStatus && { status: initialStatus }), + // DTE series 2/5: persist the active task-local effort (and its provenance) so + // reopening this task from history restores it. + ...(thinkingEffort && { thinkingEffort }), + ...(thinkingEffortSource && { thinkingEffortSource }), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e448cb16bc..8d76dd8827 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -557,6 +557,12 @@ export class Task extends EventEmitter implements TaskLike { if (historyItem) { this._taskMode = historyItem.mode || defaultModeSlug this._taskApiConfigName = historyItem.apiConfigName + // DTE series 2/5: restore the task-local thinking effort persisted with the + // history item so a reopened task keeps the effort it had instead of + // silently falling back to the settings value. + if (historyItem.thinkingEffort) { + this.setRuntimeThinkingEffort(historyItem.thinkingEffort, historyItem.thinkingEffortSource) + } this.taskModeReady = Promise.resolve() this.taskApiConfigReady = Promise.resolve() TelemetryService.instance.captureTaskRestarted(this.taskId) @@ -1129,6 +1135,10 @@ export class Task extends EventEmitter implements TaskLike { mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile. initialStatus: this.initialStatus, + // DTE series 2/5: persist the active task-local effort override so it + // survives reopening this task from history (undefined while inactive). + thinkingEffort: this.getRuntimeThinkingEffort().effort, + thinkingEffortSource: this.getRuntimeThinkingEffort().source, }) // Emit token/tool usage updates using debounced function 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 4fce91b475..63fdb06c61 100644 --- a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -4,12 +4,13 @@ // setRuntimeThinkingEffort / getRuntimeThinkingEffort, the in-memory // apiConfiguration merge + restore, and the task-end reset in dispose(). -import { ProviderSettings, type ReasoningEffortExtended } from "@roo-code/types" +import { ProviderSettings, type HistoryItem, 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" +import { taskMetadata } from "../../task-persistence" // Mock dependencies (same lightweight set as Task.throttle.test.ts) vi.mock("../../webview/ClineProvider") @@ -76,6 +77,7 @@ type RuntimeThinkingEffortAccess = { runtimeThinkingEffortSource?: string preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] getRuntimeThinkingEffortMetadata: () => { reasoningEffort?: ReasoningEffortExtended } + saveClineMessages: () => Promise } function getPrivateAccess(task: Task): RuntimeThinkingEffortAccess { @@ -308,4 +310,62 @@ describe("Task runtime thinking effort (DTE series 2/5)", () => { expect(access.preOverrideReasoningEffort).toBeUndefined() }) }) + + describe("history persistence round-trip", () => { + const baseHistoryItem: HistoryItem = { + id: "hist-task-id", + number: 2, + task: "Task from history", + ts: Date.now(), + totalCost: 0.01, + tokensIn: 10, + tokensOut: 5, + } + + function makeHistoryTask(historyItem: Partial): Task { + return new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + startTask: false, + historyItem: { ...baseHistoryItem, ...historyItem }, + }) + } + + it("restores the persisted task-local effort when constructed from a history item", () => { + const histTask = makeHistoryTask({ thinkingEffort: "xhigh", thinkingEffortSource: "you" }) + + expect(histTask.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "you" }) + // The in-memory copy carries the restored effort, so the rebuilt handler uses it. + expect(histTask.apiConfiguration).toEqual(expect.objectContaining({ reasoningEffort: "xhigh" })) + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "xhigh" })) + histTask.dispose() + }) + + it("leaves the override inactive for history items without a persisted effort", () => { + const histTask = makeHistoryTask({}) + + expect(histTask.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(histTask.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + histTask.dispose() + }) + + it("carries the active task-local effort onto the taskMetadata payload in saveClineMessages", async () => { + task.setRuntimeThinkingEffort("max", "you") + + await getPrivateAccess(task).saveClineMessages() + + expect(vi.mocked(taskMetadata)).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: "max", thinkingEffortSource: "you" }), + ) + }) + + it("omits the effort values from the taskMetadata payload while inactive", async () => { + await getPrivateAccess(task).saveClineMessages() + + expect(vi.mocked(taskMetadata)).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: undefined, thinkingEffortSource: undefined }), + ) + }) + }) }) From 4252fdd3282ca0d96917985d5cccf20d3966fe7c Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 25 Aug 2026 23:10:09 +0800 Subject: [PATCH 5/8] fix(types): use .js extension for intra-package import packages/types compiles with node16 module resolution, where relative import specifiers need an explicit file extension (./model.js). --- packages/types/src/history.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index e9acd320e7..fc7d339e1e 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { reasoningEffortExtendedSchema } from "./model" +import { reasoningEffortExtendedSchema } from "./model.js" /** * HistoryItem From 1a9604ff86d5605bbe00bf6c5d05fb7df409ed92 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 26 Aug 2026 20:25:06 +0800 Subject: [PATCH 6/8] test(dte): cover both sides of the effort spread in taskMetadata taskMetadata.ts L123-124 spread ...(thinkingEffort && {...}) / ...(thinkingEffortSource && {...}); the existing real-implementation call only exercised one side of each logical AND (codecov reported 2 partials on the combined trial tree). Drive the real taskMetadata() with both truthy and falsy effort values. --- .../__tests__/taskMetadata.spec.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/core/task-persistence/__tests__/taskMetadata.spec.ts diff --git a/src/core/task-persistence/__tests__/taskMetadata.spec.ts b/src/core/task-persistence/__tests__/taskMetadata.spec.ts new file mode 100644 index 0000000000..d59f4af65a --- /dev/null +++ b/src/core/task-persistence/__tests__/taskMetadata.spec.ts @@ -0,0 +1,75 @@ +// cd src && npx vitest run core/task-persistence/__tests__/taskMetadata.spec.ts +// +// DTE series 2/5: taskMetadata() persists the active task-local thinking effort +// (and its provenance) on the history item via +// ...(thinkingEffort && { thinkingEffort }), +// ...(thinkingEffortSource && { thinkingEffortSource }), +// so that reopening the task from history restores it. +// +// These tests drive the real taskMetadata() with both truthy and falsy effort +// values so both sides of the logical-AND spread are covered. +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as os from "os" +import * as path from "path" +import * as fs from "fs/promises" + +import type { ClineMessage, ReasoningEffortExtended } from "@roo-code/types" + +vi.mock("get-folder-size", () => ({ + __esModule: true, + default: { loose: vi.fn().mockResolvedValue(0) }, +})) +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), +})) + +// Import after mocks +import { taskMetadata } from "../taskMetadata" + +let tmpBaseDir: string + +beforeEach(async () => { + // Unique writable temp dir as the global storage path (mirrors taskMessages.spec.ts). + tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-taskmetadata-")) +}) + +function taskSayMessage(text: string): ClineMessage { + return { + ts: 1_700_000_000_000, + type: "say", + say: "task", + text, + } +} + +async function runMetadata(overrides: { thinkingEffort?: ReasoningEffortExtended; thinkingEffortSource?: string }) { + return taskMetadata({ + taskId: "task-meta-1", + taskNumber: 7, + messages: [taskSayMessage("Do the thing")], + globalStoragePath: tmpBaseDir, + workspace: "workspace", + ...overrides, + }) +} + +describe("taskMetadata thinkingEffort persistence", () => { + it("omits the effort fields from the history item when not provided", async () => { + const { historyItem } = await runMetadata({}) + + expect(historyItem.thinkingEffort).toBeUndefined() + expect(historyItem.thinkingEffortSource).toBeUndefined() + // The rest of the history item is still written. + expect(historyItem.id).toBe("task-meta-1") + expect(historyItem.task).toBe("Do the thing") + }) + + it("persists the effort and its provenance on the history item when provided", async () => { + const { historyItem } = await runMetadata({ thinkingEffort: "low", thinkingEffortSource: "you" }) + + expect(historyItem.thinkingEffort).toBe("low") + expect(historyItem.thinkingEffortSource).toBe("you") + }) +}) From 01394aeeaa08b8f0d6a61441de668a3780542f32 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 26 Aug 2026 20:37:23 +0800 Subject: [PATCH 7/8] fix(task): propagate cleared thinking effort through history-store merge taskMetadata() now always carries thinkingEffort/thinkingEffortSource keys (even while undefined) instead of conditionally spreading them. The TaskHistoryStore upsert merges {...disk, ...delta} and buildDelta only propagates keys present in the incoming item, so an absent key left the stale persisted effort in place after the override was cleared. The spec pins the key-presence contract. (CodeRabbit on trial PR #1379) --- .../__tests__/taskMetadata.spec.ts | 20 ++++++++++++------- src/core/task-persistence/taskMetadata.ts | 8 +++++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/core/task-persistence/__tests__/taskMetadata.spec.ts b/src/core/task-persistence/__tests__/taskMetadata.spec.ts index d59f4af65a..2fccc03521 100644 --- a/src/core/task-persistence/__tests__/taskMetadata.spec.ts +++ b/src/core/task-persistence/__tests__/taskMetadata.spec.ts @@ -1,13 +1,15 @@ // cd src && npx vitest run core/task-persistence/__tests__/taskMetadata.spec.ts // // DTE series 2/5: taskMetadata() persists the active task-local thinking effort -// (and its provenance) on the history item via -// ...(thinkingEffort && { thinkingEffort }), -// ...(thinkingEffortSource && { thinkingEffortSource }), -// so that reopening the task from history restores it. +// (and its provenance) on the history item so that reopening the task from +// history restores it. // -// These tests drive the real taskMetadata() with both truthy and falsy effort -// values so both sides of the logical-AND spread are covered. +// The keys are always present on the returned history item — even while +// undefined — so that clearing the override propagates through the +// TaskHistoryStore merge (an absent key would leave the stale disk value in +// place; see buildDelta/mergeWithDisk, which only propagate keys present in +// the incoming item). These tests drive the real taskMetadata() with both +// truthy and falsy effort values to pin that contract. import { describe, it, expect, vi, beforeEach } from "vitest" import * as os from "os" import * as path from "path" @@ -56,11 +58,15 @@ async function runMetadata(overrides: { thinkingEffort?: ReasoningEffortExtended } describe("taskMetadata thinkingEffort persistence", () => { - it("omits the effort fields from the history item when not provided", async () => { + it("clears the effort fields with explicit keys when not provided", async () => { const { historyItem } = await runMetadata({}) expect(historyItem.thinkingEffort).toBeUndefined() expect(historyItem.thinkingEffortSource).toBeUndefined() + // The keys must still be PRESENT (with undefined) so the history-store + // merge propagates the clear and drops any stale disk value. + expect("thinkingEffort" in historyItem).toBe(true) + expect("thinkingEffortSource" in historyItem).toBe(true) // The rest of the history item is still written. expect(historyItem.id).toBe("task-meta-1") expect(historyItem.task).toBe("Do the thing") diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index de3b6bb65f..897289d965 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -119,9 +119,11 @@ export async function taskMetadata({ ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), ...(initialStatus && { status: initialStatus }), // DTE series 2/5: persist the active task-local effort (and its provenance) so - // reopening this task from history restores it. - ...(thinkingEffort && { thinkingEffort }), - ...(thinkingEffortSource && { thinkingEffortSource }), + // reopening this task from history restores it. The keys are always present + // (even while undefined) so that clearing the override propagates through the + // history-store merge — an absent key would leave the stale disk value in place. + thinkingEffort, + thinkingEffortSource, } return { historyItem, tokenUsage } From 77ec064d3d9a2f218c4d52d3f0f7121e326578ba Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Wed, 26 Aug 2026 20:38:33 +0800 Subject: [PATCH 8/8] fix(task): record pre-dispose effort snapshot on abort final save abortTask() called dispose() (which clears the task-local effort state) before the final saveClineMessages(), so the history write for an aborted task carried thinkingEffort: undefined and the history-restore path could not recover the effort. Snapshot getRuntimeThinkingEffort() before dispose and pass it into saveClineMessages() (other callers read the live state). Two new abort tests cover the snapshot path and the inactive path. (CodeRabbit on trial PR 1379) --- src/core/task/Task.ts | 23 +++++++++++++++---- .../Task.runtime-thinking-effort.test.ts | 23 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 8d76dd8827..cb81cbcb4b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1112,7 +1112,12 @@ export class Task extends EventEmitter implements TaskLike { } } - private async saveClineMessages(): Promise { + private async saveClineMessages( + // DTE series 2/5: abortTask() snapshots the effort state before dispose() clears + // it and passes it here so the final history save still records it. Other + // callers pass nothing and the live state is read. + effortSnapshot?: { effort?: ReasoningEffortExtended; source?: string }, + ): Promise { try { await saveTaskMessages({ messages: structuredClone(this.clineMessages), @@ -1124,6 +1129,10 @@ export class Task extends EventEmitter implements TaskLike { await this.taskApiConfigReady } + // DTE series 2/5: the abort path passes a pre-dispose snapshot because + // dispose() has already cleared the live state by the time the final save runs. + const runtimeEffort = effortSnapshot ?? this.getRuntimeThinkingEffort() + const { historyItem, tokenUsage } = await taskMetadata({ taskId: this.taskId, rootTaskId: this.rootTaskId, @@ -1137,8 +1146,8 @@ export class Task extends EventEmitter implements TaskLike { initialStatus: this.initialStatus, // DTE series 2/5: persist the active task-local effort override so it // survives reopening this task from history (undefined while inactive). - thinkingEffort: this.getRuntimeThinkingEffort().effort, - thinkingEffortSource: this.getRuntimeThinkingEffort().source, + thinkingEffort: runtimeEffort.effort, + thinkingEffortSource: runtimeEffort.source, }) // Emit token/tool usage updates using debounced function @@ -2365,6 +2374,12 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.TaskAborted) + // DTE series 2/5: snapshot the transient effort state before dispose() clears + // it, so the final history save below still records the effort the task was + // using (otherwise an aborted task's history item loses its effort and the + // history-restore path cannot recover it). + const effortAtAbort = this.getRuntimeThinkingEffort() + try { this.dispose() // Call the centralized dispose method } catch (error) { @@ -2381,7 +2396,7 @@ export class Task extends EventEmitter implements TaskLike { return } try { - await this.saveClineMessages() + await this.saveClineMessages(effortAtAbort) } catch (error) { console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) } 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 63fdb06c61..5b97cda03b 100644 --- a/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -368,4 +368,27 @@ describe("Task runtime thinking effort (DTE series 2/5)", () => { ) }) }) + + describe("abortTask final save (DTE series 2/5)", () => { + it("records the active task-local effort on the final history save despite dispose() clearing it", async () => { + task.setRuntimeThinkingEffort("high", "you") + + await task.abortTask() + + // dispose() has already cleared the live state... + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + // ...but the final save still recorded the pre-dispose snapshot. + expect(vi.mocked(taskMetadata)).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: "high", thinkingEffortSource: "you" }), + ) + }) + + it("saves undefined effort fields on the final history save while inactive", async () => { + await task.abortTask() + + expect(vi.mocked(taskMetadata)).toHaveBeenCalledWith( + expect.objectContaining({ thinkingEffort: undefined, thinkingEffortSource: undefined }), + ) + }) + }) })