diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 30d3852b0a..92ad617aa6 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -627,3 +627,192 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) } + +// --------------------------------------------------------------------------- +// DTE series 5/5 — new_task thinking_effort pass-through (e2e). +// +// Three scenarios with unique, stable markers (no timestamps, no environment +// details): +// - INHERIT: the parent's new_task call carries NO thinking_effort — the child +// starts with the parent's current effective effort (PR-2 resolution) and the +// child's real request must carry it. +// - EXPLICIT: the parent's new_task call carries thinking_effort "high" on a +// model with a capability array — validation passes and the child subtask +// runs to completion on the real host. +// - NEGATIVE: the parent's new_task call carries thinking_effort on a model +// without a capability array — the tool rejects before the approval ask, no +// child is created, and the error is visible to the model. +export const DTE_NT_INHERIT_PARENT_MARKER = "DTE_E2E_NT_INHERIT_PARENT" +export const DTE_NT_INHERIT_CHILD_MARKER = "DTE_E2E_NT_INHERIT_CHILD" +const DTE_NT_INHERIT_CHILD_PROMPT = `${DTE_NT_INHERIT_CHILD_MARKER}: Complete immediately with the exact result "DTE inherit child completed".` +export const DTE_NT_INHERIT_PARENT_PROMPT = `${DTE_NT_INHERIT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${DTE_NT_INHERIT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "DTE inherit parent resumed".` +export const DTE_NT_INHERIT_CHILD_RESULT = "DTE inherit child completed" +export const DTE_NT_INHERIT_PARENT_RESULT = "DTE inherit parent resumed" + +const DTE_NT_EXPLICIT_PARENT_MARKER = "DTE_E2E_NT_EXPLICIT_PARENT" +const DTE_NT_EXPLICIT_CHILD_MARKER = "DTE_E2E_NT_EXPLICIT_CHILD" +const DTE_NT_EXPLICIT_CHILD_PROMPT = `${DTE_NT_EXPLICIT_CHILD_MARKER}: Complete immediately with the exact result "DTE explicit child completed".` +export const DTE_NT_EXPLICIT_PARENT_PROMPT = `${DTE_NT_EXPLICIT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${DTE_NT_EXPLICIT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "DTE explicit parent resumed".` +export const DTE_NT_EXPLICIT_CHILD_RESULT = "DTE explicit child completed" +export const DTE_NT_EXPLICIT_PARENT_RESULT = "DTE explicit parent resumed" + +export const DTE_NT_NEGATIVE_PARENT_MARKER = "DTE_E2E_NT_NEGATIVE_PARENT" +const DTE_NT_NEGATIVE_CHILD_MARKER = "DTE_E2E_NT_NEGATIVE_CHILD" +const DTE_NT_NEGATIVE_CHILD_PROMPT = `${DTE_NT_NEGATIVE_CHILD_MARKER}: Complete immediately with the exact result "DTE negative child completed".` +export const DTE_NT_NEGATIVE_PARENT_PROMPT = `${DTE_NT_NEGATIVE_PARENT_MARKER}: Use the new_task tool exactly once, with thinking_effort set to "high". Create an ask-mode subtask with this exact message: "${DTE_NT_NEGATIVE_CHILD_PROMPT}" Do not answer directly. If the tool call is rejected, complete with the exact result "DTE negative parent completed".` +export const DTE_NT_NEGATIVE_PARENT_RESULT = "DTE negative parent completed" + +export function addDteNewTaskEffortFixtures(mock: InstanceType) { + // INHERIT: parent turn -> new_task without an explicit effort. + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_INHERIT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_INHERIT_CHILD_PROMPT, + }), + id: "call_dte_nt_inherit_new_task_001", + }, + ], + }, + }) + + // Child turn: the child prompt is embedded verbatim in the parent prompt, so the + // parent-marker exclusion keeps parent turns out of this fixture (same collision + // class as the fast-child fixture above). + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, DTE_NT_INHERIT_CHILD_MARKER) && + !requestContains(req, [DTE_NT_INHERIT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_INHERIT_CHILD_RESULT }), + id: "call_dte_nt_inherit_child_completion_002", + }, + ], + }, + }) + + // Parent resume turn: guarded on the child-result injection (not the child result + // text, which the parent prompt embeds verbatim). + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_INHERIT_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_INHERIT_PARENT_RESULT }), + id: "call_dte_nt_inherit_parent_completion_003", + }, + ], + }, + }) + + // EXPLICIT: parent turn -> new_task with thinking_effort "high" (valid on models + // whose capability array accepts it, e.g. deepseek-v4-pro). + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_EXPLICIT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_EXPLICIT_CHILD_PROMPT, + thinking_effort: "high", + }), + id: "call_dte_nt_explicit_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, DTE_NT_EXPLICIT_CHILD_MARKER) && + !requestContains(req, [DTE_NT_EXPLICIT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_EXPLICIT_CHILD_RESULT }), + id: "call_dte_nt_explicit_child_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_EXPLICIT_PARENT_MARKER, SUBTASK_RESULT_INJECTION]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_EXPLICIT_PARENT_RESULT }), + id: "call_dte_nt_explicit_parent_completion_003", + }, + ], + }, + }) + + // NEGATIVE: parent turn -> new_task with thinking_effort "high" on a model without a + // capability array. The tool rejects before the approval ask, so the next parent + // turn is the error-recovery completion (matched on the tool-error text, which only + // appears in a request after the rejected call). + mock.addFixture({ + match: { + userMessage: new RegExp(DTE_NT_NEGATIVE_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: DTE_NT_NEGATIVE_CHILD_PROMPT, + thinking_effort: "high", + }), + id: "call_dte_nt_negative_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [DTE_NT_NEGATIVE_PARENT_MARKER, "Invalid thinking_effort"]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: DTE_NT_NEGATIVE_PARENT_RESULT }), + id: "call_dte_nt_negative_parent_completion_002", + }, + ], + }, + }) +} diff --git a/apps/vscode-e2e/src/fixtures/thinking-effort.ts b/apps/vscode-e2e/src/fixtures/thinking-effort.ts new file mode 100644 index 0000000000..736159a4d4 --- /dev/null +++ b/apps/vscode-e2e/src/fixtures/thinking-effort.ts @@ -0,0 +1,159 @@ +import { LLMock } from "@copilotkit/aimock" +import type { ChatCompletionRequest } from "@copilotkit/aimock" + +// DTE e2e fixtures for the thinking-effort-tool and thinking-effort-switching +// suites. Replaces the former JSON fixtures (fixtures/thinking-effort-*.json): +// post-tool requests end with a role:user message (fresh +// is appended after the tool result), so aimock's toolCallId matcher — which +// requires the LAST message to be role:tool — can never bind those continuation +// turns, and JSON fixtures cannot carry predicates. Each turn is instead scoped +// by a predicate that searches the whole request for its own flow identifiers: +// the unique prompt marker for the baseline turn and the previous turn's unique +// tool call id for the continuations (same pattern as deepseek-v4.ts). No other +// suite can serve these responses and these suites cannot match unrelated turns. + +const SWITCH_MODEL = "openai/gpt-5.1" +const APPLY_MODEL = "openai/gpt-5" +const SWITCH_MARKER = "DTE_E2E_SWITCH" +const APPLY_MARKER = "DTE_E2E_EFFORT_APPLY" +const SWITCH_DONE = "DTE_E2E_SWITCH_DONE" + +// Post-tool requests carry the tool result of the PREVIOUS turn and nothing +// appends another tool result before the next API call, so the LAST role:tool +// message is exactly the call whose result this request follows. +const lastToolCallId = (req: ChatCompletionRequest): string | undefined => { + const messages = Array.isArray(req?.messages) ? req.messages : [] + return messages.filter((message) => message?.role === "tool").at(-1)?.tool_call_id +} + +// aimock's userMessage matcher only inspects the LAST user message and joins +// only the type:"text" content parts (getTextContent in aimock's router) — the +// predicate replicates that semantics for the baseline turns. +const lastUserMessageContains = (req: ChatCompletionRequest, text: string): boolean => { + const userMessages = req.messages?.filter((message) => message.role === "user") ?? [] + const last = userMessages.at(-1) + if (!last) return false + const content = + typeof last.content === "string" + ? last.content + : (last.content ?? []) + .filter((part): part is { type: "text"; text: string } => part?.type === "text") + .map((part) => part.text) + .join("") + return content.includes(text) +} + +export function addThinkingEffortFixtures(mock: InstanceType) { + // --- thinking-effort-switching suite (openai/gpt-5.1) --- + // Baseline turn: bound to this suite's unique prompt marker. + mock.addFixture({ + match: { + model: SWITCH_MODEL, + predicate: (req: ChatCompletionRequest) => lastUserMessageContains(req, SWITCH_MARKER), + }, + response: { + toolCalls: [ + { + name: "set_thinking_effort", + arguments: JSON.stringify({ effort: "medium", reason: "start at medium" }), + id: "call_dte_sw_001", + }, + ], + }, + }) + + // Continuations: each turn binds to the previous turn's unique tool call id, + // so a future flow on the same model cannot serve these responses. + const switchingContinuations: Array<{ + afterCallId: string + effort: string + reason: string + responseCallId: string + }> = [ + { + afterCallId: "call_dte_sw_001", + effort: "medium", + reason: "confirm current level", + responseCallId: "call_dte_sw_002", + }, + { afterCallId: "call_dte_sw_002", effort: "high", reason: "raise to high", responseCallId: "call_dte_sw_003" }, + { + afterCallId: "call_dte_sw_003", + effort: "medium", + reason: "try returning to medium", + responseCallId: "call_dte_sw_004", + }, + ] + for (const continuation of switchingContinuations) { + mock.addFixture({ + match: { + model: SWITCH_MODEL, + predicate: (req: ChatCompletionRequest) => lastToolCallId(req) === continuation.afterCallId, + }, + response: { + toolCalls: [ + { + name: "set_thinking_effort", + arguments: JSON.stringify({ + effort: continuation.effort, + reason: continuation.reason, + }), + id: continuation.responseCallId, + }, + ], + }, + }) + } + + // Final turn: after the refused oscillation call, the task completes. + mock.addFixture({ + match: { + model: SWITCH_MODEL, + predicate: (req: ChatCompletionRequest) => lastToolCallId(req) === "call_dte_sw_004", + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SWITCH_DONE }), + id: "call_dte_sw_005", + }, + ], + }, + }) + + // --- thinking-effort-tool suite (openai/gpt-5) --- + // Baseline turn: bound to this suite's unique prompt marker. + mock.addFixture({ + match: { + model: APPLY_MODEL, + predicate: (req: ChatCompletionRequest) => lastUserMessageContains(req, APPLY_MARKER), + }, + response: { + toolCalls: [ + { + name: "set_thinking_effort", + arguments: JSON.stringify({ effort: "high", reason: "multi-step math" }), + id: "call_dte_e2e_001", + }, + ], + }, + }) + + // Continuation: binds to the set_thinking_effort call's unique tool call id. + mock.addFixture({ + match: { + model: APPLY_MODEL, + predicate: (req: ChatCompletionRequest) => lastToolCallId(req) === "call_dte_e2e_001", + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: "42" }), + id: "call_dte_e2e_002", + }, + ], + }, + }) +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index 8162f34068..2c89f285c6 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -18,7 +18,8 @@ import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile" import { addListFilesResultFixtures } from "./fixtures/list-files" import { addReadFileResultFixtures } from "./fixtures/read-file" import { addSearchFilesResultFixtures } from "./fixtures/search-files" -import { addSubtaskFixtures } from "./fixtures/subtasks" +import { addDteNewTaskEffortFixtures, addSubtaskFixtures } from "./fixtures/subtasks" +import { addThinkingEffortFixtures } from "./fixtures/thinking-effort" import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool" import { addWriteToFileResultFixtures } from "./fixtures/write-to-file" import { createScenarioWorkspace, removeScenarioWorkspace } from "./restart/scenarioWorkspace" @@ -140,6 +141,8 @@ async function main() { addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) addSubtaskFixtures(mock) + addDteNewTaskEffortFixtures(mock) + addThinkingEffortFixtures(mock) addUseMcpToolResultFixtures(mock) addWriteToFileResultFixtures(mock) addDeepSeekV4Fixtures(mock) diff --git a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts new file mode 100644 index 0000000000..d96fac2873 --- /dev/null +++ b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts @@ -0,0 +1,512 @@ +import * as assert from "assert" +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { + DTE_NT_EXPLICIT_CHILD_RESULT, + DTE_NT_EXPLICIT_PARENT_PROMPT, + DTE_NT_EXPLICIT_PARENT_RESULT, + DTE_NT_INHERIT_CHILD_MARKER, + DTE_NT_INHERIT_CHILD_RESULT, + DTE_NT_INHERIT_PARENT_MARKER, + DTE_NT_INHERIT_PARENT_PROMPT, + DTE_NT_INHERIT_PARENT_RESULT, + DTE_NT_NEGATIVE_PARENT_MARKER, + DTE_NT_NEGATIVE_PARENT_PROMPT, + DTE_NT_NEGATIVE_PARENT_RESULT, +} from "../fixtures/subtasks" +import { setDefaultSuiteTimeout } from "./test-utils" +import { sleep, waitFor, waitUntilCompleted } from "./utils" + +// Wire-boundary capture (modeled on anthropic-opus-4-7.test.ts): a local 127.0.0.1 +// proxy in front of the Anthropic base URL records every /v1/messages request body +// before forwarding it to the upstream (the aimock server in mock mode). Assertions +// below therefore run against the real request the extension host actually sent. +type CapturedEffortRequest = { + model?: string + thinkingType?: string + outputConfigEffort?: string + lastUserMessage: string + // The full request body as sent over the wire. Lets assertions check + // model-visible content (e.g. tool results) that is not part of the + // last user message. + rawBody: string +} + +const ANTHROPIC_MESSAGES_PATH = "/v1/messages" +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "host", + "content-length", +]) + +function isMessagesUrl(rawUrl: string): boolean { + try { + return new URL(rawUrl).pathname.endsWith(ANTHROPIC_MESSAGES_PATH) + } catch { + return false + } +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +function writeResponseHeaders(target: ServerResponse, source: Response) { + const headers: Record = {} + source.headers.forEach((value, key) => { + const lower = key.toLowerCase() + // fetch() automatically decompresses the body, so strip content-encoding to + // prevent the SDK from attempting a second decompression (zlib "incorrect + // header check"). Also strip content-length since the decoded body length + // differs from the compressed length. + if (lower !== "content-length" && lower !== "content-encoding") { + headers[key] = value + } + }) + target.writeHead(source.status, headers) +} + +async function pipeFetchResponse(target: ServerResponse, source: Response) { + writeResponseHeaders(target, source) + + if (!source.body) { + target.end() + return + } + + const reader = source.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + target.write(value) + } + + target.end() +} + +function resolveAllowedUpstreamUrl(baseUrl: string): URL { + const upstreamBase = new URL(baseUrl) + const isLocalProxy = upstreamBase.hostname === "127.0.0.1" || upstreamBase.hostname === "localhost" + const isLocalHttp = isLocalProxy && upstreamBase.protocol === "http:" + const isAnthropicUpstream = upstreamBase.origin === "https://api.anthropic.com" + + if (!isLocalHttp && !isAnthropicUpstream) { + throw new Error("Unexpected Anthropic proxy target: " + upstreamBase.origin) + } + + return new URL(ANTHROPIC_MESSAGES_PATH, upstreamBase) +} + +async function withEffortProxy( + baseUrl: string, + run: (args: { proxyUrl: string; requests: CapturedEffortRequest[] }) => Promise, +): Promise { + const requests: CapturedEffortRequest[] = [] + let proxyError: Error | undefined + const server = createServer(async (req, res) => { + try { + const requestUrl = req.url ?? "/" + + if (!isMessagesUrl("http://127.0.0.1" + requestUrl)) { + res.writeHead(404) + res.end("Not found") + return + } + + const bodyText = await readRequestBody(req) + const body = JSON.parse(bodyText) as { + model?: string + thinking?: { type?: string } + output_config?: { effort?: string } + messages?: Array<{ role?: string; content?: unknown }> + } + + const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user") + const lastUserMessage = + typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "") + + requests.push({ + model: body.model, + thinkingType: body.thinking?.type, + outputConfigEffort: body.output_config?.effort, + lastUserMessage, + rawBody: bodyText, + }) + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && typeof value === "string") { + forwardHeaders[key] = value + } + } + + const upstreamUrl = resolveAllowedUpstreamUrl(baseUrl) + const upstream = await fetch(upstreamUrl, { + method: req.method, + headers: forwardHeaders, + body: bodyText, + }) + + await pipeFetchResponse(res, upstream) + } catch (error) { + proxyError = error instanceof Error ? error : new Error(String(error)) + console.error("Effort proxy request failed:", proxyError) + res.writeHead(500) + res.end("Effort proxy request failed") + } + }) + + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) + const address = server.address() + if (!address || typeof address === "string") { + server.close() + throw new Error("Failed to start effort proxy server") + } + + const proxyUrl = "http://127.0.0.1:" + address.port + + try { + const result = await run({ proxyUrl, requests }) + if (proxyError) { + throw proxyError + } + return result + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} + +// Restore the OpenRouter default config after this suite so other suites are unaffected. +const restoreOpenRouterConfig = async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: aimockUrl + "/v1" }), + // This suite switches the profile to the Anthropic provider with an + // ephemeral proxy base URL and sets the global reasoning-effort fields. + // saveConfig is a full profile replacement, so the persisted profile is + // clean either way; the explicit clears also reset the in-memory provider + // settings, so a later suite selecting the anthropic provider is not + // pointed at the (closed) local port and does not inherit this suite's + // effort baseline. + anthropicBaseUrl: undefined, + apiModelId: undefined, + enableReasoningEffort: undefined, + reasoningEffort: undefined, + }) +} + +suite("new_task thinking effort (DTE series 5/5)", function () { + setDefaultSuiteTimeout(this) + + suiteTeardown(restoreOpenRouterConfig) + + // (b) Inheritance: a new_task call without thinking_effort starts the child with the + // parent's current effective effort (PR-2 resolution: no task-local override is + // reachable in e2e before DTE series 3/5, so the settings value "medium" is the + // strongest source). The child's real /v1/messages request must carry that effort. + test("child started without explicit effort carries the parent's effective effort", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) { + this.skip() + } + + await withEffortProxy(aimockUrl || "https://api.anthropic.com", async ({ proxyUrl, requests }) => { + await api.setConfiguration({ + apiProvider: "anthropic" as const, + apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!, + apiModelId: "claude-opus-4-7", + enableReasoningEffort: true, + reasoningEffort: "medium", + anthropicBaseUrl: proxyUrl, + }) + + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_INHERIT_PARENT_PROMPT, + }) + + // Wait for the child's real request to reach the proxy: an immediate child is + // only observable while its first request is in flight (the parent instance is + // disposed on delegation and re-instantiated on resume, so the UI task stack is + // not a reliable child-liveness signal here). + await waitFor( + () => requests.some((request) => request.lastUserMessage.includes(DTE_NT_INHERIT_CHILD_MARKER)), + { timeout: 45_000 }, + ) + + // The parent's completion is the terminal event of the whole flow. + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 60_000 }) + + assert.ok( + Object.entries(says).some( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === DTE_NT_INHERIT_CHILD_RESULT, + ), + ), + "Immediately-completing child should emit its expected result", + ) + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_INHERIT_PARENT_RESULT, + "Parent should resume after the child completes", + ) + + // Wire assertion: the child's real request (identified by the child prompt + // marker in its last user message) carries the parent's effective effort. + const childRequests = requests.filter((request) => + request.lastUserMessage.includes(DTE_NT_INHERIT_CHILD_MARKER), + ) + assert.ok(childRequests.length > 0, "The child subtask should issue a real API request") + const firstChildRequest = childRequests[0] + assert.ok(firstChildRequest, "Child request should be captured by the proxy") + assert.strictEqual(firstChildRequest.model, "claude-opus-4-7") + assert.strictEqual( + firstChildRequest.thinkingType, + "adaptive", + "The child request should be an adaptive-thinking request", + ) + assert.strictEqual( + firstChildRequest.outputConfigEffort, + "medium", + "The child's request should carry the parent's current effective effort (DTE series 5/5 inheritance via PR-2 resolution)", + ) + + // Control: the parent's own first request carries the same settings-derived + // baseline, confirming the envelope is resolved identically on both sides. + const parentRequests = requests.filter((request) => + request.lastUserMessage.includes(DTE_NT_INHERIT_PARENT_MARKER), + ) + assert.ok(parentRequests.length > 0, "The parent should issue a real API request") + const firstParentRequest = parentRequests[0] + assert.ok(firstParentRequest, "Parent request should be captured by the proxy") + assert.strictEqual(firstParentRequest.outputConfigEffort, "medium") + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) + }) + + // (a) Explicit effort: a new_task call with thinking_effort "high" on a model whose + // capability array accepts it (deepseek-v4-pro: ["disable","low","high","max"]). The + // parameter round-trips schema -> validation -> approval -> delegation and the child + // subtask runs to completion on the real host. + // + // No wire assertion here: the DeepSeek handler resolves the request effort from + // settings only and does not consume the per-request override — and the only handler + // that does consume it (Anthropic) serves catalog models without a capability array, + // so no model today both passes the DTE 5/5 validation and propagates an explicit + // effort to the wire. Documented in the PR body. + test("explicit thinking_effort delegates a child subtask that completes", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.DEEPSEEK_API_KEY) { + this.skip() + } + + await api.setConfiguration({ + apiProvider: "deepseek" as const, + deepSeekApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.DEEPSEEK_API_KEY!, + ...(aimockUrl && { deepSeekBaseUrl: aimockUrl + "/v1" }), + apiModelId: "deepseek-v4-pro", + // Reasoning off for this probe: the test is about the subtask flow carrying + // the explicit effort parameter, not about the reasoning envelope. + enableReasoningEffort: false, + }) + + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_EXPLICIT_PARENT_PROMPT, + }) + + // The parent's completion is the terminal event of the whole flow (the child + // completes on its first response, so its own lifecycle is covered by the + // completion_result assertions below — same pattern as the fast-child test). + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 75_000 }) + + assert.ok( + Object.entries(says).some( + ([taskId, messages]) => + taskId !== parentTaskId && + messages.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === DTE_NT_EXPLICIT_CHILD_RESULT, + ), + ), + "Explicit-effort child should emit its expected result", + ) + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_EXPLICIT_PARENT_RESULT, + "Parent should resume after the explicit-effort child completes", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) + + // (a) Negative guard: an explicit effort on a model without a capability array is + // rejected by the tool before the approval ask — no child is created and the model + // sees the tool error. claude-opus-4-7 has supportsReasoningBinary (adaptive + // thinking) but no effort capability array, so "high" must be refused. + test("explicit thinking_effort on a capability-less model is rejected without creating a child", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + + if (!aimockUrl && !process.env.ANTHROPIC_API_KEY) { + this.skip() + } + + // The rejected tool call's error reaches the model as a tool_result in the + // parent's follow-up request (the extension emits no user-visible message for + // tool results), so this flow runs through the capturing proxy and the + // visibility assertion runs against the captured wire request. + await withEffortProxy(aimockUrl || "https://api.anthropic.com", async ({ proxyUrl, requests }) => { + await api.setConfiguration({ + apiProvider: "anthropic" as const, + apiKey: aimockUrl && !isRecord ? "mock-key" : process.env.ANTHROPIC_API_KEY!, + apiModelId: "claude-opus-4-7", + anthropicBaseUrl: proxyUrl, + }) + + const says: Record = {} + const seenTaskIds = new Set() + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + seenTaskIds.add(taskId) + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + let parentTaskId: string | undefined + + try { + parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: DTE_NT_NEGATIVE_PARENT_PROMPT, + }) + + await waitUntilCompleted({ api, taskId: parentTaskId, timeout: 60_000 }) + + assert.strictEqual( + says[parentTaskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(), + DTE_NT_NEGATIVE_PARENT_RESULT, + "Parent should complete after the rejected tool call", + ) + assert.strictEqual( + seenTaskIds.size, + 1, + "No child subtask should be created for a rejected thinking_effort (task ids: " + + [...seenTaskIds].join(", ") + + ")", + ) + + // Wire assertion: the rejection must be visible to the model in the + // parent's own follow-up request (tool_result content) — a request + // carrying both the parent marker and the tool-error text. + const errorRequests = requests.filter( + (request) => + request.rawBody.includes("Invalid thinking_effort") && + request.rawBody.includes(DTE_NT_NEGATIVE_PARENT_MARKER), + ) + assert.ok( + errorRequests.length > 0, + "The rejected thinking_effort tool error should be visible to the model on the wire", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) + }) +}) diff --git a/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts b/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts new file mode 100644 index 0000000000..0a8ae434e2 --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-proxy.ts @@ -0,0 +1,212 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +/** + * Shared loopback capture proxy for the DTE e2e suites + * (thinking-effort-tool / thinking-effort-switching). + * + * Pattern from anthropic-opus-4-7.test.ts: it intercepts the + * OpenRouter-compatible chat/completions POST so request shapes can be + * asserted (model, reasoning envelope, message content), then forwards the + * request unchanged to the upstream — aimock in replay/record mode — which + * answers with the fixture-driven SSE. + */ + +export type DteReasoningEnvelope = { + effort?: string + max_tokens?: number + exclude?: boolean +} + +export type CapturedDteRequest = { + model?: string + reasoning: DteReasoningEnvelope | undefined + /** Raw JSON body, so assertions can inspect any part of the wire request (e.g. tool result text). */ + bodyText: string + lastUserMessage: string +} + +type OpenRouterChatCompletionBody = { + model?: string + reasoning?: DteReasoningEnvelope + messages?: Array<{ role?: string; content?: unknown }> +} + +const ALLOWED_PROXY_HOSTS = new Set(["127.0.0.1", "localhost"]) +const CHAT_COMPLETIONS_PATH = "/v1/chat/completions" +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "trailer", + "upgrade", + "proxy-connection", + "proxy-authenticate", + "proxy-authorization", + "host", + "content-length", +]) + +/** + * Whether a raw URL targets the OpenRouter-compatible chat/completions endpoint. + */ +function isChatCompletionsUrl(rawUrl: string): boolean { + try { + return new URL(rawUrl).pathname.endsWith(CHAT_COMPLETIONS_PATH) + } catch { + return false + } +} + +/** + * Collects the full request body as a UTF-8 string. + */ +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +/** + * Mirrors the upstream response headers onto the proxy response, dropping the + * headers that would break fetch()-decoded streaming (content-encoding / length). + */ +function writeResponseHeaders(target: ServerResponse, source: Response) { + const headers: Record = {} + source.headers.forEach((value, key) => { + const lower = key.toLowerCase() + // fetch() automatically decompresses the body, so strip content-encoding to + // prevent the SDK from attempting a second decompression. Also strip + // content-length since the decoded body length differs from the compressed one. + if (lower !== "content-length" && lower !== "content-encoding") { + headers[key] = value + } + }) + target.writeHead(source.status, headers) +} + +/** + * Streams the upstream (already-decoded) fetch body through to the proxy + * response, ending the response when the body completes. + */ +async function pipeFetchResponse(target: ServerResponse, source: Response) { + writeResponseHeaders(target, source) + + if (!source.body) { + target.end() + return + } + + const reader = source.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + target.write(value) + } + + target.end() +} + +/** + * Resolves the upstream chat/completions URL, rejecting any target that is not + * a loopback HTTP origin (the proxy must never forward to a real endpoint). + */ +function resolveAllowedUpstreamUrl(baseUrl: string): URL { + const upstreamBase = new URL(baseUrl) + + if (!ALLOWED_PROXY_HOSTS.has(upstreamBase.hostname) || upstreamBase.protocol !== "http:") { + throw new Error("Unexpected OpenRouter proxy target: " + upstreamBase.origin) + } + + return new URL(CHAT_COMPLETIONS_PATH, upstreamBase) +} + +/** + * Serves a loopback capture proxy for the OpenRouter-compatible + * chat/completions endpoint: captures each request body for assertions and + * forwards it unchanged to the upstream (aimock in replay/record mode). + */ +export async function withOpenRouterCaptureProxy( + upstreamUrl: string, + run: (args: { proxyUrl: string; requests: CapturedDteRequest[] }) => Promise, +): Promise { + const requests: CapturedDteRequest[] = [] + const upstreamTarget = resolveAllowedUpstreamUrl(upstreamUrl) + let proxyError: Error | undefined + + const server = createServer(async (req, res) => { + try { + const requestUrl = req.url ?? "/" + + if (!isChatCompletionsUrl("http://127.0.0.1" + requestUrl)) { + res.writeHead(404) + res.end("Not found") + return + } + + const bodyText = await readRequestBody(req) + const body = JSON.parse(bodyText) as OpenRouterChatCompletionBody + const lastUser = [...(body.messages ?? [])].reverse().find((message) => message.role === "user") + const lastUserMessage = + typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "") + + requests.push({ + model: body.model, + reasoning: body.reasoning, + bodyText, + lastUserMessage, + }) + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase()) && value !== undefined) { + forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value + } + } + + const upstream = await fetch(upstreamTarget, { + method: req.method, + headers: forwardHeaders, + body: bodyText, + }) + + await pipeFetchResponse(res, upstream) + } catch (error) { + proxyError = error instanceof Error ? error : new Error(String(error)) + console.error("OpenRouter proxy request failed:", proxyError) + if (!res.headersSent) { + res.writeHead(502) + res.end("Capture proxy error") + } else if (!res.writableEnded) { + res.destroy() + } + } + }) + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()) + }) + + const address = server.address() + if (address === null || typeof address === "string") { + server.close() + throw new Error("Capture proxy failed to bind a loopback port") + } + + const proxyUrl = "http://127.0.0.1:" + address.port + + try { + const result = await run({ proxyUrl, requests }) + if (proxyError) { + throw proxyError + } + return result + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} diff --git a/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts new file mode 100644 index 0000000000..cb9891f678 --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts @@ -0,0 +1,263 @@ +import * as assert from "assert" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { withOpenRouterCaptureProxy, type CapturedDteRequest } from "./thinking-effort-proxy" +import { setDefaultSuiteTimeout } from "./test-utils" +import { waitUntilCompleted, waitFor } from "./utils" + +/** + * DTE addendum: set_thinking_effort switching within a single task. + * + * Complements thinking-effort-tool.test.ts (single apply) by driving one task + * through a scripted switching sequence and asserting the per-request wire + * envelope after every call: + * + * baseline (settings reasoningEffort "low") + * -> set "medium" applied -> next request sends { effort: "medium" } + * -> set "medium" no-op -> next request still "medium" (result: "already 'medium'", no display say) + * -> set "high" applied -> next request sends { effort: "high" } + * -> set "medium" refused -> next request still "high" (A -> B -> A oscillation refusal) + * -> attempt_completion + * + * Determinism notes: + * - Runs against aimock only (replay or record); skips when aimock is absent, + * so no API keys are required. + * - The capture proxy is shared with thinking-effort-tool.test.ts via + * ./thinking-effort-proxy: it intercepts the OpenRouter-compatible + * chat/completions POST (so request shapes can be asserted) and forwards it + * to aimock for the fixture-driven SSE responses. + * - Model: openai/gpt-5.1, which advertises "reasoning" in supported_parameters + * in the public OpenRouter catalog, so the model-cache fetcher resolves + * supportsReasoningEffort: true and the dynamicThinkingEffort gate exposes + * the tool. The suite picks a model that no other fixture file uses, and the + * fixtures are scoped by model, so the two DTE suites cannot cross-match. + * - Baseline: the suite sets reasoningEffort "low" explicitly. setConfiguration + * replaces the whole provider profile (ProviderSettingsManager.saveConfig), + * so the baseline is deterministic and cannot inherit state from other + * suites; "low" is distinct from every level the tool applies here. + * - Fixture matching (apps/vscode-e2e/src/fixtures/thinking-effort.ts): + * post-tool requests end with a role:user message (fresh environment details + * are appended after the tool result), so aimock's toolCallId matcher — which + * requires the LAST message to be role:tool — can never bind the continuation + * turns, and a JSON fixture cannot carry a predicate. The fixtures live in a + * JS module added via addThinkingEffortFixtures (same pattern as + * deepseek-v4.ts): the baseline turn binds to this suite's unique prompt + * marker ("DTE_E2E_SWITCH"), and every continuation binds to the previous + * turn's unique tool call id (call_dte_sw_001 → 002 → 003 → 004 → + * completion), so no other suite can serve these responses and this suite + * cannot match unrelated turns. + */ + +const SWITCH_MODEL_ID = "openai/gpt-5.1" +const BASELINE_EFFORT = "low" +const SWITCH_MARKER = "DTE_E2E_SWITCH" +const COMPLETION_EXPECTED = "DTE_E2E_SWITCH_DONE" + +// Tool call ids; the first request whose body carries call N is the request +// made right after call N executed, so its reasoning envelope reflects N's outcome. +const CALL_APPLY_MEDIUM = "call_dte_sw_001" +const CALL_NOOP_MEDIUM = "call_dte_sw_002" +const CALL_APPLY_HIGH = "call_dte_sw_003" +const CALL_REFUSED_MEDIUM = "call_dte_sw_004" + +type ThinkingEffortSay = { + tool?: string + effort?: string + reason?: string + refusal?: string +} + +/** + * Finds the first captured wire request whose body carries the given tool + * call id — i.e. the post-tool request that follows a specific tool call. + */ +function firstRequestCarrying(requests: CapturedDteRequest[], callId: string): CapturedDteRequest | undefined { + return requests.find((request) => request.bodyText.includes(callId)) +} + +suite("set_thinking_effort switching within a task (DTE addendum)", function () { + setDefaultSuiteTimeout(this) + + // Restore the provider profile defaults so subsequent suites are unaffected. + // setConfiguration merges per-key into ContextProxy (setValues -> + // updateGlobalState), so the experiment flag and this suite's reasoning + // envelope (enableReasoningEffort + baseline reasoningEffort) are explicitly + // cleared below. + suiteTeardown(async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + experiments: { dynamicThinkingEffort: false }, + // Clear this suite's reasoning envelope so later suites do not inherit + // a persisted enableReasoningEffort / reasoningEffort pair (undefined + // deletes the key via Memento.update semantics). + enableReasoningEffort: false, + reasoningEffort: undefined, + }) + }) + + test("Should apply and refuse effort switches, updating the wire envelope only on applied changes", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + + // Deterministic, key-free: aimock replay/record only. A live run would need + // a real model that deterministically emits the scripted switching sequence. + if (!aimockUrl) { + this.skip() + } + + await withOpenRouterCaptureProxy(aimockUrl, async ({ proxyUrl, requests }) => { + // OpenRouter provider, a model that advertises per-request reasoning effort + // (public catalog: "reasoning" in supported_parameters), the + // dynamicThinkingEffort experiment enabled, and an explicit baseline effort so + // the baseline request carries a deterministic { effort: "low" } envelope. + await api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: "mock-key", + openRouterModelId: SWITCH_MODEL_ID, + openRouterBaseUrl: `${proxyUrl}/v1`, + enableReasoningEffort: true, + reasoningEffort: BASELINE_EFFORT, + experiments: { dynamicThinkingEffort: true }, + }) + + const messages: ClineMessage[] = [] + const onMessage = ({ message }: { message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + messages.push(message) + } + } + api.on(RooCodeEventName.Message, onMessage) + + const taskId = await api.startNewTask({ + configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, + text: SWITCH_MARKER + ": manage the thinking effort for this task", + }) + + const countEffortSays = () => + messages.filter( + ({ say, text }) => say === "tool" && typeof text === "string" && text.includes("thinkingEffort"), + ).length + + await waitUntilCompleted({ api, taskId }) + + // Event delivery race: the final display say can be observed after the + // TaskCompleted event (separate event channels, no cross-channel + // ordering guarantee). Settle the expected display says before + // detaching the listener; a genuine shortfall still fails below. + await waitFor(() => countEffortSays() >= 3, { timeout: 5_000, interval: 100 }) + + api.off(RooCodeEventName.Message, onMessage) + + // (a) Real boundary: the task completes after the full switching sequence. + const completion = messages.find( + ({ say, text }) => + (say === "completion_result" || say === "text") && text?.trim() === COMPLETION_EXPECTED, + ) + assert.ok( + completion, + "Task should complete with '" + COMPLETION_EXPECTED + "' after the switching sequence", + ) + + // (b) Real boundary: display says carry the applied efforts and the refusal; + // the no-op call deliberately emits no display say. + const effortSays = messages + .filter( + ({ say, text }) => say === "tool" && typeof text === "string" && text.includes("thinkingEffort"), + ) + .map(({ text }) => JSON.parse(text ?? "") as ThinkingEffortSay) + assert.strictEqual(effortSays.length, 3, "Should emit exactly three thinkingEffort display says") + + const appliedMedium = effortSays.find((say) => say.effort === "medium") + assert.ok(appliedMedium, "Should emit an applied 'medium' display say") + assert.strictEqual( + appliedMedium?.reason, + "start at medium", + "The 'medium' say should carry the model's reason", + ) + assert.strictEqual( + appliedMedium?.refusal, + undefined, + "The 'medium' change should have been applied, not refused", + ) + + const appliedHigh = effortSays.find((say) => say.effort === "high") + assert.ok(appliedHigh, "Should emit an applied 'high' display say") + assert.strictEqual(appliedHigh?.reason, "raise to high", "The 'high' say should carry the model's reason") + assert.strictEqual( + appliedHigh?.refusal, + undefined, + "The 'high' change should have been applied, not refused", + ) + + const refusal = effortSays.find((say) => say.refusal === "oscillation") + assert.ok(refusal, "The A -> B -> A return (medium -> high -> medium) should be refused as oscillation") + assert.strictEqual(refusal?.effort, undefined, "A refused say must not carry an applied effort") + + // (c) Real boundary: the wire envelope per request. Each request below is the + // first one carrying the given tool call id, i.e. the request made right after + // that call executed, so its reasoning envelope reflects the call's outcome. + const baselineRequest = requests.find((request) => request.lastUserMessage.includes(SWITCH_MARKER)) + assert.ok(baselineRequest, "Should have captured the baseline request containing the task prompt") + assert.strictEqual(baselineRequest.model, SWITCH_MODEL_ID) + assert.strictEqual( + baselineRequest.reasoning?.effort, + BASELINE_EFFORT, + "The baseline request should carry the settings-derived baseline effort", + ) + + const afterApplyMedium = firstRequestCarrying(requests, CALL_APPLY_MEDIUM) + assert.ok(afterApplyMedium, "Should have captured the request after the 'medium' change was applied") + assert.strictEqual( + afterApplyMedium.reasoning?.effort, + "medium", + "The request after the applied change should send the 'medium' effort", + ) + assert.ok( + afterApplyMedium.bodyText.includes("Thinking effort is now 'medium'."), + "The tool result after the applied change should confirm the new effort", + ) + + const afterNoOp = firstRequestCarrying(requests, CALL_NOOP_MEDIUM) + assert.ok(afterNoOp, "Should have captured the request after the no-op change") + assert.strictEqual( + afterNoOp.reasoning?.effort, + "medium", + "A no-op change must not alter the effort envelope", + ) + assert.ok( + afterNoOp.bodyText.includes("Thinking effort is already 'medium'."), + "The no-op tool result should confirm the current effort", + ) + + const afterApplyHigh = firstRequestCarrying(requests, CALL_APPLY_HIGH) + assert.ok(afterApplyHigh, "Should have captured the request after the 'high' change was applied") + assert.strictEqual( + afterApplyHigh.reasoning?.effort, + "high", + "The request after the applied change should send the 'high' effort", + ) + assert.ok( + afterApplyHigh.bodyText.includes("Thinking effort is now 'high'."), + "The tool result after the applied change should confirm the new effort", + ) + + const afterRefusal = firstRequestCarrying(requests, CALL_REFUSED_MEDIUM) + assert.ok(afterRefusal, "Should have captured the request after the refused change") + assert.strictEqual( + afterRefusal.reasoning?.effort, + "high", + "A refused change must not alter the effort envelope", + ) + assert.ok( + afterRefusal.bodyText.includes("oscillation between 'medium' and 'high' detected"), + "The refusal tool result should name the oscillation", + ) + }) + }) +}) diff --git a/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts new file mode 100644 index 0000000000..5a3df02ea6 --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts @@ -0,0 +1,166 @@ +import * as assert from "assert" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { withOpenRouterCaptureProxy } from "./thinking-effort-proxy" +import { setDefaultSuiteTimeout } from "./test-utils" +import { waitUntilCompleted } from "./utils" + +/** + * DTE addendum: set_thinking_effort mid-task workflow. + * + * Exercises the real extension-host boundary end to end with aimock fixtures: + * the model calls set_thinking_effort mid-task (no approval gate), the + * SetThinkingEffortTool display say is emitted, and the FOLLOWING API request + * carries the applied effort in the OpenRouter reasoning envelope. + * + * Determinism notes: + * - Runs against aimock only (replay or record); skips when aimock is absent, + * so no API keys are required. + * - The capture proxy is the local 127.0.0.1 pattern from + * anthropic-opus-4-7.test.ts: it intercepts the OpenRouter-compatible + * chat/completions POST (so request shapes can be asserted) and forwards it + * to aimock for the fixture-driven SSE responses. + * - The OpenRouter model catalog is resolved by the shared model-cache layer + * (fetchers/modelCache.ts) from the public OpenRouter endpoint, exactly like + * the other provider suites. openai/gpt-5 advertises "reasoning" in + * supported_parameters, so the fetcher resolves supportsReasoningEffort and + * the dynamicThinkingEffort gate exposes the tool. + * - The mid-task tool call is dispatched by name in presentAssistantMessage + * (a hard-coded case, not the request-declared tool list), and the tool + * executor re-checks the model capability after the first request has loaded + * the catalog, so the flow is correct even if the first request's tool list + * was built before the catalog fetch resolved. + * - Fixture matching (apps/vscode-e2e/src/fixtures/thinking-effort.ts): the + * post-tool request ends with a role:user message (fresh environment details + * are appended after the tool result), so aimock's toolCallId matcher — which + * requires the LAST message to be role:tool — can never bind it, and a JSON + * fixture cannot carry a predicate. The fixtures live in a JS module added via + * addThinkingEffortFixtures (same pattern as deepseek-v4.ts): the baseline turn + * binds to this suite's unique prompt marker ("DTE_E2E_EFFORT_APPLY") and the + * follow-up binds to the set_thinking_effort call's unique tool call id + * ("call_dte_e2e_001"), so no other suite can serve these responses and this + * suite cannot match unrelated turns. + */ + +const DTE_MODEL_ID = "openai/gpt-5" +const APPLY_MARKER = "DTE_E2E_EFFORT_APPLY" +const SET_EFFORT_TOOL_CALL_ID = "call_dte_e2e_001" +const COMPLETION_EXPECTED = "42" + +suite("set_thinking_effort mid-task workflow (DTE addendum)", function () { + setDefaultSuiteTimeout(this) + + // Restore the default OpenRouter configuration (and switch the experiment off) + // so subsequent suites are unaffected. + suiteTeardown(async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + experiments: { dynamicThinkingEffort: false }, + }) + }) + + test("Should apply set_thinking_effort mid-task, emit the display say, and send the applied effort on the next request", async function () { + const api = globalThis.api + const aimockUrl = process.env.AIMOCK_URL + + // Deterministic, key-free: aimock replay/record only. A live run would need + // a real model that deterministically emits the tool call. + if (!aimockUrl) { + this.skip() + } + + await withOpenRouterCaptureProxy(aimockUrl, async ({ proxyUrl, requests }) => { + // OpenRouter provider, a model that advertises per-request reasoning + // effort, and the dynamicThinkingEffort experiment enabled. + await api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: "mock-key", + openRouterModelId: DTE_MODEL_ID, + openRouterBaseUrl: `${proxyUrl}/v1`, + enableReasoningEffort: true, + experiments: { dynamicThinkingEffort: true }, + }) + + const messages: ClineMessage[] = [] + const onMessage = ({ message }: { message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + messages.push(message) + } + } + api.on(RooCodeEventName.Message, onMessage) + + const taskId = await api.startNewTask({ + configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, + text: APPLY_MARKER + ": answer the math question", + }) + + await waitUntilCompleted({ api, taskId }) + api.off(RooCodeEventName.Message, onMessage) + + // (a) Real boundary: the task completes with the math answer after the + // mid-task tool round trip. + const completion = messages.find( + ({ say, text }) => + (say === "completion_result" || say === "text") && text?.trim() === COMPLETION_EXPECTED, + ) + assert.ok(completion, "Task should complete with '" + COMPLETION_EXPECTED + "' after set_thinking_effort") + + // (b) Real boundary: the SetThinkingEffortTool display say carries the + // applied effort (not a refusal). + const effortSays = messages.filter( + ({ say, text }) => say === "tool" && typeof text === "string" && text.includes("thinkingEffort"), + ) + const appliedSay = effortSays.find(({ text }) => text?.includes('"high"')) + assert.ok(appliedSay, "SetThinkingEffortTool should emit a 'tool' say carrying the applied effort") + const effortPayload = JSON.parse(appliedSay.text ?? "") as { + tool?: string + effort?: string + reason?: string + refusal?: string + } + assert.strictEqual( + effortPayload.tool, + "thinkingEffort", + "display say should identify the thinkingEffort event", + ) + assert.strictEqual(effortPayload.effort, "high", "display say should carry the applied 'high' effort") + assert.strictEqual(effortPayload.reason, "multi-step math", "display say should carry the model's reason") + assert.strictEqual( + effortPayload.refusal, + undefined, + "the effort change should have been applied, not refused", + ) + + // (c) Real boundary: the request AFTER the tool round trip carries the + // applied effort in the OpenRouter reasoning envelope. + const preToolRequest = requests.find( + (request) => + !request.bodyText.includes(SET_EFFORT_TOOL_CALL_ID) && + request.lastUserMessage.includes(APPLY_MARKER), + ) + assert.ok(preToolRequest, "Should have captured the pre-tool request containing the task prompt") + assert.strictEqual(preToolRequest.model, DTE_MODEL_ID) + assert.notStrictEqual( + preToolRequest.reasoning?.effort, + "high", + "the baseline request should not already carry the 'high' effort", + ) + + const postToolRequest = requests.find((request) => request.bodyText.includes(SET_EFFORT_TOOL_CALL_ID)) + assert.ok(postToolRequest, "The follow-up request should carry the set_thinking_effort tool result") + assert.strictEqual(postToolRequest.model, DTE_MODEL_ID) + assert.ok(postToolRequest.reasoning, "Post-tool request should carry a reasoning envelope") + assert.strictEqual( + postToolRequest.reasoning.effort, + "high", + "Post-tool request should send the applied 'high' effort", + ) + }) + }) +}) diff --git a/packages/types/src/__tests__/experiment.test.ts b/packages/types/src/__tests__/experiment.test.ts new file mode 100644 index 0000000000..0ef4ed2e02 --- /dev/null +++ b/packages/types/src/__tests__/experiment.test.ts @@ -0,0 +1,19 @@ +import { experimentIds, experimentIdsSchema, experimentsSchema } from "../experiment.js" + +describe("dynamicThinkingEffort experiment", () => { + it("is part of the experiment id enum", () => { + expect(experimentIds).toContain("dynamicThinkingEffort") + expect(experimentIdsSchema.safeParse("dynamicThinkingEffort").success).toBe(true) + }) + + it("parses enabled and disabled states", () => { + expect(experimentsSchema.parse({ dynamicThinkingEffort: true })).toEqual({ dynamicThinkingEffort: true }) + expect(experimentsSchema.parse({ dynamicThinkingEffort: false })).toEqual({ dynamicThinkingEffort: false }) + expect(experimentsSchema.parse({})).toEqual({}) + }) + + it("rejects non-boolean values", () => { + expect(experimentsSchema.safeParse({ dynamicThinkingEffort: "yes" }).success).toBe(false) + expect(experimentIdsSchema.safeParse("dynamic-thinking-effort").success).toBe(false) + }) +}) diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index b29a93ca3e..f20b9e9f94 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -181,3 +181,58 @@ describe("getApiProtocol", () => { }) }) }) + +describe("supportedReasoningEfforts (F7)", () => { + it("accepts a canonical effort-level declaration on OpenAI-compatible providers", () => { + const settings = { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://localhost:1234/v1", + lmStudioModelId: "qwen3-32b", + supportedReasoningEfforts: ["low", "high", "max"], + } + + const parsed = providerSettingsSchemaDiscriminated.parse(settings) + expect(parsed).toEqual(settings) + }) + + it.each([ + providerIdentifiers.openai, + providerIdentifiers.ollama, + providerIdentifiers.litellm, + providerIdentifiers.baseten, + ])("accepts the declaration on the %s provider branch", (apiProvider) => { + const settings = { + apiProvider, + supportedReasoningEfforts: ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + } + expect(providerSettingsSchemaDiscriminated.safeParse(settings).success).toBe(true) + }) + + it("rejects non-canonical effort values", () => { + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.lmstudio, + supportedReasoningEfforts: ["low", "turbo"], + }).success, + ).toBe(false) + // The UI-level "disable" sentinel is a settings value, not a declarable level. + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.ollama, + supportedReasoningEfforts: ["disable"], + }).success, + ).toBe(false) + }) + + it("accepts an empty declaration and leaves the field omitted when unset", () => { + expect( + providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: providerIdentifiers.openai, + supportedReasoningEfforts: [], + }).success, + ).toBe(true) + expect(providerSettingsSchemaDiscriminated.safeParse({ apiProvider: providerIdentifiers.openai }).success).toBe( + true, + ) + }) +}) diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index 5d511859b1..f4b3a1c0a8 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -12,6 +12,7 @@ export const experimentIds = [ "runSlashCommand", "customTools", "parallelToolExecution", + "dynamicThinkingEffort", ] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -28,6 +29,7 @@ export const experimentsSchema = z.object({ runSlashCommand: z.boolean().optional(), customTools: z.boolean().optional(), parallelToolExecution: z.boolean().optional(), + dynamicThinkingEffort: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 5b173c6a6b..fc7d339e1e 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.js" + /** * 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/packages/types/src/provider-settings/common.ts b/packages/types/src/provider-settings/common.ts index e73a05f143..c159e4fa8f 100644 --- a/packages/types/src/provider-settings/common.ts +++ b/packages/types/src/provider-settings/common.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js" +import { reasoningEffortExtendedSchema, reasoningEffortSettingSchema, verbosityLevelsSchema } from "../model.js" import type { ProviderIdentifier } from "../provider-identifiers.js" export const API_PROVIDER_FIELD = "apiProvider" @@ -18,6 +18,15 @@ export const baseProviderSettingsShape = { modelMaxTokens: z.number().optional(), modelMaxThinkingTokens: z.number().optional(), verbosity: verbosityLevelsSchema.optional(), + /** + * F7: per-profile declaration of the canonical reasoning effort levels the + * selected model supports. Self-hosted / OpenAI-compatible models do not + * advertise `supportsReasoningEffort` in the model registry, so a profile can + * declare the levels its model accepts; the resolution rule fills the gap only + * where the model info has no value of its own (registry values are never + * overridden). + */ + supportedReasoningEfforts: z.array(reasoningEffortExtendedSchema).optional(), } export const apiModelIdProviderModelShape = { diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index d89a8107c1..712dc8adf4 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -45,6 +45,7 @@ export const toolNames = [ "run_slash_command", "skill", "generate_image", + "set_thinking_effort", "custom_tool", "invalid_tool_call", ] as const diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..fa1512bb8c 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -12,7 +12,7 @@ import type { CloudUserInfo, CloudOrganizationMembership, OrganizationAllowList, import type { SerializedCustomToolDefinition } from "./custom-tool.js" import type { GitCommit } from "./git.js" import type { McpServer } from "./mcp.js" -import { RouterModelsMessageType, type ModelRecord, type RouterModels } from "./model.js" +import { RouterModelsMessageType, type ModelRecord, type RouterModels, type ReasoningEffortExtended } from "./model.js" import { LmStudioModelsMessageType } from "./providers/lm-studio.js" import { OllamaModelsMessageType } from "./providers/ollama.js" import { OpenAiModelsMessageType } from "./providers/openai.js" @@ -336,6 +336,12 @@ export type ExtensionState = Pick< clineMessages: ClineMessage[] currentTaskId?: string currentTaskItem?: HistoryItem + // DTE series 4/5: task-local thinking effort override for the current task. + // Present only while a task-local override is active (set by the composer + // toggle, the set_thinking_effort tool, or a parent orchestrator); undefined + // otherwise, in which case the webview derives the display from settings -> + // model default. Authoritative state stays extension-side. + taskThinkingEffort?: { effort: string; source: string } currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings uriScheme?: string @@ -501,6 +507,7 @@ export interface WebviewMessage { | "openMention" | "cancelTask" | "cancelAutoApproval" + | "setTaskThinkingEffort" | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" @@ -648,6 +655,14 @@ export interface WebviewMessage { | "themeFixtureProbeResponse" text?: string taskId?: string + // DTE series 4/5: task-local thinking effort set from the composer toggle + // (message type "setTaskThinkingEffort"). Task-local only; persisted settings + // are never touched. + effort?: string + reason?: string + // DTE series 5/5: thinking effort chosen in the pending new_task ask block + // (sent with the ask response, see Task.handleWebviewAskResponse). + thinkingEffort?: ReasoningEffortExtended editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean @@ -847,6 +862,7 @@ export interface ClineSayTool { | "runSlashCommand" | "updateTodoList" | "skill" + | "thinkingEffort" path?: string // For readCommandOutput readStart?: number @@ -904,6 +920,13 @@ export interface ClineSayTool { description?: string // Properties for skill tool skill?: string + // Properties for thinkingEffort (DTE series 3/5) + effort?: string + refusal?: string + // DTE series 5/5: new_task thinking-effort prefill for the ask block and the + // effort levels the target model supports (see NewTaskTool). + thinkingEffort?: ReasoningEffortExtended + supportedThinkingEfforts?: ReasoningEffortExtended[] } export interface ClineAskUseMcpServer { diff --git a/src/__tests__/new-task-delegation.spec.ts b/src/__tests__/new-task-delegation.spec.ts index b6f6d4d36c..1090b00f30 100644 --- a/src/__tests__/new-task-delegation.spec.ts +++ b/src/__tests__/new-task-delegation.spec.ts @@ -20,14 +20,20 @@ describe("Task.startSubtask() metadata-driven delegation", () => { ;(parent as any).taskId = "parent-1" ;(parent as any).providerRef = { deref: () => provider } ;(parent as any).emit = vi.fn() + // DTE series 5/5: startSubtask now passes the parent's effective effort to the + // child's init; this Object.create double bypasses the constructor, so shadow + // the public resolver with the value under test. + parent.resolveNewTaskEffectiveEffort = () => undefined const child = await (Task.prototype as any).startSubtask.call(parent, "Do something", [], "code") + // DTE series 5/5: thinkingEffort is always present (undefined = inherit parent effective). expect(provider.delegateParentAndOpenChild).toHaveBeenCalledWith({ parentTaskId: "parent-1", message: "Do something", initialTodos: [], mode: "code", + thinkingEffort: undefined, }) expect(child.taskId).toBe("child-1") diff --git a/src/__tests__/provider-delegation.spec.ts b/src/__tests__/provider-delegation.spec.ts index 0154027753..61a08b8a38 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -386,4 +386,293 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => { expect(deleteTaskWithId).toHaveBeenCalledWith("child-1", false) expect(createTaskWithHistoryItem).toHaveBeenCalledWith(parentHistoryItem) }) + + it("applies the parent-supplied starting effort to the child at init (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + // The child's resolved model (post mode switch) supports the requested level. + api: { + getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "medium", "high"] } }), + }, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + const child = await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "high", + }) + await Promise.resolve() // drain scheduler microtask so child.run() is invoked + + expect(child.taskId).toBe("child-1") + // Applied as a task-local override with provenance "parent" before the child's + // first request (the child header shows it from the start). + expect(setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "parent") + }) + + it("leaves the child's effort untouched when no starting effort is supplied (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + }) + await Promise.resolve() + + expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("falls back with an observable say when the child model (post mode switch) does not support the effort (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const say = vi.fn().mockResolvedValue(undefined) + const childRun = vi.fn().mockResolvedValue(undefined) + // The mode switch resolved a DIFFERENT model than the parent's: it only + // supports low/high, so the parent-validated "xhigh" must not be applied. + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + say, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "high"] } }) }, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + // No task-local override: the child runs with the settings-derived effort. + expect(setRuntimeThinkingEffort).not.toHaveBeenCalled() + // Observable on the child task: the fallback is announced, not silent. + expect(say).toHaveBeenCalledTimes(1) + const [sayType, sayText] = say.mock.calls[0] + expect(sayType).toBe("error") + expect(sayText).toContain("xhigh") + expect(sayText).toContain("child-model") + // Delegation itself still proceeds: the child runs. + expect(childRun).toHaveBeenCalledTimes(1) + }) + + it("does not abort delegation when the fallback say rejects after the parent is disposed (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + // The parent is already disposed when this say runs, so the webview state can be + // gone and the say rejects (e.g. posting to a removed task). + const say = vi.fn().mockRejectedValue(new Error("task disposed")) + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + say, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "high"] } }) }, + }) + const taskHistoryStore = makeStoreStub() + const providerLog = vi.fn() + + // Partial provider double: the real ClineProvider.prototype.delegateParentAndOpenChild + // is invoked below via .call() with only the members that method reads, so the full + // interface is not implemented and the double assertion is the last-resort hand-off. + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: providerLog, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + // Must NOT reject: the failing notification is non-fatal. + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + // The say rejection is surfaced through the provider log, not thrown. + expect(providerLog).toHaveBeenCalledWith(expect.stringContaining("non-fatal")) + expect(providerLog).toHaveBeenCalledWith(expect.stringContaining("task disposed")) + // Delegation metadata is still persisted for the (already disposed) parent: + // capture the updater's resulting item and assert the delegated status and both + // child links, not just that the metadata transaction was entered. + expect(taskHistoryStore.atomicReadAndUpdate).toHaveBeenCalledTimes(1) + const [calledTaskId, updater] = taskHistoryStore.atomicReadAndUpdate.mock.calls[0] + expect(calledTaskId).toBe("parent-1") + expect(updater(parentHistoryItem)).toMatchObject({ + id: "parent-1", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: expect.arrayContaining(["child-1"]), + }) + // And the child is still scheduled despite the failed notification. + expect(childRun).toHaveBeenCalledTimes(1) + }) + + it("stringifies a non-Error say rejection in the non-fatal log (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + // A non-Error rejection (e.g. a raw string from the webview messaging + // layer): the catch must not assume an Error shape, where error.message + // would be undefined and the log line would read 'undefined'. + const say = vi.fn().mockRejectedValue("webview gone") + const childRun = vi.fn().mockResolvedValue(undefined) + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + say, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: ["low", "high"] } }) }, + }) + const taskHistoryStore = makeStoreStub() + const providerLog = vi.fn() + + // Partial provider double: the real ClineProvider.prototype.delegateParentAndOpenChild + // is invoked below via .call() with only the members that method reads, so the full + // interface is not implemented and the double assertion is the last-resort hand-off. + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: providerLog, + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + // Must NOT reject: the failing notification is non-fatal. + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + // The non-Error rejection is stringified into the log (the Error-instance + // arm is covered by the disposed-parent test above). + expect(providerLog).toHaveBeenCalledWith(expect.stringContaining("non-fatal")) + expect(providerLog).toHaveBeenCalledWith(expect.stringContaining("webview gone")) + // Delegation still proceeds: the child runs. + expect(childRun).toHaveBeenCalledTimes(1) + }) + + it("applies the effort when the child model (post mode switch) has a boolean-true capability (DTE series 5/5)", async () => { + const parentTask = makeParentTask() + const setRuntimeThinkingEffort = vi.fn() + const childRun = vi.fn().mockResolvedValue(undefined) + // Boolean-true capability: the child model supports every level. + const createTask = vi.fn().mockResolvedValue({ + taskId: "child-1", + start: vi.fn(), + run: childRun, + setRuntimeThinkingEffort, + api: { getModel: () => ({ id: "child-model", info: { supportsReasoningEffort: true } }) }, + }) + const taskHistoryStore = makeStoreStub() + + const provider = { + taskScheduler: new TaskScheduler(), + emit: vi.fn(), + getCurrentTask: vi.fn(() => parentTask), + removeClineFromStack: vi.fn().mockResolvedValue(undefined), + createTask, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + log: vi.fn(), + isViewLaunched: false, + recentTasksCache: undefined, + taskHistoryStore, + } as unknown as ClineProvider + + await ClineProvider.prototype.delegateParentAndOpenChild.call(provider, { + parentTaskId: "parent-1", + message: "Do something", + initialTodos: [], + mode: "code", + thinkingEffort: "xhigh", + }) + await Promise.resolve() + + expect(setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(setRuntimeThinkingEffort).toHaveBeenCalledWith("xhigh", "parent") + }) }) diff --git a/src/api/__tests__/model-capabilities.spec.ts b/src/api/__tests__/model-capabilities.spec.ts new file mode 100644 index 0000000000..70f913113d --- /dev/null +++ b/src/api/__tests__/model-capabilities.spec.ts @@ -0,0 +1,61 @@ +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { withDeclaredReasoningEffort } from "../model-capabilities" + +describe("withDeclaredReasoningEffort (F7)", () => { + const baseModel: ModelInfo = { + contextWindow: 128_000, + maxTokens: 8_192, + supportsPromptCache: false, + } + + const declared: ProviderSettings["supportedReasoningEfforts"] = ["low", "high", "max"] + + it("fills in the declared levels when the model has no capability of its own", () => { + const result = withDeclaredReasoningEffort(baseModel, { supportedReasoningEfforts: declared }) + expect(result.supportsReasoningEffort).toEqual(["low", "high", "max"]) + // Remaining model fields pass through unchanged. + expect(result.contextWindow).toBe(128_000) + expect(result.maxTokens).toBe(8_192) + }) + + it("never overrides a registry array capability (registry wins)", () => { + const model: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["disable", "low", "medium"], + } + const result = withDeclaredReasoningEffort(model, { supportedReasoningEfforts: declared }) + expect(result).toBe(model) + expect(result.supportsReasoningEffort).toEqual(["disable", "low", "medium"]) + }) + + it("never overrides a boolean registry capability", () => { + for (const capability of [true, false] as const) { + const model: ModelInfo = { ...baseModel, supportsReasoningEffort: capability } + const result = withDeclaredReasoningEffort(model, { supportedReasoningEfforts: declared }) + expect(result).toBe(model) + expect(result.supportsReasoningEffort).toBe(capability) + } + }) + + it("returns the model unchanged when no declaration is present", () => { + expect(withDeclaredReasoningEffort(baseModel, undefined)).toBe(baseModel) + expect(withDeclaredReasoningEffort(baseModel, {})).toBe(baseModel) + }) + + it("returns the model unchanged when the declaration is empty", () => { + expect(withDeclaredReasoningEffort(baseModel, { supportedReasoningEfforts: [] })).toBe(baseModel) + }) + + it("returns a fresh object with its own copy of the declared array (no shared mutation)", () => { + const declaredLevels: string[] = ["low", "high", "max"] + const result = withDeclaredReasoningEffort(baseModel, { + supportedReasoningEfforts: declaredLevels as ProviderSettings["supportedReasoningEfforts"], + }) + expect(result).not.toBe(baseModel) + expect(baseModel.supportsReasoningEffort).toBeUndefined() + const filled = result.supportsReasoningEffort as string[] + expect(filled).not.toBe(declaredLevels) + expect(filled).toEqual(["low", "high", "max"]) + }) +}) 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/model-capabilities.ts b/src/api/model-capabilities.ts new file mode 100644 index 0000000000..9c04f58179 --- /dev/null +++ b/src/api/model-capabilities.ts @@ -0,0 +1,34 @@ +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +/** + * F7: fill-in-the-gap resolution of user-declared reasoning effort capability. + * + * Self-hosted / OpenAI-compatible models (custom OpenAI endpoints, LM Studio, + * Ollama, and similar) do not advertise `supportsReasoningEffort` in the model + * registry, so the dynamic thinking effort feature is disabled for them. A + * profile can declare the canonical effort levels its model supports via the + * `supportedReasoningEfforts` provider setting. + * + * Resolution rule (single semantic, mirrored on the webview side by + * `resolveReasoningEffortCapability` in webview-ui/src/utils/thinkingEffort.ts): + * when the resolved ModelInfo has no `supportsReasoningEffort` of its own + * (`undefined`) AND the profile declares a non-empty + * `supportedReasoningEfforts`, the model is treated as supporting exactly that + * array. Registry values are NEVER overridden — this is a fill-in-the-gap only, + * so models that already advertise a capability (boolean or array) keep it. + * + * The helper is pure and non-mutating: it returns the original ModelInfo when + * nothing is filled in (callers may share catalog objects). + */ +export function withDeclaredReasoningEffort(modelInfo: ModelInfo, settings: ProviderSettings | undefined): ModelInfo { + if (modelInfo.supportsReasoningEffort !== undefined) { + return modelInfo + } + + const declared = settings?.supportedReasoningEfforts + if (!Array.isArray(declared) || declared.length === 0) { + return modelInfo + } + + return { ...modelInfo, supportsReasoningEffort: [...declared] } +} 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/__tests__/f7-declared-reasoning-effort.spec.ts b/src/api/providers/__tests__/f7-declared-reasoning-effort.spec.ts new file mode 100644 index 0000000000..08137972a0 --- /dev/null +++ b/src/api/providers/__tests__/f7-declared-reasoning-effort.spec.ts @@ -0,0 +1,249 @@ +// npx vitest run api/providers/__tests__/f7-declared-reasoning-effort.spec.ts +// +// F7: handler-level coverage for the user-declared reasoning effort fill-in +// (withDeclaredReasoningEffort) at the sites where OpenAI-compatible ModelInfo +// reaches consumers via getModel(). + +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" +import { LiteLLMHandler } from "../lite-llm" +import { LmStudioHandler } from "../lm-studio" +import { getModelsFromCache } from "../fetchers/modelCache" +import { getOllamaModels } from "../fetchers/ollama" +import { NativeOllamaHandler } from "../native-ollama" +import { OpenAiHandler } from "../openai" +import { makeApiHandlerOptions } from "../../../test-utils/api" + +vitest.mock("openai", () => ({ + __esModule: true, + default: vitest.fn().mockImplementation(function () { + return { + chat: { + completions: { + create: vitest.fn(), + }, + }, + } + }), + AzureOpenAI: vitest.fn(), +})) + +vi.mock("../fetchers/ollama", () => ({ + getOllamaModels: vi.fn(), +})) + +// RouterProvider resolves a missing instance catalog through the global model +// cache; mock it so the cache-fallback branch of getModel() is deterministic. +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + refreshModels: vi.fn().mockResolvedValue({}), + getModelsFromCache: vi.fn().mockReturnValue(undefined), + flushModels: vi.fn(), +})) + +// Concrete test implementation of the abstract base class (same pattern as +// base-openai-compatible-provider.spec.ts). +class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> { + constructor(options: Record) { + const testModels: Record<"test-model", ModelInfo> = { + "test-model": { + maxTokens: 4096, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + } + + super({ + providerName: "TestProvider", + baseURL: "https://test.example.com/v1", + defaultProviderModelId: "test-model", + providerModels: testModels, + apiKey: "test-api-key", + ...options, + }) + } +} + +const DECLARED: NonNullable = ["low", "high", "max"] + +describe("F7 declared reasoning effort fill-in at handler construction sites", () => { + describe("OpenAiHandler (custom OpenAI endpoint)", () => { + it("fills in declared levels for the sane-default model info", () => { + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "qwen3-32b", + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("fills in declared levels for custom model info without a capability", () => { + const customInfo: ModelInfo = { + contextWindow: 32_768, + maxTokens: 8_192, + supportsPromptCache: false, + } + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "local-model", + openAiCustomModelInfo: customInfo, + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + // The input object is not mutated. + expect(customInfo.supportsReasoningEffort).toBeUndefined() + }) + + it("keeps the model's own capability (registry wins)", () => { + const customInfo: ModelInfo = { + contextWindow: 32_768, + maxTokens: 8_192, + supportsPromptCache: false, + supportsReasoningEffort: ["disable", "low", "high"], + } + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "local-model", + openAiCustomModelInfo: customInfo, + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(["disable", "low", "high"]) + }) + + it("leaves the capability absent without a declaration", () => { + const handler = new OpenAiHandler( + makeApiHandlerOptions({ + openAiApiKey: "test-api-key", + openAiModelId: "qwen3-32b", + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toBeUndefined() + }) + }) + + describe("LmStudioHandler", () => { + it("fills in declared levels when the model falls back to sane defaults", () => { + const handler = new LmStudioHandler( + makeApiHandlerOptions({ + lmStudioBaseUrl: "http://localhost:1234", + lmStudioModelId: "qwen3-32b", + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + }) + + describe("NativeOllamaHandler", () => { + it("fills in declared levels for fetched models without a capability", async () => { + const handler = new NativeOllamaHandler( + makeApiHandlerOptions({ + ollamaModelId: "qwen3:32b", + supportedReasoningEfforts: DECLARED, + }), + ) + vi.mocked(getOllamaModels).mockResolvedValueOnce({ + "qwen3:32b": { + maxTokens: 8_192, + contextWindow: 32_768, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + }) + const result = await handler.fetchModel() + expect(result.info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("keeps the model's own capability (registry wins)", async () => { + const handler = new NativeOllamaHandler( + makeApiHandlerOptions({ + ollamaModelId: "qwen3:32b", + supportedReasoningEfforts: DECLARED, + }), + ) + vi.mocked(getOllamaModels).mockResolvedValueOnce({ + "qwen3:32b": { + maxTokens: 8_192, + contextWindow: 32_768, + supportsImages: false, + supportsPromptCache: false, + supportsReasoningEffort: true, + }, + }) + const result = await handler.fetchModel() + expect(result.info.supportsReasoningEffort).toBe(true) + }) + }) + + describe("BaseOpenAiCompatibleProvider subclasses", () => { + it("fills in declared levels where the model record has no capability", () => { + const handler = new TestOpenAiCompatibleProvider({ supportedReasoningEfforts: DECLARED }) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("leaves the capability absent without a declaration", () => { + const handler = new TestOpenAiCompatibleProvider({}) + expect(handler.getModel().info.supportsReasoningEffort).toBeUndefined() + }) + }) + + describe("RouterProvider subclasses (LiteLLM)", () => { + it("fills in declared levels for the default model fallback", () => { + const handler = new LiteLLMHandler( + makeApiHandlerOptions({ + litellmBaseUrl: "http://localhost:4000", + litellmModelId: "custom/model", + supportedReasoningEfforts: DECLARED, + }), + ) + // No catalog fetched yet: getModel() falls back to defaultModelInfo. + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + + it("leaves the capability absent without a declaration", () => { + const handler = new LiteLLMHandler( + makeApiHandlerOptions({ + litellmBaseUrl: "http://localhost:4000", + litellmModelId: "custom/model", + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toBeUndefined() + }) + + it("fills in declared levels from the cached catalog fallback", () => { + // No instance catalog (fetchModel was never awaited), so getModel() + // must resolve the model through the global cache fallback and apply + // the declared levels on that path too. + vi.mocked(getModelsFromCache).mockReturnValueOnce({ + "custom/model": { + maxTokens: 8_192, + contextWindow: 32_768, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + }, + }) + const handler = new LiteLLMHandler( + makeApiHandlerOptions({ + litellmBaseUrl: "http://localhost:4000", + litellmModelId: "custom/model", + supportedReasoningEfforts: DECLARED, + }), + ) + expect(handler.getModel().info.supportsReasoningEffort).toEqual(DECLARED) + }) + }) +}) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index 8fcbf4a0a1..e8c960625c 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -317,6 +317,72 @@ describe("NativeOllamaHandler", () => { ) }) + it("should let a per-request reasoningEffort override the configured value (DTE F7)", async () => { + // The per-request override (metadata.reasoningEffort) takes precedence over + // the configured reasoningEffort, mirroring the shared override-first + // resolution order. + const options: ApiHandlerOptions = { + apiModelId: "qwen3", + ollamaModelId: "qwen3", + ollamaBaseUrl: "http://localhost:11434", + enableReasoningEffort: true, + reasoningEffort: "high", + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok", thinking: "hmm" } } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }], { + taskId: "task-1", + reasoningEffort: "low", + }) + for await (const _ of stream) { + // consume + } + + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: "low", + }), + ) + }) + + it("should send a think parameter from a per-request override when no effort is configured (DTE F7)", async () => { + // The per-request override (metadata.reasoningEffort) is also the sole + // source when the configured reasoningEffort is unset (opt-in checked, + // no effort selected): the override-first resolution order still applies. + const options: ApiHandlerOptions = { + apiModelId: "qwen3", + ollamaModelId: "qwen3", + ollamaBaseUrl: "http://localhost:11434", + enableReasoningEffort: true, + // reasoningEffort intentionally unset + } + + handler = new NativeOllamaHandler(options) + + mockChat.mockImplementation(async function* () { + yield { message: { content: "ok" } } + }) + + const stream = handler.createMessage("System", [{ role: "user" as const, content: "Hi" }], { + taskId: "task-1", + reasoningEffort: "medium", + }) + for await (const _ of stream) { + // consume + } + + expect(mockChat).toHaveBeenCalledWith( + expect.objectContaining({ + think: "medium", + }), + ) + }) + it("should map reasoningEffort levels to Ollama think values", async () => { const cases: Array< [NonNullable, boolean | "high" | "medium" | "low"] diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index b55c8b3089..c0843d29a8 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -18,7 +18,11 @@ import type { ApiHandlerOptions } from "../../shared/api" import { ApiStream } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { filterNonAnthropicBlocks } from "../transform/anthropic-filter" -import { getAnthropicProviderReasoning } from "../transform/reasoning" +import { + ADAPTIVE_OUTPUT_CONFIG_EFFORTS, + getAnthropicProviderReasoning, + resolveEffectiveReasoningEffort, +} from "../transform/reasoning" import { handleProviderError } from "./utils/error-handler" import { BaseProvider } from "./base-provider" @@ -58,6 +62,21 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa }) } + /** + * Creates a streaming Anthropic message for the current model. + * + * Resolves the effective thinking effort for this request through the shared + * `resolveEffectiveReasoningEffort` point (per-request override → settings → + * model default). For adaptive-thinking models, when the resolved effort is one + * of `ADAPTIVE_OUTPUT_CONFIG_EFFORTS` (low|medium|high|xhigh|max), the request + * carries `output_config: { effort }` (DTE series 2/5); out-of-range or unset + * efforts omit it so the API default applies. + * + * @param systemPrompt - The system prompt for the request. + * @param messages - The message history to send. + * @param metadata - Per-request metadata (carries the task-local effort override). + * @returns An async iterator of parsed Anthropic stream events. + */ async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], @@ -79,6 +98,25 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa settings: this.options, }) + // DTE series 2/5: per-request adaptive effort envelope (output_config.effort). + // The task-local per-request override (metadata.reasoningEffort) takes + // precedence over the settings-derived value (shared resolution in + // resolveEffectiveReasoningEffort). Only adaptive-thinking requests whose + // effective effort is in-range get the envelope; everything else (unset, + // "disable", "none", "minimal") omits it and lets the API apply its default. + const effectiveReasoningEffort = resolveEffectiveReasoningEffort({ + override: metadata?.reasoningEffort, + settingsReasoningEffort: this.options.reasoningEffort, + modelDefaultEffort: info.reasoningEffort, + }) + const adaptiveEffort = + thinking?.type === "adaptive" && + effectiveReasoningEffort !== undefined && + effectiveReasoningEffort !== "disable" && + ADAPTIVE_OUTPUT_CONFIG_EFFORTS.includes(effectiveReasoningEffort) + ? effectiveReasoningEffort + : undefined + // Filter out non-Anthropic blocks (reasoning, thoughtSignature, etc.) before sending to the API const sanitizedMessages = filterNonAnthropicBlocks(messages) @@ -141,6 +179,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), // Setting cache breakpoint for system prompt so new tasks can reuse it. system: [{ text: systemPrompt, type: "text", cache_control: cacheControl }], messages: sanitizedMessages.map((message, index) => { @@ -216,6 +256,8 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS, temperature, thinking, + // DTE series 2/5: adaptive effort envelope (omitted unless in-range). + ...(adaptiveEffort !== undefined ? { output_config: { effort: adaptiveEffort } } : {}), system: [{ text: systemPrompt, type: "text" }], messages: sanitizedMessages, stream: true, diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index f4928b0b0a..d163800570 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -9,6 +9,7 @@ import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { convertToOpenAiMessages } from "../transform/openai-format" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" +import { withDeclaredReasoningEffort } from "../model-capabilities" import { DEFAULT_HEADERS } from "./constants" import { BaseProvider } from "./base-provider" import { handleOpenAIError } from "./utils/error-handler" @@ -242,12 +243,21 @@ export abstract class BaseOpenAiCompatibleProvider } } + /** + * Resolves the active model and its capability metadata. + * + * F7: fills in user-declared `supportedReasoningEfforts` (registry-wins) + * for self-hosted / OpenAI-compatible profiles that do not advertise the + * capability in the registry. + */ override getModel() { const id = this.options.apiModelId && this.options.apiModelId in this.providerModels ? (this.options.apiModelId as ModelName) : this.defaultProviderModelId - return { id, info: this.providerModels[id] } + // F7: fill in user-declared reasoning effort levels where the model does not + // advertise its own capability (registry values are never overridden). + return { id, info: withDeclaredReasoningEffort(this.providerModels[id], this.options) } } } diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index a5507e355a..ed9c128221 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -6,6 +6,7 @@ import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@r import type { ApiHandlerOptions } from "../../shared/api" import { shouldUseReasoningEffort, getModelMaxOutputTokens } from "../../shared/api" +import { withDeclaredReasoningEffort } from "../model-capabilities" import { convertToOpenAiMessages } from "../transform/openai-format" import { getModelParams } from "../transform/model-params" @@ -72,13 +73,22 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider the matching effort level * - "xhigh" / "max" -> "high" (highest level the SDK currently supports) */ - private getOllamaThinkParam(): boolean | "high" | "medium" | "low" | undefined { + private getOllamaThinkParam(override?: ReasoningEffortExtended): boolean | "high" | "medium" | "low" | undefined { // Require an explicit Ollama opt-in before mapping reasoningEffort. // Without this guard, a stale reasoningEffort inherited from another // provider config could still emit a think param when the UI checkbox @@ -344,7 +346,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return undefined } - const effort = this.options.reasoningEffort + const effort = override ?? this.options.reasoningEffort if (effort === undefined) { return undefined } @@ -379,6 +381,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio */ private buildChatRequestOptions( useR1Format: boolean, + effortOverride?: ReasoningEffortExtended, ): [OllamaChatOptions, boolean | "high" | "medium" | "low" | undefined] { const chatOptions: OllamaChatOptions = { temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0), @@ -389,7 +392,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio chatOptions.num_ctx = this.options.ollamaNumCtx } - const thinkParam = this.getOllamaThinkParam() + const thinkParam = this.getOllamaThinkParam(effortOverride) return [chatOptions, thinkParam] } @@ -422,7 +425,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio // reasoning models (qwen3, deepseek-r1, etc.) emit thinking via // the dedicated message.thinking field instead of (or in addition // to) think/thought tags embedded in content. - const [chatOptions, thinkParam] = this.buildChatRequestOptions(useR1Format) + const [chatOptions, thinkParam] = this.buildChatRequestOptions(useR1Format, metadata?.reasoningEffort) // Create the actual API request promise. The `stream: true` literal // is kept inline so TypeScript selects the streaming overload of @@ -542,11 +545,20 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio return this.getModel() } + /** + * Resolves the active model and its capability metadata. + * + * F7: fills in user-declared `supportedReasoningEfforts` (registry-wins) + * for self-hosted / OpenAI-compatible profiles that do not advertise the + * capability in the registry. + */ override getModel(): { id: string; info: ModelInfo } { const modelId = this.options.ollamaModelId || "" + // F7: fill in user-declared reasoning effort levels where the fetched model + // does not advertise its own capability. return { id: modelId, - info: this.models[modelId] || openAiModelInfoSaneDefaults, + info: withDeclaredReasoningEffort(this.models[modelId] || openAiModelInfoSaneDefaults, this.options), } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 5588dd37d6..4cd58558a8 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -16,6 +16,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" +import { withDeclaredReasoningEffort } from "../model-capabilities" import { convertToOpenAiMessages } from "../transform/openai-format" import { convertToR1Format } from "../transform/r1-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" @@ -285,9 +286,21 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + /** + * Resolves the active model and its capability metadata. + * + * F7: fills in user-declared `supportedReasoningEfforts` (registry-wins) + * for self-hosted / OpenAI-compatible profiles that do not advertise the + * capability in the registry. + */ override getModel() { const id = this.options.openAiModelId ?? "" - const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults + // F7: fill in user-declared reasoning effort levels where the model (custom + // model info or sane defaults) does not advertise its own capability. + const info: ModelInfo = withDeclaredReasoningEffort( + this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults, + this.options, + ) const params = getModelParams({ format: "openai", modelId: id, diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index 7292824da8..93b893e653 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -4,6 +4,7 @@ import { type ModelInfo, type ModelRecord } from "@roo-code/types" import { ApiHandlerOptions, RouterName } from "../../shared/api" +import { withDeclaredReasoningEffort } from "../model-capabilities" import { BaseProvider } from "./base-provider" import { getModels, getModelsFromCache, refreshModels } from "./fetchers/modelCache" @@ -112,6 +113,13 @@ export abstract class RouterProvider extends BaseProvider { await this.fetchModel() } + /** + * Resolves the active model and its capability metadata. + * + * F7: fills in user-declared `supportedReasoningEfforts` (registry-wins) + * for self-hosted / OpenAI-compatible profiles that do not advertise the + * capability in the registry. + */ override getModel(): { id: string; info: ModelInfo } { // Use `||` (not `??`) so an empty-string modelId also falls back to the default, // guaranteeing a non-empty id rather than forwarding "" to the API as an invalid @@ -123,7 +131,9 @@ export abstract class RouterProvider extends BaseProvider { // First check instance models (populated by fetchModel) if (this.models[id]) { - return { id, info: this.models[id] } + // F7: fill in user-declared reasoning effort levels where the fetched + // model does not advertise its own capability. + return { id, info: withDeclaredReasoningEffort(this.models[id], this.options) } } // Fall back to global cache (synchronous disk/memory cache). @@ -137,7 +147,7 @@ export abstract class RouterProvider extends BaseProvider { if (cachedModels?.[id]) { // Also populate instance models for future calls this.models = cachedModels - return { id, info: cachedModels[id] } + return { id, info: withDeclaredReasoningEffort(cachedModels[id], this.options) } } // Last resort: keep the configured id so we don't swap models, but zero @@ -145,19 +155,26 @@ export abstract class RouterProvider extends BaseProvider { if (id !== this.defaultModelId) { return { id, - info: { - ...this.defaultModelInfo, - inputPrice: 0, - outputPrice: 0, - cacheWritesPrice: 0, - cacheReadsPrice: 0, - }, + info: withDeclaredReasoningEffort( + { + ...this.defaultModelInfo, + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + }, + this.options, + ), } } - return { id, info: this.defaultModelInfo } + return { id, info: withDeclaredReasoningEffort(this.defaultModelInfo, this.options) } } + /** + * Router/LiteLLM model ids are opaque, so temperature support is + * inferred: only the known openai/o3-mini ids reject temperature. + */ protected supportsTemperature(modelId: string): boolean { return !modelId.startsWith("openai/o3-mini") } diff --git a/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts new file mode 100644 index 0000000000..f126cae97c --- /dev/null +++ b/src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts @@ -0,0 +1,58 @@ +// npx vitest run src/api/transform/__tests__/dte-effective-reasoning-effort.spec.ts + +import { ADAPTIVE_OUTPUT_CONFIG_EFFORTS, resolveEffectiveReasoningEffort } from "../reasoning" + +describe("DTE series 2/5 — resolveEffectiveReasoningEffort", () => { + const settingsEffort = "high" + const modelDefault = "medium" + + it("returns the per-request override when present (strongest precedence)", () => { + expect( + resolveEffectiveReasoningEffort({ + override: "xhigh", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("xhigh") + }) + + it("lets the override win even when it is out-of-range for the adaptive envelope", () => { + // "minimal" is a valid override value but outside the adaptive envelope set; + // resolution still returns it — envelope gating is the caller's concern. + expect( + resolveEffectiveReasoningEffort({ + override: "minimal", + settingsReasoningEffort: settingsEffort, + modelDefaultEffort: modelDefault, + }), + ).toBe("minimal") + }) + + it("falls back to the settings value when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "low", modelDefaultEffort: modelDefault }), + ).toBe("low") + }) + + it("preserves the settings 'disable' sentinel when no override is present", () => { + expect( + resolveEffectiveReasoningEffort({ settingsReasoningEffort: "disable", modelDefaultEffort: modelDefault }), + ).toBe("disable") + }) + + it("an explicit override wins over a settings 'disable' sentinel", () => { + expect(resolveEffectiveReasoningEffort({ override: "low", settingsReasoningEffort: "disable" })).toBe("low") + }) + + it("falls back to the model default when neither override nor settings is set", () => { + expect(resolveEffectiveReasoningEffort({ modelDefaultEffort: "low" })).toBe("low") + }) + + it("returns undefined when nothing is set", () => { + expect(resolveEffectiveReasoningEffort({})).toBeUndefined() + }) + + it("exposes exactly the in-range adaptive envelope efforts", () => { + expect([...ADAPTIVE_OUTPUT_CONFIG_EFFORTS]).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) +}) diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index c51111125a..14bdaba889 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -22,6 +22,51 @@ export type AnthropicProviderReasoningParams = AnthropicReasoningParams | { type export type OpenAiReasoningParams = { reasoning_effort: OpenAI.Chat.ChatCompletionCreateParams["reasoning_effort"] } +/** + * DTE series 2/5 — effort levels accepted by the Claude 4.7+ adaptive-thinking + * `output_config.effort` envelope. Efforts outside this set (e.g. "none", + * "minimal", "disable") omit the envelope so the API applies its own default. + */ +export const ADAPTIVE_OUTPUT_CONFIG_EFFORTS: readonly ReasoningEffortExtended[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +] + +/** + * DTE series 2/5 — resolves the effective thinking effort for a single request. + * + * Resolution order (strongest first): + * 1. `override` — the per-request task-local effort + * (`ApiHandlerCreateMessageMetadata.reasoningEffort`), + * 2. `settingsReasoningEffort` — the settings-derived value, + * 3. `modelDefaultEffort` — the model's default effort. + * + * This is the single shared resolution point for the per-request override: + * providers that resolve the effective effort through it inherit the override + * without duplicating precedence logic. The override is transient (next request + * only) and never persisted to settings. + */ +export const resolveEffectiveReasoningEffort = ({ + override, + settingsReasoningEffort, + modelDefaultEffort, +}: { + override?: ReasoningEffortExtended + settingsReasoningEffort?: ReasoningEffortExtended | "disable" + modelDefaultEffort?: ReasoningEffortExtended +}): ReasoningEffortExtended | "disable" | undefined => { + if (override !== undefined) { + return override + } + if (settingsReasoningEffort !== undefined) { + return settingsReasoningEffort + } + return modelDefaultEffort +} + // Valid Gemini thinking levels for effort-based reasoning const GEMINI_THINKING_LEVELS = ["minimal", "low", "medium", "high"] as const diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 9639ae1baa..572317dfee 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -510,6 +510,15 @@ export class NativeToolCallParser { } break + case "set_thinking_effort": + if (partialArgs.effort !== undefined || partialArgs.reason !== undefined) { + nativeArgs = { + effort: partialArgs.effort, + reason: partialArgs.reason, + } + } + break + case "run_slash_command": if (partialArgs.command !== undefined) { nativeArgs = { @@ -633,6 +642,9 @@ export class NativeToolCallParser { mode: partialArgs.mode, message: partialArgs.message, todos: partialArgs.todos, + // DTE series 5/5: optional subtask start effort must reach execute() + // for capability validation (dropping it made the arg a silent no-op). + thinking_effort: partialArgs.thinking_effort, } } break @@ -852,6 +864,17 @@ export class NativeToolCallParser { } break + case "set_thinking_effort": + // Both values must be strings: a non-string payload is an + // invalid tool call and must not reach the executor. + if (typeof args.effort === "string" && typeof args.reason === "string") { + nativeArgs = { + effort: args.effort, + reason: args.reason, + } as NativeArgsFor + } + break + case "run_slash_command": if (args.command !== undefined) { nativeArgs = { @@ -988,6 +1011,9 @@ export class NativeToolCallParser { mode: args.mode, message: args.message, todos: args.todos, + // DTE series 5/5: optional subtask start effort must reach execute() + // for capability validation (dropping it made the arg a silent no-op). + thinking_effort: args.thinking_effort, } as NativeArgsFor } break diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts new file mode 100644 index 0000000000..b4bbe556ed --- /dev/null +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts @@ -0,0 +1,133 @@ +// npx vitest run src/core/assistant-message/__tests__/NativeToolCallParser.setThinkingEffort.spec.ts +// +// DTE series 3/5 — set_thinking_effort parsing in NativeToolCallParser: +// complete, partial-streaming, and finalize paths. + +import { NativeToolCallParser } from "../NativeToolCallParser" + +describe("NativeToolCallParser — set_thinking_effort", () => { + beforeEach(() => { + NativeToolCallParser.clearAllStreamingToolCalls() + NativeToolCallParser.clearRawChunkState() + }) + + describe("parseToolCall (complete)", () => { + it("parses effort and reason into nativeArgs", () => { + const toolCall = { + id: "toolu_dte_1", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + if (result?.type === "tool_use") { + expect(result.name).toBe("set_thinking_effort") + expect(result.nativeArgs).toEqual({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }) + expect(result.params).toEqual({ + effort: "high", + reason: "Deep multi-file refactor ahead", + }) + } + }) + + it("returns null when the required reason is missing", () => { + const toolCall = { + id: "toolu_dte_2", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ effort: "high" }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + expect(result).toBeNull() + }) + + it("rejects a non-string reason (no nativeArgs, so the executor is not reached)", () => { + const toolCall = { + id: "toolu_dte_3", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ effort: "high", reason: {} }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + expect(result).toBeNull() + }) + + it("rejects a non-string effort (no nativeArgs, so the executor is not reached)", () => { + const toolCall = { + id: "toolu_dte_4", + name: "set_thinking_effort" as const, + arguments: JSON.stringify({ effort: 123, reason: "escalating" }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + expect(result).toBeNull() + }) + }) + + describe("processStreamingChunk (partial)", () => { + it("emits a partial ToolUse carrying the streamed effort", () => { + const id = "toolu_dte_stream_1" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + const result = NativeToolCallParser.processStreamingChunk( + id, + JSON.stringify({ effort: "high", reason: "escalating" }), + ) + + expect(result).not.toBeNull() + const nativeArgs = result?.nativeArgs as { effort?: string; reason?: string } | undefined + expect(nativeArgs).toBeDefined() + expect(nativeArgs?.effort).toBe("high") + expect(nativeArgs?.reason).toBe("escalating") + }) + + it("emits a partial ToolUse carrying only the streamed reason", () => { + const id = "toolu_dte_stream_2" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + const result = NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ reason: "escalating" })) + + expect(result).not.toBeNull() + const nativeArgs = result?.nativeArgs as { effort?: string; reason?: string } | undefined + expect(nativeArgs?.effort).toBeUndefined() + expect(nativeArgs?.reason).toBe("escalating") + }) + + it("emits a partial ToolUse without nativeArgs when neither param has streamed yet", () => { + const id = "toolu_dte_stream_3" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + const result = NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ other: "value" })) + + expect(result).not.toBeNull() + expect((result as { nativeArgs?: unknown }).nativeArgs).toBeUndefined() + }) + }) + + describe("finalizeStreamingToolCall", () => { + it("parses complete args on finalize", () => { + const id = "toolu_dte_final_1" + NativeToolCallParser.startStreamingToolCall(id, "set_thinking_effort") + + NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ effort: "low", reason: "mechanical step" })) + + const result = NativeToolCallParser.finalizeStreamingToolCall(id) + + expect(result).not.toBeNull() + if (result?.type === "tool_use") { + expect(result.nativeArgs).toEqual({ + effort: "low", + reason: "mechanical step", + }) + } + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 2c15e12069..5532e3e8e6 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -291,6 +291,57 @@ describe("NativeToolCallParser", () => { }) }) }) + describe("new_task tool", () => { + it("should carry the optional thinking_effort argument into nativeArgs (DTE series 5/5)", () => { + const toolCall = { + id: "toolu_new_task_effort", + name: "new_task" as const, + arguments: JSON.stringify({ + mode: "ask", + message: "Complete the delegated subtask", + todos: "- [ ] step one", + thinking_effort: "high", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + const nativeArgs = result.nativeArgs as { + mode: string + message: string + todos?: string + thinking_effort?: string + } + expect(nativeArgs.mode).toBe("ask") + expect(nativeArgs.message).toBe("Complete the delegated subtask") + expect(nativeArgs.todos).toBe("- [ ] step one") + expect(nativeArgs.thinking_effort).toBe("high") + } + }) + + it("should leave nativeArgs.thinking_effort undefined when the argument is omitted", () => { + const toolCall = { + id: "toolu_new_task_no_effort", + name: "new_task" as const, + arguments: JSON.stringify({ + mode: "ask", + message: "Complete the delegated subtask", + }), + } + + const result = NativeToolCallParser.parseToolCall(toolCall) + + expect(result).not.toBeNull() + expect(result?.type).toBe("tool_use") + if (result?.type === "tool_use") { + const nativeArgs = result.nativeArgs as { thinking_effort?: string } + expect(nativeArgs.thinking_effort).toBeUndefined() + } + }) + }) }) describe("processStreamingChunk", () => { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts new file mode 100644 index 0000000000..a3d1f71536 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts @@ -0,0 +1,229 @@ +// npx vitest run src/core/assistant-message/__tests__/presentAssistantMessage-setThinkingEffort.spec.ts +// +// DTE series 3/5 — set_thinking_effort dispatch in presentAssistantMessage: +// a completed native tool_use block is routed to SetThinkingEffortTool.handle +// with the standard callbacks (no approval gate). + +import { describe, it, expect, beforeEach, vi, type Mock } from "vitest" +import type { ModelInfo } from "@roo-code/types" + +import { presentAssistantMessage } from "../presentAssistantMessage" +import { setThinkingEffortTool } from "../../tools/SetThinkingEffortTool" +import type { Task } from "../../task/Task" + +// Mock dependencies +vi.mock("../../task/Task") +vi.mock("../../tools/validateToolUse", () => ({ + validateToolUse: vi.fn(), + isValidToolName: vi.fn((toolName: string) => toolName === "set_thinking_effort"), +})) +// The mock handler mirrors the real tool: it pushes exactly one tool result +// through the callbacks (the pushToolResultToUserContent mock records it). +vi.mock("../../tools/SetThinkingEffortTool", () => ({ + setThinkingEffortTool: { + handle: vi.fn( + async (_task: unknown, _block: unknown, callbacks: { pushToolResult: (content: string) => void }) => { + callbacks.pushToolResult("Thinking effort applied") + }, + ), + }, +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +/** Structural double covering every Task surface this dispatch path touches. */ +interface PamTaskDouble { + taskId: string + instanceId: string + abort: boolean + presentAssistantMessageLocked: boolean + presentAssistantMessageHasPendingUpdates: boolean + currentStreamingContentIndex: number + assistantMessageContent: unknown[] + userMessageContent: unknown[] + didCompleteReadingStream: boolean + didRejectTool: boolean + didAlreadyUseTool: boolean + consecutiveMistakeCount: number + clineMessages: unknown[] + api: { getModel: () => { id: string; info: ModelInfo } } + recordToolUsage: Mock + recordToolError: Mock + toolRepetitionDetector: { check: Mock } + providerRef: { + deref: () => { + getState: () => Promise<{ mode: string; customModes: unknown[] }> + } + } + say: Mock + ask: Mock + pushToolResultToUserContent: Mock +} + +describe("presentAssistantMessage - set_thinking_effort dispatch", () => { + let mockTask: PamTaskDouble + + beforeEach(() => { + vi.clearAllMocks() + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + clineMessages: [], + api: { + getModel: () => ({ + id: "test-model", + info: { contextWindow: 1, supportsPromptCache: false }, + }), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + // Records tool results so the dispatched tool_result can be asserted. + pushToolResultToUserContent: vi.fn().mockImplementation((toolResult: unknown) => { + mockTask.userMessageContent.push(toolResult) + return true + }), + } + }) + + // The structural double covers every Task surface presentAssistantMessage + // touches for this dispatch path; a full Task is not needed here. + function asTask(): Task { + return mockTask as unknown as Task + } + + function toolCallId() { + return "tool_call_dte_dispatch_1" + } + + function makeBlock() { + const id = toolCallId() + return { + type: "tool_use" as const, + id, + name: "set_thinking_effort" as const, + params: { effort: "high", reason: "deep analysis ahead" }, + partial: false, + nativeArgs: { effort: "high", reason: "deep analysis ahead" }, + } + } + + function dispatchedToolResult(): unknown { + return mockTask.userMessageContent.find( + (item) => + typeof item === "object" && + item !== null && + (item as { type?: string; tool_use_id?: string }).type === "tool_result" && + (item as { type?: string; tool_use_id?: string }).tool_use_id === toolCallId(), + ) + } + + it("routes a completed set_thinking_effort block to the tool handler", async () => { + mockTask.assistantMessageContent = [makeBlock()] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).toHaveBeenCalledTimes(1) + const [taskArg, blockArg, callbacksArg] = handle.mock.calls[0] + expect(taskArg).toBe(mockTask) + expect(blockArg).toMatchObject({ + name: "set_thinking_effort", + nativeArgs: { effort: "high", reason: "deep analysis ahead" }, + }) + expect(callbacksArg).toEqual( + expect.objectContaining({ + askApproval: expect.any(Function), + handleError: expect.any(Function), + pushToolResult: expect.any(Function), + }), + ) + + // Usage is recorded under the real tool name (not a telemetry alias). + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("set_thinking_effort") + // The handler pushes a tool_result for the tool call id. + expect(dispatchedToolResult()).toBeDefined() + }) + + it("does not route other tools through the set_thinking_effort handler", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use" as const, + id: "tool_call_other_1", + name: "nonexistent_tool", + params: { some: "param" }, + partial: false, + }, + ] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).not.toHaveBeenCalled() + }) + + it("describes a skipped set_thinking_effort block via the tool description when the task already rejected a tool", async () => { + mockTask.didRejectTool = true + mockTask.assistantMessageContent = [makeBlock()] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).not.toHaveBeenCalled() + const result = dispatchedToolResult() + expect(result).toBeDefined() + const content = (result as { content?: string }).content + expect(content).toContain("set_thinking_effort to 'high'") + expect(content).toContain("rejecting") + }) + + it("describes a set_thinking_effort block without an effort param via the tool description fallback", async () => { + mockTask.didRejectTool = true + mockTask.assistantMessageContent = [ + { + type: "tool_use" as const, + id: toolCallId(), + name: "set_thinking_effort", + params: {}, + partial: false, + }, + ] + + await presentAssistantMessage(asTask()) + + const handle = vi.mocked(setThinkingEffortTool.handle) + expect(handle).not.toHaveBeenCalled() + const result = dispatchedToolResult() + expect(result).toBeDefined() + const content = (result as { content?: string }).content + expect(content).toContain("set_thinking_effort to ''") + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 7383a7a35a..cc23495250 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -34,6 +34,7 @@ import { updateTodoListTool } from "../tools/UpdateTodoListTool" import { runSlashCommandTool } from "../tools/RunSlashCommandTool" import { skillTool } from "../tools/SkillTool" import { generateImageTool } from "../tools/GenerateImageTool" +import { setThinkingEffortTool } from "../tools/SetThinkingEffortTool" import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool" import { isValidToolName, validateToolUse } from "../tools/validateToolUse" import { codebaseSearchTool } from "../tools/CodebaseSearchTool" @@ -405,6 +406,8 @@ export async function presentAssistantMessage(cline: Task) { return `[${block.name} for '${block.params.skill}'${block.params.args ? ` with args: ${block.params.args}` : ""}]` case "generate_image": return `[${block.name} for '${block.params.path}']` + case "set_thinking_effort": + return `[${block.name} to '${block.params.effort ?? ""}']` default: return `[${block.name}]` } @@ -878,6 +881,15 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult, }) break + case "set_thinking_effort": + // DTE series 3/5: model-driven thinking effort — no approval gate, + // no checkpoint (non-destructive, task-local, clamped). + await setThinkingEffortTool.handle(cline, block as ToolUse<"set_thinking_effort">, { + askApproval, + handleError, + pushToolResult, + }) + break default: { // Handle unknown/invalid tool names OR custom tools // This is critical for native tool calling where every tool_use MUST have a tool_result diff --git a/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts new file mode 100644 index 0000000000..44671847db --- /dev/null +++ b/src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts @@ -0,0 +1,151 @@ +// npx vitest run src/core/prompts/tools/__tests__/filter-thinking-effort.spec.ts +// +// DTE series 3/5 — set_thinking_effort task-start gating: experiment flag +// AND model capability, stable tool list within a task. + +import { describe, it, expect } from "vitest" +import type OpenAI from "openai" +import type { ModelInfo } from "@roo-code/types" + +import { filterNativeToolsForMode, isSetThinkingEffortEnabled, isToolAllowedInMode } from "../filter-tools-for-mode" + +import { getNativeTools } from "../native-tools/index" + +function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { + return { + type: "function", + function: { + name, + description: name + " tool", + parameters: { type: "object", properties: {} }, + }, + } as OpenAI.Chat.ChatCompletionTool +} + +/** Minimal ModelInfo (contextWindow + supportsPromptCache are the only required fields). */ +function modelInfo(supportsReasoningEffort: ModelInfo["supportsReasoningEffort"]): ModelInfo { + return { contextWindow: 1, supportsPromptCache: false, supportsReasoningEffort } +} + +const TOOLS = [makeTool("execute_command"), makeTool("set_thinking_effort")] + +function toolNames(tools: OpenAI.Chat.ChatCompletionTool[]): string[] { + // The union also includes custom tools (no .function); only function tools carry names. + return tools.flatMap((t) => (t.type === "function" ? [t.function.name] : [])) +} + +describe("isSetThinkingEffortEnabled", () => { + it("is false when the experiment is off, even with capability", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: false }, modelInfo(["low", "high"]))).toBe(false) + expect(isSetThinkingEffortEnabled(undefined, modelInfo(["low", "high"]))).toBe(false) + }) + + it("is false when the model lacks per-request effort support", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, undefined)).toBe(false) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(false))).toBe(false) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo([]))).toBe(false) + }) + + it("is true for a capability array or boolean support", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(["low", "high"]))).toBe(true) + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(true))).toBe(true) + }) + + it("is false for a capability array that only lists 'disable' (no settable level)", () => { + expect(isSetThinkingEffortEnabled({ dynamicThinkingEffort: true }, modelInfo(["disable"]))).toBe(false) + }) +}) + +describe("filterNativeToolsForMode set_thinking_effort gate", () => { + it("removes the tool when the experiment is off", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: false }, undefined, { + modelInfo: modelInfo(["low", "high"]), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + expect(toolNames(result)).toContain("execute_command") + }) + + it("keeps the tool when experiment on and model supports effort", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(["low", "high"]), + }) + expect(toolNames(result)).toContain("set_thinking_effort") + }) + + it("removes the tool when the model does not support effort", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(false), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + }) + + it("removes the tool for a capability array that only lists 'disable'", () => { + const result = filterNativeToolsForMode(TOOLS, "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(["disable"]), + }) + expect(toolNames(result)).not.toContain("set_thinking_effort") + expect(toolNames(result)).toContain("execute_command") + }) + + it("keeps the tool list stable across repeated calls (prompt-cache safety)", () => { + const experiments = { dynamicThinkingEffort: true } + const settings = { modelInfo: modelInfo(["low", "high"]) } + const a = filterNativeToolsForMode(TOOLS, "code", undefined, experiments, undefined, settings) + const b = filterNativeToolsForMode(TOOLS, "code", undefined, experiments, undefined, settings) + expect(toolNames(a)).toEqual(toolNames(b)) + }) +}) + +describe("getNativeTools — set_thinking_effort schema", () => { + it("exposes the tool with strict effort + reason parameters", () => { + const schema = getNativeTools().find((t) => t.type === "function" && t.function.name === "set_thinking_effort") + if (!schema || schema.type !== "function") { + expect(schema).toBeDefined() + return + } + expect(schema.function.strict).toBe(true) + const parameters = schema.function.parameters as { + required?: string[] + properties?: Record + } + expect(parameters.required).toEqual(["effort", "reason"]) + expect(parameters.properties?.effort?.type).toBe("string") + expect(parameters.properties?.reason?.type).toBe("string") + expect(schema.function.description).toContain("no user approval") + }) +}) + +describe("isToolAllowedInMode — set_thinking_effort gate (prompt-side)", () => { + it("allows the tool only when the experiment is on and the model supports effort", () => { + const settings = { modelInfo: modelInfo(["low", "high"]) } + expect( + isToolAllowedInMode( + "set_thinking_effort", + "code", + undefined, + { dynamicThinkingEffort: true }, + undefined, + settings, + ), + ).toBe(true) + expect( + isToolAllowedInMode( + "set_thinking_effort", + "code", + undefined, + { dynamicThinkingEffort: false }, + undefined, + settings, + ), + ).toBe(false) + expect( + isToolAllowedInMode("set_thinking_effort", "code", undefined, { dynamicThinkingEffort: true }, undefined, { + modelInfo: modelInfo(false), + }), + ).toBe(false) + // Other always-available tools remain unconditional; in particular the + // DTE branch is skipped for them (non-set_thinking_effort path). + expect(isToolAllowedInMode("execute_command", "code", undefined, undefined, undefined, undefined)).toBe(true) + expect(isToolAllowedInMode("switch_mode", "code", undefined, undefined, undefined, undefined)).toBe(true) + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 2b31714a4c..5d1b293e31 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -6,6 +6,7 @@ import { defaultModeSlug } from "../../../shared/modes" import type { CodeIndexManager } from "../../../services/code-index/manager" import type { McpHub } from "../../../services/mcp/McpHub" import { isToolAllowedForMode } from "../../../core/tools/validateToolUse" +import { EXPERIMENT_IDS } from "../../../shared/experiments" /** * Reverse lookup map - maps alias name to canonical tool name. @@ -127,6 +128,16 @@ export function getToolAliasGroup(toolName: string): readonly string[] { return ALIAS_GROUPS.get(toolName) ?? [toolName] } +/** + * Result of applying model tool customization. + * Contains the set of allowed tools and any alias renames to apply. + */ +interface ModelToolCustomizationResult { + allowedTools: Set + /** Maps canonical tool name to alias name for tools that should be renamed */ + aliasRenames: Map +} + /** * Apply model-specific tool customization to a set of allowed tools. * @@ -139,16 +150,6 @@ export function getToolAliasGroup(toolName: string): readonly string[] { * @param modelInfo - Model configuration with tool customization * @returns Modified set of tools after applying model customization */ -/** - * Result of applying model tool customization. - * Contains the set of allowed tools and any alias renames to apply. - */ -interface ModelToolCustomizationResult { - allowedTools: Set - /** Maps canonical tool name to alias name for tools that should be renamed */ - aliasRenames: Map -} - export function applyModelToolCustomization( allowedTools: Set, modeConfig: ModeConfig, @@ -295,6 +296,14 @@ export function filterNativeToolsForMode( allowedToolNames.delete("run_slash_command") } + // DTE series 3/5: conditionally exclude set_thinking_effort unless the + // dynamicThinkingEffort experiment is enabled AND the current model supports + // per-request reasoning effort. The gate is evaluated here at task start so + // the tool list stays stable within a task (prompt-cache safety). + if (!isSetThinkingEffortEnabled(experiments, settings?.modelInfo as ModelInfo | undefined)) { + allowedToolNames.delete("set_thinking_effort") + } + // Remove tools that are explicitly disabled via the disabledTools setting if (settings?.disabledTools?.length) { for (const toolName of settings.disabledTools) { @@ -354,6 +363,35 @@ function hasAnyMcpResources(mcpHub: McpHub, allowedServers?: string[]): boolean return servers.some((server) => server.resources && server.resources.length > 0) } +/** + * DTE series 3/5: whether the set_thinking_effort tool should be exposed. + * + * Requires both the dynamicThinkingEffort experiment to be enabled and the + * model to advertise per-request reasoning effort support (a + * `supportsReasoningEffort` capability array with at least one settable + * non-`disable` level, or boolean/adaptive-class support). Evaluated at + * task start only (prompt-cache safety). + * + * @param experiments - Experiment flags from the current state + * @param modelInfo - Current model info (from apiConfiguration) + * @returns true when the tool should be included in the task tool list + */ +export function isSetThinkingEffortEnabled( + experiments: Record | undefined, + modelInfo: ModelInfo | undefined, +): boolean { + if (experiments?.[EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT] !== true) { + return false + } + const capability = modelInfo?.supportsReasoningEffort + if (Array.isArray(capability)) { + // A "disable"-only array exposes a tool that cannot apply any level + // (the executor's clamp would land on "disable" and refuse every call). + return capability.some((effort) => effort !== "disable") + } + return capability === true +} + /** * Checks if a specific tool is allowed in the current mode. * This is useful for dynamically filtering system prompt content. @@ -396,6 +434,9 @@ export function isToolAllowedInMode( if (toolName === "run_slash_command") { return experiments?.runSlashCommand === true } + if (toolName === "set_thinking_effort") { + return isSetThinkingEffortEnabled(experiments, settings?.modelInfo as ModelInfo | undefined) + } return true } diff --git a/src/core/prompts/tools/native-tools/index.ts b/src/core/prompts/tools/native-tools/index.ts index 758914d2d6..28836a902a 100644 --- a/src/core/prompts/tools/native-tools/index.ts +++ b/src/core/prompts/tools/native-tools/index.ts @@ -13,6 +13,7 @@ import newTask from "./new_task" import readCommandOutput from "./read_command_output" import { createReadFileTool, type ReadFileToolOptions } from "./read_file" import runSlashCommand from "./run_slash_command" +import setThinkingEffort from "./set_thinking_effort" import skill from "./skill" import searchReplace from "./search_replace" import edit_file from "./edit_file" @@ -60,6 +61,7 @@ export function getNativeTools(options: NativeToolsOptions = {}): OpenAI.Chat.Ch readCommandOutput, createReadFileTool(readFileOptions), runSlashCommand, + setThinkingEffort, skill, searchReplace, edit_file, diff --git a/src/core/prompts/tools/native-tools/new_task.ts b/src/core/prompts/tools/native-tools/new_task.ts index f8e29e549d..17c2f5b524 100644 --- a/src/core/prompts/tools/native-tools/new_task.ts +++ b/src/core/prompts/tools/native-tools/new_task.ts @@ -10,6 +10,8 @@ const MESSAGE_PARAMETER_DESCRIPTION = `Initial user instructions or context for const TODOS_PARAMETER_DESCRIPTION = `Optional initial todo list written as a markdown checklist; required when the workspace mandates todos` +const THINKING_EFFORT_PARAMETER_DESCRIPTION = `Optional thinking effort the new task starts with (e.g., "low", "medium", "high"). Must be a level the target model supports. When omitted, the new task starts with the current task's effective effort. The user can still change it before entering the new task.` + export default { type: "function", function: { @@ -31,8 +33,16 @@ export default { type: ["string", "null"], description: TODOS_PARAMETER_DESCRIPTION, }, + thinking_effort: { + // strict: true + additionalProperties: false requires every property to be + // listed in `required` (the Anthropic API rejects the tool definition + // otherwise), so the optional parameter uses the same ["string", "null"] + // pattern as `todos`: the model sends null to omit it. + type: ["string", "null"], + description: THINKING_EFFORT_PARAMETER_DESCRIPTION, + }, }, - required: ["mode", "message", "todos"], + required: ["mode", "message", "todos", "thinking_effort"], additionalProperties: false, }, }, diff --git a/src/core/prompts/tools/native-tools/set_thinking_effort.ts b/src/core/prompts/tools/native-tools/set_thinking_effort.ts new file mode 100644 index 0000000000..029c8451e6 --- /dev/null +++ b/src/core/prompts/tools/native-tools/set_thinking_effort.ts @@ -0,0 +1,49 @@ +import type OpenAI from "openai" + +/** + * DTE series 3/5: native tool schema for model-driven per-turn thinking effort. + * + * The tool is only exposed when the dynamicThinkingEffort experiment is on and + * the current model supports per-request reasoning effort (see + * filter-tools-for-mode.ts). The gate is evaluated at task start only so the + * tool list stays stable within a task (prompt-cache safety). + */ +const SET_THINKING_EFFORT_DESCRIPTION = `Adjust your own thinking (reasoning) effort for the remainder of this task. Use it when the task complexity changes mid-task — for example, when a simple lookup turns into a deep multi-file refactor, or when a straightforward step follows a hard one. The change takes effect from the next model request and applies to the current task only; it is never written to persisted settings and requires no user approval. + +Parameters: +- effort: (required) The new thinking effort level. Must be one of the levels supported by the current model. +- reason: (required) A one-sentence explanation of why the effort is changing. It is shown to the user alongside the new level. + +Example: Escalating after a complex bug +{ "effort": "high", "reason": "The refactor spans 6 files with cross-cutting type changes; deeper reasoning is needed." } + +Example: De-escalating after a hard phase +{ "effort": "low", "reason": "Remaining work is mechanical test updates for already-verified behavior." }` + +const EFFORT_PARAMETER_DESCRIPTION = `The new thinking effort level (one of the levels supported by the current model)` + +const REASON_PARAMETER_DESCRIPTION = `A one-sentence explanation of why the effort is changing; shown to the user` + +export default { + type: "function", + function: { + name: "set_thinking_effort", + description: SET_THINKING_EFFORT_DESCRIPTION, + strict: true, + parameters: { + type: "object", + properties: { + effort: { + type: "string", + description: EFFORT_PARAMETER_DESCRIPTION, + }, + reason: { + type: "string", + description: REASON_PARAMETER_DESCRIPTION, + }, + }, + required: ["effort", "reason"], + additionalProperties: false, + }, + }, +} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/task-persistence/__tests__/taskMetadata.spec.ts b/src/core/task-persistence/__tests__/taskMetadata.spec.ts new file mode 100644 index 0000000000..2fccc03521 --- /dev/null +++ b/src/core/task-persistence/__tests__/taskMetadata.spec.ts @@ -0,0 +1,81 @@ +// 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 so that reopening the task from +// history restores it. +// +// 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" +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("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") + }) + + 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") + }) +}) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index ec2e6cceeb..897289d965 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,12 @@ 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. 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 } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 349d9c51d3..dd2088ef69 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, @@ -57,6 +58,7 @@ import { providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { resolveEffectiveReasoningEffort } from "../../api/transform/reasoning" import { CloudService } from "@roo-code/cloud" // api @@ -289,6 +291,16 @@ 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"] + // DTE series 5/5: thinking effort chosen in the webview new_task ask block; carried + // by the ask response (handleWebviewAskResponse) and consumed once by NewTaskTool. + private newTaskAskThinkingEffort?: ReasoningEffortExtended private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler @@ -549,6 +561,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) @@ -1098,7 +1116,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), @@ -1110,6 +1133,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, @@ -1121,6 +1148,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: runtimeEffort.effort, + thinkingEffortSource: runtimeEffort.source, }) // Emit token/tool usage updates using debounced function @@ -1439,7 +1470,16 @@ export class Task extends EventEmitter implements TaskLike { return result } - handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + /** + * DTE series 5/5: the optional `thinkingEffort` is the user's new_task ask-block + * selection (webview `WebviewMessage.thinkingEffort`), consumed by NewTaskTool. + */ + handleWebviewAskResponse( + askResponse: ClineAskResponse, + text?: string, + images?: string[], + thinkingEffort?: ReasoningEffortExtended, + ) { // Clear any pending auto-approval timeout when user responds this.cancelAutoApprovalTimeout() @@ -1447,6 +1487,10 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = text this.askResponseImages = images + if (thinkingEffort !== undefined) { + this.newTaskAskThinkingEffort = thinkingEffort + } + // Create a checkpoint whenever the user sends a message. // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. @@ -1517,14 +1561,121 @@ export class Task extends EventEmitter implements TaskLike { * Updates the API configuration and rebuilds the API handler. * There is no tool-protocol switching or tool parser swapping. * + * DTE series 2/5: when a task-local thinking effort override is active + * (`setRuntimeThinkingEffort`), the incoming configuration's `reasoningEffort` + * becomes the new restore value and the override is re-applied on top of the + * fresh in-memory copy — clearing the override later restores the NEW profile's + * value, not a stale one. + * * @param newApiConfiguration - The new API configuration to use */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { // Update the configuration and rebuild the API handler - this.apiConfiguration = newApiConfiguration + if (this.runtimeThinkingEffort !== undefined) { + // DTE series 2/5: a task-local override is active, so re-capture the + // incoming profile's value as the restore value and re-apply the + // override on top of the new in-memory copy — clearing the override + // must restore the NEW profile's value, not the stale one. + this.preOverrideReasoningEffort = newApiConfiguration.reasoningEffort + this.apiConfiguration = { ...newApiConfiguration, reasoningEffort: this.runtimeThinkingEffort } + } else { + this.apiConfiguration = newApiConfiguration + } this.api = buildApiHandler(this.apiConfiguration) } + /** + * DTE series 2/5: sets — or clears with `undefined` — the task-local thinking + * effort override. + * + * Resolution order for the affected requests (strongest first): this + * task-local override → settings `reasoningEffort` → model default. The + * override applies to the NEXT API request only (no mid-stream effect): it is + * passed per request as `metadata.reasoningEffort` and, while active, is + * merged into the in-memory `apiConfiguration` copy (profile-switch / + * `updateApiConfiguration` precedent) so the rebuilt handler reflects it too. + * `undefined` clears the override and restores the settings-derived value in + * the copy. Nothing is ever written to persisted settings. + * + * @param effort - The task-local effort, or `undefined` to clear. + * @param source - Optional provenance label (UI wiring lands in a later PR). + */ + public setRuntimeThinkingEffort(effort: ReasoningEffortExtended | undefined, source?: string): void { + const wasActive = this.runtimeThinkingEffort !== undefined + this.runtimeThinkingEffort = effort + this.runtimeThinkingEffortSource = effort === undefined ? undefined : source + + if (effort !== undefined) { + // Capture the settings-derived value once so clearing can restore it. + if (!wasActive) { + this.preOverrideReasoningEffort = this.apiConfiguration.reasoningEffort + } + // Merge into the in-memory copy (never the persisted settings object). + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: effort } + } else if (wasActive) { + // Restore the settings-derived value captured when the override activated. + this.apiConfiguration = { ...this.apiConfiguration, reasoningEffort: this.preOverrideReasoningEffort } + this.preOverrideReasoningEffort = undefined + } else { + // Already inactive: nothing to clear. + return + } + + // Rebuild the handler from the updated copy so the next request uses it. + this.api = buildApiHandler(this.apiConfiguration) + } + + /** + * DTE series 2/5: reads the current task-local thinking effort override. + */ + public getRuntimeThinkingEffort(): { effort?: ReasoningEffortExtended; source?: string } { + return { + effort: this.runtimeThinkingEffort, + source: this.runtimeThinkingEffortSource, + } + } + + /** + * DTE series 2/5: metadata fragment carrying the active task-local effort + * override on a single request. Empty when no override is active, so the + * existing settings resolution applies unchanged. + */ + private getRuntimeThinkingEffortMetadata(): Pick { + return this.runtimeThinkingEffort !== undefined ? { reasoningEffort: this.runtimeThinkingEffort } : {} + } + + /** + * DTE series 5/5: resolves this task's current effective thinking effort — used to + * pre-fill the new_task ask block and to inherit the effort into a child task when + * neither the model nor the user specifies one. + * + * Resolution reuses the PR-2 point (task-local override → settings + * `reasoningEffort` → model default). The settings "disable" sentinel is excluded: + * it is a UI off-switch, not a level a child task can start with. + */ + public resolveNewTaskEffectiveEffort(): ReasoningEffortExtended | undefined { + const { effort: runtimeEffort } = this.getRuntimeThinkingEffort() + if (runtimeEffort !== undefined) { + return runtimeEffort + } + const resolved = resolveEffectiveReasoningEffort({ + settingsReasoningEffort: this.apiConfiguration?.reasoningEffort, + modelDefaultEffort: this.api.getModel().info.reasoningEffort, + }) + return resolved === "disable" ? undefined : resolved + } + + /** + * DTE series 5/5: reads and clears the thinking effort the user chose in the + * pending new_task ask block (set from the webview ask response). The value is + * consumed once by NewTaskTool so a later, different ask cannot reuse it. + */ + public takeNewTaskAskThinkingEffort(): ReasoningEffortExtended | undefined { + const effort = this.newTaskAskThinkingEffort + this.newTaskAskThinkingEffort = undefined + return effort + } + public async submitUserMessage( text: string, images?: string[], @@ -1641,6 +1792,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) @@ -2283,6 +2436,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) { @@ -2299,15 +2458,30 @@ 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) } } + /** + * Centralized task teardown: releases task resources and resets transient + * task-local state. + * + * DTE series 2/5: also clears the task-local thinking effort override (the + * `setRuntimeThinkingEffort` state) — the override never outlives the task. + */ public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // DTE series 2/5: the task-local effort override is transient — clear it on + // task end so a disposed task never carries it forward. DTE series 5/5: the + // pending new_task ask-block selection is consumed or discarded the same way. + this.runtimeThinkingEffort = undefined + this.runtimeThinkingEffortSource = undefined + this.preOverrideReasoningEffort = undefined + this.newTaskAskThinkingEffort = undefined + // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. @@ -2404,6 +2578,9 @@ export class Task extends EventEmitter implements TaskLike { message, initialTodos, mode, + // DTE series 5/5: the child starts with the parent's current effective + // effort (source "parent") so its header shows it from the first request. + thinkingEffort: this.resolveNewTaskEffectiveEffort(), }) return child } @@ -3968,6 +4145,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } try { @@ -4194,6 +4373,8 @@ export class Task extends EventEmitter implements TaskLike { parallelToolCalls: true, } : {}), + // DTE series 2/5: carry the active task-local effort override. + ...this.getRuntimeThinkingEffortMetadata(), } // Only generate environment details when context management will actually run. @@ -4359,6 +4540,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.new-task-effort.spec.ts b/src/core/task/__tests__/Task.new-task-effort.spec.ts new file mode 100644 index 0000000000..04d7a88948 --- /dev/null +++ b/src/core/task/__tests__/Task.new-task-effort.spec.ts @@ -0,0 +1,194 @@ +// npx vitest run src/core/task/__tests__/Task.new-task-effort.spec.ts +// +// DTE series 5/5 — new_task thinking effort plumbing on Task: +// resolveNewTaskEffectiveEffort (task-local override → settings reasoningEffort +// → model default, with the settings "disable" sentinel mapped to undefined), +// the single-consume takeNewTaskAskThinkingEffort, the ask-response capture in +// handleWebviewAskResponse, and the dispose() discard. + +import { ProviderSettings } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +// Mock dependencies (same lightweight set as Task.runtime-thinking-effort.test.ts) +vi.mock("../../webview/ClineProvider") +vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") +vi.mock("../../context-tracking/FileContextTracker") +vi.mock("../../../integrations/editor/DiffViewProvider") +vi.mock("../../tools/ToolRepetitionDetector") + +// The model info object the mocked API handler reports; tests mutate it to steer +// the model-default branch of resolveNewTaskEffectiveEffort. +const { modelInfo } = vi.hoisted(() => ({ + modelInfo: {} as { reasoningEffort?: string }, +})) + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn(() => ({ + getModel: () => ({ info: modelInfo, id: "test-model" }), + })), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + }, + }, +})) + +// Mock task persistence to avoid disk writes +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + readTaskMessages: vi.fn().mockResolvedValue([]), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + taskMetadata: vi.fn().mockResolvedValue({ + historyItem: { + id: "test-task-id", + number: 1, + task: "Test task", + ts: Date.now(), + totalCost: 0.01, + tokensIn: 100, + tokensOut: 50, + }, + tokenUsage: { + totalTokensIn: 100, + totalTokensOut: 50, + totalCost: 0.01, + contextTokens: 150, + totalCacheWrites: 0, + totalCacheReads: 0, + }, + }), +})) + +describe("Task new_task thinking effort (DTE series 5/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + const makeTask = (apiConfiguration: ProviderSettings) => + new Task({ + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + provider: mockProvider as unknown as ClineProvider, + apiConfiguration, + startTask: false, + }) + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + modelInfo.reasoningEffort = undefined + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/path" }, + }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: "low", + } as ProviderSettings + + task = makeTask(mockApiConfiguration) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("resolveNewTaskEffectiveEffort", () => { + it("prefers the task-local runtime override", () => { + task.setRuntimeThinkingEffort("xhigh", "source") + + expect(task.resolveNewTaskEffectiveEffort()).toBe("xhigh") + }) + + it("falls back to the settings reasoningEffort without an override", () => { + expect(task.resolveNewTaskEffectiveEffort()).toBe("low") + }) + + it("falls back to the model default when settings carries no effort", () => { + modelInfo.reasoningEffort = "high" + const noSettingsTask = makeTask({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + } as ProviderSettings) + + expect(noSettingsTask.resolveNewTaskEffectiveEffort()).toBe("high") + noSettingsTask.dispose() + }) + + it("maps the settings 'disable' sentinel to undefined", () => { + const disableTask = makeTask({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: "disable", + } as ProviderSettings) + + expect(disableTask.resolveNewTaskEffectiveEffort()).toBeUndefined() + disableTask.dispose() + }) + }) + + describe("takeNewTaskAskThinkingEffort", () => { + it("is empty until the ask response carries a selection", () => { + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + + it("stores the selection from handleWebviewAskResponse and consumes it once", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "high") + + expect(task.takeNewTaskAskThinkingEffort()).toBe("high") + // Consumed: a second read (or a later, different ask) cannot reuse it. + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + + it("leaves a stored selection untouched when a later response carries none", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "medium") + // A non-new_task response never carries the field, so the stored value + // survives until the new_task approval consumes it. + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined) + + expect(task.takeNewTaskAskThinkingEffort()).toBe("medium") + }) + }) + + describe("dispose", () => { + it("discards the pending ask-block selection at task end", () => { + task.handleWebviewAskResponse("yesButtonClicked", undefined, undefined, "max") + task.dispose() + + expect(task.takeNewTaskAskThinkingEffort()).toBeUndefined() + }) + }) +}) diff --git a/src/core/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..5b97cda03b --- /dev/null +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -0,0 +1,394 @@ +// 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 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") +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 } + saveClineMessages: () => Promise +} + +function getPrivateAccess(task: Task): RuntimeThinkingEffortAccess { + return task as unknown as RuntimeThinkingEffortAccess +} + +const SETTINGS_EFFORT: ReasoningEffortExtended = "low" + +describe("Task runtime thinking effort (DTE series 2/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/path" }, + }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + } as ProviderSettings + + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + startTask: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("setRuntimeThinkingEffort", () => { + it("stores effort + source, merges into the in-memory apiConfiguration, and rebuilds the handler", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + expect(getPrivateAccess(task).runtimeThinkingEffort).toBe("xhigh") + expect(getPrivateAccess(task).runtimeThinkingEffortSource).toBe("test-source") + + // The in-memory copy carries the override... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + reasoningEffort: "xhigh", + }), + ) + // ...without mutating the settings object the provider handed in. + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + reasoningEffort: SETTINGS_EFFORT, + }), + ) + // The handler is rebuilt from the merged copy (last build call). + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "xhigh" })) + // The merged copy is a fresh object, not the settings object. + expect(lastCall?.[0]).not.toBe(mockApiConfiguration) + }) + + it("does not re-capture the settings value when re-set while active", () => { + task.setRuntimeThinkingEffort("high", "first") + task.setRuntimeThinkingEffort("medium", "second") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "medium", source: "second" }) + // The settings-derived value captured at first activation is preserved. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe(SETTINGS_EFFORT) + + // Clearing restores the original settings value, not the intermediate one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + }) + + it("restores the settings-derived effort when cleared with undefined", () => { + task.setRuntimeThinkingEffort("max") + expect(task.apiConfiguration.reasoningEffort).toBe("max") + + task.setRuntimeThinkingEffort(undefined) + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + // The rest of the configuration is preserved through the restore. + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + }), + ) + // The handler is rebuilt from the restored copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: SETTINGS_EFFORT })) + }) + + it("is a no-op when cleared while inactive (no handler rebuild)", () => { + const callsBefore = vi.mocked(buildApiHandler).mock.calls.length + + task.setRuntimeThinkingEffort(undefined) + + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(callsBefore) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration).toBe(mockApiConfiguration) + }) + + it("never writes to the provider or persisted settings", () => { + task.setRuntimeThinkingEffort("xhigh") + task.setRuntimeThinkingEffort(undefined) + + // Nothing is posted to the webview and the handed-in settings object is intact. + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + }), + ) + }) + }) + + describe("updateApiConfiguration while an override is active", () => { + it("re-captures the incoming profile's effort as the restore value and keeps the override applied", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + // A profile switch lands a different settings-derived effort while the override is active. + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + task.updateApiConfiguration(newConfig) + + // The override still wins in the in-memory copy... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "xhigh", + }), + ) + // ...the override remains active... + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + // ...and the NEW profile's value is now the restore target. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe("medium") + // The handler was rebuilt from the merged new copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual( + expect.objectContaining({ apiModelId: "claude-opus-4-8", reasoningEffort: "xhigh" }), + ) + expect(lastCall?.[0]).not.toBe(newConfig) + + // Clearing restores the NEW profile's effort, not the stale original one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + }), + ) + const lastCallAfterClear = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCallAfterClear?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "medium" })) + }) + + it("replaces the configuration as usual while inactive", () => { + const newConfig = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-8", + apiKey: "test-key-2", + reasoningEffort: "medium", + } as ProviderSettings + + task.updateApiConfiguration(newConfig) + + expect(task.apiConfiguration).toBe(newConfig) + expect(task.apiConfiguration.reasoningEffort).toBe("medium") + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toBe(newConfig) + }) + }) + + describe("request metadata fragment", () => { + it("is empty while inactive and carries the override while active", () => { + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + + task.setRuntimeThinkingEffort("high") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "high" }) + + task.setRuntimeThinkingEffort("low") + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({ reasoningEffort: "low" }) + + task.setRuntimeThinkingEffort(undefined) + expect(getPrivateAccess(task).getRuntimeThinkingEffortMetadata()).toEqual({}) + }) + }) + + describe("dispose", () => { + it("clears the task-local override at task end", () => { + task.setRuntimeThinkingEffort("xhigh", "source") + task.dispose() + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + const access = getPrivateAccess(task) + expect(access.runtimeThinkingEffort).toBeUndefined() + expect(access.runtimeThinkingEffortSource).toBeUndefined() + expect(access.preOverrideReasoningEffort).toBeUndefined() + }) + }) + + 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 }), + ) + }) + }) + + 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 }), + ) + }) + }) +}) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e37..b2a42b049f 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" -import { TodoItem } from "@roo-code/types" +import { TodoItem, type ReasoningEffortExtended } from "@roo-code/types" import { Task } from "../task/Task" import { getModeBySlug } from "../../shared/modes" @@ -15,13 +15,35 @@ interface NewTaskParams { mode: string message: string todos?: string + // DTE series 5/5: optional subtask start effort (validated against the target model). + // "null" is the strict-mode "omitted" sentinel (the schema type is + // ["string", "null"] so the parameter can be required without forcing a + // value); treated as absent, same as undefined/"". + thinking_effort?: string | null } +// DTE series 5/5: the effort levels a new task can start with. "disable" is a settings +// off-switch, not a start level, so it is excluded from this list. +const NEW_TASK_EFFORT_LEVELS: readonly ReasoningEffortExtended[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] + +// Narrows a raw tool argument to a reasoning-effort level (single documented cast: +// the literal list above is exactly the value set of ReasoningEffortExtended). +const isNewTaskEffortLevel = (value: string): value is ReasoningEffortExtended => + (NEW_TASK_EFFORT_LEVELS as readonly string[]).includes(value) + export class NewTaskTool extends BaseTool<"new_task"> { readonly name = "new_task" as const async execute(params: NewTaskParams, task: Task, callbacks: ToolCallbacks): Promise { - const { mode, message, todos } = params + const { mode, message, todos, thinking_effort } = params const { askApproval, handleError, pushToolResult } = callbacks try { @@ -42,6 +64,54 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the child task is created with the parent's API configuration, + // so the child model is the parent's current model. Validate the optional start + // effort against that model's capability before asking for approval. + // + // ModelInfo.supportsReasoningEffort is `boolean | string[] | undefined`: the bare + // `true` means the model supports reasoning effort without an explicit allow-list, + // so normalize it to the full level set. `false`/`undefined` stay unsupported + // (argument rejected below). The normalized array is the single source of truth + // for the argument validation, the ask payload, and the ask-selection check. + const modelCapabilities = task.api.getModel().info.supportsReasoningEffort + // "disable" stays in the element type: capability arrays may carry it (it is a + // settings off-switch, not a start level) and is filtered where levels are listed. + // Registry capability arrays are never trusted blindly — normalize to the known + // level set so an unknown string cannot reach the ask payload or the child effort. + const supportedLevels: readonly (ReasoningEffortExtended | "disable")[] = + modelCapabilities === true + ? NEW_TASK_EFFORT_LEVELS + : Array.isArray(modelCapabilities) + ? modelCapabilities.filter( + (level): level is ReasoningEffortExtended | "disable" => + level === "disable" || isNewTaskEffortLevel(level), + ) + : [] + // "disable" is a settings off-switch, never a start level: filtering it can + // leave an empty list (a model whose only capability is "disable"). In that + // case the unsupported-model wording is the accurate hint — an empty list + // would otherwise suggest sending "none", which fails validation again. + const startLevels = supportedLevels.filter((level) => level !== "disable") + let validatedEffort: ReasoningEffortExtended | undefined + if (thinking_effort !== undefined && thinking_effort !== null && thinking_effort !== "") { + if (!isNewTaskEffortLevel(thinking_effort) || !supportedLevels.includes(thinking_effort)) { + // Consistent with every other failure path in this tool: advance the + // consecutive-mistake guardrail and record the failure for telemetry, + // so a model repeating an unsupported effort trips the mistake loop. + task.consecutiveMistakeCount++ + task.recordToolError("new_task") + task.didToolFailInCurrentTurn = true + const reason = !isNewTaskEffortLevel(thinking_effort) + ? `must be one of: ${NEW_TASK_EFFORT_LEVELS.join(", ")}` + : startLevels.length > 0 + ? `the target model only supports: ${startLevels.join(", ")}` + : "the target model does not support thinking_effort" + pushToolResult(formatResponse.toolError(`Invalid thinking_effort '${thinking_effort}'. ${reason}`)) + return + } + validatedEffort = thinking_effort + } + // Get the VSCode setting for requiring todos. const provider = task.providerRef.deref() @@ -96,11 +166,20 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the ask payload pre-fills the webview effort selector with + // the validated model effort (falling back to the parent's current effective + // effort) and lists the levels the target model supports ("disable" is a + // settings off-switch, not a level a child task can start with). const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, content: message, todos: todoItems, + thinkingEffort: validatedEffort ?? task.resolveNewTaskEffectiveEffort(), + supportedThinkingEfforts: + supportedLevels.length > 0 + ? supportedLevels.filter((level): level is ReasoningEffortExtended => level !== "disable") + : undefined, }) const didApprove = await askApproval("tool", toolMessage) @@ -109,12 +188,23 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // DTE series 5/5: the user may have switched the effort in the ask block — + // the ask response carries it (consumed once from Task) and wins over the + // model-specified value, which wins over the parent's effective effort. An + // ask selection the target model does not support falls back the same way. + const askEffort = task.takeNewTaskAskThinkingEffort() + const askEffortSupported = askEffort !== undefined && supportedLevels.includes(askEffort) + const childThinkingEffort = askEffortSupported + ? askEffort + : (validatedEffort ?? task.resolveNewTaskEffectiveEffort()) + // Delegate parent and open child as sole active task const child = await (provider as any).delegateParentAndOpenChild({ parentTaskId: task.taskId, message: unescapedMessage, initialTodos: todoItems, mode, + thinkingEffort: childThinkingEffort, }) // Reflect delegation in tool result (no pause/unpause, no wait) diff --git a/src/core/tools/SetThinkingEffortTool.ts b/src/core/tools/SetThinkingEffortTool.ts new file mode 100644 index 0000000000..80d532ff7a --- /dev/null +++ b/src/core/tools/SetThinkingEffortTool.ts @@ -0,0 +1,337 @@ +import { type ClineSayTool, type ModelInfo } from "@roo-code/types" + +import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" +import type { ToolUse } from "../../shared/tools" +import { formatResponse } from "../prompts/responses" +import { Task } from "../task/Task" +import { BaseTool, ToolCallbacks } from "./BaseTool" + +/** + * DTE series 3/5: model-driven per-turn thinking effort. + * + * The model calls this tool to adjust its own thinking effort mid-task. + * There is NO approval gate (non-destructive, clamped to the model + * capability, instantly undoable); guardrails replace approval: + * - always a one-line chat notification (success or refusal) + * - escalation cap: max 3 upward changes per task + * - oscillation detection: A -> B -> A ping-pong within a task (including a + * return to the task baseline) is refused + * - hard clamp to the model capability array + * + * The tool is only exposed when the dynamicThinkingEffort experiment is on + * and the model supports per-request effort (see filter-tools-for-mode.ts); + * the checks below are defense in depth for stale or direct invocations. + */ + +interface SetThinkingEffortParams { + effort: string + reason: string +} + +/** + * Canonical effort ordering used to detect upward changes. "disable" ranks + * lowest: it is a UI/control value that can only appear as the + * settings-derived baseline, never as a value this tool may set. + */ +export const EFFORT_RANK: Record = { + disable: 0, + none: 1, + minimal: 2, + low: 3, + medium: 4, + high: 5, + xhigh: 6, + max: 7, +} + +/** Effort levels this tool may set (disable excluded — see above). */ +export const SETTABLE_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const + +type SettableEffort = (typeof SETTABLE_EFFORTS)[number] + +/** Max upward (escalating) changes per task before the tool refuses. */ +export const MAX_UPWARD_CHANGES = 3 + +/** Per-task guardrail state (scoped per Task; see guardState WeakMap). */ +interface EffortGuardState { + upwardChanges: number + /** + * Applied efforts, most recent last; seeded with the task's effective + * baseline (when defined) so returning to it counts as oscillation. + */ + history: string[] +} + +/** + * Ordinal rank of a settable effort level (drives nearest-level + * clamping and the escalation/oscillation guardrails). Unknown or + * undefined values rank as "disable" — the bottom of the scale. + */ +function effortRank(level: string | undefined): number { + return level === undefined ? EFFORT_RANK.disable : (EFFORT_RANK[level] ?? EFFORT_RANK.disable) +} + +/** + * Hard clamp to the model capability array: an in-array request passes + * through unchanged; any other valid level is mapped to the nearest + * supported level (ties resolved toward the lower level). + * + * Only recognized effort values (SETTABLE_EFFORTS plus "disable") count as + * supported: capability arrays may carry provider-specific garbage, and the + * nearest-level selection must never yield an unrecognized value that would + * be applied as the runtime effort. Returns `undefined` when the array holds + * no recognized value at all, in which case the caller must refuse the call. + */ +function clampToCapability( + requested: SettableEffort, + capability: ModelInfo["supportsReasoningEffort"], +): SettableEffort | "disable" | undefined { + if (!Array.isArray(capability) || capability.length === 0) { + return requested + } + const supported = capability.filter( + (level): level is SettableEffort | "disable" => + (SETTABLE_EFFORTS as readonly string[]).includes(level) || level === "disable", + ) + if (supported.length === 0) { + return undefined + } + if (supported.includes(requested)) { + return requested + } + const requestedRank = effortRank(requested) + let best = supported[0] + let bestDistance = Number.POSITIVE_INFINITY + for (const level of supported) { + const distance = Math.abs(effortRank(level) - requestedRank) + // Ties resolve toward the lower effort level. + if (distance < bestDistance || (distance === bestDistance && effortRank(level) < effortRank(best))) { + best = level + bestDistance = distance + } + } + return best +} + +export class SetThinkingEffortTool extends BaseTool<"set_thinking_effort"> { + readonly name = "set_thinking_effort" as const + + /** + * Guardrail state is per-task. The tool instance is a module singleton, + * so state is keyed by Task instance in a WeakMap: each task starts + * fresh and state is garbage-collected with the task. + */ + private guardState = new WeakMap() + + /** + * Returns the per-task guardrail state, creating it on first use with + * the task's effective baseline seeded into the history. + */ + private getGuardState(task: Task, baseline: string | undefined): EffortGuardState { + let state = this.guardState.get(task) + if (!state) { + state = { + upwardChanges: 0, + // Seed the history with the task's effective baseline so that + // returning from a changed value to the original baseline is + // detected as oscillation (A -> B -> A) instead of re-applied. + history: baseline === undefined ? [] : [baseline], + } + this.guardState.set(task, state) + } + return state + } + + /** + * Applies a model-requested thinking-effort change with guardrails: + * clamps to the model's capability array, refuses oscillation + * (A -> B -> A returns) and escalation-cap violations, applies the + * task-local runtime effort, and publishes the one-line display say. + * There is no approval gate — the model decides, and the user can + * adjust the effort in chat at any time. + */ + async execute(params: SetThinkingEffortParams, task: Task, callbacks: ToolCallbacks): Promise { + const { effort, reason } = params + const { handleError, pushToolResult } = callbacks + + if (!effort) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + pushToolResult(await task.sayAndCreateMissingParamError("set_thinking_effort", "effort")) + return + } + + if (!reason) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + pushToolResult(await task.sayAndCreateMissingParamError("set_thinking_effort", "reason")) + return + } + + try { + // Defense in depth: the tool is only exposed when the experiment is + // on and the model supports per-request effort (task-start gate in + // filter-tools-for-mode.ts), but stale or direct calls can reach here. + const provider = task.providerRef.deref() + const state = await provider?.getState() + if (!experiments.isEnabled(state?.experiments ?? {}, EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT)) { + pushToolResult( + formatResponse.toolError( + "set_thinking_effort is unavailable: the dynamic thinking effort experiment is not enabled.", + ), + ) + return + } + + const capability = task.api.getModel().info.supportsReasoningEffort + const hasCapability = capability === true || (Array.isArray(capability) && capability.length > 0) + if (!hasCapability) { + pushToolResult( + formatResponse.toolError("The current model does not support per-request thinking effort."), + ) + return + } + + if (!(SETTABLE_EFFORTS as readonly string[]).includes(effort)) { + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + "Invalid thinking effort '" + effort + "'. Valid levels: " + SETTABLE_EFFORTS.join(", ") + ".", + ), + ) + return + } + // Validated above: `effort` is one of the settable literal levels. + const requested = effort as SettableEffort + + // Hard clamp to the model capability array. + const clamped = clampToCapability(requested, capability) + if (clamped === undefined) { + // The capability array contains no recognizable effort level, so no valid + // value can be applied; refuse the call (standard refusal path) instead of + // applying an unrecognized value as the runtime effort. + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + pushToolResult( + formatResponse.toolError( + "The current model does not advertise any usable thinking effort levels; keeping the current effort.", + ), + ) + return + } + if (clamped === "disable") { + // The clamp landed on "disable", which this tool cannot set (the + // task-local API takes an effort level, not a UI off-switch). + task.consecutiveMistakeCount++ + task.recordToolError("set_thinking_effort") + task.didToolFailInCurrentTurn = true + // Invariant: clampToCapability only returns "disable" when the + // capability is a non-empty array containing "disable", so the + // capability is a (non-empty) array here — single documented cast, + // no double assertion. + const supported = (capability as string[]).filter((l) => l !== "disable").join(", ") + pushToolResult( + formatResponse.toolError( + "'" + effort + "' is not supported by the current model. Supported levels: " + supported + ".", + ), + ) + return + } + + const current = task.getRuntimeThinkingEffort().effort ?? task.apiConfiguration.reasoningEffort + const guard = this.getGuardState(task, current) + + // No-op: already at the requested level — confirm without churn. + if (clamped === current) { + pushToolResult("Thinking effort is already '" + clamped + "'.") + return + } + + // Oscillation: A -> B -> A ping-pong within the task is refused. + const last = guard.history[guard.history.length - 1] + const secondLast = guard.history[guard.history.length - 2] + if (secondLast !== undefined && secondLast === clamped && last !== clamped) { + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", refusal: "oscillation" } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult( + formatResponse.toolError( + "Thinking effort change refused: oscillation between '" + + secondLast + + "' and '" + + last + + "' detected. Keep the current effort.", + ), + ) + return + } + + const isUpward = effortRank(clamped) > effortRank(current) + if (isUpward && guard.upwardChanges >= MAX_UPWARD_CHANGES) { + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", refusal: "escalation_cap" } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult( + formatResponse.toolError( + "Thinking effort change refused: the escalation limit of " + + MAX_UPWARD_CHANGES + + " upward changes per task has been reached.", + ), + ) + return + } + + // Apply (no approval gate) and notify with a single chat line. + task.consecutiveMistakeCount = 0 + task.setRuntimeThinkingEffort(clamped, "model") + if (isUpward) { + guard.upwardChanges++ + } + guard.history.push(clamped) + + const clampNote = + clamped === effort + ? "" + : " Requested '" + effort + "' was clamped to '" + clamped + "' (model capability)." + await task.say( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: clamped, reason } satisfies ClineSayTool), + undefined, + false, + ) + pushToolResult("Thinking effort is now '" + clamped + "'." + clampNote + " (Reason: " + reason + ")") + } catch (error) { + await handleError("setting thinking effort", error as Error) + } + } + + /** + * Streams a partial display say while the model's tool arguments are + * still streaming in (updates the same one-line display as it arrives). + */ + override async handlePartial(task: Task, block: ToolUse<"set_thinking_effort">): Promise { + const effort: string | undefined = block.params.effort + const reason: string | undefined = block.params.reason + if (!effort && !reason) { + return + } + const message = JSON.stringify({ + tool: "thinkingEffort", + effort: effort ?? "", + reason: reason ?? "", + } satisfies ClineSayTool) + // Partial say: updates the same one-line display as it streams in. + await task.say("tool", message, undefined, true).catch(() => {}) + } +} + +export const setThinkingEffortTool = new SetThinkingEffortTool() diff --git a/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts new file mode 100644 index 0000000000..8034280d18 --- /dev/null +++ b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts @@ -0,0 +1,429 @@ +// npx vitest core/tools/__tests__/newTaskThinkingEffort.spec.ts +// +// DTE series 5/5 — orchestrator new_task thinking_effort: +// - the tool schema exposes the optional thinking_effort param +// - a model-specified effort is validated against the target model's +// capability array (the child starts with the parent's model) +// - the ask payload pre-fills the effort and lists the supported levels +// ("disable" is a settings off-switch, never a start level) +// - the ask-block selection (carried by the ask response) wins over the +// model-specified value, which wins over the parent's effective effort + +import type { AskApproval, HandleError, NativeToolArgs, PushToolResult, ToolUse } from "../../../shared/tools" + +// Mock the vscode module +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn(() => false), + })), + }, +})) + +// Mock Package module +vi.mock("../../../shared/package", () => ({ + Package: { + name: "zoo-code", + publisher: "ZooCodeOrganization", + version: "1.0.0", + outputChannel: "Zoo-Code", + }, +})) + +vi.mock("../../../shared/modes", () => ({ + getModeBySlug: vi.fn(), + defaultModeSlug: "ask", +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Tool Error: ${msg}`), + }, +})) + +vi.mock("../updateTodoListTool", () => ({ + parseMarkdownChecklist: vi.fn().mockReturnValue([]), +})) + +import { newTaskTool } from "../NewTaskTool" +import { getModeBySlug } from "../../../shared/modes" +import newTaskSchema from "../../prompts/tools/native-tools/new_task" +import type { Task } from "../../task/Task" + +interface RunOptions { + /** Target model capability: array = allow-list; true = full level set; false/undefined = unsupported. */ + supportsReasoningEffort?: boolean | string[] + /** Effort the user chose in the ask block (carried by the ask response). */ + askEffort?: string + /** Parent's current effective effort (Task.resolveNewTaskEffectiveEffort). */ + parentEffort?: string +} + +/** + * Task double with the members new_task reads: the API handler (target model + * lookup), the PR-2/5/5 Task effort methods, and the provider delegation hook. + */ +function makeTask(options: RunOptions = {}) { + const delegateParentAndOpenChild = vi.fn().mockResolvedValue({ taskId: "child-1" }) + const resolveNewTaskEffectiveEffort = vi.fn().mockReturnValue(options.parentEffort) + const takeNewTaskAskThinkingEffort = vi.fn().mockReturnValue(options.askEffort) + // Structural double; the cast documents that handle() expects a real Task. + const task = { + taskId: "parent-1", + ask: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing param error"), + emit: vi.fn(), + recordToolError: vi.fn(), + consecutiveMistakeCount: 0, + isPaused: false, + pausedModeSlug: "ask", + enableCheckpoints: false, + checkpointSave: vi.fn(), + startSubtask: vi.fn(), + api: { + getModel: () => ({ + id: "test-model", + info: { + supportsReasoningEffort: options.supportsReasoningEffort, + reasoningEffort: undefined, + }, + }), + }, + resolveNewTaskEffectiveEffort, + takeNewTaskAskThinkingEffort, + providerRef: { + deref: vi.fn(() => ({ + getState: vi.fn().mockResolvedValue({ mode: "ask", customModes: [], experiments: {} }), + delegateParentAndOpenChild, + })), + }, + } as unknown as Task + + return { + task, + delegateParentAndOpenChild, + resolveNewTaskEffectiveEffort, + takeNewTaskAskThinkingEffort, + } +} + +const makeCallbacks = () => ({ + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult: vi.fn(), +}) + +const runNewTask = async ( + task: Task, + params: { mode?: string; message?: string; todos?: string; thinking_effort?: string | null }, + callbacks: ReturnType, +) => { + const args = { + mode: params.mode ?? "code", + message: params.message ?? "Do the delegated work", + todos: params.todos, + thinking_effort: params.thinking_effort, + } + // Native tool calling: nativeArgs is the source of truth for execution; the + // resolved defaults land on both surfaces so missing mode/message fall back + // identically instead of tripping the missing-param guard. + const block: ToolUse<"new_task"> = { + type: "tool_use", + name: "new_task", + params: { + mode: args.mode, + message: args.message, + todos: args.todos, + thinking_effort: args.thinking_effort, + }, + partial: false, + nativeArgs: { + mode: args.mode, + message: args.message, + todos: args.todos, + thinking_effort: args.thinking_effort, + } as unknown as NativeToolArgs["new_task"], + } + await newTaskTool.handle(task, block, callbacks) +} + +describe("new_task thinking_effort schema (DTE series 5/5)", () => { + it("exposes the optional thinking_effort parameter in strict-mode form", () => { + const parameters = newTaskSchema.function.parameters + + // strict: true + additionalProperties: false requires every property to be + // listed in `required` (the Anthropic API rejects the tool definition + // otherwise), so the optional parameter uses the same ["string", "null"] + // pattern as `todos`: the model sends null when it wants to omit it. + expect(parameters.properties.thinking_effort).toEqual({ + type: ["string", "null"], + description: expect.stringContaining("thinking effort"), + }) + expect(parameters.required).toEqual(["mode", "message", "todos", "thinking_effort"]) + expect(parameters.additionalProperties).toBe(false) + // Strict-mode invariant: no property may be optional. + for (const key of Object.keys(parameters.properties)) { + expect((parameters.required as string[]).includes(key)).toBe(true) + } + }) +}) + +describe("new_task thinking_effort validation (DTE series 5/5)", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getModeBySlug).mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "Test role definition", + groups: ["command", "read", "edit"], + }) + }) + + it("delegates with the model-specified effort when the target model supports it", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium", "high"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "medium" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do the delegated work", + initialTodos: [], + mode: "code", + thinkingEffort: "medium", + }) + }) + + it("rejects a value that is not a reasoning effort level", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "ultra" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Invalid thinking_effort 'ultra'"), + ) + expect(callbacks.pushToolResult).toHaveBeenCalledWith(expect.stringContaining("must be one of")) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + expect(callbacks.askApproval).not.toHaveBeenCalled() + // The invalid-effort failure path advances the mistake guardrail and records + // the tool error like every other failure path, so a model repeating an + // unsupported effort trips the consecutive-mistake loop. + expect(task.consecutiveMistakeCount).toBe(1) + expect(task.recordToolError).toHaveBeenCalledWith("new_task") + expect(task.didToolFailInCurrentTurn).toBe(true) + }) + + it("treats an explicit null thinking_effort as omitted (strict-mode null sentinel)", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + parentEffort: "medium", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: null }, callbacks) + + // null is the strict-mode "omitted" sentinel: validation is skipped and the + // child starts with the parent's effective effort. + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "medium" })) + expect(callbacks.pushToolResult).not.toHaveBeenCalledWith(expect.stringContaining("Invalid thinking_effort")) + expect(task.consecutiveMistakeCount).toBe(0) + }) + + it("rejects a level the target model does not support", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "high" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("the target model only supports: low"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("uses the unsupported-model wording when the only capability is 'disable' (DTE series 5/5)", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["disable"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + // 'disable' is a settings off-switch, never a start level: filtering it + // leaves an empty start-level list, so the hint must not name a level + // (such as "none") that would fail validation again. + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("rejects an effort when the target model exposes no capability array", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("filters unknown capability strings out of the supported levels (DTE series 5/5)", async () => { + const { task } = makeTask({ + supportsReasoningEffort: ["turbo", "low", "disable"], + askEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + // Registry data is normalized to the known level set: the unknown "turbo" + // and the settings off-switch "disable" never reach the ask payload. + expect(callbacks.askApproval).toHaveBeenCalledTimes(1) + const [askType, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + expect(askType).toBe("tool") + const payload = JSON.parse(toolMessage as string) as { + tool: string + supportedThinkingEfforts?: string[] + } + expect(payload.tool).toBe("newTask") + expect(payload.supportedThinkingEfforts).toEqual(["low"]) + }) + + it("treats a capability array of only unknown values as unsupported (DTE series 5/5)", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["turbo"], + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + // Normalization drops the unknown value, leaving no start level: the + // unsupported-model wording is the accurate hint. + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("pre-fills the ask payload with the effort and the supported levels, filtering 'disable'", async () => { + const { task } = makeTask({ + supportsReasoningEffort: ["disable", "low", "medium"], + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.askApproval).toHaveBeenCalledTimes(1) + const [askType, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + expect(askType).toBe("tool") + const payload = JSON.parse(toolMessage as string) as { + tool: string + thinkingEffort?: string + supportedThinkingEfforts?: string[] + } + expect(payload.tool).toBe("newTask") + expect(payload.thinkingEffort).toBe("low") + expect(payload.supportedThinkingEfforts).toEqual(["low", "medium"]) + }) + + it("falls back to the parent's effective effort when no effort is specified", async () => { + const { task, delegateParentAndOpenChild, resolveNewTaskEffectiveEffort } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + parentEffort: "medium", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(resolveNewTaskEffectiveEffort).toHaveBeenCalled() + expect(delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do the delegated work", + initialTodos: [], + mode: "code", + thinkingEffort: "medium", + }) + }) + + it("prefers the ask-block selection over the model-specified effort", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium", "high"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "high" })) + }) + + it("ignores an ask-block selection the target model does not support", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) + + it("falls back to the parent's effective effort when the ask selection is unsupported and no model effort was given", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + askEffort: "high", + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) + + it("accepts a valid level when the capability is boolean true (full level set)", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: true, + }) + const callbacks = makeCallbacks() + + // xhigh is a valid level but is not in any provider allow-list today: only the + // boolean-true normalization (full level set) accepts it. + await runNewTask(task, { thinking_effort: "xhigh" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "xhigh" })) + + // The ask payload lists the full level set for a boolean-true capability. + const [, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + const payload = JSON.parse(toolMessage as string) as { supportedThinkingEfforts?: string[] } + expect(payload.supportedThinkingEfforts).toEqual(["none", "minimal", "low", "medium", "high", "xhigh", "max"]) + }) + + it("rejects an effort when the capability is boolean false", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: false, + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/__tests__/newTaskTool.spec.ts b/src/core/tools/__tests__/newTaskTool.spec.ts index 9e61bc7fab..5789fa50ef 100644 --- a/src/core/tools/__tests__/newTaskTool.spec.ts +++ b/src/core/tools/__tests__/newTaskTool.spec.ts @@ -97,6 +97,11 @@ const mockCline = { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: mockStartSubtask, + // DTE series 5/5: new_task resolves the target model's capability from the + // task's API handler and consults the pending new_task ask effort on Task. + api: { getModel: () => ({ id: "test-model", info: {} }) }, + resolveNewTaskEffectiveEffort: vi.fn().mockReturnValue(undefined), + takeNewTaskAskThinkingEffort: vi.fn().mockReturnValue(undefined), providerRef: { deref: vi.fn(() => ({ getState: vi.fn(() => ({ customModes: [], mode: "ask" })), @@ -635,6 +640,10 @@ describe("newTaskTool delegation flow", () => { enableCheckpoints: false, checkpointSave: mockCheckpointSave, startSubtask: localStartSubtask, + // DTE series 5/5: target model lookup + ask-block effort plumbing. + api: { getModel: () => ({ id: "test-model", info: {} }) }, + resolveNewTaskEffectiveEffort: vi.fn().mockReturnValue(undefined), + takeNewTaskAskThinkingEffort: vi.fn().mockReturnValue(undefined), providerRef: { deref: vi.fn(() => providerSpy), }, @@ -659,11 +668,14 @@ describe("newTaskTool delegation flow", () => { }) // Assert: provider method called with correct params + // DTE series 5/5: thinkingEffort is always present; undefined here because the + // tool, the ask block, and the parent's effective resolution all yield none. expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({ parentTaskId: "mock-parent-task-id", message: "Do something", initialTodos: [], mode: "code", + thinkingEffort: undefined, }) // Assert: legacy path not used diff --git a/src/core/tools/__tests__/setThinkingEffortTool.spec.ts b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts new file mode 100644 index 0000000000..20ec6d37d8 --- /dev/null +++ b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts @@ -0,0 +1,504 @@ +// npx vitest run src/core/tools/__tests__/setThinkingEffortTool.spec.ts +// +// DTE series 3/5 — set_thinking_effort executor: clamp, escalation cap, +// oscillation, no-op, no-approval, and one-line chat display. + +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest" + +import { setThinkingEffortTool, MAX_UPWARD_CHANGES } from "../SetThinkingEffortTool" +import { Task } from "../../task/Task" +import type { ToolUse } from "../../../shared/tools" + +type Capability = string[] | true | false | undefined + +/** Structural double covering every Task surface this tool touches. */ +interface TaskDouble { + taskId: string + consecutiveMistakeCount: number + didToolFailInCurrentTurn: boolean + recordToolError: Mock + sayAndCreateMissingParamError: Mock + say: Mock + setRuntimeThinkingEffort: Mock + getRuntimeThinkingEffort: Mock + apiConfiguration: { reasoningEffort?: string } + api: { getModel: () => { id: string; info: { supportsReasoningEffort: Capability } } } + providerRef: { + deref: () => { + getState: () => Promise<{ experiments?: Record }> + } + } +} + +interface CallbackDoubles { + askApproval: Mock + handleError: Mock + pushToolResult: Mock +} + +function makeTask( + overrides: { capability?: Capability; experimentsOn?: boolean; settingsEffort?: string } = {}, +): TaskDouble { + const { capability = ["low", "medium", "high", "max"], experimentsOn = true, settingsEffort } = overrides + // Mirrors the real Task API: getRuntimeThinkingEffort() reflects only the + // task-local override (undefined until setRuntimeThinkingEffort is called); + // the settings baseline is read separately from apiConfiguration. + let override: string | undefined = undefined + return { + taskId: "task-1", + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + recordToolError: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing parameter error"), + say: vi.fn().mockResolvedValue(undefined), + setRuntimeThinkingEffort: vi.fn((effort: string | undefined) => { + override = effort + }), + getRuntimeThinkingEffort: vi.fn().mockImplementation(() => ({ + effort: override, + source: override === undefined ? undefined : "model", + })), + apiConfiguration: { reasoningEffort: settingsEffort }, + api: { getModel: () => ({ id: "test-model", info: { supportsReasoningEffort: capability } }) }, + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + experiments: { dynamicThinkingEffort: experimentsOn }, + }), + }), + }, + } +} + +function sayPayloads(double: TaskDouble): unknown[] { + return double.say.mock.calls.filter((call) => call[0] === "tool").map((call) => JSON.parse(call[1] as string)) +} + +describe("setThinkingEffortTool", () => { + let double: TaskDouble + let task: Task + let callbacks: CallbackDoubles + + // Rebuild the double and bind it to the Task-typed reference the tool + // expects. The structural double covers every Task surface this unit + // exercises, so a full Task construction is unnecessary here. + function use(overrides?: { capability?: Capability; experimentsOn?: boolean; settingsEffort?: string }) { + double = makeTask(overrides) + task = double as unknown as Task + } + + beforeEach(() => { + vi.clearAllMocks() + use() + callbacks = { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn().mockResolvedValue(undefined), + pushToolResult: vi.fn(), + } + }) + + describe("parameter validation", () => { + it("reports a missing effort parameter and records a tool error", async () => { + await setThinkingEffortTool.execute({ effort: "", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.sayAndCreateMissingParamError).toHaveBeenCalledWith("set_thinking_effort", "effort") + expect(callbacks.pushToolResult).toHaveBeenCalledWith("missing parameter error") + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + }) + + it("reports a missing reason parameter and records a tool error", async () => { + await setThinkingEffortTool.execute({ effort: "high", reason: "" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.sayAndCreateMissingParamError).toHaveBeenCalledWith("set_thinking_effort", "reason") + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + }) + + describe("defense-in-depth gating", () => { + it("rejects when the experiment is off", async () => { + use({ experimentsOn: false }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("experiment") + expect(result).toContain("error") + }) + + it("rejects when the model does not support per-request effort", async () => { + use({ capability: false }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("does not support") + }) + + it("rejects an empty capability array", async () => { + use({ capability: [] }) + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("does not support") + }) + + it("rejects when the provider state carries no experiment flags", async () => { + use() + double.providerRef.deref().getState = vi.fn().mockResolvedValue({}) + + await setThinkingEffortTool.execute({ effort: "high", reason: "because" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("experiment") + }) + }) + + describe("clamp to model capability", () => { + it("rejects an unknown effort level", async () => { + await setThinkingEffortTool.execute({ effort: "ultra", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("Invalid thinking effort") + expect(result).toContain("ultra") + }) + + it("rejects 'disable' (a UI off-switch the tool cannot set)", async () => { + await setThinkingEffortTool.execute({ effort: "disable", reason: "because" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("Invalid thinking effort") + }) + + it("clamps an out-of-array request to the nearest supported level", async () => { + use({ capability: ["low", "medium", "high"], settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "max", reason: "deeper reasoning" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "high", reason: "deeper reasoning" }) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'high'") + expect(result).toContain("deeper reasoning") + }) + + it("resolves nearest-level ties toward the lower level", async () => { + use({ capability: ["high", "low"], settingsEffort: "high" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "tie-break" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "low", reason: "tie-break" }) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'low'") + }) + + it("resolves nearest-level ties toward the lower level regardless of array order", async () => { + use({ capability: ["low", "high"], settingsEffort: "high" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "tie-break order" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'low'") + }) + + it("clamps robustly when the capability array contains an unknown level", async () => { + use({ capability: ["weird", "low"], settingsEffort: "high" }) + await setThinkingEffortTool.execute({ effort: "max", reason: "robust clamp" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("clamped to 'low'") + }) + + it("refuses a capability array that contains no settable effort", async () => { + use({ capability: ["weird"], settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "high", reason: "multi-step math" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.recordToolError).toHaveBeenCalledWith("set_thinking_effort") + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("error") + expect(result).toContain("usable thinking effort levels") + }) + + it("still passes through a settable level when the capability array also contains garbage", async () => { + use({ capability: ["weird", "high"], settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "high", reason: "passthrough" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).not.toContain("clamped") + }) + + it("rejects a request that clamps to 'disable' (capability without settable levels)", async () => { + use({ capability: ["disable"], settingsEffort: "disable" }) + await setThinkingEffortTool.execute({ effort: "low", reason: "some reasoning" }, task, callbacks) + + expect(double.consecutiveMistakeCount).toBe(1) + expect(double.didToolFailInCurrentTurn).toBe(true) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("not supported by the current model") + }) + }) + + describe("successful application (no approval gate)", () => { + it("applies the effort, notifies with a one-line say, and never asks for approval", async () => { + use({ settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "high", reason: "deep analysis" }, task, callbacks) + + expect(callbacks.askApproval).not.toHaveBeenCalled() + expect(double.consecutiveMistakeCount).toBe(0) + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + const display = sayPayloads(double)[0] + expect(display).toEqual({ tool: "thinkingEffort", effort: "high", reason: "deep analysis" }) + expect(double.say).toHaveBeenCalledWith("tool", JSON.stringify(display), undefined, false) + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("high") + expect(result).toContain("deep analysis") + }) + + it("passes through unchanged for a boolean-capability model (all levels supported)", async () => { + use({ capability: true, settingsEffort: "low" }) + await setThinkingEffortTool.execute({ effort: "xhigh", reason: "all levels" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("xhigh", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("xhigh") + expect(result).not.toContain("clamped") + }) + it("is a no-op (without a chat line) when already at the requested level", async () => { + use({ settingsEffort: "medium" }) + await setThinkingEffortTool.execute({ effort: "medium", reason: "confirm" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(double.say).not.toHaveBeenCalled() + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("already") + }) + + it("applies normally when the task has no settings baseline (undefined current)", async () => { + use() + await setThinkingEffortTool.execute({ effort: "high", reason: "no baseline" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "model") + expect(sayPayloads(double).some((p) => (p as Record).refusal !== undefined)).toBe(false) + }) + + it("ranks an unknown settings baseline as the bottom of the scale (defensive rank fallback)", async () => { + // A settings baseline that predates the effort scale is not validated + // here (only the requested effort is); effortRank must still rank it + // as the bottom of the scale instead of throwing. + use({ capability: ["low", "medium"], settingsEffort: "custom-legacy" }) + await setThinkingEffortTool.execute({ effort: "low", reason: "legacy baseline" }, task, callbacks) + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledWith("low", "model") + const result = callbacks.pushToolResult.mock.calls[0][0] as string + expect(result).toContain("Thinking effort is now 'low'") + }) + }) + + describe("escalation cap", () => { + it("allows up to MAX_UPWARD_CHANGES upward changes and refuses the next", async () => { + use({ capability: ["low", "medium", "high", "xhigh", "max"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "up" }, task, callbacks) + + await step("medium") + await step("high") + await step("xhigh") + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(MAX_UPWARD_CHANGES) + + await step("max") // 4th upward change: refused + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(MAX_UPWARD_CHANGES) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "escalation_cap" }) + const result = callbacks.pushToolResult.mock.calls.at(-1)?.[0] as string + expect(result).toContain("escalation limit") + }) + + it("does not count downward changes toward the cap", async () => { + use({ capability: ["none", "low", "medium", "high", "xhigh", "max"], settingsEffort: "max" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("none") // downward: not counted + await step("medium") // upward 1 + await step("high") // upward 2 + await step("xhigh") // upward 3 + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(4) + + await step("max") // 4th upward change: refused + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(4) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "escalation_cap" }) + }) + }) + + describe("oscillation detection", () => { + it("refuses an A -> B -> A ping-pong within the task", async () => { + use({ capability: ["low", "medium", "high"], settingsEffort: "high" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("low") // downward from the baseline, allowed + await step("medium") // upward + await step("low") // ping-pong back: refused + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(2) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "oscillation" }) + const result = callbacks.pushToolResult.mock.calls.at(-1)?.[0] as string + expect(result).toContain("oscillation") + expect(result).toContain("'medium'") + expect(result).toContain("'low'") + }) + + it("refuses a return to the task baseline (baseline oscillation)", async () => { + use({ capability: ["low", "medium"], settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("low") // at the baseline: no-op, not a change + expect(callbacks.pushToolResult).toHaveBeenLastCalledWith("Thinking effort is already 'low'.") + + await step("medium") // move away from the baseline + await step("low") // return to the baseline: refused as oscillation + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + const refusal = sayPayloads(double).at(-1) + expect(refusal).toEqual({ tool: "thinkingEffort", refusal: "oscillation" }) + const result = callbacks.pushToolResult.mock.calls.at(-1)?.[0] as string + expect(result).toContain("oscillation") + }) + + it("does not refuse the same level twice in a row (no-op path instead)", async () => { + use({ settingsEffort: "low" }) + const step = (effort: string) => setThinkingEffortTool.execute({ effort, reason: "x" }, task, callbacks) + + await step("medium") + await step("medium") // identical level: no-op, not oscillation + + expect(double.setRuntimeThinkingEffort).toHaveBeenCalledTimes(1) + expect(sayPayloads(double).some((p) => (p as Record).refusal !== undefined)).toBe(false) + }) + }) + + describe("error handling", () => { + it("routes unexpected errors to handleError", async () => { + use({ settingsEffort: "low" }) + double.setRuntimeThinkingEffort = vi.fn().mockImplementation(() => { + throw new Error("boom") + }) + + await setThinkingEffortTool.execute({ effort: "high", reason: "x" }, task, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith("setting thinking effort", expect.any(Error)) + }) + }) + + describe("handle() entry point", () => { + it("emits a partial say with the streamed effort and reason", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: { effort: "high", reason: "deep" }, + partial: true, + nativeArgs: { effort: "high", reason: "deep" }, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).toHaveBeenCalledWith( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: "high", reason: "deep" }), + undefined, + true, + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("emits a partial say with the streamed effort when the reason is not streamed yet", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: { effort: "high" }, + partial: true, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).toHaveBeenCalledWith( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: "high", reason: "" }), + undefined, + true, + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("emits a partial say with the streamed reason when the effort is not streamed yet", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: { reason: "deep" }, + partial: true, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).toHaveBeenCalledWith( + "tool", + JSON.stringify({ tool: "thinkingEffort", effort: "", reason: "deep" }), + undefined, + true, + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("ignores a partial block with no args yet", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: {}, + partial: true, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(double.say).not.toHaveBeenCalled() + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + + it("reports a parse error when a complete block carries no native args", async () => { + const block: ToolUse<"set_thinking_effort"> = { + type: "tool_use" as const, + name: "set_thinking_effort" as const, + params: {}, + partial: false, + } + + await setThinkingEffortTool.handle(task, block, callbacks) + + expect(callbacks.handleError).toHaveBeenCalledWith( + "parsing set_thinking_effort args", + expect.objectContaining({ message: expect.stringContaining("missing native arguments") }), + ) + expect(double.setRuntimeThinkingEffort).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4621cb3fc4..c5745e0ae4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -17,6 +17,7 @@ import { type ProviderName, type ProviderSettings, type RooCodeSettings, + type ReasoningEffortExtended, type ProviderSettingsEntry, type StaticAppProperties, type DynamicAppProperties, @@ -35,6 +36,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type ClineSayTool, type ExtensionMessage, type ExtensionState, type WebviewThemeFixture, @@ -201,6 +203,10 @@ export class ClineProvider private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void + // DTE: thinking effort selected in the composer while no task was open. + // Parked here and applied to the next top-level task in createTask + // (non-persisted, like task-local overrides). + private pendingTaskThinkingEffort: { effort: ReasoningEffortExtended; source: string } | undefined private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false @@ -2649,6 +2655,11 @@ export class ClineProvider const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd const currentTask = this.getCurrentTask() + // DTE series 4/5: task-local thinking effort override for the current task + // (undefined while no override is active — the webview then derives the + // display from settings -> model default). The optional call keeps this + // tolerant of partial task doubles in extension tests. + const currentTaskRuntimeEffort = currentTask?.getRuntimeThinkingEffort?.() let zooCodeState: { zooCodeIsAuthenticated: boolean zooCodeUserName: string | undefined @@ -2706,6 +2717,9 @@ export class ClineProvider currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, clineMessages: currentTask?.clineMessages || [], currentTaskTodos: currentTask?.todoList || [], + taskThinkingEffort: currentTaskRuntimeEffort?.effort + ? { effort: currentTaskRuntimeEffort.effort, source: currentTaskRuntimeEffort.source ?? "default" } + : this.pendingTaskThinkingEffort, messageQueue: currentTask?.messageQueueService?.messages, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task) @@ -3357,6 +3371,16 @@ export class ClineProvider // from the stack and the caller is resumed in this way we can have a chain // of tasks, each one being a sub task of the previous one until the main // task is finished. + // DTE: park a composer-selected thinking effort for the next top-level + // task (no task open yet). createTask consumes and validates it. + public setPendingTaskThinkingEffort(effort: ReasoningEffortExtended): void { + this.pendingTaskThinkingEffort = { effort, source: "you" } + } + + public getPendingTaskThinkingEffort(): { effort: ReasoningEffortExtended; source: string } | undefined { + return this.pendingTaskThinkingEffort + } + public async createTask( text?: string, images?: string[], @@ -3447,6 +3471,33 @@ export class ClineProvider }) await this.addClineToStack(task) + + // DTE: apply the composer-parked thinking effort (selected while no task + // was open) to the new top-level task when its model supports the level. + // Consumed regardless of support so a stale selection never leaks into a + // later task. + if (!parentTask && this.pendingTaskThinkingEffort) { + const pendingEffort = this.pendingTaskThinkingEffort + this.pendingTaskThinkingEffort = undefined + const pendingCapability = task.api.getModel().info.supportsReasoningEffort + const pendingSupported = Array.isArray(pendingCapability) + ? (pendingCapability as string[]).includes(pendingEffort.effort) + : pendingCapability === true + if (pendingSupported) { + task.setRuntimeThinkingEffort(pendingEffort.effort, pendingEffort.source) + await task.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: pendingEffort.effort, + source: pendingEffort.source, + } satisfies ClineSayTool), + undefined, + false, + ) + } + } + if (options.startTask !== false) { scheduleTask(this.taskScheduler, task, "createTask") } @@ -3769,8 +3820,11 @@ export class ClineProvider message: string initialTodos: TodoItem[] mode: string + // DTE series 5/5: the subtask start effort (model-specified or the parent's + // current effective effort); applied to the child at init below. + thinkingEffort?: ReasoningEffortExtended }): Promise { - const { parentTaskId, message, initialTodos, mode } = params + const { parentTaskId, message, initialTodos, mode, thinkingEffort } = params // Metadata-driven delegation is always enabled @@ -3863,6 +3917,43 @@ export class ClineProvider startTask: false, }) + // DTE series 5/5: the mode switch above can change the provider profile and + // therefore the model the child actually runs on (mode-specific provider + // profiles), so a level validated against the parent model can be invalid for + // the child's. Re-validate against the child's resolved model immediately before + // applying; when the child model does not support the level, fall back to no + // task-local override (the settings-derived effort applies) with an observable + // say on the child instead of failing the whole delegation. + if (thinkingEffort !== undefined) { + const childModel = child.api.getModel() + const childCapability = childModel.info.supportsReasoningEffort + const childSupportsEffort = + childCapability === true || (Array.isArray(childCapability) && childCapability.includes(thinkingEffort)) + if (childSupportsEffort) { + // Applied as a task-local override before the child's first request so the + // child header shows it from the start. Source "parent" — set by the + // orchestrator, not the child's own settings. + child.setRuntimeThinkingEffort(thinkingEffort, "parent") + } else { + // Non-fatal: the parent is already disposed at this point, so a rejecting + // say must not abort the delegation — the metadata transaction and child + // scheduling below are the recovery path, and losing them would leave the + // child active while the parent has no delegation metadata. + await child + .say( + "error", + `new_task thinking_effort '${thinkingEffort}' is not supported by the child model (${childModel.id}); the child starts without the effort override.`, + ) + .catch((error) => { + this.log( + `[delegateParentAndOpenChild] Failed to notify child of unsupported thinking_effort (non-fatal): ${ + error instanceof Error ? error.message : String(error) + }`, + ) + }) + } + } + // 5) Persist parent delegation metadata BEFORE the child starts writing. // atomicReadAndUpdate reads from the in-memory cache and writes back within a // single lock acquisition — no concurrent writer can slip between the read and diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 731124cccc..72fc7b9198 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -9,6 +9,7 @@ import axios from "axios" import { type ProviderSettingsEntry, + type RooCodeSettings, type ClineMessage, type ExtensionMessage, type ExtensionState, @@ -261,10 +262,40 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { } }) +// DTE: the capability advertised by the mocked Task's model, mutable per test. +// Defaults to the deepseek catalog levels; tests that exercise the boolean +// (all-levels) side of the support check override it and restore in `finally`. +const { taskModelCapabilityRef } = vi.hoisted(() => ({ + taskModelCapabilityRef: { value: ["disable", "low", "high", "max"] as string[] | boolean }, +})) + vi.mock("../../task/Task", () => ({ Task: vi.fn().mockImplementation(function (options: any) { + // DTE: per-instance runtime-effort state and a capability-advertising + // api model (mirrors the deepseek catalog levels) so createTask's + // pending-effort consumption path can be exercised in these unit tests. + let runtimeEffort: string | undefined + let runtimeSource: string | undefined + const messages: Array> = [] return { - api: undefined, + api: { + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + info: { + supportsReasoningEffort: taskModelCapabilityRef.value, + }, + }), + }, + setRuntimeThinkingEffort: vi.fn((effort: string, source?: string) => { + runtimeEffort = effort + runtimeSource = source + }), + getRuntimeThinkingEffort: vi.fn(() => + runtimeEffort !== undefined ? { effort: runtimeEffort, source: runtimeSource } : {}, + ), + say: vi.fn(async (type: string, text?: string) => { + messages.push({ type: "say", say: type, text, ts: Date.now() }) + }), abortTask: vi.fn(), handleWebviewAskResponse: vi.fn(), clineMessages: [], @@ -410,11 +441,37 @@ afterAll(() => { describe("ClineProvider", () => { beforeAll(() => { vi.mocked(Task).mockImplementation(function (options: any) { + // DTE: per-instance runtime-effort state and a capability-advertising + // api model (mirrors the deepseek catalog levels) so createTask's + // pending-effort consumption path can be exercised in these unit tests. + let runtimeEffort: string | undefined + let runtimeSource: string | undefined + const messages: Array> = [] const task: any = { - api: undefined, + api: { + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + info: { + // DTE: read from the shared ref (set per test) so the boolean + // (all-levels) capability side of the support check can be + // exercised; defaults to the deepseek catalog levels. + supportsReasoningEffort: taskModelCapabilityRef.value, + }, + }), + }, + setRuntimeThinkingEffort: vi.fn((effort: string, source?: string) => { + runtimeEffort = effort + runtimeSource = source + }), + getRuntimeThinkingEffort: vi.fn(() => + runtimeEffort !== undefined ? { effort: runtimeEffort, source: runtimeSource } : {}, + ), + say: vi.fn(async (type: string, text?: string) => { + messages.push({ type: "say", say: type, text, ts: Date.now() }) + }), abortTask: vi.fn(), handleWebviewAskResponse: vi.fn(), - clineMessages: [], + clineMessages: messages, apiConversationHistory: [], overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), @@ -936,6 +993,100 @@ describe("ClineProvider", () => { expect(state.taskHistory).toEqual([historyItem]) }) + test("getStateToPostToWebview surfaces the task-local thinking effort override (DTE 4/5)", async () => { + // Models the real Task contract: getRuntimeThinkingEffort always returns an + // object; the no-override state is the empty object (effort undefined). + // The double is partial on purpose — the spy only needs the method under test; + // Task has many constructor-dependent required members, hence the cast. + const task = (runtime: { effort?: string; source?: string }) => + ({ + taskId: "effort-task", + clineMessages: [], + todoList: [], + getRuntimeThinkingEffort: () => runtime, + }) as unknown as Task + vi.spyOn(provider.taskHistoryStore, "getAll").mockReturnValue([]) + const getCurrentTaskSpy = vi.spyOn(provider, "getCurrentTask") + + getCurrentTaskSpy.mockReturnValue(task({ effort: "high", source: "you" })) + let state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) + + // A source-less runtime override is reported as the default source. + getCurrentTaskSpy.mockReturnValue(task({ effort: "medium" })) + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "medium", source: "default" }) + + // Without an active override (the real empty-object shape) the field is omitted. + getCurrentTaskSpy.mockReturnValue(task({})) + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toBeUndefined() + + // A parked composer effort (selected while no task was open) surfaces as + // the webview state fallback until the next task consumes it. + provider.setPendingTaskThinkingEffort("high") + state = await provider.getStateToPostToWebview() + expect(state.taskThinkingEffort).toEqual({ effort: "high", source: "you" }) + // Reset the private field for the other tests (no public clear needed — + // createTask consumes it; the narrow cast documents the intent). + ;(provider as unknown as { pendingTaskThinkingEffort?: unknown }).pendingTaskThinkingEffort = undefined + + getCurrentTaskSpy.mockRestore() + }) + + test("createTask applies a parked composer effort to a new top-level task when the model supports it (DTE)", async () => { + await provider.setValues({ + apiConfiguration: { + apiProvider: providerIdentifiers.deepseek, + apiModelId: "deepseek-v4-flash", + }, + } as unknown as RooCodeSettings) + + provider.setPendingTaskThinkingEffort("max") + + const task = await provider.createTask("pending effort task", undefined, undefined, { startTask: false }) + + expect(task.api.getModel().info.supportsReasoningEffort).toEqual(["disable", "low", "high", "max"]) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "max", source: "you" }) + expect(provider.getPendingTaskThinkingEffort()).toBeUndefined() + const sayLines = task.clineMessages.filter((m) => m.type === "say" && m.say === "tool") + expect(sayLines.length).toBeGreaterThan(0) + expect(JSON.parse(sayLines[sayLines.length - 1].text ?? ("{}" as string))).toEqual({ + tool: "thinkingEffort", + effort: "max", + source: "you", + }) + }) + + test("createTask consumes a parked effort the new task's model does not support (DTE)", async () => { + // deepseek-v4-flash does not advertise the "xhigh" level. + provider.setPendingTaskThinkingEffort("xhigh") + + const task = await provider.createTask("stale effort task", undefined, undefined, { startTask: false }) + + expect(task.getRuntimeThinkingEffort()).toEqual({}) + expect(provider.getPendingTaskThinkingEffort()).toBeUndefined() + }) + + test("createTask applies a parked effort when the model advertises a boolean capability (DTE)", async () => { + // A non-array capability reaches the other side of the support check: + // boolean `true` means every level is supported, so the parked effort + // is applied (and consumed) exactly like the array path. + taskModelCapabilityRef.value = true + try { + provider.setPendingTaskThinkingEffort("high") + + const task = await provider.createTask("boolean capability task", undefined, undefined, { + startTask: false, + }) + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "high", source: "you" }) + expect(provider.getPendingTaskThinkingEffort()).toBeUndefined() + } finally { + taskModelCapabilityRef.value = ["disable", "low", "high", "max"] + } + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..9b60b1a335 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -73,6 +73,7 @@ import type { ModelRecord } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" +import type { Task } from "../../task/Task" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio" import { getCommands } from "../../../services/command/commands" @@ -356,9 +357,41 @@ describe("webviewMessageHandler - image mentions", () => { }) expect(vi.mocked(resolveImageMentions)).toHaveBeenCalled() - expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "See @/img.png", [ - "data:image/png;base64,from-mention", - ]) + // DTE series 5/5: the handler always forwards the ask-block effort as the 4th + // argument (undefined for responses without a selection). + expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith( + "messageResponse", + "See @/img.png", + ["data:image/png;base64,from-mention"], + undefined, + ) + }) + + it("forwards the new_task ask-block thinking effort to the task (DTE series 5/5)", async () => { + const mockHandleWebviewAskResponse = vi.fn() + // Structural double: the askResponse case only dereferences the current task + // to forward the response (single documented double assertion, last resort). + vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({ + cwd: "/mock/workspace", + rooIgnoreController: undefined, + handleWebviewAskResponse: mockHandleWebviewAskResponse, + } as unknown as Task) + + await webviewMessageHandler(mockClineProvider, { + type: "askResponse", + askResponse: "yesButtonClicked", + text: "", + thinkingEffort: "high", + }) + + // The ask-block selection is forwarded as the 4th argument; every other ask + // type omits the field, so the task only stores it for new_task approvals. + expect(mockHandleWebviewAskResponse).toHaveBeenCalledWith( + "yesButtonClicked", + "", + ["data:image/png;base64,from-mention"], + "high", + ) }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts new file mode 100644 index 0000000000..6a84aa07ec --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi } from "vitest" + +import { webviewMessageHandler } from "../webviewMessageHandler" + +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string) => key), + changeLanguage: vi.fn(), +})) + +vi.mock("vscode", () => ({ + window: { + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + workspace: { + workspaceFolders: undefined, + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + update: vi.fn(), + })), + }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, + Uri: { + parse: vi.fn((str) => ({ toString: () => str })), + file: vi.fn((path) => ({ fsPath: path })), + }, +})) + +describe("webviewMessageHandler setTaskThinkingEffort (DTE series 4/5)", () => { + const makeTask = (supportsReasoningEffort: unknown) => { + const say = vi.fn(async (_say: string, _text?: string, ..._rest: unknown[]) => {}) + return { + taskId: "test-task-id", + api: { getModel: () => ({ id: "test-model", info: { supportsReasoningEffort } }) }, + setRuntimeThinkingEffort: vi.fn(), + say, + } + } + + const makeProvider = (task: unknown) => ({ + getCurrentTask: vi.fn(() => task), + postStateToWebviewWithoutTaskHistory: vi.fn(async () => {}), + setPendingTaskThinkingEffort: vi.fn(), + }) + + const apply = (provider: ReturnType, message: Record) => + webviewMessageHandler(provider as never, message as never) + + it("applies a task-local effort for a supported level, records the chat line, and pushes state", async () => { + const task = makeTask(["low", "medium", "high"]) + const provider = makeProvider(task) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + + expect(task.setRuntimeThinkingEffort).toHaveBeenCalledWith("high", "you") + const [say, text] = task.say.mock.calls[0] + expect(say).toBe("tool") + expect(text).toBe(JSON.stringify({ tool: "thinkingEffort", effort: "high", source: "you" })) + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it("marks the display line isNonInteractive so a pending ask is never superseded (regression)", async () => { + // Regression: setting the effort while the task was blocked on a pending ask + // (e.g. the followup ask after a subtask returned) made say() bump lastMessageTs; + // Task.ask's pWaitFor then resolved and threw AskIgnoredError("superseded"), + // killing the request loop. The next user message reached + // handleWebviewAskResponse with no waiter — no API call, frozen UI. + // isNonInteractive keeps the display line out of the ask superseding flow. + const task = makeTask(["low", "medium", "high"]) + const provider = makeProvider(task) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "low" }) + + // Task.say(type, text, images, partial, checkpoint, progressStatus, options) + const options = task.say.mock.calls[0]?.[6] + expect(options).toEqual({ isNonInteractive: true }) + }) + + it("accepts boolean/adaptive-class capability", async () => { + const provider = makeProvider(makeTask(true)) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "medium" }) + + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it.each([ + ["an unsupported level", ["low", "medium", "high"], { effort: "max" }], + ["an effort outside the canonical enum", ["low", "medium", "high"], { effort: "bogus" }], + ["a missing effort", ["low", "medium", "high"], {}], + ["a model without effort support", false, { effort: "high" }], + ])("ignores %s", async (_name, capability, message) => { + const task = makeTask(capability) + const provider = makeProvider(task) + + await apply(provider, { type: "setTaskThinkingEffort", ...message }) + + expect(task.setRuntimeThinkingEffort).not.toHaveBeenCalled() + expect(task.say).not.toHaveBeenCalled() + expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) + + it("parks a pending effort for the next task when there is no current task", async () => { + const provider = makeProvider(undefined) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + + expect(provider.setPendingTaskThinkingEffort).toHaveBeenCalledWith("high") + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) + }) + + it.each([ + ["an effort outside the canonical enum", { effort: "bogus" }], + ["a missing effort", {}], + ])("does not park %s when there is no current task", async (_name, message) => { + const provider = makeProvider(undefined) + + await apply(provider, { type: "setTaskThinkingEffort", ...message }) + + expect(provider.setPendingTaskThinkingEffort).not.toHaveBeenCalled() + expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..a1e5f9e367 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -16,6 +16,8 @@ import { type Command as SlashCommand, type WebviewMessage, type EditQueuedMessagePayload, + type ClineSayTool, + reasoningEffortExtendedSchema, TelemetryEventName, RooCodeSettings, ExperimentId, @@ -721,7 +723,14 @@ export const webviewMessageHandler = async ( const resolved = await resolveIncomingImages({ text: message.text, images: message.images }) provider .getCurrentTask() - ?.handleWebviewAskResponse(message.askResponse!, resolved.text, resolved.images) + // DTE series 5/5: forward the new_task ask-block effort selection (undefined + // for all other ask responses). + ?.handleWebviewAskResponse( + message.askResponse!, + resolved.text, + resolved.images, + message.thinkingEffort, + ) } break @@ -1655,6 +1664,58 @@ export const webviewMessageHandler = async ( // Cancel any pending auto-approval timeout for the current task provider.getCurrentTask()?.cancelAutoApprovalTimeout() break + case "setTaskThinkingEffort": { + // DTE series 4/5: task-local thinking effort set from the composer + // toggle. Task-local only — persisted settings are never touched. + // Defense in depth: the composer menu only offers model-supported + // levels, but the webview is never trusted blindly. + const setEffortTask = provider.getCurrentTask() + // Validate the webview-supplied effort against the canonical enum. + const setEffortParsed = reasoningEffortExtendedSchema.safeParse(message.effort) + if (setEffortParsed.success) { + if (setEffortTask) { + const setEffortValue = setEffortParsed.data + const capability = setEffortTask.api.getModel().info.supportsReasoningEffort + const supported = Array.isArray(capability) + ? (capability as string[]).includes(setEffortValue) + : capability === true + if (supported) { + setEffortTask.setRuntimeThinkingEffort(setEffortValue, "you") + // Single in-chat line (same ChatRow case as model-initiated changes). + // isNonInteractive: the line is display-only and can fire while the task + // is blocked on a pending ask (e.g. the followup ask after a subtask + // returns). Without it, say() bumps lastMessageTs and Task.ask's pWaitFor + // treats the pending ask as superseded (AskIgnoredError) — killing the + // request loop, so the next user message has no waiter (no API call, + // frozen UI). + await setEffortTask.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: setEffortValue, + source: "you", + } satisfies ClineSayTool), + undefined, + false, + undefined /* checkpoint */, + undefined /* progressStatus */, + { isNonInteractive: true }, + ) + // Push the authoritative display state to the webview. + await provider.postStateToWebviewWithoutTaskHistory() + } + } else { + // No open task: park the selection as the pending effort for the + // next top-level task (createTask applies it after validating the + // new task's model capability). The toggle keeps showing the + // selection because the webview state falls back to the pending + // value while no task is open. + provider.setPendingTaskThinkingEffort(setEffortParsed.data) + await provider.postStateToWebviewWithoutTaskHistory() + } + } + break + } case "allowedCommands": { // Validate and sanitize the commands array const commands = message.commands ?? [] diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index f2261a5c09..b6e8993df4 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -2,7 +2,7 @@ import type { ExperimentId } from "@roo-code/types" -import { EXPERIMENT_IDS, experimentConfigsMap, experiments as Experiments } from "../experiments" +import { EXPERIMENT_IDS, experimentConfigsMap, experimentDefault, experiments as Experiments } from "../experiments" describe("experiments", () => { describe("PREVENT_FOCUS_DISRUPTION", () => { @@ -22,6 +22,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -33,6 +34,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) }) @@ -44,6 +46,7 @@ describe("experiments", () => { runSlashCommand: false, customTools: false, parallelToolExecution: false, + dynamicThinkingEffort: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -66,4 +69,28 @@ describe("experiments", () => { expect(Experiments.isEnabled({ parallelToolExecution: true }, "parallelToolExecution")).toBe(true) }) }) + + describe("DYNAMIC_THINKING_EFFORT", () => { + it("is configured correctly", () => { + expect(EXPERIMENT_IDS.DYNAMIC_THINKING_EFFORT).toBe("dynamicThinkingEffort") + expect(experimentConfigsMap.DYNAMIC_THINKING_EFFORT).toMatchObject({ + enabled: false, + }) + // Visible in the Settings panel (showInSettings defaults to true). + expect(experimentConfigsMap.DYNAMIC_THINKING_EFFORT.showInSettings).toBeUndefined() + }) + + it("is disabled by default", () => { + expect(experimentDefault.dynamicThinkingEffort).toBe(false) + expect(Experiments.isEnabled({}, "dynamicThinkingEffort")).toBe(false) + }) + + it("returns true when enabled", () => { + expect(Experiments.isEnabled({ dynamicThinkingEffort: true }, "dynamicThinkingEffort")).toBe(true) + }) + + it("returns false when explicitly disabled", () => { + expect(Experiments.isEnabled({ dynamicThinkingEffort: false }, "dynamicThinkingEffort")).toBe(false) + }) + }) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index ae538b9138..c0d461a454 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -6,6 +6,7 @@ export const EXPERIMENT_IDS = { RUN_SLASH_COMMAND: "runSlashCommand", CUSTOM_TOOLS: "customTools", PARALLEL_TOOL_EXECUTION: "parallelToolExecution", + DYNAMIC_THINKING_EFFORT: "dynamicThinkingEffort", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -25,6 +26,7 @@ export const experimentConfigsMap: Record = { CUSTOM_TOOLS: { enabled: false }, // TODO: add i18n keys (settings:experimental.PARALLEL_TOOL_EXECUTION.name/.description) in the same PR that sets showInSettings: true PARALLEL_TOOL_EXECUTION: { enabled: false, showInSettings: false }, + DYNAMIC_THINKING_EFFORT: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 1a1fb03200..5d3ef45459 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -66,6 +66,7 @@ export const toolParamNames = [ "new_string", // search_replace and edit_file parameter "replace_all", // edit tool parameter for replacing all occurrences "expected_replacements", // edit_file parameter for multiple occurrences + "effort", // set_thinking_effort parameter "timeout", // execute_command parameter "artifact_id", // read_command_output parameter "search", // read_command_output parameter for grep-like search @@ -81,6 +82,7 @@ export const toolParamNames = [ // read_file legacy format parameter (backward compatibility) "files", "line_ranges", + "thinking_effort", // new_task parameter: optional subtask start effort (DTE series 5/5) ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -102,13 +104,17 @@ export type NativeToolArgs = { edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } list_files: { path: string; recursive?: boolean } - new_task: { mode: string; message: string; todos?: string } + // thinking_effort is ["string", "null"] in the strict-mode schema: null is the + // "omitted" sentinel the model sends (the parameter must be required under + // strict: true + additionalProperties: false). + new_task: { mode: string; message: string; todos?: string; thinking_effort?: string | null } ask_followup_question: { question: string follow_up: Array<{ text: string; mode?: string }> } codebase_search: { query: string; path?: string } generate_image: GenerateImageParams + set_thinking_effort: { effort: string; reason: string } run_slash_command: { command: string; args?: string } skill: { skill: string; args?: string } search_files: { path: string; regex: string; file_pattern?: string | null } @@ -134,8 +140,9 @@ export interface ToolUse { * Used to preserve tool names in API conversation history. */ originalName?: string - // params is a partial record, allowing only some or none of the possible parameters to be used - params: Partial> + // params is a partial record, allowing only some or none of the possible parameters to be used. + // new_task.thinking_effort may be the strict-mode null sentinel (see NativeToolArgs.new_task). + params: Omit>, "thinking_effort"> & { thinking_effort?: string | null } partial: boolean // nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never @@ -240,7 +247,7 @@ export interface SwitchModeToolUse extends ToolUse<"switch_mode"> { export interface NewTaskToolUse extends ToolUse<"new_task"> { name: "new_task" - params: Partial, "mode" | "message" | "todos">> + params: Partial, "mode" | "message" | "todos" | "thinking_effort">> } export interface RunSlashCommandToolUse extends ToolUse<"run_slash_command"> { @@ -289,6 +296,7 @@ export const TOOL_DISPLAY_NAMES: Record = { run_slash_command: "run slash command", skill: "load skill", generate_image: "generate images", + set_thinking_effort: "set thinking effort", custom_tool: "use custom tools", invalid_tool_call: "invalid tool call", } as const @@ -323,6 +331,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ "update_todo_list", "run_slash_command", "skill", + "set_thinking_effort", ] as const /** diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 952322084f..a849c4b597 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -65,6 +65,7 @@ import { SquareArrowOutUpRight, FileCode2, PocketKnife, + Brain, FolderTree, SquareTerminal, MessageCircle, @@ -1549,6 +1550,35 @@ export const ChatRowContent = ({ ) } + case "thinkingEffort": { + const info = sayTool + return ( +
+ + + {info.refusal ? ( + info.refusal === "oscillation" ? ( + t("chat:thinkingEffort.oscillationRefused") + ) : ( + t("chat:thinkingEffort.escalationCapRefused") + ) + ) : ( + {info.effort}, + }} + values={{ effort: info.effort, reason: info.reason }} + /> + )} + +
+ ) + } default: return null } diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 3b94fb6e74..eab4830443 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -28,6 +28,7 @@ import Thumbnails from "../common/Thumbnails" import { ModeSelector } from "./ModeSelector" import { ApiConfigSelector } from "./ApiConfigSelector" import { AutoApproveDropdown } from "./AutoApproveDropdown" +import { ThinkingEffortToggle } from "./ThinkingEffortToggle" import { MAX_IMAGES_PER_MESSAGE } from "./constants" import ContextMenu from "./ContextMenu" import { IndexingStatusBadge } from "./IndexingStatusBadge" @@ -1311,6 +1312,7 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} /> +
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b6c3b0bdf0..5a35a62250 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -13,6 +13,7 @@ import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" import useSound from "use-sound" import { LRUCache } from "lru-cache" +import { Brain } from "lucide-react" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" @@ -20,7 +21,15 @@ import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" import { batchNearby } from "@src/utils/batchNearby" import { isBoundary, isIgnorableBetweenTargets } from "@src/utils/chatBatchingPredicates" -import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types" +import type { + ClineAsk, + ClineSayTool, + ClineMessage, + ExtensionMessage, + AudioType, + SuggestionItem, + ReasoningEffortExtended, +} from "@roo-code/types" import { getCompletionCheckpoint, getSuggestionMode, isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" @@ -182,6 +191,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [primaryButtonText, setPrimaryButtonText] = useState(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) + // DTE series 5/5: the effort chosen in the pending new_task ask block (pre-filled + // from the tool payload) and the levels the target model supports for it. + const [newTaskAskEffort, setNewTaskAskEffort] = useState(undefined) + const [newTaskAskSupportedEfforts, setNewTaskAskSupportedEfforts] = useState( + undefined, + ) const [_didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) @@ -305,6 +320,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 + ? tool.thinkingEffort && supported.includes(tool.thinkingEffort) + ? tool.thinkingEffort + : supported[0] + : tool.thinkingEffort, + ) + } switch (tool.tool) { case "editedExistingFile": case "appliedDiff": @@ -703,6 +740,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked", text: trimmedInput, images: images, + thinkingEffort: newTaskAskEffort, }) // Clear input state after sending setInputValue("") setSelectedImages([]) } else { - vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: newTaskAskEffort, + }) } break case "resume_task": @@ -849,7 +897,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : ( <> + {/* DTE series 5/5: the new_task ask effort selector — pre-filled from the + tool payload, switchable before entering the subtask. Rich surfaces are PR-4. */} + {clineAsk === "tool" && + newTaskAskSupportedEfforts && + newTaskAskSupportedEfforts.length > 0 && ( + // DTE series 5/5: wrapped in a relative container so the Brain icon can sit + // inside the trigger — native