diff --git a/apps/vscode-e2e/fixtures/thinking-effort-switching.json b/apps/vscode-e2e/fixtures/thinking-effort-switching.json new file mode 100644 index 0000000000..995447230f --- /dev/null +++ b/apps/vscode-e2e/fixtures/thinking-effort-switching.json @@ -0,0 +1,84 @@ +{ + "fixtures": [ + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": false, + "turnIndex": 0 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"medium\", \"reason\": \"start at medium\"}", + "id": "call_dte_sw_001" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 1 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"medium\", \"reason\": \"confirm current level\"}", + "id": "call_dte_sw_002" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 2 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"high\", \"reason\": \"raise to high\"}", + "id": "call_dte_sw_003" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 3 + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"medium\", \"reason\": \"try returning to medium\"}", + "id": "call_dte_sw_004" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5.1", + "hasToolResult": true, + "turnIndex": 4 + }, + "response": { + "toolCalls": [ + { + "name": "attempt_completion", + "arguments": "{\"result\": \"DTE_E2E_SWITCH_DONE\"}", + "id": "call_dte_sw_005" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/fixtures/thinking-effort-tool.json b/apps/vscode-e2e/fixtures/thinking-effort-tool.json new file mode 100644 index 0000000000..fab08ae57d --- /dev/null +++ b/apps/vscode-e2e/fixtures/thinking-effort-tool.json @@ -0,0 +1,35 @@ +{ + "fixtures": [ + { + "match": { + "sequenceIndex": 0, + "userMessage": "DTE_E2E_EFFORT_APPLY: answer the math question" + }, + "response": { + "toolCalls": [ + { + "name": "set_thinking_effort", + "arguments": "{\"effort\": \"high\", \"reason\": \"multi-step math\"}", + "id": "call_dte_e2e_001" + } + ] + } + }, + { + "match": { + "model": "openai/gpt-5", + "hasToolResult": true, + "turnIndex": 1 + }, + "response": { + "toolCalls": [ + { + "name": "attempt_completion", + "arguments": "{\"result\": \"42\"}", + "id": "call_dte_e2e_002" + } + ] + } + } + ] +} 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/runTest.ts b/apps/vscode-e2e/src/runTest.ts index 8162f34068..7fd4d1e22c 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -18,7 +18,7 @@ import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile" import { addListFilesResultFixtures } from "./fixtures/list-files" import { addReadFileResultFixtures } from "./fixtures/read-file" import { addSearchFilesResultFixtures } from "./fixtures/search-files" -import { addSubtaskFixtures } from "./fixtures/subtasks" +import { addDteNewTaskEffortFixtures, addSubtaskFixtures } from "./fixtures/subtasks" import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool" import { addWriteToFileResultFixtures } from "./fixtures/write-to-file" import { createScenarioWorkspace, removeScenarioWorkspace } from "./restart/scenarioWorkspace" @@ -140,6 +140,7 @@ async function main() { addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) addSubtaskFixtures(mock) + addDteNewTaskEffortFixtures(mock) addUseMcpToolResultFixtures(mock) addWriteToFileResultFixtures(mock) addDeepSeekV4Fixtures(mock) diff --git a/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts b/apps/vscode-e2e/src/suite/new-task-thinking-effort.test.ts new file mode 100644 index 0000000000..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..08c7adcbb1 --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-switching.test.ts @@ -0,0 +1,255 @@ +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/fixtures/thinking-effort-switching.json): + * post-tool requests end with a role:user message (fresh environment details + * are appended after the tool result), so aimock's toolCallId matcher — which + * inspects only the LAST message — can never match them. Each fixture is + * scoped by model + hasToolResult + turnIndex instead; aimock's + * selectByTurnIndex picks the highest turnIndex <= the request's assistant + * count, and each turnIndex is unique per fixture, so every request matches + * exactly one fixture. Note the fixture file is plain JSON: aimock's + * fixture loader uses JSON.parse and SKIPS the whole file on parse errors, + * so no // comments may be added to it. + */ + +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) + + // Replace the provider profile with the defaults (the suite setup replaces it + // again, and saveConfig is a full replacement) so subsequent suites are + // unaffected, including this suite's baseline effort and the experiment flag. + 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 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..f97120efe9 --- /dev/null +++ b/apps/vscode-e2e/src/suite/thinking-effort-tool.test.ts @@ -0,0 +1,164 @@ +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/fixtures/thinking-effort-tool.json): 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 + * inspects only the LAST message — can never match it. The follow-up request is + * scoped by the DTE-only model + hasToolResult instead, with turnIndex 1 as a + * tie-break. Note the fixture file is plain JSON: aimock's fixture-loader uses + * JSON.parse and SKIPS the whole file on parse errors, so no // comments may be + * added to it. + */ + +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/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..7073778888 100644 --- a/src/__tests__/provider-delegation.spec.ts +++ b/src/__tests__/provider-delegation.spec.ts @@ -386,4 +386,240 @@ 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("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..1f0d0b4f41 --- /dev/null +++ b/src/api/providers/__tests__/f7-declared-reasoning-effort.spec.ts @@ -0,0 +1,215 @@ +// 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 { 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(), +})) + +// 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() + }) + }) +}) 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 { + 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/Task.ts b/src/core/task/Task.ts index 349d9c51d3..f11d939893 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 @@ -1439,7 +1451,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 +1468,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 +1542,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 +1773,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) @@ -2305,9 +2439,24 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Centralized task teardown: releases task resources and resets transient + * task-local state. + * + * DTE series 2/5: also clears the task-local thinking effort override (the + * `setRuntimeThinkingEffort` state) — the override never outlives the task. + */ public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + // 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 +2553,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 +4120,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 +4348,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 +4515,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..4fce91b475 --- /dev/null +++ b/src/core/task/__tests__/Task.runtime-thinking-effort.test.ts @@ -0,0 +1,311 @@ +// npx vitest run src/core/task/__tests__/Task.runtime-thinking-effort.test.ts +// +// DTE series 2/5 — task-local thinking effort state on Task: +// setRuntimeThinkingEffort / getRuntimeThinkingEffort, the in-memory +// apiConfiguration merge + restore, and the task-end reset in dispose(). + +import { ProviderSettings, type ReasoningEffortExtended } from "@roo-code/types" +import { providerIdentifiers } from "@roo-code/types/provider-identifiers" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { buildApiHandler } from "../../../api" + +// Mock dependencies (same lightweight set as Task.throttle.test.ts) +vi.mock("../../webview/ClineProvider") +vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({ + TerminalRegistry: { + releaseTerminalsForTask: vi.fn(), + }, +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../protect/RooProtectedController") +vi.mock("../../context-tracking/FileContextTracker") +vi.mock("../../../integrations/editor/DiffViewProvider") +vi.mock("../../tools/ToolRepetitionDetector") +vi.mock("../../../api", () => ({ + // Returns a fresh handler object per call so tests can assert on the exact + // configuration each rebuild received (via vi.mocked(buildApiHandler).mock.calls). + buildApiHandler: vi.fn((configuration: { apiModelId?: string }) => ({ + getModel: () => ({ info: {}, id: configuration.apiModelId ?? "test-model" }), + })), +})) + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCreated: vi.fn(), + captureTaskRestarted: vi.fn(), + }, + }, +})) + +// Mock task persistence to avoid disk writes +vi.mock("../../task-persistence", async (importOriginal) => ({ + ...(await importOriginal()), + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + readTaskMessages: vi.fn().mockResolvedValue([]), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + taskMetadata: vi.fn().mockResolvedValue({ + historyItem: { + id: "test-task-id", + number: 1, + task: "Test task", + ts: Date.now(), + totalCost: 0.01, + tokensIn: 100, + tokensOut: 50, + }, + tokenUsage: { + totalTokensIn: 100, + totalTokensOut: 50, + totalCost: 0.01, + contextTokens: 150, + totalCacheWrites: 0, + totalCacheReads: 0, + }, + }), +})) + +// Typed access to the intentionally-private DTE state, mirroring the +// getTaskTestAccess pattern in Task.spec.ts (single double assertion, documented). +type RuntimeThinkingEffortAccess = { + runtimeThinkingEffort?: ReasoningEffortExtended + runtimeThinkingEffortSource?: string + preOverrideReasoningEffort?: ProviderSettings["reasoningEffort"] + getRuntimeThinkingEffortMetadata: () => { reasoningEffort?: ReasoningEffortExtended } +} + +function getPrivateAccess(task: Task): RuntimeThinkingEffortAccess { + return task as unknown as RuntimeThinkingEffortAccess +} + +const SETTINGS_EFFORT: ReasoningEffortExtended = "low" + +describe("Task runtime thinking effort (DTE series 2/5)", () => { + let mockProvider: Record + let mockApiConfiguration: ProviderSettings + let task: Task + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + mockProvider = { + context: { + globalStorageUri: { fsPath: "/test/path" }, + }, + getState: vi.fn().mockResolvedValue({ mode: "code" }), + log: vi.fn(), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), + postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), + updateTaskHistory: vi.fn().mockResolvedValue(undefined), + } + + mockApiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + } as ProviderSettings + + // mockProvider is a minimal structural double (ClineProvider is auto-mocked + // by the vi.mock above); the task only touches the members supplied here. + task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfiguration, + startTask: false, + }) + }) + + afterEach(() => { + vi.useRealTimers() + if (task && !task.abort) { + task.dispose() + } + }) + + describe("setRuntimeThinkingEffort", () => { + it("stores effort + source, merges into the in-memory apiConfiguration, and rebuilds the handler", () => { + task.setRuntimeThinkingEffort("xhigh", "test-source") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "xhigh", source: "test-source" }) + expect(getPrivateAccess(task).runtimeThinkingEffort).toBe("xhigh") + expect(getPrivateAccess(task).runtimeThinkingEffortSource).toBe("test-source") + + // The in-memory copy carries the override... + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-key", + reasoningEffort: "xhigh", + }), + ) + // ...without mutating the settings object the provider handed in. + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + reasoningEffort: SETTINGS_EFFORT, + }), + ) + // The handler is rebuilt from the merged copy (last build call). + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: "xhigh" })) + // The merged copy is a fresh object, not the settings object. + expect(lastCall?.[0]).not.toBe(mockApiConfiguration) + }) + + it("does not re-capture the settings value when re-set while active", () => { + task.setRuntimeThinkingEffort("high", "first") + task.setRuntimeThinkingEffort("medium", "second") + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: "medium", source: "second" }) + // The settings-derived value captured at first activation is preserved. + expect(getPrivateAccess(task).preOverrideReasoningEffort).toBe(SETTINGS_EFFORT) + + // Clearing restores the original settings value, not the intermediate one. + task.setRuntimeThinkingEffort(undefined) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + }) + + it("restores the settings-derived effort when cleared with undefined", () => { + task.setRuntimeThinkingEffort("max") + expect(task.apiConfiguration.reasoningEffort).toBe("max") + + task.setRuntimeThinkingEffort(undefined) + + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration.reasoningEffort).toBe(SETTINGS_EFFORT) + // The rest of the configuration is preserved through the restore. + expect(task.apiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + }), + ) + // The handler is rebuilt from the restored copy. + const lastCall = vi.mocked(buildApiHandler).mock.calls.at(-1) + expect(lastCall?.[0]).toEqual(expect.objectContaining({ reasoningEffort: SETTINGS_EFFORT })) + }) + + it("is a no-op when cleared while inactive (no handler rebuild)", () => { + const callsBefore = vi.mocked(buildApiHandler).mock.calls.length + + task.setRuntimeThinkingEffort(undefined) + + expect(vi.mocked(buildApiHandler).mock.calls.length).toBe(callsBefore) + expect(task.getRuntimeThinkingEffort()).toEqual({ effort: undefined, source: undefined }) + expect(task.apiConfiguration).toBe(mockApiConfiguration) + }) + + it("never writes to the provider or persisted settings", () => { + task.setRuntimeThinkingEffort("xhigh") + task.setRuntimeThinkingEffort(undefined) + + // Nothing is posted to the webview and the handed-in settings object is intact. + expect(mockProvider.postStateToWebview).not.toHaveBeenCalled() + expect(mockApiConfiguration).toEqual( + expect.objectContaining({ + apiProvider: providerIdentifiers.anthropic, + apiModelId: "claude-opus-4-7", + apiKey: "test-key", + reasoningEffort: SETTINGS_EFFORT, + }), + ) + }) + }) + + describe("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() + }) + }) +}) diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e37..bda316f846 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,46 @@ 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. + const supportedLevels: readonly (ReasoningEffortExtended | "disable")[] = + modelCapabilities === true + ? NEW_TASK_EFFORT_LEVELS + : Array.isArray(modelCapabilities) + ? modelCapabilities + : [] + let validatedEffort: ReasoningEffortExtended | undefined + if (thinking_effort !== undefined && thinking_effort !== 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(", ")}` + : supportedLevels.length > 0 + ? `the target model only supports: ${ + supportedLevels.filter((level) => level !== "disable").join(", ") || "none" + }` + : "the target model does not support thinking_effort" + pushToolResult(formatResponse.toolError(`Invalid thinking_effort '${thinking_effort}'. ${reason}`)) + return + } + validatedEffort = thinking_effort + } + // Get the VSCode setting for requiring todos. const provider = task.providerRef.deref() @@ -96,11 +158,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 +180,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..611cbf6337 --- /dev/null +++ b/src/core/tools/__tests__/newTaskThinkingEffort.spec.ts @@ -0,0 +1,374 @@ +// 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("rejects an effort when the target model exposes no capability array", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.stringContaining("does not support thinking_effort"), + ) + expect(delegateParentAndOpenChild).not.toHaveBeenCalled() + }) + + it("pre-fills the ask payload with the effort and the supported levels, filtering 'disable'", async () => { + const { task } = makeTask({ + supportsReasoningEffort: ["disable", "low", "medium"], + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(callbacks.askApproval).toHaveBeenCalledTimes(1) + const [askType, toolMessage] = vi.mocked(callbacks.askApproval).mock.calls[0] + expect(askType).toBe("tool") + const payload = JSON.parse(toolMessage as string) as { + tool: string + thinkingEffort?: string + supportedThinkingEfforts?: string[] + } + expect(payload.tool).toBe("newTask") + expect(payload.thinkingEffort).toBe("low") + expect(payload.supportedThinkingEfforts).toEqual(["low", "medium"]) + }) + + it("falls back to the parent's effective effort when no effort is specified", async () => { + const { task, delegateParentAndOpenChild, resolveNewTaskEffectiveEffort } = makeTask({ + supportsReasoningEffort: ["low", "medium"], + parentEffort: "medium", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(resolveNewTaskEffectiveEffort).toHaveBeenCalled() + expect(delegateParentAndOpenChild).toHaveBeenCalledWith({ + parentTaskId: "parent-1", + message: "Do the delegated work", + initialTodos: [], + mode: "code", + thinkingEffort: "medium", + }) + }) + + it("prefers the ask-block selection over the model-specified effort", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low", "medium", "high"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "high" })) + }) + + it("ignores an ask-block selection the target model does not support", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: ["low"], + askEffort: "high", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, { thinking_effort: "low" }, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) + + it("falls back to the parent's effective effort when the ask selection is unsupported and no model effort was given", async () => { + const { task, delegateParentAndOpenChild } = makeTask({ + supportsReasoningEffort: undefined, + askEffort: "high", + parentEffort: "low", + }) + const callbacks = makeCallbacks() + + await runNewTask(task, {}, callbacks) + + expect(delegateParentAndOpenChild).toHaveBeenCalledWith(expect.objectContaining({ thinkingEffort: "low" })) + }) + + 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..200c14dec3 --- /dev/null +++ b/src/core/tools/__tests__/setThinkingEffortTool.spec.ts @@ -0,0 +1,492 @@ +// 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) + }) + }) + + 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..946d708057 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -35,6 +35,7 @@ import { type CreateTaskOptions, type TokenUsage, type ToolUsage, + type ReasoningEffortExtended, type ExtensionMessage, type ExtensionState, type WebviewThemeFixture, @@ -2649,6 +2650,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 +2712,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" } + : undefined, messageQueue: currentTask?.messageQueueService?.messages, taskHistory: includeTaskHistory ? this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task) @@ -3769,8 +3778,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 +3875,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..aa0b61f8e0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -936,6 +936,38 @@ 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() + + getCurrentTaskSpy.mockRestore() + }) + 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..b4c06d60be --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.thinking-effort.spec.ts @@ -0,0 +1,97 @@ +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) => {}) + 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 () => {}), + }) + + 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("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("ignores the message when there is no current task", async () => { + const provider = makeProvider(undefined) + + await apply(provider, { type: "setTaskThinkingEffort", effort: "high" }) + + expect(provider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..1154e5ebb8 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,39 @@ 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 (setEffortTask && setEffortParsed.success) { + 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). + await setEffortTask.say( + "tool", + JSON.stringify({ + tool: "thinkingEffort", + effort: setEffortValue, + source: "you", + } satisfies ClineSayTool), + undefined, + false, + ) + // Push the authoritative display state to the webview. + 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..004d628755 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -20,7 +20,15 @@ import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" import { batchNearby } from "@src/utils/batchNearby" import { isBoundary, isIgnorableBetweenTargets } from "@src/utils/chatBatchingPredicates" -import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType, SuggestionItem } from "@roo-code/types" +import type { + ClineAsk, + ClineSayTool, + ClineMessage, + ExtensionMessage, + AudioType, + SuggestionItem, + ReasoningEffortExtended, +} from "@roo-code/types" import { getCompletionCheckpoint, getSuggestionMode, isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" @@ -182,6 +190,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction(false) const [primaryButtonText, setPrimaryButtonText] = useState(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) + // DTE series 5/5: the effort chosen in the pending new_task ask block (pre-filled + // from the tool payload) and the levels the target model supports for it. + const [newTaskAskEffort, setNewTaskAskEffort] = useState(undefined) + const [newTaskAskSupportedEfforts, setNewTaskAskSupportedEfforts] = useState( + undefined, + ) const [_didClickCancel, setDidClickCancel] = useState(false) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) @@ -305,6 +319,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0 + ? tool.thinkingEffort && supported.includes(tool.thinkingEffort) + ? tool.thinkingEffort + : supported[0] + : tool.thinkingEffort, + ) + } switch (tool.tool) { case "editedExistingFile": case "appliedDiff": @@ -703,6 +739,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 +896,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : ( <> + {/* DTE series 5/5: the new_task ask effort selector — pre-filled from the + tool payload, switchable before entering the subtask. Rich surfaces are PR-4. */} + {clineAsk === "tool" && + newTaskAskSupportedEfforts && + newTaskAskSupportedEfforts.length > 0 && ( + + )} {primaryButtonText && ( { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem } = useExtensionState() + const { apiConfiguration, currentTaskItem, taskThinkingEffort } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -86,6 +89,24 @@ const TaskHeader = ({ // vscode-lm reports maxTokens: -1 (unlimited); a negative reserve must not distort the window math. const reservedForOutput = maxTokens && maxTokens > 0 ? maxTokens : 0 + // DTE series 4/5: current effective thinking effort + source badge + // (task-local override → settings → model default/adaptive). + const thinkingEffortDisplay = useMemo( + () => + computeThinkingEffortDisplay({ + apiConfiguration, + model, + taskThinkingEffort, + }), + [apiConfiguration, model, taskThinkingEffort], + ) + const thinkingEffortSourceKey = + thinkingEffortDisplay?.source === "you" + ? "chat:thinkingEffort.sourceYou" + : thinkingEffortDisplay?.source === "auto" + ? "chat:thinkingEffort.sourceAuto" + : "chat:thinkingEffort.sourceDefault" + const condenseButton = (
e.stopPropagation()}> + {thinkingEffortDisplay && ( + + + + + {thinkingEffortDisplay.effort} + + {t(thinkingEffortSourceKey)} + + + )} + ))} + + + ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx new file mode 100644 index 0000000000..51c4f92c04 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.thinking-effort.spec.tsx @@ -0,0 +1,135 @@ +import React from "react" +import { render, screen } from "@/utils/test-utils" +import { ChatRowContent } from "../ChatRow" +import type { ClineMessage, ClineSayTool } from "@roo-code/types" + +/** The thinkingEffort say-tool payload shape emitted by SetThinkingEffortTool. */ +type ThinkingEffortSayTool = Pick & { + tool: "thinkingEffort" +} + +/** A non-thinkingEffort say-tool payload (arbitrary tool name) for negative tests. */ +type OtherSayTool = Pick & { + tool: string +} + +// Mock vscode API +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (msg: unknown) => mockPostMessage(msg), + }, +})) + +// Mock i18n (value-substituting Trans for the one-line display) +const tMap: Record = { + "chat:thinkingEffort.applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "chat:thinkingEffort.appliedByUser": "🧠 Thinking effort set to: {{effort}}", + "chat:thinkingEffort.escalationCapRefused": + "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "chat:thinkingEffort.oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected", +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => tMap[key] ?? key, + i18n: { exists: () => true }, + }), + Trans: ({ i18nKey, values }: { i18nKey?: string; values?: Record }) => { + const raw = (i18nKey && (tMap[i18nKey] ?? i18nKey)) || "" + return <>{String(raw).replace(/{{(\w+)}}/g, (_, k: string) => String(values?.[k] ?? ""))} + }, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock extension state context +let mockClineMessages: ClineMessage[] = [] +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + mcpServers: [], + alwaysAllowMcp: false, + currentCheckpoint: null, + mode: "code", + apiConfiguration: {}, + clineMessages: mockClineMessages, + currentTaskItem: undefined, + }), +})) + +// Mock useSelectedModel hook +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ info: { supportsImages: true } }), +})) + +function renderChatRow(message: ClineMessage) { + mockClineMessages = [message] + return render( + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + />, + ) +} + +function sayToolMessage(text: ThinkingEffortSayTool | OtherSayTool): ClineMessage { + return { + ts: Date.now(), + type: "say" as const, + say: "tool" as const, + text: JSON.stringify(text), + } +} + +describe("ChatRow - thinkingEffort display (DTE series 3/5)", () => { + beforeEach(() => { + mockPostMessage.mockClear() + }) + + it("renders the one-line applied display with effort and reason", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", reason: "deep analysis ahead" })) + + expect(screen.getByText("🧠 Thinking effort: high (Zoo) — deep analysis ahead")).toBeInTheDocument() + }) + + it("renders the oscillation refusal line", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", refusal: "oscillation" })) + + expect( + screen.getByText("🧠 Thinking effort unchanged: oscillation between levels detected"), + ).toBeInTheDocument() + }) + + it("renders the escalation-cap refusal line", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", refusal: "escalation_cap" })) + + expect( + screen.getByText("🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached"), + ).toBeInTheDocument() + }) + + // DTE series 4/5: user-set (composer toggle) changes reuse the same one-line + // display with a user-specific phrasing. + it("renders the applied display with user phrasing for source 'you'", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", source: "you" })) + + expect(screen.getByText("🧠 Thinking effort set to: high")).toBeInTheDocument() + }) + + it("keeps the model phrasing for non-user sources", () => { + renderChatRow(sayToolMessage({ tool: "thinkingEffort", effort: "high", source: "model", reason: "deep dive" })) + + expect(screen.getByText("🧠 Thinking effort: high (Zoo) — deep dive")).toBeInTheDocument() + }) + + it("renders nothing for unknown say-tool payloads", () => { + const { container } = renderChatRow(sayToolMessage({ tool: "someOtherTool" })) + + expect(container.textContent).toBe("") + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 6b2fa177c9..8198ce62bd 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -1531,3 +1531,216 @@ describe("ChatView - Context Condensing Indicator Tests", () => { ) }) }) + +describe("ChatView - new_task thinking effort selector (DTE series 5/5)", () => { + // Posts a fresh state snapshot whose last message is the given tool ask. + const postToolAsk = (toolPayload: Record) => + mockPostMessage({ + clineMessages: [ + { type: "say", say: "task", ts: 1, text: "Parent task" }, + { type: "ask", ask: "tool", ts: 2, text: JSON.stringify(toolPayload) }, + ], + }) + + const NEW_TASK_ASK: Record = { + tool: "newTask", + mode: "Code Mode", + content: "Do the delegated work", + todos: [], + thinkingEffort: "low", + supportedThinkingEfforts: ["low", "medium", "high"], + } + + beforeEach(() => { + vi.clearAllMocks() + mockTaskHeaderState.renders.length = 0 + }) + + it("renders the effort selector pre-filled from the newTask ask payload", async () => { + const { getByLabelText } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) + // Pre-filled with the effort the extension resolved for the new task... + expect(select).toHaveValue("low") + // ...and offers exactly the levels the target model supports. + expect(Array.from(select.options).map((option) => option.value)).toEqual(["low", "medium", "high"]) + // Option labels are bound to the translated level keys (settings:providers.reasoningEffort.*); + // in this test the effective t() is the identity function, so the raw keys render verbatim. + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + "settings:providers.reasoningEffort.low", + "settings:providers.reasoningEffort.medium", + "settings:providers.reasoningEffort.high", + ]) + }) + + it("falls back to the first supported level when the pre-fill is not supported", async () => { + const { getByLabelText } = renderChatView() + + await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) + + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) + expect(select).toHaveValue("low") + }) + + it("posts the displayed level when approving an unsupported prefill without touching the select", async () => { + const { getByLabelText, getByRole } = renderChatView() + + // The payload's effort ("xhigh") is not in the supported list (["low", "high"]): + // the select displays "low" (the first supported level). If the user approves + // without changing the selector, the posted effort must be the displayed level — + // not the raw payload value the user never saw. + await postToolAsk({ ...NEW_TASK_ASK, thinkingEffort: "xhigh", supportedThinkingEfforts: ["low", "high"] }) + + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) + expect(select).toHaveValue("low") + + await act(async () => { + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: "low", + }) + }) + + it("hides the selector for non-newTask tool asks (the ask effect resets the state)", async () => { + const { getByLabelText, queryByLabelText } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + await waitFor(() => { + expect(getByLabelText("settings:providers.reasoningEffort.label")).toBeInTheDocument() + }) + + // A subsequent readFile ask must drop the selector: the effort state is + // cleared for every unanswered ask and only re-set for newTask asks. + mockPostMessage({ + clineMessages: [ + { type: "say", say: "task", ts: 1, text: "Parent task" }, + { type: "ask", ask: "tool", ts: 3, text: JSON.stringify({ tool: "readFile", path: "a.ts" }) }, + ], + }) + + await waitFor(() => { + expect(queryByLabelText("settings:providers.reasoningEffort.label")).not.toBeInTheDocument() + }) + }) + + it("hides the selector when the payload carries no supported efforts", async () => { + const { getByRole, queryByLabelText } = renderChatView() + + await postToolAsk({ tool: "newTask", mode: "Code Mode", content: "Do the work", todos: [] }) + + // Wait for the ask UI to settle (approve button rendered) before asserting absence. + await waitFor(() => { + expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() + }) + expect(queryByLabelText("settings:providers.reasoningEffort.label")).not.toBeInTheDocument() + }) + + it("posts the selected effort when the user approves the newTask ask", async () => { + const { getByLabelText, getByRole } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) + + // The user switches the effort before entering the subtask... + await act(async () => { + fireEvent.change(select, { target: { value: "high" } }) + }) + + // ...and approves without typing feedback (bare yesButtonClicked branch). + await act(async () => { + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: "high", + }) + }) + + it("posts the selected effort along with feedback text on approval", async () => { + const { getByLabelText, getByRole, getByTestId } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) + await act(async () => { + fireEvent.change(select, { target: { value: "medium" } }) + }) + + const input = getByTestId("chat-textarea").querySelector("input")! as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { value: "focus on tests" } }) + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + text: "focus on tests", + images: [], + thinkingEffort: "medium", + }) + }) + + it("posts undefined effort when approving a non-newTask tool ask", async () => { + const { getByRole } = renderChatView() + + await postToolAsk({ tool: "readFile", path: "a.ts" }) + await waitFor(() => { + expect(getByRole("button", { name: "chat:approve.title" })).toBeInTheDocument() + }) + + await act(async () => { + fireEvent.click(getByRole("button", { name: "chat:approve.title" })) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "yesButtonClicked", + thinkingEffort: undefined, + }) + }) + + it("posts the effort when a message is sent during the pending newTask ask", async () => { + const { getByLabelText, getByTestId } = renderChatView() + + await postToolAsk(NEW_TASK_ASK) + const select = await waitFor( + () => getByLabelText("settings:providers.reasoningEffort.label") as HTMLSelectElement, + ) + await act(async () => { + fireEvent.change(select, { target: { value: "high" } }) + }) + + vscodePostMessageMock.cleanup() + const input = getByTestId("chat-textarea").querySelector("input")! as HTMLInputElement + await act(async () => { + fireEvent.change(input, { target: { value: "please hurry" } }) + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) + }) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "askResponse", + askResponse: "messageResponse", + text: "please hurry", + images: [], + thinkingEffort: "high", + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx new file mode 100644 index 0000000000..5797d262b3 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.thinking-effort.spec.tsx @@ -0,0 +1,162 @@ +import React from "react" +import { renderWithExtensionState, screen } from "@/utils/test-utils" +import type { ProviderSettings } from "@roo-code/types" + +import TaskHeader, { TaskHeaderProps } from "../TaskHeader" + +// i18n: keys, with exact badge strings for the thinking-effort keys +const effortKeys: Record = { + "chat:thinkingEffort.sourceYou": "you", + "chat:thinkingEffort.sourceAuto": "Zoo (auto)", + "chat:thinkingEffort.sourceDefault": "default", + "chat:thinkingEffort.chipTooltip": "thinking-effort-chip", +} +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => effortKeys[key] ?? key, + }), + initReactI18next: { + type: "3rdParty", + init: vi.fn(), + }, +})) + +const { mockPostMessage } = vi.hoisted(() => ({ mockPostMessage: vi.fn() })) +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, +})) + +const mockState: { + apiConfiguration: ProviderSettings + currentTaskItem: { id: string } | null + clineMessages: any[] + taskHistory: any[] + experiments: Record + taskThinkingEffort: { effort: string; source: string } | undefined +} = { + apiConfiguration: { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings, + currentTaskItem: { id: "test-task-id" }, + clineMessages: [], + taskHistory: [], + experiments: { dynamicThinkingEffort: true }, + taskThinkingEffort: undefined, +} +vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, + useExtensionState: () => mockState, +})) + +vi.mock("@roo/array", () => ({ + findLastIndex: (array: any[], predicate: (item: any) => boolean) => array.map(predicate).findLastIndex(Boolean), +})) + +let mockModelInfo: any = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", +} +vi.mock("@/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ + provider: "anthropic", + id: "test-model", + info: mockModelInfo, + isLoading: false, + isError: false, + }), +})) + +let mockMaxOutputTokens = 0 +vi.mock("@roo/api", () => ({ + getModelMaxOutputTokens: () => mockMaxOutputTokens, +})) + +describe("TaskHeader - thinking effort chip (DTE series 4/5)", () => { + const defaultProps: TaskHeaderProps = { + task: { type: "say", ts: Date.now(), text: "Test task", images: [] }, + tokensIn: 100, + tokensOut: 50, + totalCost: 0.05, + contextTokens: 200, + buttonsDisabled: false, + handleCondenseContext: vi.fn(), + } as TaskHeaderProps + + beforeEach(() => { + mockMaxOutputTokens = 0 + mockState.experiments = { dynamicThinkingEffort: true } + mockState.taskThinkingEffort = undefined + mockState.apiConfiguration = { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["low", "medium", "high"], + reasoningEffort: "medium", + } + }) + + const renderChip = () => renderWithExtensionState() + + it("shows the effective effort with a 'you' source badge for a task-local override", () => { + mockState.taskThinkingEffort = { effort: "high", source: "you" } + renderChip() + expect(screen.getByText("high")).toBeInTheDocument() + expect(screen.getByText("you")).toBeInTheDocument() + }) + + it("shows the 'Zoo (auto)' source badge for model/parent-sourced overrides", () => { + mockState.taskThinkingEffort = { effort: "low", source: "model" } + renderChip() + expect(screen.getByText("low")).toBeInTheDocument() + expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() + }) + + it("shows the settings-derived effort with a 'default' source badge", () => { + mockState.apiConfiguration = { apiProvider: "anthropic", reasoningEffort: "medium" } as ProviderSettings + renderChip() + expect(screen.getByText("medium")).toBeInTheDocument() + expect(screen.getByText("default")).toBeInTheDocument() + }) + + it("shows the adaptive soft-guidance level with 'Zoo (auto)' for boolean-class models", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + renderChip() + expect(screen.getByText("adaptive")).toBeInTheDocument() + expect(screen.getByText("Zoo (auto)")).toBeInTheDocument() + }) + + it("shows the chip when the dynamic-thinking-effort experiment is disabled", () => { + mockState.experiments = { dynamicThinkingEffort: false } + renderChip() + // The chip is a normal feature: gated by model capability, not the experiment. + expect(screen.getByText("medium")).toBeInTheDocument() + expect(screen.getByText("default")).toBeInTheDocument() + }) + + it("hides the chip when the model does not advertise effort support", () => { + mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + renderChip() + expect(screen.queryByText("medium")).toBeNull() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx new file mode 100644 index 0000000000..6231857dff --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.spec.tsx @@ -0,0 +1,213 @@ +import React from "react" +import { fireEvent, render, screen, within } from "@/utils/test-utils" +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { ThinkingEffortToggle } from "../ThinkingEffortToggle" + +const mockPostMessage = vi.hoisted(() => vi.fn()) +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@src/components/ui/hooks/useRooPortal", () => ({ + useRooPortal: () => document.body, +})) + +const mockState: { + experiments: Record + apiConfiguration: ProviderSettings + taskThinkingEffort: { effort: string; source: string } | undefined +} = { + experiments: { dynamicThinkingEffort: true }, + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + taskThinkingEffort: undefined, +} +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => mockState, +})) + +let mockModelInfo: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], +} +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ id: "test-model", info: mockModelInfo }), +})) + +// Faithful popover double: the trigger flips the open state; content mounts only while open. +const PopoverState = React.createContext<{ open: boolean; setOpen: (open: boolean) => void } | null>(null) +vi.mock("@src/components/ui", () => ({ + Popover: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode + open: boolean + onOpenChange?: (open: boolean) => void + }) => ( + onOpenChange?.(next) }}> + {children} + + ), + PopoverTrigger: (props: { + children?: React.ReactNode + disabled?: boolean + className?: string + "data-testid"?: string + }) => { + const state = React.useContext(PopoverState) + const { children, ...rest } = props + return ( + + ) + }, + PopoverContent: (props: { children?: React.ReactNode; "data-testid"?: string }) => { + const state = React.useContext(PopoverState) + if (!state?.open) { + return null + } + const { children, ...rest } = props + return
{children}
+ }, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +const renderToggle = (props: { disabled?: boolean } = {}) => render() + +const openMenu = () => { + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + return screen.getByTestId("thinking-effort-toggle-menu") +} + +const option = (level: string) => screen.getByTestId("thinking-effort-option-" + level) + +describe("ThinkingEffortToggle (DTE series 4/5)", () => { + beforeEach(() => { + mockPostMessage.mockClear() + mockState.experiments = { dynamicThinkingEffort: true } + mockState.apiConfiguration = { reasoningEffort: "low" } as ProviderSettings + mockState.taskThinkingEffort = undefined + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + } + }) + + it("renders when the dynamic-thinking-effort experiment is disabled", () => { + mockState.experiments = { dynamicThinkingEffort: false } + renderToggle() + // The manual toggle is a normal feature: gated by model capability, not the experiment. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toBeInTheDocument() + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) + }) + + it("exposes the localized accessible name on the icon-only trigger", () => { + renderToggle() + // The mocked i18n returns keys, so the exact localized label is the raw key. + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveAttribute( + "aria-label", + "chat:thinkingEffort.toggleTitle", + ) + }) + + it("renders nothing when the model does not advertise effort support", () => { + mockModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + const { container } = renderToggle() + expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("renders nothing when the capability array only advertises the disable sentinel", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: ["disable"], + } + const { container } = renderToggle() + expect(screen.queryByTestId("thinking-effort-toggle-trigger")).toBeNull() + expect(container.textContent).toBe("") + }) + + it("lists only the model-supported levels (never the disable sentinel)", () => { + renderToggle() + const menu = openMenu() + expect(menu).toHaveTextContent("chat:thinkingEffort.toggleTitle") + for (const level of ["low", "medium", "high", "max"]) { + expect(within(menu).getByTestId("thinking-effort-option-" + level)).toBeInTheDocument() + } + expect(screen.queryByTestId("thinking-effort-option-disable")).toBeNull() + }) + + it("marks the currently effective level and follows the task-local override", () => { + const view = renderToggle() + openMenu() + expect(option("low").querySelector("svg")).not.toBeNull() + expect(option("high").querySelector("svg")).toBeNull() + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + + mockState.taskThinkingEffort = { effort: "high", source: "you" } + view.rerender() + openMenu() + expect(option("high").querySelector("svg")).not.toBeNull() + expect(option("low").querySelector("svg")).toBeNull() + }) + + it("posts a task-local set request when a level is selected and closes the menu", () => { + renderToggle() + openMenu() + fireEvent.click(option("max")) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "setTaskThinkingEffort", effort: "max" }) + expect(screen.queryByTestId("thinking-effort-toggle-menu")).toBeNull() + }) + + it("dims and disables the trigger when the disabled prop is set", () => { + renderToggle({ disabled: true }) + expect(screen.getByTestId("thinking-effort-toggle-trigger")).toHaveClass("opacity-50") + // The trigger button still mounts while disabled (Radix blocks the open). + fireEvent.click(screen.getByTestId("thinking-effort-toggle-trigger")) + expect(screen.queryByTestId("thinking-effort-toggle-menu")).toBeNull() + }) + + it("highlights the trigger icon for a user-sourced override", () => { + mockState.taskThinkingEffort = { effort: "medium", source: "you" } + renderToggle() + const icon = screen.getByTestId("thinking-effort-toggle-trigger").querySelector("svg") + expect(icon).toHaveClass("text-vscode-textLink-foreground") + }) + + it("shows the adaptive soft-guidance hint and a single adaptive level for boolean-class models", () => { + mockModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + mockState.apiConfiguration = {} as ProviderSettings + renderToggle() + const menu = openMenu() + expect(menu).toHaveTextContent("chat:thinkingEffort.adaptiveHint") + expect(within(menu).getByTestId("thinking-effort-option-adaptive")).toBeInTheDocument() + fireEvent.click(within(menu).getByTestId("thinking-effort-option-adaptive")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "setTaskThinkingEffort", effort: "adaptive" }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx new file mode 100644 index 0000000000..bed1a7f4c5 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.fixture.tsx @@ -0,0 +1,27 @@ +import React from "react" + +import { AppProviders } from "../../../../playwright/AppProviders" +import { ThinkingEffortToggle } from "../ThinkingEffortToggle" + +// DTE series 4/5: CT story for the composer thinking-effort toggle. Uses a real +// model (gpt-5.6-sol) that advertises a per-request effort array; the toggle +// renders for capable models regardless of the experiment flag. The experiment +// state is kept in the initial state so the story renders exactly the component +// state the baselines were generated with. +export function ThinkingEffortToggleStory() { + return ( + +
+ Composer bottom bar + + +
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx new file mode 100644 index 0000000000..86d379905b --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ThinkingEffortToggle.visual.tsx @@ -0,0 +1,26 @@ +import React from "react" + +import { expect, test } from "../../../../playwright/coverage-fixture" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" +import { ThinkingEffortToggleStory } from "./ThinkingEffortToggle.visual.fixture" + +// DTE series 4/5: the toggle only renders for models that advertise per-request +// effort support, so the story pins such a model (see the fixture). +for (const theme of visualThemes.filter((candidate) => candidate.name === "dark" || candidate.name === "light")) { + test(`renders the thinking effort toggle in the VS Code ${theme.name} theme`, async ({ mount, page }) => { + await applyVisualTheme(page, theme) + // The full provider bundle leaves a bare Zod reference after CT tree-shaking. + await page.evaluate(() => Object.assign(globalThis, { z: undefined })) + const component = await mount() + const story = component.getByTestId("thinking-effort-toggle-story") + const trigger = story.getByTestId("thinking-effort-toggle-trigger") + await expect(trigger).toBeVisible() + await expect(story).toHaveScreenshot(`thinking-effort-toggle-resting-${theme.name}.png`) + + await trigger.click() + const menu = page.getByTestId("thinking-effort-toggle-menu") + await expect(menu).toBeVisible() + await expect(menu.getByTestId("thinking-effort-option-high")).toBeVisible() + await expect(story).toHaveScreenshot(`thinking-effort-toggle-menu-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png new file mode 100644 index 0000000000..5272ceb5f9 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png new file mode 100644 index 0000000000..aa82329d36 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-menu-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png new file mode 100644 index 0000000000..e445bfb7a4 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-light.png new file mode 100644 index 0000000000..8df38ab259 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/thinking-effort-toggle-resting-light.png differ diff --git a/webview-ui/src/components/settings/ExperimentalSettings.tsx b/webview-ui/src/components/settings/ExperimentalSettings.tsx index d5c55297ea..5d3f99d3eb 100644 --- a/webview-ui/src/components/settings/ExperimentalSettings.tsx +++ b/webview-ui/src/components/settings/ExperimentalSettings.tsx @@ -118,6 +118,14 @@ export const ExperimentalSettings = ({ ) } /> + {/* F7: hint for declaring supported effort levels on OpenAI-compatible profiles */} + {config[0] === "DYNAMIC_THINKING_EFFORT" && ( +

+ {t("settings:experimental.DYNAMIC_THINKING_EFFORT.hint")} +

+ )} ) })} diff --git a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx index b31f87dc7e..3feddad1d4 100644 --- a/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ExperimentalSettings.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" import { experimentDefault } from "@roo/experiments" @@ -32,4 +32,78 @@ describe("ExperimentalSettings", () => { expect(screen.getByText("settings:experimental.CUSTOM_TOOLS.name")).toBeInTheDocument() expect(screen.queryByText("settings:experimental.PARALLEL_TOOL_EXECUTION.name")).not.toBeInTheDocument() }) + + it("renders the dynamic thinking effort toggle", () => { + render() + + expect(screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name")).toBeInTheDocument() + }) + + it("leaves the dynamic thinking effort toggle unchecked when the value is false or omitted", () => { + const getCheckbox = () => { + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + return label?.querySelector("input[type='checkbox']") + } + + // Explicit false + let result = render( + , + ) + expect(getCheckbox()).not.toBeNull() + expect(getCheckbox()).not.toBeChecked() + result.unmount() + + // Omitted (absent from the persisted config) + const omitted: Record = { ...experimentDefault } + delete omitted.dynamicThinkingEffort + result = render() + expect(getCheckbox()).not.toBeNull() + expect(getCheckbox()).not.toBeChecked() + result.unmount() + }) + + it("binds the dynamic thinking effort toggle to setExperimentEnabled", () => { + const setExperimentEnabled = vi.fn() + render( + , + ) + + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + const checkbox = label?.querySelector("input[type='checkbox']") + expect(checkbox).not.toBeNull() + expect(checkbox).toBeChecked() + + fireEvent.click(checkbox!) + + expect(setExperimentEnabled).toHaveBeenCalledTimes(1) + expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", false) + }) + + it("toggles the dynamic thinking effort on when clicked from the unchecked state", () => { + const setExperimentEnabled = vi.fn() + render( + , + ) + + const label = screen.getByText("settings:experimental.DYNAMIC_THINKING_EFFORT.name").closest("label") + const checkbox = label?.querySelector("input[type='checkbox']") + expect(checkbox).not.toBeNull() + expect(checkbox).not.toBeChecked() + + fireEvent.click(checkbox!) + + expect(setExperimentEnabled).toHaveBeenCalledTimes(1) + expect(setExperimentEnabled).toHaveBeenCalledWith("dynamicThinkingEffort", true) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 203d54f6ae..b388b901b9 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -466,6 +466,18 @@ "wantsToRun": "Zoo vol executar una comanda slash", "didRun": "Zoo ha executat una comanda slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Esforç de raonament establert a: {{effort}}", + "chipTooltip": "Esforç de raonament: {{effort}} ({{source}})", + "sourceDefault": "per defecte", + "sourceAuto": "Zoo (auto)", + "sourceYou": "tu", + "toggleTitle": "Esforç de raonament", + "adaptiveHint": "Aquest model decideix el seu esforç automàticament — la teva selecció és només una guia suau.", + "applied": "🧠 Esforç de pensament: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Esforç de pensament sense canvis: s'ha assolit el límit d'escalada de 3 canvis cap amunt per tasca", + "oscillationRefused": "🧠 Esforç de pensament sense canvis: s'ha detectat oscil·lació entre nivells" + }, "contextMenu": { "noResults": "Sense resultats", "problems": "Problemes", diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 52805e74e8..a42ecaeebd 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Eines actualitzades correctament", "refreshError": "Error en actualitzar les eines", "toolParameters": "Paràmetres" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Esforç de pensament dinàmic", + "description": "Permet que el model decideixi el seu esforç de pensament per pas i que tu l'ajustis en el xat. (experimental)", + "hint": "Els perfils compatibles amb OpenAI (punt final d'OpenAI personalitzat, LM Studio, Ollama, LiteLLM i similars) poden declarar a la configuració supportedReasoningEfforts els nivells d'esforç de raonament que el model admet. Els nivells declarats s'envien amb cada sol·licitud, però el servidor local pot ignorar el paràmetre." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 590914a9ee..e1f2e456d3 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo möchte einen Slash-Befehl ausführen", "didRun": "Zoo hat einen Slash-Befehl ausgeführt" }, + "thinkingEffort": { + "appliedByUser": "🧠 Denkanstrengung festgelegt auf: {{effort}}", + "chipTooltip": "Denkanstrengung: {{effort}} ({{source}})", + "sourceDefault": "Standard", + "sourceAuto": "Zoo (auto)", + "sourceYou": "Sie", + "toggleTitle": "Denkanstrengung", + "adaptiveHint": "Dieses Modell bestimmt seine Denkanstrengung automatisch — Ihre Auswahl ist nur eine sanfte Vorgabe.", + "applied": "🧠 Denkintensität: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Denkintensität unverändert: Limit von 3 Erhöhungen pro Aufgabe erreicht", + "oscillationRefused": "🧠 Denkintensität unverändert: Oszillation zwischen Stufen erkannt" + }, "todo": { "partial": "{{completed}} von {{total}} To-Dos erledigt", "complete": "{{total}} To-Dos erledigt", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index b895717422..293b632e78 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Tools erfolgreich aktualisiert", "refreshError": "Fehler beim Aktualisieren der Tools", "toolParameters": "Parameter" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamische Denkintensität", + "description": "Lässt das Modell die Denkintensität pro Schritt selbst bestimmen und ermöglicht Ihnen, sie im Chat anzupassen. (experimentell)", + "hint": "OpenAI-kompatible Profile (eigener OpenAI-Endpunkt, LM Studio, Ollama, LiteLLM und ähnliche) können in der supportedReasoningEfforts-Einstellung deklarieren, welche Reasoning-Bemühungsstufen ihr Modell unterstützt. Deklarierte Stufen werden mit jeder Anfrage gesendet, aber der lokale Server kann den Parameter ignorieren." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 1caacde55f..385a6566e5 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -450,6 +450,18 @@ "wantsToRun": "Zoo wants to run a slash command", "didRun": "Zoo ran a slash command" }, + "thinkingEffort": { + "appliedByUser": "🧠 Thinking effort set to: {{effort}}", + "chipTooltip": "Thinking effort: {{effort}} ({{source}})", + "sourceDefault": "default", + "sourceAuto": "Zoo (auto)", + "sourceYou": "you", + "toggleTitle": "Thinking effort", + "adaptiveHint": "This model decides its effort automatically — your selection is soft guidance only.", + "applied": "🧠 Thinking effort: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Thinking effort unchanged: escalation limit of 3 upward changes per task reached", + "oscillationRefused": "🧠 Thinking effort unchanged: oscillation between levels detected" + }, "queuedMessages": { "title": "Queued Messages", "clickToEdit": "Click to edit message" diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index eaa37b7034..0c7f6f10c7 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1053,6 +1053,11 @@ "refreshSuccess": "Tools refreshed successfully", "refreshError": "Failed to refresh tools", "toolParameters": "Parameters" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamic thinking effort", + "description": "Let the model decide its thinking effort per step, and let you adjust it in-chat. (experimental)", + "hint": "OpenAI-compatible profiles (custom OpenAI endpoint, LM Studio, Ollama, LiteLLM, and similar) can declare in the supportedReasoningEfforts setting the reasoning effort levels their model supports. Declared efforts are sent with every request, but the local server may ignore the parameter." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 527d78aed5..5b5386839b 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo quiere ejecutar un comando slash", "didRun": "Zoo ejecutó un comando slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Esfuerzo de razonamiento establecido en: {{effort}}", + "chipTooltip": "Esfuerzo de razonamiento: {{effort}} ({{source}})", + "sourceDefault": "predeterminado", + "sourceAuto": "Zoo (auto)", + "sourceYou": "tú", + "toggleTitle": "Esfuerzo de razonamiento", + "adaptiveHint": "Este modelo decide su esfuerzo automáticamente — tu selección es solo una guía suave.", + "applied": "🧠 Esfuerzo de pensamiento: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Esfuerzo de pensamiento sin cambios: se alcanzó el límite de escalada de 3 cambios hacia arriba por tarea", + "oscillationRefused": "🧠 Esfuerzo de pensamiento sin cambios: se detectó una oscilación entre niveles" + }, "todo": { "partial": "{{completed}} de {{total}} tareas pendientes realizadas", "complete": "{{total}} tareas pendientes realizadas", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index abb8a60609..9ae1418501 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Herramientas actualizadas correctamente", "refreshError": "Error al actualizar las herramientas", "toolParameters": "Parámetros" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Esfuerzo de pensamiento dinámico", + "description": "Permite que el modelo decida su esfuerzo de pensamiento por paso y que tú lo ajustes en el chat. (experimental)", + "hint": "Los perfiles compatibles con OpenAI (punto de conexión personalizado de OpenAI, LM Studio, Ollama, LiteLLM y similares) pueden declarar en la configuración supportedReasoningEfforts los niveles de esfuerzo de razonamiento que su modelo admite. Los niveles declarados se envían con cada solicitud, pero el servidor local puede ignorar el parámetro." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 638b2c0227..036adbbbd9 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo veut exécuter une commande slash", "didRun": "Zoo a exécuté une commande slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Effort de raisonnement défini sur : {{effort}}", + "chipTooltip": "Effort de raisonnement : {{effort}} ({{source}})", + "sourceDefault": "par défaut", + "sourceAuto": "Zoo (auto)", + "sourceYou": "vous", + "toggleTitle": "Effort de raisonnement", + "adaptiveHint": "Ce modèle détermine son effort automatiquement — votre choix n'est qu'un guide souple.", + "applied": "🧠 Effort de réflexion : {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Effort de réflexion inchangé : limite d'escalade de 3 modifications vers le haut par tâche atteinte", + "oscillationRefused": "🧠 Effort de réflexion inchangé : oscillation entre les niveaux détectée" + }, "todo": { "partial": "{{completed}} sur {{total}} tâches terminées", "complete": "{{total}} tâches terminées", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 272f21a6ee..0b267b3a47 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Outils actualisés avec succès", "refreshError": "Échec de l'actualisation des outils", "toolParameters": "Paramètres" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Effort de réflexion dynamique", + "description": "Permet au modèle de décider de son effort de réflexion à chaque étape et de l'ajuster dans le chat. (expérimental)", + "hint": "Les profils compatibles OpenAI (point de terminaison OpenAI personnalisé, LM Studio, Ollama, LiteLLM et similaires) peuvent déclarer dans le paramètre supportedReasoningEfforts les niveaux d'effort de raisonnement pris en charge par leur modèle. Les niveaux déclarés sont envoyés avec chaque requête, mais le serveur local peut ignorer le paramètre." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 31270bc937..a093bf8d14 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo एक स्लैश कमांड चलाना चाहता है", "didRun": "Zoo ने एक स्लैश कमांड चलाया" }, + "thinkingEffort": { + "appliedByUser": "🧠 सोच प्रयास सेट किया गया: {{effort}}", + "chipTooltip": "सोच प्रयास: {{effort}} ({{source}})", + "sourceDefault": "डिफ़ॉल्ट", + "sourceAuto": "Zoo (ऑटो)", + "sourceYou": "आप", + "toggleTitle": "सोच प्रयास", + "adaptiveHint": "यह मॉडल अपने प्रयास को स्वतः निर्धारित करता है — आपका चयन केवल एक लचीली मार्गदर्शिका है।", + "applied": "🧠 चिंतन प्रयास: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 चिंतन प्रयास अपरिवर्तित: प्रति कार्य अधिकतम 3 वृद्धि की सीमा पहुँची", + "oscillationRefused": "🧠 चिंतन प्रयास अपरिवर्तित: स्तरों के बीच दोलन का पता चला" + }, "todo": { "partial": "{{total}} में से {{completed}} टू-डू हो गए", "complete": "{{total}} टू-डू हो गए", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 0a4152b17a..b052096c0e 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "टूल्स सफलतापूर्वक रिफ्रेश हुए", "refreshError": "टूल्स रिफ्रेश करने में विफल", "toolParameters": "पैरामीटर्स" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "डायनामिक चिंतन प्रयास", + "description": "मॉडल को हर चरण में अपना चिंतन प्रयास स्वयं तय करने दें, और आप चैट में इसे समायोजित कर सकते हैं। (प्रयोगात्मक)", + "hint": "OpenAI-संगत प्रोफ़ाइल (कस्टम OpenAI एंडपॉइंट, LM Studio, Ollama, LiteLLM और समान) supportedReasoningEfforts सेटिंग में अपने मॉडल द्वारा समर्थित रीज़निंग एफर्ट स्तरों की घोषणा कर सकती हैं। घोषित स्तर हर अनुरोध के साथ भेजे जाते हैं, लेकिन लोकल सर्वर पैरामीटर को नज़रअंदाज़ कर सकता है।" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 3b11773652..96d9d97518 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -478,6 +478,18 @@ "wantsToRun": "Zoo ingin menjalankan perintah slash", "didRun": "Zoo telah menjalankan perintah slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Upaya pemikiran diatur ke: {{effort}}", + "chipTooltip": "Upaya pemikiran: {{effort}} ({{source}})", + "sourceDefault": "bawaan", + "sourceAuto": "Zoo (otomatis)", + "sourceYou": "anda", + "toggleTitle": "Upaya pemikiran", + "adaptiveHint": "Model ini menentukan usahanya secara otomatis — pilihan Anda hanya panduan lunak.", + "applied": "🧠 Usaha berpikir: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Usaha berpikir tidak berubah: batas eskalasi 3 perubahan naik per tugas tercapai", + "oscillationRefused": "🧠 Usaha berpikir tidak berubah: osilasi antar tingkat terdeteksi" + }, "todo": { "partial": "{{completed}} dari {{total}} to-do selesai", "complete": "{{total}} to-do selesai", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b8abe9ab25..d75d2a3f8d 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Tool berhasil direfresh", "refreshError": "Gagal merefresh tool", "toolParameters": "Parameter" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Usaha berpikir dinamis", + "description": "Biarkan model memutuskan usaha berpikirnya per langkah, dan biarkan Anda menyesuaikannya di obrolan. (eksperimental)", + "hint": "Profil yang kompatibel dengan OpenAI (endpoint OpenAI kustom, LM Studio, Ollama, LiteLLM, dan sejenisnya) dapat mendeklarasikan level usaha penalaran yang didukung modelnya dalam pengaturan supportedReasoningEfforts. Level yang dideklarasikan dikirim dengan setiap permintaan, tetapi server lokal dapat mengabaikan parameter ini." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index f473b9e454..eb77f29278 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo vuole eseguire un comando slash", "didRun": "Zoo ha eseguito un comando slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Sforzo di ragionamento impostato su: {{effort}}", + "chipTooltip": "Sforzo di ragionamento: {{effort}} ({{source}})", + "sourceDefault": "predefinito", + "sourceAuto": "Zoo (auto)", + "sourceYou": "tu", + "toggleTitle": "Sforzo di ragionamento", + "adaptiveHint": "Questo modello decide il proprio sforzo automaticamente — la tua selezione è solo una guida flessibile.", + "applied": "🧠 Sforzo di pensiero: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Sforzo di pensiero invariato: raggiunto il limite di escalation di 3 modifiche in salita per task", + "oscillationRefused": "🧠 Sforzo di pensiero invariato: oscillazione tra i livelli rilevata" + }, "todo": { "partial": "{{completed}} di {{total}} cose da fare completate", "complete": "{{total}} cose da fare completate", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index e49ede9cec..d70767cc5d 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Strumenti aggiornati con successo", "refreshError": "Impossibile aggiornare gli strumenti", "toolParameters": "Parametri" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Sforzo di pensiero dinamico", + "description": "Consente al modello di decidere lo sforzo di pensiero per ogni passaggio e di regolarlo nella chat. (sperimentale)", + "hint": "I profili compatibili con OpenAI (endpoint OpenAI personalizzato, LM Studio, Ollama, LiteLLM e simili) possono dichiarare nelle impostazioni supportedReasoningEfforts i livelli di impegno di ragionamento supportati dal modello. I livelli dichiarati vengono inviati con ogni richiesta, ma il server locale potrebbe ignorare il parametro." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 470e66bd65..5b7914fd23 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zooはスラッシュコマンドを実行したい", "didRun": "Zooはスラッシュコマンドを実行しました" }, + "thinkingEffort": { + "appliedByUser": "🧠 思考努力度を {{effort}} に設定しました", + "chipTooltip": "思考努力度: {{effort}} ({{source}})", + "sourceDefault": "デフォルト", + "sourceAuto": "Zoo (自動)", + "sourceYou": "ユーザー", + "toggleTitle": "思考努力度", + "adaptiveHint": "このモデルは自動的に努力度を決定します。選択は参考情報です。", + "applied": "🧠 思考強度: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 思考強度を変更できません: タスクごとの最大 3 回の引き上げ制限に達しました", + "oscillationRefused": "🧠 思考強度を変更できません: レベル間での振動を検出しました" + }, "todo": { "partial": "{{total}}件中{{completed}}件のTo-Doが完了", "complete": "{{total}}件のTo-Doが完了", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index d58c86c95d..17eab7410a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "ツールが正常に更新されました", "refreshError": "ツールの更新に失敗しました", "toolParameters": "パラメーター" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "ダイナミック思考強度", + "description": "ステップごとにモデルが思考強度を決定し、チャット内で調整できます。 (実験的機能)", + "hint": "OpenAI互換のプロファイル(カスタムOpenAIエンドポイント、LM Studio、Ollama、LiteLLM など)は、supportedReasoningEfforts の設定で、モデルがサポートする思考レベルを宣言できます。宣言されたレベルはリクエストごとに送信されますが、ローカルサーバーがそのパラメータを無視する場合があります。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index c988d469dd..2c8e92df2d 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo가 슬래시 명령어를 실행하려고 합니다", "didRun": "Zoo가 슬래시 명령어를 실행했습니다" }, + "thinkingEffort": { + "appliedByUser": "🧠 사고 노력이 {{effort}}(으)로 설정됨", + "chipTooltip": "사고 노력: {{effort}} ({{source}})", + "sourceDefault": "기본값", + "sourceAuto": "Zoo (자동)", + "sourceYou": "사용자", + "toggleTitle": "사고 노력", + "adaptiveHint": "이 모델은 자동으로 노력도를 결정합니다. 선택은 단순 참고용입니다.", + "applied": "🧠 사고 노력: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 사고 노력 변경 없음: 작업당 최대 3회 상향 조정 한도에 도달했습니다", + "oscillationRefused": "🧠 사고 노력 변경 없음: 레벨 간 진동 감지됨" + }, "todo": { "partial": "{{total}}개의 할 일 중 {{completed}}개 완료", "complete": "{{total}}개의 할 일 완료", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 68ce8b2523..c40a1080ae 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "도구가 성공적으로 새로고침되었습니다", "refreshError": "도구 새로고침에 실패했습니다", "toolParameters": "매개변수" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "동적 사고 노력", + "description": "단계별로 모델이 사고 노력을 결정하도록 하고, 채팅에서 조정할 수 있습니다. (실험적 기능)", + "hint": "OpenAI 호환 프로필(사용자 지정 OpenAI 엔드포인트, LM Studio, Ollama, LiteLLM 등)은 supportedReasoningEfforts 설정에서 모델이 지원하는 추론 수준을 선언할 수 있습니다. 선언된 수준은 각 요청과 함께 전송되지만 로컬 서버가 해당 파라미터를 무시할 수 있습니다." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index e6f388281e..09ea856564 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo wil een slash commando uitvoeren", "didRun": "Zoo heeft een slash commando uitgevoerd" }, + "thinkingEffort": { + "appliedByUser": "🧠 Denkinspanning ingesteld op: {{effort}}", + "chipTooltip": "Denkinspanning: {{effort}} ({{source}})", + "sourceDefault": "standaard", + "sourceAuto": "Zoo (auto)", + "sourceYou": "jij", + "toggleTitle": "Denkinspanning", + "adaptiveHint": "Dit model bepaalt zijn inspanning automatisch — je keuze is slechts een zachte aanwijzing.", + "applied": "🧠 Denkwerk: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Denkwerk ongewijzigd: limiet van 3 verhogingen per taak bereikt", + "oscillationRefused": "🧠 Denkwerk ongewijzigd: oscillatie tussen niveaus gedetecteerd" + }, "todo": { "partial": "{{completed}} van {{total}} to-do's voltooid", "complete": "{{total}} to-do's voltooid", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 8d90d7747e..9f383abf4c 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Tools succesvol vernieuwd", "refreshError": "Fout bij vernieuwen van tools", "toolParameters": "Parameters" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamisch denkwerk", + "description": "Laat het model per stap het denkwerk zelf bepalen en u dat in het gesprek aanpassen. (experimenteel)", + "hint": "OpenAI-compatibiele profielen (aangepast OpenAI-endpoint, LM Studio, Ollama, LiteLLM en vergelijkbare) kunnen in de supportedReasoningEfforts-instelling de redeneer-inspanningsniveaus declareren die hun model ondersteunt. Gedecreëerde inspanningen worden met elk verzoek verzonden, maar de lokale server kan de parameter negeren." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 39c2d1c9cd..12fb288446 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo chce uruchomić komendę slash", "didRun": "Zoo uruchomił komendę slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Wysiłek myślowy ustawiony na: {{effort}}", + "chipTooltip": "Wysiłek myślowy: {{effort}} ({{source}})", + "sourceDefault": "domyślny", + "sourceAuto": "Zoo (auto)", + "sourceYou": "ty", + "toggleTitle": "Wysiłek myślowy", + "adaptiveHint": "Ten model samodzielnie decyduje o wysiłku — Twój wybór to tylko luźna wskazówka.", + "applied": "🧠 Wysiłek myślowy: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Wysiłek myślowy bez zmian: osiągnięto limit 3 eskalacji w górę na zadanie", + "oscillationRefused": "🧠 Wysiłek myślowy bez zmian: wykryto oscylację między poziomami" + }, "todo": { "partial": "Ukończono {{completed}} z {{total}} zadań do wykonania", "complete": "Ukończono {{total}} zadań do wykonania", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ffc1cdf1a4..38c81c1d7b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Narzędzia odświeżone pomyślnie", "refreshError": "Nie udało się odświeżyć narzędzi", "toolParameters": "Parametry" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dynamiczny wysiłek myślowy", + "description": "Pozwala modelowi samodzielnie decydować o wysiłku myślowym na każdym kroku oraz dostosowywać go w czacie. (eksperymentalne)", + "hint": "Profile zgodne z OpenAI (własny endpoint OpenAI, LM Studio, Ollama, LiteLLM i podobne) mogą w ustawieniu supportedReasoningEfforts zadeklarować poziomy wysiłku rozumowania obsługiwane przez model. Zadeklarowane poziomy są wysyłane z każdym żądaniem, ale lokalny serwer może zignorować ten parametr." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 9dc67a627c..b64ab187dd 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -472,6 +472,18 @@ "wantsToRun": "Zoo quer executar um comando slash", "didRun": "Zoo executou um comando slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Esforço de raciocínio definido para: {{effort}}", + "chipTooltip": "Esforço de raciocínio: {{effort}} ({{source}})", + "sourceDefault": "padrão", + "sourceAuto": "Zoo (auto)", + "sourceYou": "você", + "toggleTitle": "Esforço de raciocínio", + "adaptiveHint": "Este modelo decide seu esforço automaticamente — sua seleção é apenas um guia suave.", + "applied": "🧠 Esforço de pensamento: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Esforço de pensamento inalterado: limite de escalonamento de 3 aumentos por tarefa atingido", + "oscillationRefused": "🧠 Esforço de pensamento inalterado: oscilação entre níveis detectada" + }, "todo": { "partial": "{{completed}} de {{total}} tarefas concluídas", "complete": "{{total}} tarefas concluídas", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index cf92b76ac7..ac919f0c4b 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Ferramentas atualizadas com sucesso", "refreshError": "Falha ao atualizar ferramentas", "toolParameters": "Parâmetros" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Esforço de pensamento dinâmico", + "description": "Permite que o modelo decida seu esforço de pensamento em cada etapa e que você o ajuste na conversa. (experimental)", + "hint": "Perfis compatíveis com OpenAI (endpoint OpenAI personalizado, LM Studio, Ollama, LiteLLM e similares) podem declarar na configuração supportedReasoningEfforts os níveis de esforço de raciocínio que o modelo suporta. Os níveis declarados são enviados em cada solicitação, mas o servidor local pode ignorar o parâmetro." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 7eb863904f..7c76702f3d 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo хочет выполнить слеш-команду", "didRun": "Zoo выполнил слеш-команду" }, + "thinkingEffort": { + "appliedByUser": "🧠 Усиление размышлений установлено: {{effort}}", + "chipTooltip": "Усиление размышлений: {{effort}} ({{source}})", + "sourceDefault": "по умолчанию", + "sourceAuto": "Zoo (авто)", + "sourceYou": "вы", + "toggleTitle": "Усиление размышлений", + "adaptiveHint": "Эта модель сама определяет усиление — ваш выбор носит рекомендательный характер.", + "applied": "🧠 Усилие размышления: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Усилие размышления не изменено: достигнут предел в 3 повышения на задачу", + "oscillationRefused": "🧠 Усилие размышления не изменено: обнаружена осцилляция между уровнями" + }, "todo": { "partial": "{{completed}} из {{total}} задач выполнено", "complete": "{{total}} задач выполнено", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 23ff32faa9..6cd0cf05f2 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Инструменты успешно обновлены", "refreshError": "Не удалось обновить инструменты", "toolParameters": "Параметры" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Динамическое усилие размышления", + "description": "Позволяет модели самостоятельно определять усилие размышления на каждом шаге и корректировать его в чате. (экспериментальная функция)", + "hint": "Профили, совместимые с OpenAI (собственный эндпоинт OpenAI, LM Studio, Ollama, LiteLLM и подобные), могут в настройке supportedReasoningEfforts задекларировать уровни усилия рассуждений, которые поддерживает модель. Заявленные уровни отправляются с каждым запросом, но локальный сервер может игнорировать параметр." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index b6d43b4b12..8b2c8545f0 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo bir slash komutu çalıştırmak istiyor", "didRun": "Zoo bir slash komutu çalıştırdı" }, + "thinkingEffort": { + "appliedByUser": "🧠 Düşünme çabası şuna ayarlandı: {{effort}}", + "chipTooltip": "Düşünme çabası: {{effort}} ({{source}})", + "sourceDefault": "varsayılan", + "sourceAuto": "Zoo (otomatik)", + "sourceYou": "sen", + "toggleTitle": "Düşünme çabası", + "adaptiveHint": "Bu model çabasını otomatik olarak belirler — seçiminiz yalnızca yumuşak bir yönergedir.", + "applied": "🧠 Düşünme çabası: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Düşünme çabası değişmedi: görev başına 3 artış limiti aşıldı", + "oscillationRefused": "🧠 Düşünme çabası değişmedi: seviyeler arası salınım algılandı" + }, "todo": { "partial": "{{total}} yapılacaklar listesinden {{completed}} tanesi tamamlandı", "complete": "{{total}} yapılacaklar listesi tamamlandı", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index f674e116d2..8c4bc16d88 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Araçlar başarıyla yenilendi", "refreshError": "Araçlar yenilenemedi", "toolParameters": "Parametreler" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Dinamik düşünme çabası", + "description": "Modelin her adımda kendi düşünme çabasını belirlemesini ve onu sohbette ayarlamayı sağlar. (deneysel)", + "hint": "OpenAI uyumlu profiller (özel OpenAI uç noktası, LM Studio, Ollama, LiteLLM ve benzerleri) supportedReasoningEfforts ayarında modellerinin desteklediği akıl yürütme çaba düzeylerini bildirebilir. Bildirilen düzeyler her istekle gönderilir, ancak yerel sunucu bu parametreyi yok sayabilir." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index e6c7ded31a..6365604dd7 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo muốn chạy lệnh slash", "didRun": "Zoo đã chạy lệnh slash" }, + "thinkingEffort": { + "appliedByUser": "🧠 Đã đặt nỗ lực suy luận: {{effort}}", + "chipTooltip": "Nỗ lực suy luận: {{effort}} ({{source}})", + "sourceDefault": "mặc định", + "sourceAuto": "Zoo (tự động)", + "sourceYou": "bạn", + "toggleTitle": "Nỗ lực suy luận", + "adaptiveHint": "Mô hình này tự quyết định nỗ lực — lựa chọn của bạn chỉ là gợi ý mềm.", + "applied": "🧠 Nỗ lực suy nghĩ: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 Nỗ lực suy nghĩ không đổi: đã đạt giới hạn 3 lần nâng cao mỗi tác vụ", + "oscillationRefused": "🧠 Nỗ lực suy nghĩ không đổi: phát hiện dao động giữa các mức" + }, "todo": { "partial": "{{completed}} trong tổng số {{total}} công việc đã hoàn thành", "complete": "{{total}} công việc đã hoàn thành", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4b908ca658..5511da27c6 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "Làm mới công cụ thành công", "refreshError": "Không thể làm mới công cụ", "toolParameters": "Thông số" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "Nỗ lực suy nghĩ động", + "description": "Để model tự quyết định nỗ lực suy nghĩ của từng bước và cho phép bạn điều chỉnh trong trò chuyện. (thực nghiệm)", + "hint": "Các hồ sơ tương thích OpenAI (điểm cuối OpenAI tùy chỉnh, LM Studio, Ollama, LiteLLM và tương tự) có thể khai báo trong cài đặt supportedReasoningEfforts các mức nỗ lực suy luận mà mô hình hỗ trợ. Các mức đã khai báo được gửi theo mỗi yêu cầu, nhưng máy chủ cục bộ có thể bỏ qua tham số này." } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index bc8f5dba93..f5a5f36f52 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -473,6 +473,18 @@ "wantsToRun": "Zoo 想要运行斜杠命令", "didRun": "Zoo 运行了斜杠命令" }, + "thinkingEffort": { + "appliedByUser": "🧠 思考强度已设为:{{effort}}", + "chipTooltip": "思考强度:{{effort}}({{source}})", + "sourceDefault": "默认", + "sourceAuto": "Zoo(自动)", + "sourceYou": "你", + "toggleTitle": "思考强度", + "adaptiveHint": "此模型会自动决定思考强度,你的选择仅作为软性指引。", + "applied": "🧠 思考强度: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 思考强度未改变: 已达到每任务 3 次上调的升级上限", + "oscillationRefused": "🧠 思考强度未改变: 检测到等级间振荡" + }, "todo": { "partial": "已完成 {{completed}} / {{total}} 个待办事项", "complete": "已完成 {{total}} 个待办事项", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index d79edca302..4c7fbb5383 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -973,6 +973,11 @@ "refreshSuccess": "工具刷新成功", "refreshError": "工具刷新失败", "toolParameters": "参数" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "动态思考强度", + "description": "让模型按步骤自行决定思考强度,并允许你在对话中调整。 (实验性功能)", + "hint": "OpenAI 兼容配置文件(自定义 OpenAI 端点、LM Studio、Ollama、LiteLLM 等)可在 supportedReasoningEfforts 设置中声明模型支持的思考力度等级。声明的等级会随每个请求发送,但本地服务器可能会忽略该参数。" } }, "promptCaching": { diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index d2fe37a774..10b4a6abb4 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -453,6 +453,18 @@ "wantsToRun": "Zoo 想要執行斜線指令", "didRun": "Zoo 執行了斜線指令" }, + "thinkingEffort": { + "appliedByUser": "🧠 思考強度已設為:{{effort}}", + "chipTooltip": "思考強度:{{effort}}({{source}})", + "sourceDefault": "預設", + "sourceAuto": "Zoo(自動)", + "sourceYou": "你", + "toggleTitle": "思考強度", + "adaptiveHint": "此模型會自動決定思考強度,你的選擇僅作為軟性指引。", + "applied": "🧠 思考強度: {{effort}} (Zoo) — {{reason}}", + "escalationCapRefused": "🧠 思考強度未改變: 已達到每任務 3 次上調的升級上限", + "oscillationRefused": "🧠 思考強度未改變: 偵測到等級間震盪" + }, "queuedMessages": { "title": "佇列中的訊息", "clickToEdit": "點選以編輯訊息" diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 250cc2111b..73cd8e5952 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -1000,6 +1000,11 @@ "refreshSuccess": "工具重新整理成功", "refreshError": "工具重新整理失敗", "toolParameters": "參數" + }, + "DYNAMIC_THINKING_EFFORT": { + "name": "動態思考強度", + "description": "讓模型自行決定每一步的思考強度,並讓您在對話中調整。(實驗性)", + "hint": "OpenAI 相容的設定檔(自訂 OpenAI 端點、LM Studio、Ollama、LiteLLM 等)可在 supportedReasoningEfforts 設定中宣告模型支援的思考強度等級。宣告的等級會隨每個請求傳送,但本機伺服器可能會忽略此參數。" } }, "promptCaching": { diff --git a/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts new file mode 100644 index 0000000000..2b708c3532 --- /dev/null +++ b/webview-ui/src/utils/__tests__/thinkingEffort.spec.ts @@ -0,0 +1,271 @@ +import type { ModelInfo, ProviderSettings } from "@roo-code/types" + +import { + computeThinkingEffortDisplay, + resolveReasoningEffortCapability, + THINKING_EFFORT_ADAPTIVE_LEVEL, +} from "../thinkingEffort" + +describe("computeThinkingEffortDisplay (DTE series 4/5)", () => { + const modelWithLevels: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: true, + supportsReasoningEffort: ["disable", "low", "medium", "high", "max"], + reasoningEffort: "medium", + } + + const modelAdaptive: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + supportsReasoningEffort: true, + } + + const modelNone: ModelInfo = { contextWindow: 1_000_000, maxTokens: 128_000, supportsPromptCache: false } + + it("resolves the display for capable models without the experiment flag", () => { + // The manual surfaces are normal features: resolution is gated only by + // model capability. Settings effort wins over the model default. + const settings = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + }) + expect(settings?.effort).toBe("low") + expect(settings?.source).toBe("default") + // Model default. + const modelDefault = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(modelDefault?.effort).toBe("medium") + expect(modelDefault?.source).toBe("default") + // Boolean/adaptive-class model. + const adaptive = computeThinkingEffortDisplay({ model: modelAdaptive }) + expect(adaptive?.effort).toBe(THINKING_EFFORT_ADAPTIVE_LEVEL) + expect(adaptive?.source).toBe("auto") + }) + + it("shows the task-local value with source 'you' when the experiment flag is absent", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "max", source: "you" }, + }) + expect(display?.effort).toBe("max") + expect(display?.source).toBe("you") + }) + + it("returns null when the model does not advertise effort support", () => { + expect(computeThinkingEffortDisplay({ model: modelNone })).toBeNull() + expect(computeThinkingEffortDisplay({ model: undefined })).toBeNull() + }) + + it("returns null when the capability array only advertises the disable sentinel", () => { + const disableOnly: ModelInfo = { + contextWindow: 1, + maxTokens: 1, + supportsPromptCache: false, + supportsReasoningEffort: ["disable"], + } + expect(computeThinkingEffortDisplay({ model: disableOnly })).toBeNull() + }) + + it("excludes the disable sentinel from the supported levels", () => { + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(display?.supportedLevels).toEqual(["low", "medium", "high", "max"]) + expect(display?.isAdaptiveClass).toBe(false) + }) + + it("resolves a task-local override with source 'you'", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + taskThinkingEffort: { effort: "max", source: "you" }, + }) + expect(display?.effort).toBe("max") + expect(display?.source).toBe("you") + }) + + it("resolves task-local overrides from model/parent sources as auto", () => { + for (const source of ["model", "parent"]) { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source }, + }) + expect(display?.effort).toBe("high") + expect(display?.source).toBe("auto") + } + }) + + it("resolves an unrecognized task-local source as default", () => { + const display = computeThinkingEffortDisplay({ + model: modelWithLevels, + taskThinkingEffort: { effort: "high", source: "unknown-origin" }, + }) + expect(display?.source).toBe("default") + }) + + it("resolves the settings effort with source 'default' when no override is active", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "low" } as ProviderSettings, + model: modelWithLevels, + }) + expect(display?.effort).toBe("low") + expect(display?.source).toBe("default") + }) + + it("treats the settings 'disable' sentinel as unset and falls through", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, + model: modelWithLevels, + }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("falls back to the model default effort", () => { + const display = computeThinkingEffortDisplay({ model: modelWithLevels }) + expect(display?.effort).toBe("medium") + expect(display?.source).toBe("default") + }) + + it("returns null for a level-array model with no settings or model default", () => { + const noDefault: ModelInfo = { ...modelWithLevels, reasoningEffort: undefined } + expect(computeThinkingEffortDisplay({ model: noDefault })).toBeNull() + }) + + it("resolves boolean/adaptive-class models to the adaptive soft-guidance level", () => { + const display = computeThinkingEffortDisplay({ + apiConfiguration: { reasoningEffort: "disable" } as ProviderSettings, + model: modelAdaptive, + }) + expect(display?.effort).toBe(THINKING_EFFORT_ADAPTIVE_LEVEL) + expect(display?.source).toBe("auto") + expect(display?.supportedLevels).toEqual([THINKING_EFFORT_ADAPTIVE_LEVEL]) + expect(display?.isAdaptiveClass).toBe(true) + }) + + it("lets a task-local override win over the adaptive fallback", () => { + const display = computeThinkingEffortDisplay({ + model: modelAdaptive, + taskThinkingEffort: { effort: "adaptive", source: "you" }, + }) + expect(display?.effort).toBe("adaptive") + expect(display?.source).toBe("you") + }) +}) + +describe("resolveReasoningEffortCapability (F7)", () => { + const modelNoCapability: ModelInfo = { + contextWindow: 1_000_000, + maxTokens: 128_000, + supportsPromptCache: false, + } + + it("fills in the declared levels when the model has no capability of its own", () => { + const result = resolveReasoningEffortCapability(modelNoCapability, { + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings) + expect(result?.supportsReasoningEffort).toEqual(["low", "high", "max"]) + // Other model fields pass through unchanged. + expect(result?.contextWindow).toBe(1_000_000) + }) + + it("never overrides a registry capability (registry wins over declaration)", () => { + const registryModel: ModelInfo = { + ...modelNoCapability, + supportsReasoningEffort: ["low", "medium"], + } + const result = resolveReasoningEffortCapability(registryModel, { + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings) + expect(result).toBe(registryModel) + expect(result?.supportsReasoningEffort).toEqual(["low", "medium"]) + }) + + it("never overrides a boolean registry capability", () => { + const adaptiveModel: ModelInfo = { ...modelNoCapability, supportsReasoningEffort: true } + const result = resolveReasoningEffortCapability(adaptiveModel, { + supportedReasoningEfforts: ["low", "high"], + } as ProviderSettings) + expect(result).toBe(adaptiveModel) + expect(result?.supportsReasoningEffort).toBe(true) + }) + + it("returns the model unchanged without a declaration or with an empty one", () => { + expect(resolveReasoningEffortCapability(modelNoCapability, undefined)).toBe(modelNoCapability) + expect(resolveReasoningEffortCapability(modelNoCapability, {} as ProviderSettings)).toBe(modelNoCapability) + expect( + resolveReasoningEffortCapability(modelNoCapability, { supportedReasoningEfforts: [] } as ProviderSettings), + ).toBe(modelNoCapability) + }) + + it("returns undefined for an undefined model", () => { + expect( + resolveReasoningEffortCapability(undefined, { + supportedReasoningEfforts: ["low"], + } as ProviderSettings), + ).toBeUndefined() + }) + + it("does not mutate the input model or share the declared array", () => { + const declaredLevels: string[] = ["low", "high"] + const result = resolveReasoningEffortCapability(modelNoCapability, { + supportedReasoningEfforts: declaredLevels as ProviderSettings["supportedReasoningEfforts"], + }) + expect(result).not.toBe(modelNoCapability) + expect(modelNoCapability.supportsReasoningEffort).toBeUndefined() + expect(result?.supportsReasoningEffort).not.toBe(declaredLevels) + }) +}) + +describe("computeThinkingEffortDisplay with declared capability (F7)", () => { + const selfHostedModel: ModelInfo = { + contextWindow: 32_768, + maxTokens: 8_192, + supportsPromptCache: false, + } + + it("resolves with the declared levels when the model has no capability of its own", () => { + const display = computeThinkingEffortDisplay({ + model: selfHostedModel, + apiConfiguration: { + reasoningEffort: "high", + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings, + }) + expect(display?.supportedLevels).toEqual(["low", "high", "max"]) + expect(display?.effort).toBe("high") + expect(display?.isAdaptiveClass).toBe(false) + }) + + it("excludes the disable sentinel from declared levels", () => { + // "disable" cannot be declared (not a canonical level), but the menu must + // still stay sentinel-free for arrays carrying it defensively. + const display = computeThinkingEffortDisplay({ + model: selfHostedModel, + apiConfiguration: { + supportedReasoningEfforts: ["low", "high"], + } as ProviderSettings, + taskThinkingEffort: { effort: "high", source: "you" }, + }) + expect(display?.supportedLevels).toEqual(["low", "high"]) + }) + + it("keeps the registry capability over the declaration", () => { + const registryModel: ModelInfo = { + ...selfHostedModel, + supportsReasoningEffort: ["low", "medium"], + reasoningEffort: "medium", + } + const display = computeThinkingEffortDisplay({ + model: registryModel, + apiConfiguration: { + supportedReasoningEfforts: ["low", "high", "max"], + } as ProviderSettings, + }) + expect(display?.supportedLevels).toEqual(["low", "medium"]) + expect(display?.effort).toBe("medium") + }) + + it("returns null without a declaration (existing behavior)", () => { + expect(computeThinkingEffortDisplay({ model: selfHostedModel })).toBeNull() + }) +}) diff --git a/webview-ui/src/utils/thinkingEffort.ts b/webview-ui/src/utils/thinkingEffort.ts new file mode 100644 index 0000000000..9daf3ec011 --- /dev/null +++ b/webview-ui/src/utils/thinkingEffort.ts @@ -0,0 +1,109 @@ +import type { ModelInfo, ProviderSettings, ReasoningEffortExtended } from "@roo-code/types" + +export type ThinkingEffortSource = "default" | "auto" | "you" + +export interface ThinkingEffortDisplay { + effort: string + source: ThinkingEffortSource + /** Levels the model advertises (menu entries); "adaptive" for boolean-class models. */ + supportedLevels: string[] + /** True for boolean/adaptive-class models (soft guidance). */ + isAdaptiveClass: boolean +} + +export const THINKING_EFFORT_ADAPTIVE_LEVEL = "adaptive" + +/** + * F7: webview-side mirror of the extension fill-in + * (`withDeclaredReasoningEffort` in src/api/model-capabilities.ts). + * + * Self-hosted / OpenAI-compatible models do not advertise + * `supportsReasoningEffort` in the model registry, so the webview state + * ModelInfo has no capability of its own and the DTE surfaces are hidden. + * When the profile declares a non-empty `supportedReasoningEfforts` and the + * model has no value of its own (`undefined`), the model is treated as + * supporting exactly that array. Registry values are NEVER overridden + * (fill-in-the-gap only), so models that already advertise a capability + * (boolean or array) keep it. + * + * Pure and non-mutating: returns the original model when nothing is filled in. + */ +export function resolveReasoningEffortCapability( + model: ModelInfo | undefined, + apiConfiguration: ProviderSettings | undefined, +): ModelInfo | undefined { + if (!model || model.supportsReasoningEffort !== undefined) { + return model + } + + const declared = apiConfiguration?.supportedReasoningEfforts + if (!Array.isArray(declared) || declared.length === 0) { + return model + } + + return { ...model, supportsReasoningEffort: [...declared] } +} + +/** + * DTE series 4/5: webview-side computation of the current effective thinking + * effort and its source, shared by the TaskHeader chip and the composer + * bottom-bar toggle. + * + * Resolution (strongest first): task-local override (authoritative + * extension-side push via `taskThinkingEffort`) → settings `reasoningEffort` + * (provider profile) → model default (`model.reasoningEffort`); boolean/ + * adaptive-class models fall back to the "adaptive" soft-guidance display. + * Returns `null` when the model does not advertise per-request effort support. + */ +export function computeThinkingEffortDisplay(args: { + apiConfiguration?: ProviderSettings + model?: ModelInfo + taskThinkingEffort?: { effort: string; source: string } +}): ThinkingEffortDisplay | null { + const { apiConfiguration, model, taskThinkingEffort } = args + + // F7: apply the profile-declared reasoning effort capability fill-in so the + // composer toggle and TaskHeader chip render for models whose registry entry + // does not advertise the capability (OpenAI-compatible / self-hosted). + const effectiveModel = resolveReasoningEffortCapability(model, apiConfiguration) + + const capability = effectiveModel?.supportsReasoningEffort + const isAdaptiveClass = capability === true + // The "disable" sentinel is a UI off-switch (settings value), not a level a + // task can be set to — keep it out of the menu even when a model advertises it. + const supportedLevels = Array.isArray(capability) + ? capability.filter((level) => level !== "disable") + : isAdaptiveClass + ? [THINKING_EFFORT_ADAPTIVE_LEVEL] + : [] + if (supportedLevels.length === 0) { + return null + } + + // 1. Task-local override (authoritative extension push). + if (taskThinkingEffort?.effort) { + const source: ThinkingEffortSource = + taskThinkingEffort.source === "you" + ? "you" + : taskThinkingEffort.source === "model" || taskThinkingEffort.source === "parent" + ? "auto" + : "default" + return { effort: taskThinkingEffort.effort, source, supportedLevels, isAdaptiveClass } + } + + // 2. Settings-derived effort (provider profile). The "disable" sentinel + // means "no effort" for the per-request envelope resolution. + const settingsEffort = apiConfiguration?.reasoningEffort as ReasoningEffortExtended | "disable" | undefined + if (settingsEffort && settingsEffort !== "disable") { + return { effort: settingsEffort, source: "default", supportedLevels, isAdaptiveClass } + } + + // 3. Model default / adaptive soft guidance. + if (isAdaptiveClass) { + return { effort: THINKING_EFFORT_ADAPTIVE_LEVEL, source: "auto", supportedLevels, isAdaptiveClass } + } + if (effectiveModel?.reasoningEffort) { + return { effort: effectiveModel.reasoningEffort, source: "default", supportedLevels, isAdaptiveClass } + } + return null +}