Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -648,6 +648,9 @@ export interface WebviewMessage {
| "themeFixtureProbeResponse"
text?: string
taskId?: 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
Expand Down Expand Up @@ -904,6 +907,10 @@ export interface ClineSayTool {
description?: string
// Properties for skill tool
skill?: 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 {
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/new-task-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
236 changes: 236 additions & 0 deletions src/__tests__/provider-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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")
})
})
9 changes: 9 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
retiredProviderIdentifiers,
type ProviderSettings,
type ModelInfo,
type ReasoningEffortExtended,
} from "@roo-code/types"

import { getRouterRemovalMessage } from "../core/config/routerRemoval"
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading