From 7350c58978f980a42a54f3c250231740652e152c Mon Sep 17 00:00:00 2001 From: Eason Liang <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:38:28 +0800 Subject: [PATCH] refresh(#1523): replace the 64-commit branch line with one commit at main 53cb5b5e7c80 The branch line feat/dte-v2-3-task-runtime-effort carried 64 commits against main merge base e943530c8486, 35 of them non-merge and 29 merge. A flat replay of the 35 onto today's main stopped at the first commit in the range, 19788c0316, with 20 conflicted paths, so the line is replaced rather than replayed. This commit's tree is the conflict-free merge of main 53cb5b5e7c80 with the previous head 79e1c2499ba1, tree e4462be79785. Nobody authored a resolution: git merged the 18 webview-ui locale settings files, scripts/stryker-diff.mjs and scripts/stryker-diff.test.mjs without one, and in this tree all 20 of those paths hold main's own blobs unchanged. The delta against main is five paths: src/api/index.ts 11 added 0 deleted src/core/condense/__tests__/condense-effort-metadata.spec.ts 98 added 0 deleted src/core/task/Task.ts 121 added 8 deleted src/core/task/__tests__/Task.runtime-thinking-effort.test.ts 549 added 0 deleted src/core/webview/__tests__/ClineProvider.spec.ts 32 added 0 deleted No content beyond that merge is introduced here, and no changeset or changelog entry was added. --- src/api/index.ts | 11 + .../condense-effort-metadata.spec.ts | 98 ++++ src/core/task/Task.ts | 129 +++- .../Task.runtime-thinking-effort.test.ts | 549 ++++++++++++++++++ .../webview/__tests__/ClineProvider.spec.ts | 32 + 5 files changed, 811 insertions(+), 8 deletions(-) create mode 100644 src/core/condense/__tests__/condense-effort-metadata.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 e737ebd4bd..af010d308c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,6 +7,7 @@ import { retiredProviderIdentifiers, type ProviderSettings, type ModelInfo, + type ReasoningEffortExtended, type ModelRecord, } from "@roo-code/types" @@ -127,6 +128,16 @@ export interface ApiHandlerCreateMessageMetadata { * when the user clicks stop, preventing wasted API tokens/compute on the provider side. */ abortSignal?: AbortSignal + /** + * Per-request thinking effort override applied by rebuilding the handler. + * Providers resolve effort from the handler options (each provider reads + * `options.reasoningEffort` through its own model-params step), so passing the + * override in the configuration handed to `buildApiHandler` is what takes effect; + * it outranks the settings-derived `reasoningEffort` for the handler built from + * this object. Task-scoped and transient: it applies to the next request only + * (no mid-stream effect) and is never persisted to settings. + */ + reasoningEffort?: ReasoningEffortExtended } /** diff --git a/src/core/condense/__tests__/condense-effort-metadata.spec.ts b/src/core/condense/__tests__/condense-effort-metadata.spec.ts new file mode 100644 index 0000000000..bfb2ed8b50 --- /dev/null +++ b/src/core/condense/__tests__/condense-effort-metadata.spec.ts @@ -0,0 +1,98 @@ +// npx vitest run src/core/condense/__tests__/condense-effort-metadata.spec.ts +// +// The summarizer boundary: Task hands the task-local thinking effort override to +// to summarizeConversation inside request metadata. This checks the other half of that hand-off: +// the real summarizer forwards the metadata to the handler's createMessage instead of dropping +// the effort. The Task-side tests cover Task-to-summarizer propagation only. + +import { Anthropic } from "@anthropic-ai/sdk" +import type { ModelInfo, ReasoningEffortExtended } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import type { ApiHandlerCreateMessageMetadata } from "../../../api" +import { BaseProvider } from "../../../api/providers/base-provider" +import { ApiStream, ApiStreamChunk } from "../../../api/transform/stream" +import { ApiMessage } from "../../task-persistence/apiMessages" +import { summarizeConversation } from "../index" + +const OVERRIDE_EFFORT: ReasoningEffortExtended = "xhigh" +const TASK_ID = "dte-condense-task" + +class RecordingApiHandler extends BaseProvider { + /** The metadata of the last request this handler was asked to make. */ + public requestedMetadata: ApiHandlerCreateMessageMetadata | undefined + + override createMessage( + _systemPrompt: string, + _messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + this.requestedMetadata = metadata + return (async function* (): AsyncGenerator { + yield { type: "text", text: "summary of the conversation" } + yield { type: "usage", inputTokens: 10, outputTokens: 5, totalCost: 0.01 } + })() + } + + override getModel(): { id: string; info: ModelInfo } { + return { + id: "dte-condense-model", + info: { contextWindow: 100_000, maxTokens: 5_000, supportsPromptCache: false, supportsImages: false }, + } + } +} + +// Three messages with no summary in them: getMessagesSinceLastSummary returns all of them, so +// summarizeConversation gets past its "not enough messages" guard and issues the request. +function conversationHistory(): ApiMessage[] { + return [ + { role: "user", content: [{ type: "text", text: "read the failing test" }], ts: 1 }, + { role: "assistant", content: [{ type: "text", text: "the assertion drifted" }], ts: 2 }, + { role: "user", content: [{ type: "text", text: "fix it" }], ts: 3 }, + ] +} + +describe("summarizeConversation reasoning-effort forwarding", () => { + beforeEach(() => { + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + }) + + it("forwards an active override to the handler request", async () => { + const handler = new RecordingApiHandler() + + const result = await summarizeConversation({ + messages: conversationHistory(), + apiHandler: handler, + systemPrompt: "system prompt", + taskId: TASK_ID, + isAutomaticTrigger: false, + metadata: { mode: "code", taskId: TASK_ID, reasoningEffort: OVERRIDE_EFFORT }, + environmentDetails: "environment details", + cwd: "/workspace", + }) + + expect(result.error).toBeUndefined() + expect(result.summary).toBe("summary of the conversation") + expect(handler.requestedMetadata?.reasoningEffort).toBe(OVERRIDE_EFFORT) + }) + + it("omits the key when the caller hands no override", async () => { + const handler = new RecordingApiHandler() + + const result = await summarizeConversation({ + messages: conversationHistory(), + apiHandler: handler, + systemPrompt: "system prompt", + taskId: TASK_ID, + isAutomaticTrigger: false, + metadata: { mode: "code", taskId: TASK_ID }, + environmentDetails: "environment details", + cwd: "/workspace", + }) + + expect(result.summary).toBe("summary of the conversation") + expect(handler.requestedMetadata).not.toHaveProperty("reasoningEffort") + }) +}) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 77a1333b39..c491e9f3f0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -21,6 +21,7 @@ import { type TaskMetadata, type TaskEvents, type ProviderSettings, + type ReasoningEffortExtended, type TokenUsage, type ToolUsage, type ToolName, @@ -429,6 +430,10 @@ export class Task extends EventEmitter implements TaskLike { // API apiConfiguration: ProviderSettings api: ApiHandler + // Task-local thinking effort override. Transient per-task state — + // never persisted to settings; cleared on dispose (see dispose()). + private runtimeThinkingEffort?: ReasoningEffortExtended + private runtimeThinkingEffortSource?: string /** * Units of in-flight API work: a request from setup through its retries and * background usage drain, or a manual condense. Work can hold any handler it @@ -2555,6 +2560,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. * + * 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 { @@ -2562,9 +2573,26 @@ export class Task extends EventEmitter implements TaskLike { // VS Code LM / GitHub Copilot own host listeners, so the old one is // released — immediately when idle, or when the last request, retry, // background drain, or condense that leased it settles. + // apiConfiguration stays the profile-derived projection; + // an active task-local override is applied to the rebuilt handler only, so + // a profile switch keeps the override live and clearing it later restores + // the NEW profile's value (it is the configuration itself now). + // + // Same disposal guard as setRuntimeThinkingEffort: this is the other path that rebuilds the + // handler, and once disposal has started its handler set is already collected, so a rebuild + // would strand the replacement and re-retire a disposed handler. The task is going away, so + // there is nothing left to re-project either. + if (this.disposalPromise !== undefined) { + return + } + const previousApi = this.api this.apiConfiguration = newApiConfiguration - this.api = buildApiHandler(this.apiConfiguration) + this.api = buildApiHandler( + this.runtimeThinkingEffort !== undefined + ? { ...newApiConfiguration, reasoningEffort: this.runtimeThinkingEffort } + : newApiConfiguration, + ) this.retireApi(previousApi) // Refresh only the soft tool-repetition limit from the new profile so an @@ -2581,6 +2609,76 @@ export class Task extends EventEmitter implements TaskLike { this.toolRepetitionDetector.updateLimits(this.toolRepetitionSoftLimit, this.consecutiveMistakeLimit) } + /** + * 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 applied to the + * rebuilt API handler. `apiConfiguration` itself stays the profile-derived + * projection — the override never enters it — so the webview state (which + * posts the active task's `apiConfiguration`) and any profile save built + * from it can never carry the task-local effort. `undefined` clears the + * override and rebuilds the handler from the projection. + * + * Not inherited: child, handoff, and resumed tasks construct their own Task and + * start with no override — it lives in this task's private fields and is never + * persisted. + * @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 { + // Disposal has started (or finished): `disposalPromise` is set once and never cleared, so it + // is the liveness signal. The override is dropped rather than applied — the task serves no + // further request, and the handler set that dispose collects was already snapshotted, so + // rebuilding here would produce a handler nobody owns (still holding its configuration + // listeners) and retire the handler disposal has just disposed. + if (this.disposalPromise !== undefined) { + return + } + + const wasActive = this.runtimeThinkingEffort !== undefined + this.runtimeThinkingEffort = effort + this.runtimeThinkingEffortSource = effort === undefined ? undefined : source + + if (effort === undefined && !wasActive) { + // Already inactive: nothing to clear. + return + } + + // Rebuild the handler with the effective effort applied. The projection + // (this.apiConfiguration) is untouched, so clearing needs no restore: the + // handler is simply rebuilt from the profile-derived configuration. The + // replaced handler is retired like any other swap: handlers with host + // listeners must not leak when the effort changes. + const previousApi = this.api + this.api = buildApiHandler( + effort !== undefined ? { ...this.apiConfiguration, reasoningEffort: effort } : this.apiConfiguration, + ) + this.retireApi(previousApi) + } + + /** + * Reads the current task-local thinking effort override. + */ + public getRuntimeThinkingEffort(): { effort?: ReasoningEffortExtended; source?: string } { + return { + effort: this.runtimeThinkingEffort, + source: this.runtimeThinkingEffortSource, + } + } + + /** + * 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 } : {} + } + /** * Marks the start of in-flight API work. The returned release is idempotent; * the last release disposes every handler replaced while work was running. @@ -2760,6 +2858,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // Carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Generate environment details to include in the condensed summary const environmentDetails = await getEnvironmentDetails(this, true) @@ -3707,6 +3807,10 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Centralized task teardown: releases task resources and resets transient + * task-local state. + */ public dispose(): Promise { if (this.disposalPromise) { return this.disposalPromise @@ -3826,6 +3930,16 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error reverting diff changes:", error) } + // Clear the DTE runtime-effort override so a retained disposed task never + // reports a stale override. The fields are cleared directly instead of via + // setRuntimeThinkingEffort: that setter rebuilds the handler, and during + // disposal the rebuild would (a) create a replacement handler this pass has + // already collected and disposed, and (b) retire the just-disposed handler a + // second time. `apiConfiguration` never carries the override (it stays the + // profile-derived projection), so there is nothing to restore. + this.runtimeThinkingEffort = undefined + this.runtimeThinkingEffortSource = undefined + await pendingCleanup await this.diffReversionPromise } @@ -4372,7 +4486,6 @@ export class Task extends EventEmitter implements TaskLike { // under the canonical name slip past a name comparison. The first call wins. const idAlreadyUsed = this.assistantMessageContent.some( (entry) => - // Stryker disable next-line ConditionalExpression: equivalent mutant, only tool_use and mcp_tool_use entries carry an id, so the type check cannot change entry.id === event.id (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) @@ -4425,7 +4538,6 @@ export class Task extends EventEmitter implements TaskLike { // keep the deprecated single-entry-per-ID lookup. let streamingKey: string | undefined - // Stryker disable next-line ConditionalExpression: equivalent mutant, event.name is always a string here, so the legacy nameless branch is unreachable if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { @@ -4442,7 +4554,6 @@ export class Task extends EventEmitter implements TaskLike { } const partialToolUse = - // Stryker disable next-line ConditionalExpression: equivalent mutant, streamingKey is always a string here (makeStreamingKey), so the null side is unreachable streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( @@ -4451,7 +4562,6 @@ export class Task extends EventEmitter implements TaskLike { nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression, EqualityOperator: equivalent mutants, streamingKey is always defined here, so both conditions reduce to the partialToolUse check if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. @@ -4870,7 +4980,6 @@ export class Task extends EventEmitter implements TaskLike { if (event.type === "tool_call_end") { // Create compound key for deduplication (same pattern as streaming handler). // End events carry the tool name; fall back to the id for events that don't. - // Stryker disable next-line LogicalOperator: equivalent mutant, finalizeRawChunks emits end events with a string name, so the ?? event.id fallback is unreachable let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) @@ -4887,13 +4996,11 @@ export class Task extends EventEmitter implements TaskLike { // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. - // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: equivalent mutants, the both-miss case is unreachable via the raw-chunk pipeline if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) - // Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there). if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) @@ -5637,6 +5744,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // Carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } try { @@ -5888,6 +5997,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // Carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Only generate environment details when context management will actually run. @@ -6016,6 +6127,8 @@ export class Task extends EventEmitter implements TaskLike { taskId: this.taskId, suppressPreviousResponseId: this.skipPrevResponseIdOnce, abortSignal, + // 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..d640fbb313 --- /dev/null +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -0,0 +1,549 @@ +// npx vitest run src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +// +// Task-local thinking effort state on Task: +// setRuntimeThinkingEffort / getRuntimeThinkingEffort, the in-memory +// effective-effort handler rebuild (the profile-derived apiConfiguration +// projection never carries the override). + +import { ProviderSettings, type ModelInfo, 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 { DeepSeekHandler } from "../../../api/providers/deepseek" +import { disposeApiHandler } from "../../../api/dispose-api-handler" +import { summarizeConversation } from "../../condense" +import { ApiStreamChunk } from "../../../api/transform/stream" + +// 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/dispose-api-handler", () => ({ disposeApiHandler: vi.fn() })) +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" }), + countTokens: vi.fn().mockResolvedValue(0), + createMessage: vi.fn(), + })), +})) + +// Request-path tests below: the summarization and tool-array collaborators are replaced so the +// four request sites are reachable without MCP or condensing machinery, while the real +// willManageContext/manageContext still decide the branch and hand the same metadata object to +// summarizeConversation - which is where the assertions read it. +vi.mock("../../condense", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + summarizeConversation: vi.fn().mockResolvedValue({ + messages: [{ role: "user", content: [{ type: "text", text: "condensed" }], ts: 1 }], + summary: "summary", + cost: 0, + newContextTokens: 1, + }), + } +}) + +vi.mock("../build-tools", () => ({ + buildNativeToolsArrayWithRestrictions: vi.fn().mockResolvedValue({ tools: [], allowedFunctionNames: undefined }), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue("environment details"), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + captureTaskCompleted: vi.fn(), + captureLlmCompletion: vi.fn(), + captureConsecutiveMistakeError: 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 + getRuntimeThinkingEffortMetadata: () => { reasoningEffort?: ReasoningEffortExtended } + getSystemPrompt: (state: unknown, modelInfo: ModelInfo | undefined) => Promise + handleContextWindowExceededError: (requestModelInfo: ModelInfo) => Promise +} + +function getPrivateAccess(task: Task): RuntimeThinkingEffortAccess { + return task as unknown as RuntimeThinkingEffortAccess +} + +const SETTINGS_EFFORT: ReasoningEffortExtended = "low" + +describe("Task runtime thinking effort override", () => { + 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(async () => { + vi.useRealTimers() + if (task) { + await task.dispose().catch(() => {}) + } + }) + + describe("dispose", () => { + it("clears the override and leaves the profile-derived apiConfiguration unchanged", async () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + const access = getPrivateAccess(task) + const handlerBefore = (task as unknown as { api: unknown }).api + const buildsBefore = vi.mocked(buildApiHandler).mock.calls.length + vi.mocked(disposeApiHandler).mockClear() + + await task.dispose() + + expect(access.runtimeThinkingEffort).toBeUndefined() + expect(access.runtimeThinkingEffortSource).toBeUndefined() + // The public read reports the same cleared state. Nothing is restored into + // apiConfiguration here: it is the profile-derived projection and never carried + // the override, so disposal can only leave the profile value it already had. + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + // A disposed task never serves another request: disposal must not rebuild + // a replacement handler it would then have to collect and dispose. + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(buildsBefore) + // The handler the task owned is disposed exactly once, not retired a + // second time by the clearing path. + const disposedHandlers = vi.mocked(disposeApiHandler).mock.calls.map(([api]) => api) + expect(disposedHandlers.filter((api) => api === handlerBefore)).toHaveLength(1) + }) + }) + + describe("setRuntimeThinkingEffort", () => { + it("stores effort + source and rebuilds the handler with the override applied", () => { + // The handler this task owned before the swap must be disposed by the swap: + // handlers that hold host listeners (VS Code LM, GitHub Copilot) leak otherwise. + const handlerBefore = task.api + vi.mocked(disposeApiHandler).mockClear() + 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 settings object the provider handed in is never mutated. + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + reasoningEffort: SETTINGS_EFFORT, + }), + ) + // The handler is rebuilt with the override applied (last build call)... + // Exact configuration, not a shape subset: dropping any other profile field + // from the rebuild would fail here. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual({ ...mockApiConfiguration, reasoningEffort: "xhigh" }) + // ...from a fresh copy, not the settings object itself. + expect(lastCall?.[0]).not.toBe(mockApiConfiguration) + const disposedHandlers = vi.mocked(disposeApiHandler).mock.calls.map(([api]) => api) + expect(disposedHandlers.filter((api) => api === handlerBefore)).toHaveLength(1) + }) + + it("hands buildApiHandler a configuration a real provider handler honours", async () => { + // The mocked builder proves the override reaches buildApiHandler; this feeds + // the configuration it handed over to the REAL builder and checks that an + // actual provider resolves it. deepseek's model-params step reads + // `options.reasoningEffort` and outranks the model's own default ("high"), + // while the profile asks for SETTINGS_EFFORT, so all three values differ. + const { buildApiHandler: buildRealHandler } = + await vi.importActual("../../../api") + // The real builder routes this configuration to deepseek's handler, whose + // model-params step is what reads the effort. + const deepSeekEffortOf = (configuration: ProviderSettings): ReasoningEffortExtended | undefined => { + const handler = buildRealHandler({ + ...configuration, + apiProvider: providerIdentifiers.deepseek, + apiModelId: "deepseek-v4-flash", + }) + if (!(handler instanceof DeepSeekHandler)) { + throw new Error("the real builder must route this configuration to deepseek's handler") + } + return handler.getModel().reasoningEffort + } + + expect(deepSeekEffortOf(mockApiConfiguration)).toBe(SETTINGS_EFFORT) + + task.setRuntimeThinkingEffort("max", "real-builder") + const handedConfiguration = vi.mocked(buildApiHandler).mock.calls.at(-1)?.[0] as ProviderSettings + expect(deepSeekEffortOf(handedConfiguration)).toBe("max") + + task.setRuntimeThinkingEffort(undefined) + const clearedConfiguration = vi.mocked(buildApiHandler).mock.calls.at(-1)?.[0] as ProviderSettings + expect(deepSeekEffortOf(clearedConfiguration)).toBe(SETTINGS_EFFORT) + }) + + it("replaces effort + source when re-set while active and restores on clear", () => { + task.setRuntimeThinkingEffort("high", "first") + task.setRuntimeThinkingEffort("medium", "second") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "medium", source: "second" }) + const lastSet = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastSet?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "medium" })) + + // Clearing rebuilds from the profile projection itself — the same object, not + // a copy carrying the intermediate effort. + task.setRuntimeThinkingEffort(undefined) + const lastClear = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastClear?.[0]).toBe(task.apiConfiguration) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + }) + + it("restores the settings-derived effort when cleared with undefined", () => { + task.setRuntimeThinkingEffort("max") + const lastSet = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastSet?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "max" })) + + // The handler that served the override must be disposed by the clearing swap. + const handlerUnderOverride = task.api + vi.mocked(disposeApiHandler).mockClear() + // A label passed on the clearing call must not survive into source. + task.setRuntimeThinkingEffort(undefined, "stale-source") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + // The profile effort survives the set/clear cycle with the rest of the + // configuration intact. + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + }), + ) + // The handler is rebuilt from the projection object itself, and the handler + // that served the override is gone. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toBe(task.apiConfiguration) + const disposedHandlers = vi.mocked(disposeApiHandler).mock.calls.map(([api]) => api) + expect(disposedHandlers.filter((api) => api === handlerUnderOverride)).toHaveLength(1) + }) + + it("never lets the override enter apiConfiguration (webview projection + profile save contract)", () => { + // ClineProvider.getStateToPostToWebview posts the active task's + // apiConfiguration object to the webview, and SettingsView returns it on + // save (upsertApiConfiguration -> saveConfig). If the override entered the + // projection, any settings save during an active override would persist the + // task-local effort into the selected profile. + task.setRuntimeThinkingEffort("xhigh", "test-ui") + + expect(task.apiConfiguration).toBe(mockApiConfiguration) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + + // A profile switch while active keeps the projection profile-pure too. + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key", + reasoningEffort: "medium", + } as ProviderSettings + task.updateApiConfiguration(newConfig) + expect(task.apiConfiguration).toBe(newConfig) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + }) + + it("is a no-op when cleared while inactive (no handler rebuild)", () => { + const callsBefore = vi.mocked(buildApiHandler).mock.calls.length + + task.setRuntimeThinkingEffort(undefined) + + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(callsBefore) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration).toBe(mockApiConfiguration) + }) + + it("never writes to the provider or persisted settings", () => { + task.setRuntimeThinkingEffort("xhigh") + task.setRuntimeThinkingEffort(undefined) + + // Nothing is posted to the webview and the handed-in settings object is intact. + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + }), + ) + }) + }) + + describe("updateApiConfiguration while an override is active", () => { + it("keeps the override applied over the new profile and restores the new profile on clear", () => { + 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 remains active... + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + // ...and the handler was rebuilt over the NEW profile with the override applied. + 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).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", () => { + // Key absence, not toEqual({}): toEqual ignores keys whose value is undefined, + // so only an absence assertion kills the always-consequent metadata mutant. + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).not.toHaveProperty("reasoningEffort") + + 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()).not.toHaveProperty("reasoningEffort") + }) + }) + + describe("disposal race (lifecycle guard)", () => { + it("drops a runtime-effort update once disposal has started", async () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + const access = getPrivateAccess(task) + const handlerAtDisposal = (task as unknown as { api: unknown }).api + const buildsAtDisposal = vi.mocked(buildApiHandler).mock.calls.length + vi.mocked(disposeApiHandler).mockClear() + + const disposing = task.dispose() + // Both handler-rebuilding entry points, called while disposal is in flight. + task.setRuntimeThinkingEffort("max", "during-disposal") + task.updateApiConfiguration({ ...mockApiConfiguration, apiModelId: "claude-sonnet-4-6" }) + + await disposing + + // No handler was constructed after the disposal snapshot and the task still points at + // the handler that snapshot collected, so nothing built during disposal is left without + // an owner, and the collected handler is disposed exactly once. + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(buildsAtDisposal) + expect((task as unknown as { api: unknown }).api).toBe(handlerAtDisposal) + const disposedHandlers = vi.mocked(disposeApiHandler).mock.calls.map(([api]) => api) + expect(disposedHandlers.filter((api) => api === handlerAtDisposal)).toHaveLength(1) + // The dropped call left no state behind: neither the override nor the projection moved. + expect(access.runtimeThinkingEffort).toBeUndefined() + expect(task.apiConfiguration.apiModelId).toBe("claude-opus-4-7") + }) + }) + + describe("request paths (all four metadata sites)", () => { + const MODEL_INFO: ModelInfo = { contextWindow: 50_000, maxTokens: 1024, supportsPromptCache: false } + + function oneChunkStream(text: string): AsyncGenerator { + return (async function* () { + yield { type: "text", text } + })() + } + + // Called after every rebuild: setRuntimeThinkingEffort replaces this.api, so a spy installed on + // the previous handler would never see the request. + function attachRequestDoubles() { + vi.spyOn(task.api, "getModel").mockReturnValue({ id: "dte-model", info: MODEL_INFO }) + Object.assign(task.api, { + ensureModelFetched: vi.fn().mockResolvedValue(undefined), + countTokens: vi.fn().mockResolvedValue(10), + }) + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + return vi.spyOn(task.api, "createMessage").mockImplementation(() => oneChunkStream("response")) + } + + const lastSummarizeMetadata = () => vi.mocked(summarizeConversation).mock.calls.at(-1)?.[0].metadata + + beforeEach(() => { + // Real timers: these paths await abort-aware delays that fake timers would park. + vi.useRealTimers() + mockProvider.getState = vi.fn().mockResolvedValue({ + mode: "code", + apiConfiguration: mockApiConfiguration, + autoApprovalEnabled: true, + autoCondenseContext: true, + autoCondenseContextPercent: 80, + requestDelaySeconds: 0, + profileThresholds: {}, + customModes: [], + experiments: {}, + disabledTools: [], + customSupportPrompts: {}, + }) + // 90% of the 50k window against an 80% threshold: the automatic trigger fires. + vi.spyOn(task, "getTokenUsage").mockReturnValue({ + totalCost: 0, + totalTokensIn: 0, + totalTokensOut: 0, + contextTokens: 45_000, + }) + vi.spyOn(getPrivateAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt") + task.apiConversationHistory = [{ role: "user", content: [{ type: "text", text: "task history" }], ts: 1 }] + }) + + it("carries the override into the manual condense request and drops it once cleared", async () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + attachRequestDoubles() + await task.condenseContext() + expect(lastSummarizeMetadata()?.reasoningEffort).toBe("xhigh") + + task.setRuntimeThinkingEffort(undefined) + attachRequestDoubles() + await task.condenseContext() + expect(lastSummarizeMetadata()).not.toHaveProperty("reasoningEffort") + }) + + it("carries the override into the forced context-management request and drops it once cleared", async () => { + const access = getPrivateAccess(task) + task.setRuntimeThinkingEffort("xhigh", "test-source") + attachRequestDoubles() + await access.handleContextWindowExceededError(MODEL_INFO) + expect(lastSummarizeMetadata()?.reasoningEffort).toBe("xhigh") + + task.setRuntimeThinkingEffort(undefined) + attachRequestDoubles() + await access.handleContextWindowExceededError(MODEL_INFO) + expect(lastSummarizeMetadata()).not.toHaveProperty("reasoningEffort") + }) + + it("carries the override into automatic context management and the request stream, and drops both", async () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + const createMessage = attachRequestDoubles() + + const iterator = task.attemptApiRequest(0, { skipProviderRateLimit: true }) + await iterator.next() + await iterator.return(undefined) + + expect(lastSummarizeMetadata()?.reasoningEffort).toBe("xhigh") + expect(createMessage.mock.calls.at(-1)?.[2]).toHaveProperty("reasoningEffort", "xhigh") + + task.setRuntimeThinkingEffort(undefined) + const cleared = attachRequestDoubles() + const secondIterator = task.attemptApiRequest(0, { skipProviderRateLimit: true }) + await secondIterator.next() + await secondIterator.return(undefined) + + expect(lastSummarizeMetadata()).not.toHaveProperty("reasoningEffort") + expect(cleared.mock.calls.at(-1)?.[2]).not.toHaveProperty("reasoningEffort") + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 464889418e..58037b37aa 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -289,6 +289,7 @@ vi.mock("../../task/Task", () => ({ apiConversationHistory: [], overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), + apiConfiguration: options?.apiConfiguration, getTaskNumber: vi.fn().mockReturnValue(0), setTaskNumber: vi.fn(), setParentTask: vi.fn(), @@ -437,6 +438,7 @@ describe("ClineProvider", () => { apiConversationHistory: [], overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), + apiConfiguration: options?.apiConfiguration, getTaskNumber: vi.fn().mockReturnValue(0), setTaskNumber: vi.fn(), setParentTask: vi.fn(), @@ -4040,6 +4042,36 @@ describe("ClineProvider", () => { }, ) + test("getStateToPostToWebview posts the active task's apiConfiguration object to the webview", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setProviderSettings({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + reasoningEffort: "low", + }) + + const taskConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + reasoningEffort: "high", + } as ProviderSettings + const mockCline = new Task({ ...defaultTaskOptions, apiConfiguration: taskConfiguration }) + await provider.addClineToStack(mockCline) + + try { + // The webview projection is the ACTIVE TASK's apiConfiguration object itself: + // SettingsView buffers it into cachedState and returns it on save + // (upsertApiConfiguration -> saveConfig). That is why the task-local thinking + // effort override must never enter apiConfiguration (asserted at the Task + // layer in Task.runtime-thinking-effort.test.ts): a settings save during an + // active override would otherwise persist the override into the profile. + const posted = await provider.getStateToPostToWebview() + expect(posted.apiConfiguration).toBe(taskConfiguration) + } finally { + await provider.removeClineFromStack() + } + }) + describe("display settings written through setValues and a task's configuration", () => { const hand = (values: Record) => values as unknown as RooCodeSettings