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 90cf7bf7fb..a00863f253 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, @@ -418,6 +419,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 @@ -2489,6 +2494,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 { @@ -2496,9 +2507,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 @@ -2515,6 +2543,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. @@ -2694,6 +2792,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) @@ -3629,6 +3729,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 @@ -3740,6 +3844,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 } @@ -4286,7 +4400,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, ) @@ -4339,7 +4452,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 { @@ -4356,7 +4468,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( @@ -4365,7 +4476,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. @@ -4784,7 +4894,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) @@ -4801,13 +4910,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) @@ -5501,6 +5608,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // Carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } try { @@ -5779,6 +5888,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. @@ -5948,6 +6059,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 9826fc206b..7e2702da9f 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