diff --git a/apps/vscode-e2e/src/fixtures/inline-flatten.ts b/apps/vscode-e2e/src/fixtures/inline-flatten.ts new file mode 100644 index 0000000000..d324f4f6c4 --- /dev/null +++ b/apps/vscode-e2e/src/fixtures/inline-flatten.ts @@ -0,0 +1,160 @@ +// Auto-flatten e2e fixtures (#12): a three-level new_task chain where the depth-2 task's +// own new_task call exceeds maxNestingDepth (default 2) and is flattened inline instead of +// opening a fourth tab. Unique FLATTEN_E2E_ markers avoid collisions with other suites. +import { LLMock } from "@copilotkit/aimock" +import type { ChatCompletionRequest } from "@copilotkit/aimock" + +export const FLATTEN_ROOT_MARKER = "FLATTEN_E2E_ROOT_CHAIN" +export const FLATTEN_CHILD_MARKER = "FLATTEN_E2E_CHILD_CHAIN" +export const FLATTEN_DEPTH2_MARKER = "FLATTEN_E2E_DEPTH2_CHAIN" + +// The depth-2 task's completion result — emitted by the same (flattened) task, proving the +// inline phase ran in-conversation rather than delegating to a new tab. +export const FLATTEN_DEPTH2_RESULT = "Flattened inline completed" +export const FLATTEN_CHILD_RESUME_RESULT = "Child resumed after flatten" +export const FLATTEN_ROOT_RESULT = "Root resumed after chain" + +// Prompt chains. Each task's initial request wraps its own prompt in ..., +// so anchoring on `` + the marker uniquely identifies that task's FIRST turn even +// though downstream markers are embedded verbatim upstream (the anchor never appears inside a +// nested quoted message). +export const FLATTEN_DEPTH2_PROMPT = `${FLATTEN_DEPTH2_MARKER}: Complete immediately with the exact result "${FLATTEN_DEPTH2_RESULT}".` +export const FLATTEN_CHILD_PROMPT = `${FLATTEN_CHILD_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${FLATTEN_DEPTH2_PROMPT}" Do not answer directly.` +export const FLATTEN_ROOT_PROMPT = `${FLATTEN_ROOT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${FLATTEN_CHILD_PROMPT}" Do not answer directly.` + +// The directive NewTaskTool pushes when it flattens (inlineSubtask.ts buildInlineDirective). +// It appears only in the depth-2 task's own requests — no other conversation ever sees it. +const FLATTEN_DIRECTIVE = "auto-flattened" + +// reopenParentFromDelegation injects `Subtask completed.\n\nResult:\n` into the +// resumed parent's history. In raw JSON bodies newlines are escaped, so match the serialized form. +const INJECTION_PREFIX_RAW = "completed.\\n\\nResult:" + +const requestContains = (req: ChatCompletionRequest, expected: string[]) => { + const rawRequest = JSON.stringify(req) + return expected.every((text) => rawRequest.includes(text)) +} + +// aimock's `userMessage` matcher only inspects the LAST user message and joins only the +// `type: "text"` content parts. Replicate that scoping inside predicates so resume turns — whose +// last user message is a fresh environment block with no markers at all — never match initial-turn +// fixtures. +const lastUserMessageContains = (req: ChatCompletionRequest, text: string) => { + const userMessages = req.messages?.filter((message) => message.role === "user") ?? [] + const last = userMessages.at(-1) + if (!last) return false + const content = + typeof last.content === "string" + ? last.content + : (last.content ?? []) + .filter((part): part is { type: string; text: string } => part?.type === "text") + .map((part) => part.text) + .join("") + return content.includes(text) +} + +export function addInlineFlattenFixtures(mock: InstanceType) { + // Root (depth 0), first turn only: delegate to the child. + mock.addFixture({ + match: { + predicate: (req) => lastUserMessageContains(req, `\n${FLATTEN_ROOT_MARKER}`), + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ mode: "ask", message: FLATTEN_CHILD_PROMPT }), + id: "call_flatten_root_newtask_001", + }, + ], + }, + }) + + // Child (depth 1), first turn only: delegate to the depth-2 task. + mock.addFixture({ + match: { + predicate: (req) => lastUserMessageContains(req, `\n${FLATTEN_CHILD_MARKER}`), + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ mode: "ask", message: FLATTEN_DEPTH2_PROMPT }), + id: "call_flatten_child_newtask_002", + }, + ], + }, + }) + + // Depth-2 task (depth 2), first turn only: its new_task call would exceed maxNestingDepth, + // so the tool flattens it inline and pushes a directive containing FLATTEN_DIRECTIVE. + mock.addFixture({ + match: { + predicate: (req) => lastUserMessageContains(req, `\n${FLATTEN_DEPTH2_MARKER}`), + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: `Complete immediately with the exact result "${FLATTEN_DEPTH2_RESULT}".`, + }), + id: "call_flatten_depth2_newtask_003", + }, + ], + }, + }) + + // Depth-2 task after the flatten directive is in its history (and on the follow-up turn): + // complete with the inline result. While the inline phase is active this ends the phase + // without completing the task; once the phase has ended the same response completes it and + // delegates back to the child — no new tab was ever opened. + mock.addFixture({ + match: { + predicate: (req) => requestContains(req, [FLATTEN_DIRECTIVE]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: FLATTEN_DEPTH2_RESULT }), + id: "call_flatten_depth2_inline_004", + }, + ], + }, + }) + + // Child resumes after its (flattened) subtask returns: the injected result carries the + // depth-2 completion summary. Complete so the root can resume. + mock.addFixture({ + match: { + predicate: (req) => requestContains(req, [`${INJECTION_PREFIX_RAW}\\n${FLATTEN_DEPTH2_RESULT}`]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: FLATTEN_CHILD_RESUME_RESULT }), + id: "call_flatten_child_resume_005", + }, + ], + }, + }) + + // Root resumes last: the injected result carries the child's completion summary. + mock.addFixture({ + match: { + predicate: (req) => requestContains(req, [`${INJECTION_PREFIX_RAW}\\n${FLATTEN_CHILD_RESUME_RESULT}`]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: FLATTEN_ROOT_RESULT }), + id: "call_flatten_root_resume_006", + }, + ], + }, + }) +} diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index 8162f34068..228ffc60c1 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -9,6 +9,7 @@ import { LLMock } from "@copilotkit/aimock" import { addApplyDiffResultFixtures } from "./fixtures/apply-diff" import { addDeepSeekV4Fixtures } from "./fixtures/deepseek-v4" import { addExecuteCommandResultFixtures } from "./fixtures/execute-command" +import { addInlineFlattenFixtures } from "./fixtures/inline-flatten" import { addFastExitShellRaceResultFixtures } from "./fixtures/fast-exit-shell-race" import { addZeroChunkShellRaceResultFixtures } from "./fixtures/zero-chunk-shell-race" import { addTerminalReuseShellRaceFixtures } from "./fixtures/terminal-reuse-shell-race" @@ -139,6 +140,7 @@ async function main() { addListFilesResultFixtures(mock) addReadFileResultFixtures(mock) addSearchFilesResultFixtures(mock) + addInlineFlattenFixtures(mock) addSubtaskFixtures(mock) addUseMcpToolResultFixtures(mock) addWriteToFileResultFixtures(mock) diff --git a/apps/vscode-e2e/src/suite/inline-flatten.test.ts b/apps/vscode-e2e/src/suite/inline-flatten.test.ts new file mode 100644 index 0000000000..0075675551 --- /dev/null +++ b/apps/vscode-e2e/src/suite/inline-flatten.test.ts @@ -0,0 +1,107 @@ +import * as assert from "assert" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { + FLATTEN_CHILD_RESUME_RESULT, + FLATTEN_DEPTH2_RESULT, + FLATTEN_ROOT_PROMPT, + FLATTEN_ROOT_RESULT, +} from "../fixtures/inline-flatten" +import { setDefaultSuiteTimeout } from "./test-utils" +import { sleep, waitUntilCompleted } from "./utils" + +suite("Roo Code Inline Flatten", function () { + setDefaultSuiteTimeout(this) + + // A three-level new_task chain: root (depth 0) -> child (depth 1) -> depth-2 task. + // The depth-2 task's own new_task call would exceed maxNestingDepth (default 2), so the + // tool flattens it inline instead of opening a fourth tab. This crosses real extension- + // host boundaries (task instances, delegation links, webview events) that unit tests + // cannot represent. + test("depth-limit new_task flattens inline: no fourth task opens", async () => { + const api = globalThis.api + const says: Record = {} + let maxStackLength = 0 + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + + const stackLength = api.getCurrentTaskStack().length + if (stackLength > maxStackLength) { + maxStackLength = stackLength + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + try { + const rootTaskId = await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: FLATTEN_ROOT_PROMPT, + }), + }) + + const taskIds = Object.keys(says) + assert.strictEqual( + taskIds.length, + 3, + `Expected exactly 3 tasks (root, child, depth-2); observed ${taskIds.join(", ")}`, + ) + + // The flattened work ran in the depth-2 task's own conversation — its completion + // carries the inline result. No fourth task exists to carry it. + const depth2TaskId = taskIds.find( + (taskId) => + taskId !== rootTaskId && + says[taskId]?.some( + ({ say, text }) => say === "completion_result" && text?.trim() === FLATTEN_DEPTH2_RESULT, + ), + ) + assert.ok(depth2TaskId, `Depth-2 task should complete with the inline result "${FLATTEN_DEPTH2_RESULT}"`) + + // The child resumed after its (flattened) subtask returned. + const childTaskId = taskIds.find((taskId) => taskId !== rootTaskId && taskId !== depth2TaskId) + assert.ok( + says[childTaskId!]?.some( + ({ say, text }) => say === "completion_result" && text?.trim() === FLATTEN_CHILD_RESUME_RESULT, + ), + "Child should resume and complete after the flattened subtask returns", + ) + + // The root resumed last with the chain result. + assert.strictEqual( + says[rootTaskId] + ?.filter(({ say }) => say === "completion_result") + .map(({ text }) => text?.trim()) + .find((text): text is string => !!text), + FLATTEN_ROOT_RESULT, + "Root should resume with the chain result after the child returns", + ) + + // The task stack never held more than the three real tasks — flattening opened no tab. + assert.ok( + maxStackLength <= 3, + `Task stack should never exceed 3 (flattened work opens no new task); observed max ${maxStackLength}`, + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await sleep(1_500) + } + }) +}) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index bd440512ce..7fefe5fde9 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -48,6 +48,25 @@ export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0 export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false +/** + * Default maximum task nesting depth (root = 0). + * + * A value of `0` disables delegation entirely — every `new_task` call is executed + * inline in the current conversation instead of opening a child tab. The range is + * clamped to 0–5 by the settings UI and validated here. + */ +export const DEFAULT_MAX_NESTING_DEPTH = 2 + +/** + * Default for auto-flattening when the nesting limit is reached. + * + * When `true`, a `new_task` call that would exceed `maxNestingDepth` is executed inline + * in the current conversation (same Task instance, phase marker) rather than opening a + * child tab. When `false`, such a call is rejected with an error result so the model + * continues working directly. + */ +export const DEFAULT_AUTO_FLATTEN_ON_LIMIT = true + /** * Terminal output preview size options for persisted command output. * @@ -151,6 +170,18 @@ export const globalSettingsSchema = z.object({ autoCondenseContext: z.boolean().optional(), autoCondenseContextPercent: z.number().optional(), + /** + * Maximum task nesting depth (root = 0). Range 0–5; `0` disables delegation entirely. + * @default 2 + */ + maxNestingDepth: z.number().int().min(0).max(5).optional(), + /** + * When the nesting limit is reached, execute the subtask inline in the current + * conversation instead of opening a child tab. When `false`, such calls are rejected. + * @default true + */ + autoFlattenOnLimit: z.boolean().optional(), + /** * Whether to include current time in the environment details * @default true diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 5b173c6a6b..e2a016a450 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -24,6 +24,7 @@ export const historyItemSchema = z.object({ delegatedToId: z.string().optional(), // Last child this parent delegated to childIds: z.array(z.string()).optional(), // All children spawned by this task awaitingChildId: z.string().optional(), // Child currently awaited (set when delegated) + depth: z.number().int().min(0).optional(), // Nesting level; root = 0, child = parent.depth + 1 completedByChildId: z.string().optional(), // Child that completed and resumed this parent completionResultSummary: z.string().optional(), // Summary from completed child }) diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 572302861b..b253fc39c4 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -94,6 +94,8 @@ export interface CreateTaskOptions { /** Whether to start the task loop immediately (default: true). * When false, the caller must invoke `task.start()` manually. */ startTask?: boolean + /** Nesting level for newly created tasks; root = 0, child = parent.depth + 1. */ + depth?: number } export enum TaskStatus { @@ -116,6 +118,8 @@ export interface TaskLike { readonly rootTaskId?: string readonly parentTaskId?: string readonly childTaskId?: string + /** Nesting level; root = 0, child = parent.depth + 1. */ + readonly depth: number readonly metadata: TaskMetadata readonly taskStatus: TaskStatus readonly taskAsk: ClineMessage | undefined diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ea52c09599..92e0e8c127 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -341,6 +341,8 @@ export type ExtensionState = Pick< writeDelayMs: number diffFuzzyThreshold: number + maxNestingDepth?: number // Maximum task nesting depth (root = 0); default 2, range 0–5 + autoFlattenOnLimit?: boolean // Execute subtasks inline when the nesting limit is reached; default true enableCheckpoints: boolean checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15) diff --git a/src/__tests__/cancel-cascade.spec.ts b/src/__tests__/cancel-cascade.spec.ts new file mode 100644 index 0000000000..0b471958ed --- /dev/null +++ b/src/__tests__/cancel-cascade.spec.ts @@ -0,0 +1,128 @@ +// npx vitest run __tests__/cancel-cascade.spec.ts + +import { describe, it, expect, vi } from "vitest" +import type { HistoryItem } from "@roo-code/types" +import { ClineProvider } from "../core/webview/ClineProvider" +import { TaskRegistry } from "../core/task/TaskRegistry" +import type { Task } from "../core/task/Task" + +/** + * Minimal live-child double carrying only the fields interruptLiveChildren touches. + * `abortTask` is a real mock so we can assert it was invoked (and that its inlineSubtask + * phase marker would be cleared by the abort path). + */ +function makeChildDouble(taskId: string, opts: { abort?: boolean; abandoned?: boolean } = {}) { + const abortTask = vi.fn().mockResolvedValue(undefined) + return { + taskId, + abort: opts.abort ?? false, + abandoned: opts.abandoned ?? false, + inlineSubtask: undefined as { message: string; todos: unknown[] } | undefined, + abortTask, + } +} + +function makeProvider(opts: { + parentChildIds?: string[] + childStatuses?: Record + children: Array> +}) { + const store = new Map() + store.set("parent-1", { + id: "parent-1", + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds: opts.parentChildIds ?? [], + } as unknown as HistoryItem) + for (const [id, status] of Object.entries(opts.childStatuses ?? {})) { + store.set(id, { + id, + task: `Child ${id}`, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + status, + } as unknown as HistoryItem) + } + + const registry = new TaskRegistry() + for (const child of opts.children) { + registry.push(child as unknown as Task) + } + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + + return { + taskHistoryStore: { get: (id: string) => store.get(id) }, + taskRegistry: registry, + updateTaskHistory, + log: vi.fn(), + } +} + +async function callInterruptLiveChildren(provider: object, parentTaskId: string): Promise { + const proto = ClineProvider.prototype as unknown as { + interruptLiveChildren: (this: object, id: string) => Promise + } + await proto.interruptLiveChildren.call(provider, parentTaskId) +} + +describe("ClineProvider.cancel cascade — interruptLiveChildren", () => { + it("aborts live children and marks them interrupted", async () => { + const child = makeChildDouble("child-1") + const provider = makeProvider({ + parentChildIds: ["child-1"], + childStatuses: { "child-1": "active" }, + children: [child], + }) + + await callInterruptLiveChildren(provider, "parent-1") + + expect(child.abortTask).toHaveBeenCalledTimes(1) + const update = (provider as { updateTaskHistory: ReturnType }).updateTaskHistory + expect(update).toHaveBeenCalledWith(expect.objectContaining({ id: "child-1", status: "interrupted" })) + }) + + it("skips children already in a terminal state (never overwrites completed/interrupted)", async () => { + const done = makeChildDouble("child-done") + const interrupted = makeChildDouble("child-interrupted") + const provider = makeProvider({ + parentChildIds: ["child-done", "child-interrupted"], + childStatuses: { "child-done": "completed", "child-interrupted": "interrupted" }, + children: [done, interrupted], + }) + + await callInterruptLiveChildren(provider, "parent-1") + + expect(done.abortTask).not.toHaveBeenCalled() + expect(interrupted.abortTask).not.toHaveBeenCalled() + const update = (provider as { updateTaskHistory: ReturnType }).updateTaskHistory + expect(update).not.toHaveBeenCalled() + }) + + it("skips children that are not live in the registry (already aborted/abandoned or evicted)", async () => { + const abandoned = makeChildDouble("child-abandoned", { abandoned: true }) + const provider = makeProvider({ + parentChildIds: ["child-abandoned", "child-evicted"], // child-evicted has no registry entry + childStatuses: { "child-abandoned": "active", "child-evicted": "active" }, + children: [abandoned], + }) + + await callInterruptLiveChildren(provider, "parent-1") + + expect(abandoned.abortTask).not.toHaveBeenCalled() + const update = (provider as { updateTaskHistory: ReturnType }).updateTaskHistory + expect(update).not.toHaveBeenCalled() + }) + + it("is a no-op when the parent has no children", async () => { + const provider = makeProvider({ parentChildIds: [], childStatuses: {}, children: [] }) + + await callInterruptLiveChildren(provider, "parent-1") + + const update = (provider as { updateTaskHistory: ReturnType }).updateTaskHistory + expect(update).not.toHaveBeenCalled() + }) +}) diff --git a/src/__tests__/conversation-checkpoint-task.spec.ts b/src/__tests__/conversation-checkpoint-task.spec.ts new file mode 100644 index 0000000000..e034ec8b6c --- /dev/null +++ b/src/__tests__/conversation-checkpoint-task.spec.ts @@ -0,0 +1,79 @@ +// npx vitest run __tests__/conversation-checkpoint-task.spec.ts + +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import type { ClineMessage } from "@roo-code/types" +import { Task } from "../core/task/Task" + +function makeMessages(n: number): ClineMessage[] { + return Array.from( + { length: n }, + (_, i) => + ({ + type: "user", + text: `message ${i}`, + ts: 1000 + i, + }) as unknown as ClineMessage, + ) +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "conv-checkpoint-task-")) +}) + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +/** + * Task double carrying only the fields the checkpoint trigger methods read. + * The real prototype methods are bound onto the stub so we exercise the actual + * Task code path (getTaskDirectoryPath + storage module) without instantiating a Task. + */ +function makeTaskDouble(taskId: string, messages: ClineMessage[]) { + const proto = Task.prototype as unknown as { + createConversationCheckpoint: (this: object, summary?: string) => Promise + listConversationCheckpoints: (this: object) => Promise + } + const stub = { taskId, globalStoragePath: tmpDir, clineMessages: messages } + return Object.assign(stub, { + createConversationCheckpoint: proto.createConversationCheckpoint.bind(stub), + listConversationCheckpoints: proto.listConversationCheckpoints.bind(stub), + }) as unknown as Task +} + +describe("Task.createConversationCheckpoint", () => { + it("persists the full message history under /checkpoints and returns the checkpoint", async () => { + const task = makeTaskDouble("task-1", makeMessages(2)) + + const cp = await task.createConversationCheckpoint("halfway") + + expect(cp.taskId).toBe("task-1") + expect(cp.summary).toBe("halfway") + expect(cp.messages).toHaveLength(2) + + // Lands in the standard task directory layout: /tasks//checkpoints/.json + const raw = JSON.parse( + await fs.readFile(path.join(tmpDir, "tasks", "task-1", "checkpoints", `${cp.id}.json`), "utf8"), + ) as { taskId: string; messages: unknown[] } + expect(raw.taskId).toBe("task-1") + expect(raw.messages).toHaveLength(2) + }) + + it("lists checkpoints newest first via listConversationCheckpoints", async () => { + const task = makeTaskDouble("task-1", makeMessages(1)) + + const cp1 = await task.createConversationCheckpoint() + // Ensure a distinct timestamp so ordering is unambiguous. + await new Promise((resolve) => setTimeout(resolve, 5)) + const cp2 = await task.createConversationCheckpoint("second") + + const list = await task.listConversationCheckpoints() + expect(list.map((c) => c.id)).toEqual([cp2.id, cp1.id]) + }) +}) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 954261b145..5458b73e94 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -169,6 +169,7 @@ describe("Single-open-task invariant", () => { getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating taskHistoryStore: { get: vi.fn(() => undefined) }, markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), + backfillTaskDepth: vi.fn().mockResolvedValue(undefined), get evictCurrentTask() { return privateClineProvider.evictCurrentTask.bind(this) }, @@ -268,6 +269,7 @@ describe("Single-open-task invariant", () => { taskScheduler: { schedule: schedulespy }, taskEventListeners: new WeakMap(), performPreparationTasks: vi.fn().mockResolvedValue(undefined), + backfillTaskDepth: vi.fn().mockResolvedValue(undefined), context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, contextProxy: { extensionUri: {}, @@ -340,6 +342,7 @@ describe("Single-open-task invariant", () => { taskScheduler: { schedule: schedulespy }, taskEventListeners: new WeakMap(), performPreparationTasks: vi.fn().mockResolvedValue(undefined), + backfillTaskDepth: vi.fn().mockResolvedValue(undefined), context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, contextProxy: { extensionUri: {}, diff --git a/src/core/checkpoints/__tests__/conversation-checkpoint.spec.ts b/src/core/checkpoints/__tests__/conversation-checkpoint.spec.ts new file mode 100644 index 0000000000..0481c80d61 --- /dev/null +++ b/src/core/checkpoints/__tests__/conversation-checkpoint.spec.ts @@ -0,0 +1,143 @@ +// npx vitest run core/checkpoints/__tests__/conversation-checkpoint.spec.ts + +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import type { ClineMessage } from "@roo-code/types" +import { + saveConversationCheckpoint, + listConversationCheckpoints, + loadConversationCheckpoint, +} from "../conversation-checkpoint" + +function makeMessages(n: number): ClineMessage[] { + return Array.from( + { length: n }, + (_, i) => + ({ + type: "user", + text: `message ${i}`, + ts: 1000 + i, + }) as unknown as ClineMessage, + ) +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "conv-checkpoint-")) +}) + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe("saveConversationCheckpoint", () => { + it("writes a JSON file named by the creation timestamp and returns the checkpoint", async () => { + const messages = makeMessages(3) + + const cp = await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "task-1", messages, now: 2000 }) + + expect(cp.id).toBe("2000") + expect(cp.taskId).toBe("task-1") + expect(cp.createdAt).toBe(2000) + const raw = await fs.readFile(path.join(tmpDir, "checkpoints", "2000.json"), "utf8") + const parsed = JSON.parse(raw) as { id: string; taskId: string; messages: unknown[] } + expect(parsed.id).toBe("2000") + expect(parsed.taskId).toBe("task-1") + expect(parsed.messages).toHaveLength(3) + }) + + it("omits the summary field when none is provided and includes it when given", async () => { + const noSummary = await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(1), + now: 100, + }) + expect(noSummary.summary).toBeUndefined() + + const withSummary = await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(1), + summary: "halfway done", + now: 200, + }) + expect(withSummary.summary).toBe("halfway done") + + const raw = await fs.readFile(path.join(tmpDir, "checkpoints", "100.json"), "utf8") + expect(JSON.parse(raw) as Record).not.toHaveProperty("summary") + }) + + it("appends a numeric suffix on same-millisecond collisions instead of overwriting", async () => { + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 500 }) + const second = await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(2), + now: 500, + }) + + expect(second.id).toBe("500-1") + // Both files coexist. + await expect(fs.access(path.join(tmpDir, "checkpoints", "500.json"))).resolves.toBeUndefined() + await expect(fs.access(path.join(tmpDir, "checkpoints", "500-1.json"))).resolves.toBeUndefined() + }) + + it("does not mutate the caller's messages array (deep clone)", async () => { + const messages = makeMessages(2) + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages, now: 300 }) + ;(messages[0] as { text?: string }).text = "mutated" + + const loaded = await loadConversationCheckpoint(tmpDir, "300") + expect((loaded?.messages[0] as { text?: string }).text).toBe("message 0") + }) +}) + +describe("listConversationCheckpoints", () => { + it("returns an empty list when the checkpoints directory does not exist", async () => { + const result = await listConversationCheckpoints(tmpDir) + expect(result).toEqual([]) + }) + + it("lists checkpoints newest first (createdAt desc, then id desc on ties)", async () => { + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 100 }) + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 300 }) + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 200 }) + + const result = await listConversationCheckpoints(tmpDir) + expect(result.map((c) => c.id)).toEqual(["300", "200", "100"]) + }) + + it("skips corrupt files rather than failing the listing", async () => { + await saveConversationCheckpoint({ taskDir: tmpDir, taskId: "t", messages: makeMessages(1), now: 100 }) + const dir = path.join(tmpDir, "checkpoints") + await fs.writeFile(path.join(dir, "999.json"), "{ not valid json", "utf8") + + const result = await listConversationCheckpoints(tmpDir) + expect(result.map((c) => c.id)).toEqual(["100"]) + }) +}) + +describe("loadConversationCheckpoint", () => { + it("loads a saved checkpoint by id and returns undefined when missing", async () => { + await saveConversationCheckpoint({ + taskDir: tmpDir, + taskId: "t", + messages: makeMessages(2), + summary: "s", + now: 400, + }) + + const loaded = await loadConversationCheckpoint(tmpDir, "400") + expect(loaded?.taskId).toBe("t") + expect(loaded?.summary).toBe("s") + expect(loaded?.messages).toHaveLength(2) + + const missing = await loadConversationCheckpoint(tmpDir, "does-not-exist") + expect(missing).toBeUndefined() + }) +}) diff --git a/src/core/checkpoints/conversation-checkpoint.ts b/src/core/checkpoints/conversation-checkpoint.ts new file mode 100644 index 0000000000..c60fab2355 --- /dev/null +++ b/src/core/checkpoints/conversation-checkpoint.ts @@ -0,0 +1,117 @@ +import * as fs from "fs/promises" +import * as path from "path" + +import type { ClineMessage } from "@roo-code/types" + +/** + * A manually-triggered conversation checkpoint. + * + * Unlike the git-based file checkpoints (RepoPerTaskCheckpointService), a conversation + * checkpoint snapshots the task's full message history so the user can restore the + * conversation to this point later. Stored as JSON under `/checkpoints/`. + */ +export interface ConversationCheckpoint { + /** Filename stem: epoch ms, with a `-N` suffix on same-millisecond collisions. */ + id: string + taskId: string + createdAt: number + summary?: string + messages: ClineMessage[] +} + +const CHECKPOINT_DIR = "checkpoints" + +function checkpointDir(taskDir: string): string { + return path.join(taskDir, CHECKPOINT_DIR) +} + +/** + * Saves a conversation checkpoint to `/checkpoints/.json`. + * + * The id is the creation timestamp; if that file already exists (two checkpoints in the + * same millisecond) a `-1`, `-2`, ... suffix is appended so no data is lost. + */ +export async function saveConversationCheckpoint(opts: { + taskDir: string + taskId: string + messages: ClineMessage[] + summary?: string + /** Injectable clock for deterministic tests. Defaults to Date.now(). */ + now?: number +}): Promise { + const dir = checkpointDir(opts.taskDir) + await fs.mkdir(dir, { recursive: true }) + + const createdAt = opts.now ?? Date.now() + let id = String(createdAt) + // Same-millisecond collision guard: append a numeric suffix until the file is free. + for (let n = 1; await fileExists(path.join(dir, `${id}.json`)); n++) { + id = `${createdAt}-${n}` + } + + const checkpoint: ConversationCheckpoint = { + id, + taskId: opts.taskId, + createdAt, + ...(opts.summary !== undefined ? { summary: opts.summary } : {}), + messages: structuredClone(opts.messages), + } + + await fs.writeFile(path.join(dir, `${id}.json`), JSON.stringify(checkpoint, null, 2), "utf8") + return checkpoint +} + +/** Lists all conversation checkpoints for a task, newest first. Missing dir → empty list. */ +export async function listConversationCheckpoints(taskDir: string): Promise { + const dir = checkpointDir(taskDir) + let entries: string[] + try { + entries = await fs.readdir(dir) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return [] + } + throw error + } + + const checkpoints: ConversationCheckpoint[] = [] + for (const entry of entries) { + if (!entry.endsWith(".json")) { + continue + } + try { + const raw = await fs.readFile(path.join(dir, entry), "utf8") + checkpoints.push(JSON.parse(raw) as ConversationCheckpoint) + } catch { + // Skip corrupt/partial files rather than failing the whole listing. + } + } + + return checkpoints.sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)) +} + +/** Loads a single checkpoint by id. Returns undefined when not found or unreadable. */ +export async function loadConversationCheckpoint( + taskDir: string, + id: string, +): Promise { + const file = path.join(checkpointDir(taskDir), `${id}.json`) + try { + const raw = await fs.readFile(file, "utf8") + return JSON.parse(raw) as ConversationCheckpoint + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined + } + throw error + } +} + +async function fileExists(file: string): Promise { + try { + await fs.access(file) + return true + } catch { + return false + } +} diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index ec2e6cceeb..72a261f946 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -25,6 +25,8 @@ export type TaskMetadataOptions = { apiConfigName?: string /** Initial status for the task (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" | "interrupted" + /** Nesting level; root = 0, child = parent.depth + 1. Persisted when known. */ + depth?: number } export async function taskMetadata({ @@ -38,6 +40,7 @@ export async function taskMetadata({ mode, apiConfigName, initialStatus, + depth, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, id) @@ -112,6 +115,7 @@ export async function taskMetadata({ mode, ...(typeof apiConfigName === "string" && apiConfigName.length > 0 ? { apiConfigName } : {}), ...(initialStatus && { status: initialStatus }), + ...(typeof depth === "number" && Number.isInteger(depth) && depth >= 0 ? { depth } : {}), } return { historyItem, tokenUsage } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4be087394e..80d48c7558 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -117,6 +117,11 @@ import { saveTaskMessages, taskMetadata, } from "../task-persistence" +import { + saveConversationCheckpoint, + listConversationCheckpoints, + type ConversationCheckpoint, +} from "../checkpoints/conversation-checkpoint" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { @@ -142,6 +147,24 @@ const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +/** + * In-memory phase marker for an auto-flattened inline subtask. + * + * When `new_task` would exceed `maxNestingDepth` and `autoFlattenOnLimit` is set, + * the subtask is NOT opened as a child Task. Instead the parent Task records this + * marker and executes the instruction inline in its own conversation (the tool_result + * doubles as the inline prompt). The marker is cleared when the inline phase completes + * (`attempt_completion`) or the task is aborted/cancelled. + * + * Deliberately NOT persisted: inline state is a transient execution phase, not lineage. + */ +export interface InlineSubtask { + /** The subtask instruction to execute inline. */ + message: string + /** Parsed todos for the subtask (empty when none were provided). */ + todos: TodoItem[] +} + export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider apiConfiguration: ProviderSettings @@ -170,6 +193,19 @@ export class Task extends EventEmitter implements TaskLike { readonly rootTaskId?: string readonly parentTaskId?: string childTaskId?: string + /** Nesting level; root = 0, child = parent.depth + 1. */ + readonly depth: number + /** + * True when `depth` was derived authoritatively (persisted value, live parent, + * or a genuine root) and is safe to persist on save. False for legacy children + * resumed without their live parent — the provider backfills those before first save. + */ + readonly depthAuthoritative: boolean + /** + * Set while an auto-flattened subtask is executing inline in this task's own + * conversation. Cleared on completion or abort. Never persisted. + */ + inlineSubtask?: InlineSubtask pendingNewTaskToolCallId?: string readonly instanceId: string @@ -505,6 +541,28 @@ export class Task extends EventEmitter implements TaskLike { this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId this.childTaskId = undefined + // Nesting depth (root = 0). A persisted value is authoritative; otherwise derive it + // from the live parent task (parent.depth + 1) or default to root (0). + const persistedDepth = historyItem?.depth + if (typeof persistedDepth === "number" && Number.isInteger(persistedDepth) && persistedDepth >= 0) { + this.depth = persistedDepth + this.depthAuthoritative = true + } else if (parentTask) { + // Live parent available: its depth is authoritative, so the child's is too. + this.depth = parentTask.depth + 1 + this.depthAuthoritative = true + } else if (!this.parentTaskId) { + // No persisted depth and no parent reference at all: this task is a root. + this.depth = 0 + this.depthAuthoritative = true + } else { + // Legacy child resumed without its live parent (e.g. reopened from history): + // the depth cannot be derived here, so mark it non-authoritative and let + // ClineProvider.createTaskWithHistoryItem() backfill it before first save. + this.depth = 0 + this.depthAuthoritative = false + } + this.metadata = { task: historyItem ? historyItem.task : task, images: historyItem ? [] : images, @@ -1121,6 +1179,9 @@ export class Task extends EventEmitter implements TaskLike { mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile. initialStatus: this.initialStatus, + // Only persist depth when it was derived authoritatively; a legacy child resumed + // without its live parent carries a placeholder 0 that must not be written back. + depth: this.depthAuthoritative ? this.depth : undefined, }) // Emit token/tool usage updates using debounced function @@ -1140,6 +1201,29 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Manually-triggered conversation checkpoint (P4 of the Task Tree plan). + * + * Snapshots the task's full message history to `/checkpoints/.json` so the + * user can restore the conversation to this point later. This is distinct from the + * git-based file checkpoints: it captures the CONVERSATION, not the working tree. + */ + async createConversationCheckpoint(summary?: string): Promise { + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, this.taskId) + return saveConversationCheckpoint({ + taskDir, + taskId: this.taskId, + messages: structuredClone(this.clineMessages), + summary, + }) + } + + /** Lists all conversation checkpoints for this task, newest first. */ + async listConversationCheckpoints(): Promise { + const taskDir = await getTaskDirectoryPath(this.globalStoragePath, this.taskId) + return listConversationCheckpoints(taskDir) + } + private findMessageByTimestamp(ts: number): ClineMessage | undefined { for (let i = this.clineMessages.length - 1; i >= 0; i--) { if (this.clineMessages[i].ts === ts) { @@ -2252,6 +2336,10 @@ export class Task extends EventEmitter implements TaskLike { this.abort = true + // Clear any in-flight inline subtask phase so a cancelled task resumes as an + // ordinary parent conversation with no orphaned marker. + this.inlineSubtask = undefined + // Reset consecutive error counters on abort (manual intervention) this.consecutiveNoToolUseCount = 0 this.consecutiveNoAssistantMessagesCount = 0 diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 163da0c478..79676a95ec 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -547,6 +547,98 @@ describe("Cline", () => { new Task({ provider: mockProvider, apiConfiguration: mockApiConfig }) }).toThrow("Either historyItem or task/images must be provided") }) + + describe("nesting depth", () => { + it("assigns depth 0 and authoritative to a new root task", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "root task", + startTask: false, + }) + + expect(task.depth).toBe(0) + expect(task.depthAuthoritative).toBe(true) + }) + + it("derives child depth from the live parent (parent.depth + 1)", () => { + const parent = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "parent task", + startTask: false, + }) + + const child = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "child task", + parentTask: parent, + startTask: false, + }) + + expect(parent.depth).toBe(0) + expect(child.depth).toBe(1) + expect(child.depthAuthoritative).toBe(true) + expect(child.parentTaskId).toBe(parent.taskId) + }) + + it("prefers a persisted depth over the live parent", () => { + const parent = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "parent task", + startTask: false, + }) + + const child = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "child task", + parentTask: parent, + historyItem: { + id: "persisted-child", + ts: Date.now(), + task: "child task", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + parentTaskId: parent.taskId, + depth: 7, + }, + startTask: false, + }) + + expect(child.depth).toBe(7) + expect(child.depthAuthoritative).toBe(true) + }) + + it("marks a legacy child resumed without its live parent as non-authoritative", () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "legacy-child", + ts: Date.now(), + task: "legacy child", + number: 1, + tokensIn: 0, + tokensOut: 0, + cacheWrites: 0, + cacheReads: 0, + totalCost: 0, + parentTaskId: "missing-parent", + }, + startTask: false, + }) + + expect(task.depth).toBe(0) + expect(task.depthAuthoritative).toBe(false) + }) + }) }) describe("task-local configuration isolation", () => { diff --git a/src/core/task/__tests__/taskDepth.spec.ts b/src/core/task/__tests__/taskDepth.spec.ts new file mode 100644 index 0000000000..4802efff9f --- /dev/null +++ b/src/core/task/__tests__/taskDepth.spec.ts @@ -0,0 +1,85 @@ +// npx vitest core/task/__tests__/taskDepth.spec.ts + +import { computeTaskDepth, MAX_DEPTH_WALK } from "../taskDepth" + +describe("computeTaskDepth", () => { + it("returns a valid persisted depth as-is", () => { + expect(computeTaskDepth("a", 3, () => undefined)).toBe(3) + }) + + it("rejects non-integer / negative persisted depths and falls through to the walk", () => { + // ownDepth invalid -> walk; no parent -> root at depth 0 + expect(computeTaskDepth("a", -1, (id) => ({ parentTaskId: undefined }))).toBe(0) + expect(computeTaskDepth("a", 1.5, (id) => ({ parentTaskId: undefined }))).toBe(0) + }) + + it("derives depth from a live root with no persisted value", () => { + // c -> b -> a(root). No depths persisted anywhere. + const lookup = (id: string) => { + switch (id) { + case "c": + return { parentTaskId: "b" } + case "b": + return { parentTaskId: "a" } + case "a": + return { parentTaskId: undefined } + } + return undefined + } + expect(computeTaskDepth("c", undefined, lookup)).toBe(2) + }) + + it("derives depth from the nearest ancestor with a persisted value", () => { + // c -> b(depth 5) -> a. Only b has a persisted depth. + const lookup = (id: string) => { + switch (id) { + case "c": + return { parentTaskId: "b" } + case "b": + return { parentTaskId: "a", depth: 5 } + case "a": + return { parentTaskId: undefined, depth: 4 } + } + return undefined + } + expect(computeTaskDepth("c", undefined, lookup)).toBe(6) + }) + + it("returns undefined for a cycle in the parent chain", () => { + const lookup = (id: string) => { + switch (id) { + case "a": + return { parentTaskId: "b" } + case "b": + return { parentTaskId: "c" } + case "c": + return { parentTaskId: "a" } // cycle + } + return undefined + } + expect(computeTaskDepth("a", undefined, lookup)).toBeUndefined() + }) + + it("returns undefined when the chain dangles (parent not loadable)", () => { + const lookup = (id: string) => { + if (id === "c") return { parentTaskId: "missing" } + return undefined // "missing" cannot be loaded + } + expect(computeTaskDepth("c", undefined, lookup)).toBeUndefined() + }) + + it("returns undefined for a chain longer than MAX_DEPTH_WALK hops", () => { + // Build a linear chain of length > MAX_DEPTH_WALK with no persisted depths. + const nodes: Record = {} + for (let i = 0; i < MAX_DEPTH_WALK + 5; i++) { + nodes[`t${i}`] = { parentTaskId: i === 0 ? undefined : `t${i - 1}` } + } + const lookup = (id: string) => nodes[id] + expect(computeTaskDepth(`t${MAX_DEPTH_WALK + 4}`, undefined, lookup)).toBeUndefined() + }) + + it("handles a self-referencing parent", () => { + const lookup = (id: string) => ({ parentTaskId: id }) + expect(computeTaskDepth("a", undefined, lookup)).toBeUndefined() + }) +}) diff --git a/src/core/task/taskDepth.ts b/src/core/task/taskDepth.ts new file mode 100644 index 0000000000..265324ba0c --- /dev/null +++ b/src/core/task/taskDepth.ts @@ -0,0 +1,69 @@ +/** + * Pure helpers for task nesting depth. + * + * Depth is the number of delegation hops from the root task (root = 0). + * It is derived from the `parentTaskId` chain so that legacy tasks without a + * persisted `depth` field can be backfilled on load, and so that a corrupted + * or circular parent chain cannot produce an invalid depth. + */ + +/** Maximum ancestor hops to follow before giving up (guards against cycles). */ +export const MAX_DEPTH_WALK = 32 + +export type DepthLookup = (taskId: string) => { parentTaskId?: string; depth?: number } | undefined + +function isValidDepth(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0 +} + +/** + * Compute the nesting depth for a task. + * + * - A persisted `depth` on the task itself is authoritative. + * - Otherwise, walk up the `parentTaskId` chain: + * - if an ancestor has a valid persisted depth, the task sits that many hops below it; + * - if we reach a root (no parent) without a persisted depth, the task's depth equals + * the number of hops taken to get there (a root is at depth 0); + * - on a cycle, a dangling reference, or an overly long chain, return `undefined` + * so callers fall back to treating the task as a root without persisting a bogus value. + */ +export function computeTaskDepth( + taskId: string, + ownDepth: number | undefined, + lookup: DepthLookup, +): number | undefined { + if (isValidDepth(ownDepth)) { + return ownDepth + } + + const seen = new Set([taskId]) + let currentId: string | undefined = taskId + let hopsFromTask = 0 + + while (hopsFromTask < MAX_DEPTH_WALK) { + const node: { parentTaskId?: string; depth?: number } | undefined = currentId ? lookup(currentId) : undefined + if (!node) { + // Parent chain references a task we cannot load — stop. + return undefined + } + if (isValidDepth(node.depth)) { + // Nearest ancestor with an authoritative depth: the original task sits + // `hopsFromTask` levels below it. + return node.depth + hopsFromTask + } + const parentId: string | undefined = node.parentTaskId + if (parentId === undefined) { + // Reached a root without a persisted depth (root = 0). + return hopsFromTask + } + if (seen.has(parentId)) { + // Cycle detected — refuse to persist a derived depth. + return undefined + } + seen.add(parentId) + currentId = parentId + hopsFromTask += 1 + } + + return undefined +} diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index b5f19decb0..1750fdf1b5 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -78,6 +78,16 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { task.consecutiveMistakeCount = 0 + // Inline subtask phase completion (auto-flattened): clear the marker and let the + // loop continue with a tool_result. No askFinishSubTaskApproval — the user is + // already watching this same conversation, so an approval popup would be double + // friction. This task itself continues as before; it has NOT completed. + if (task.inlineSubtask) { + task.inlineSubtask = undefined + pushToolResult(`[inline subtask completed]\n${result}\nThe parent conversation continues.`) + return + } + await task.say("completion_result", result, undefined, false) // Whether this attempt_completion call is a stale replay of an already-completed diff --git a/src/core/tools/NewTaskTool.ts b/src/core/tools/NewTaskTool.ts index f36d8e1e37..b5716f70d8 100644 --- a/src/core/tools/NewTaskTool.ts +++ b/src/core/tools/NewTaskTool.ts @@ -2,7 +2,10 @@ import * as vscode from "vscode" import { TodoItem } from "@roo-code/types" +import { DEFAULT_AUTO_FLATTEN_ON_LIMIT, DEFAULT_MAX_NESTING_DEPTH } from "@roo-code/types" + import { Task } from "../task/Task" +import { decideInlineFlatten } from "./inlineSubtask" import { getModeBySlug } from "../../shared/modes" import { formatResponse } from "../prompts/responses" import { t } from "../../i18n" @@ -96,6 +99,38 @@ export class NewTaskTool extends BaseTool<"new_task"> { return } + // Auto-flatten inline decision (depth check BEFORE any approval prompt). + // When the subtask would exceed maxNestingDepth and autoFlattenOnLimit is set, + // it runs inline in this task's own conversation instead of opening a child tab. + const maxNestingDepth = state?.maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH + const autoFlattenOnLimit = state?.autoFlattenOnLimit ?? DEFAULT_AUTO_FLATTEN_ON_LIMIT + const decision = decideInlineFlatten({ + childDepth: task.depth + 1, + maxNestingDepth, + autoFlattenOnLimit, + inlineActive: task.inlineSubtask !== undefined, + message: unescapedMessage, + todos: todoItems, + }) + + if (decision.action === "reject-nested") { + pushToolResult(formatResponse.toolError(decision.message)) + return + } + + if (decision.action === "flatten") { + // Set the phase marker and let the tool_result double as the inline prompt. + task.inlineSubtask = { message: unescapedMessage, todos: todoItems } + pushToolResult(decision.directive) + return + } + + if (decision.action === "reject-limit") { + pushToolResult(formatResponse.toolError(decision.message)) + return + } + + // decision.action === "delegate" — normal flow unchanged. const toolMessage = JSON.stringify({ tool: "newTask", mode: targetMode.name, diff --git a/src/core/tools/__tests__/inlineSubtask.spec.ts b/src/core/tools/__tests__/inlineSubtask.spec.ts new file mode 100644 index 0000000000..bbc9b16077 --- /dev/null +++ b/src/core/tools/__tests__/inlineSubtask.spec.ts @@ -0,0 +1,146 @@ +// npx vitest run core/tools/__tests__/inlineSubtask.spec.ts + +import { describe, it, expect } from "vitest" +import type { TodoItem } from "@roo-code/types" +import { decideInlineFlatten, buildInlineDirective } from "../inlineSubtask" + +const todos: TodoItem[] = [ + { content: "step one", status: "pending" }, + { content: "step two", status: "completed" }, +] as unknown as TodoItem[] + +describe("decideInlineFlatten (pure decision)", () => { + it("delegates normally when within the limit", () => { + const d = decideInlineFlatten({ + childDepth: 2, + maxNestingDepth: 2, + autoFlattenOnLimit: true, + inlineActive: false, + message: "m", + todos, + }) + expect(d).toEqual({ action: "delegate" }) + }) + + it("delegates when exactly at the limit (childDepth === max)", () => { + const d = decideInlineFlatten({ + childDepth: 2, + maxNestingDepth: 2, + autoFlattenOnLimit: true, + inlineActive: false, + message: "m", + todos, + }) + expect(d.action).toBe("delegate") + }) + + it("flattens when over the limit and autoFlattenOnLimit is true", () => { + const d = decideInlineFlatten({ + childDepth: 3, + maxNestingDepth: 2, + autoFlattenOnLimit: true, + inlineActive: false, + message: "do X", + todos, + }) + expect(d.action).toBe("flatten") + if (d.action === "flatten") { + expect(d.directive).toContain("auto-flattened") + expect(d.directive).toContain("nesting limit 2 reached") + expect(d.directive).toContain("do X") + } + }) + + it("rejects when over the limit and autoFlattenOnLimit is false", () => { + const d = decideInlineFlatten({ + childDepth: 3, + maxNestingDepth: 2, + autoFlattenOnLimit: false, + inlineActive: false, + message: "m", + todos, + }) + expect(d.action).toBe("reject-limit") + if (d.action === "reject-limit") { + expect(d.message).toContain("auto-flatten is disabled") + } + }) + + it("treats maxNestingDepth 0 as delegation-disabled → always flatten when over", () => { + const d = decideInlineFlatten({ + childDepth: 1, + maxNestingDepth: 0, + autoFlattenOnLimit: true, + inlineActive: false, + message: "m", + todos, + }) + expect(d.action).toBe("flatten") + }) + + it("rejects a nested new_task while an inline phase is active (precedence over delegate)", () => { + const d = decideInlineFlatten({ + childDepth: 1, + maxNestingDepth: 5, + autoFlattenOnLimit: true, + inlineActive: true, + message: "m", + todos, + }) + expect(d.action).toBe("reject-nested") + }) + + it("rejects a nested new_task while active even when over the limit (nested wins)", () => { + const d = decideInlineFlatten({ + childDepth: 9, + maxNestingDepth: 2, + autoFlattenOnLimit: true, + inlineActive: true, + message: "m", + todos, + }) + expect(d.action).toBe("reject-nested") + }) + + it("includes todos in the flatten directive when present", () => { + const d = decideInlineFlatten({ + childDepth: 3, + maxNestingDepth: 2, + autoFlattenOnLimit: true, + inlineActive: false, + message: "m", + todos, + }) + if (d.action === "flatten") { + expect(d.directive).toContain("step one") + expect(d.directive).toContain("step two") + } + }) + + it("omits the Todos section when no todos are provided", () => { + const d = decideInlineFlatten({ + childDepth: 3, + maxNestingDepth: 2, + autoFlattenOnLimit: true, + inlineActive: false, + message: "m", + todos: [], + }) + if (d.action === "flatten") { + expect(d.directive).not.toContain("Todos:") + } + }) +}) + +describe("buildInlineDirective", () => { + it("embeds the instruction and todos", () => { + const dir = buildInlineDirective("fix the bug", todos, 2) + expect(dir).toContain("fix the bug") + expect(dir).toContain("step one") + }) + + it("instructs to call attempt_completion when done", () => { + const dir = buildInlineDirective("m", [], 3) + expect(dir).toContain("attempt_completion") + }) +}) diff --git a/src/core/tools/__tests__/newTaskInlineFlatten.spec.ts b/src/core/tools/__tests__/newTaskInlineFlatten.spec.ts new file mode 100644 index 0000000000..3c960966a9 --- /dev/null +++ b/src/core/tools/__tests__/newTaskInlineFlatten.spec.ts @@ -0,0 +1,191 @@ +// npx vitest run core/tools/__tests__/newTaskInlineFlatten.spec.ts + +import { describe, it, expect, vi } from "vitest" +import type { TodoItem } from "@roo-code/types" +import { Task } from "../../task/Task" +import type { InlineSubtask } from "../../task/Task" +import { newTaskTool } from "../NewTaskTool" +import { attemptCompletionTool } from "../AttemptCompletionTool" +import type { ToolCallbacks } from "../BaseTool" + +/** + * Minimal provider double. `getState` returns the taskTree settings; the delegate case + * additionally needs `delegateParentAndOpenChild`. Cast once to ClineProvider so the + * tool's `(provider as any).delegateParentAndOpenChild` call resolves. + */ +function makeProvider(overrides: { maxNestingDepth?: number; autoFlattenOnLimit?: boolean } = {}) { + const delegateParentAndOpenChild = vi.fn().mockResolvedValue({ taskId: "child-1" }) + return { + getState: vi.fn().mockResolvedValue({ + maxNestingDepth: overrides.maxNestingDepth ?? 2, + autoFlattenOnLimit: overrides.autoFlattenOnLimit ?? true, + }), + delegateParentAndOpenChild, + } +} + +/** Precise Task double carrying only the fields NewTaskTool/AttemptCompletionTool touch. */ +function makeTask(opts: { depth?: number; inlineSubtask?: InlineSubtask; provider: unknown }) { + // Build a plain double carrying only the fields NewTaskTool/AttemptCompletionTool touch, + // then cast once to Task (same pattern as new-task-delegation.spec.ts). A single + // `as unknown as Task` avoids per-field intersection-type conflicts with Task's real members. + const task = { + taskId: "parent-1", + depth: opts.depth ?? 0, + inlineSubtask: opts.inlineSubtask, + consecutiveMistakeCount: 0, + didToolFailInCurrentTurn: false, + recordToolError: vi.fn(), + sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing param"), + providerRef: { deref: () => opts.provider }, + } + return task as unknown as Task +} + +function makeCallbacks() { + const askApproval = vi.fn().mockResolvedValue(true) + const pushToolResult = vi.fn() + const handleError = vi.fn() + const callbacks: ToolCallbacks = { askApproval, handleError, pushToolResult } + return { askApproval, pushToolResult, handleError, callbacks } +} + +describe("NewTaskTool auto-flatten inline", () => { + it("delegates normally when within the limit (approval + child opened)", async () => { + const provider = makeProvider({ maxNestingDepth: 2 }) + const task = makeTask({ depth: 0, provider }) // child would be depth 1 <= 2 + const { askApproval, pushToolResult } = makeCallbacks() + + await newTaskTool.execute({ mode: "code", message: "do X" }, task, { + askApproval, + handleError: vi.fn(), + pushToolResult, + }) + + expect(askApproval).toHaveBeenCalledTimes(1) + expect(provider.delegateParentAndOpenChild).toHaveBeenCalledWith( + expect.objectContaining({ parentTaskId: expect.anything() }), + ) + expect(task.inlineSubtask).toBeUndefined() + // Delegation reflected in the tool result, not an inline directive. + expect(pushToolResult).toHaveBeenCalledWith("Delegated to child task child-1") + }) + + it("flattens inline when over the limit (no approval, no child, marker set)", async () => { + const provider = makeProvider({ maxNestingDepth: 2 }) + const task = makeTask({ depth: 2, provider }) // child would be depth 3 > 2 + const { askApproval, pushToolResult } = makeCallbacks() + + await newTaskTool.execute({ mode: "code", message: "do X" }, task, { + askApproval, + handleError: vi.fn(), + pushToolResult, + }) + + // No approval prompt and no child opened. + expect(askApproval).not.toHaveBeenCalled() + expect(provider.delegateParentAndOpenChild).not.toHaveBeenCalled() + // Phase marker set with the instruction. + expect(task.inlineSubtask).toEqual({ message: "do X", todos: [] }) + // tool_result doubles as the inline directive. + const pushed = pushToolResult.mock.calls[0][0] as string + expect(pushed).toContain("auto-flattened") + expect(pushed).toContain("do X") + }) + + it("rejects when over the limit and autoFlattenOnLimit is false (error result, no marker)", async () => { + const provider = makeProvider({ maxNestingDepth: 2, autoFlattenOnLimit: false }) + const task = makeTask({ depth: 2, provider }) // child would be depth 3 > 2 + const { askApproval, pushToolResult } = makeCallbacks() + + await newTaskTool.execute({ mode: "code", message: "do X" }, task, { + askApproval, + handleError: vi.fn(), + pushToolResult, + }) + + expect(askApproval).not.toHaveBeenCalled() + expect(provider.delegateParentAndOpenChild).not.toHaveBeenCalled() + expect(task.inlineSubtask).toBeUndefined() + const pushed = pushToolResult.mock.calls[0][0] as string + expect(pushed.toLowerCase()).toContain("error") + }) + + it("rejects a nested new_task while an inline phase is already active", async () => { + const provider = makeProvider({ maxNestingDepth: 5 }) + const task = makeTask({ depth: 1, provider, inlineSubtask: { message: "outer", todos: [] } }) + const { askApproval, pushToolResult } = makeCallbacks() + + await newTaskTool.execute({ mode: "code", message: "inner" }, task, { + askApproval, + handleError: vi.fn(), + pushToolResult, + }) + + expect(askApproval).not.toHaveBeenCalled() + expect(provider.delegateParentAndOpenChild).not.toHaveBeenCalled() + // Existing marker preserved (not overwritten by the rejected nested call). + expect(task.inlineSubtask?.message).toBe("outer") + const pushed = pushToolResult.mock.calls[0][0] as string + expect(pushed.toLowerCase()).toContain("error") + }) +}) + +describe("AttemptCompletionTool inline-phase completion", () => { + it("clears the marker, pushes a continue result, and skips askFinishSubTaskApproval", async () => { + const provider = makeProvider() + const task = makeTask({ depth: 2, provider, inlineSubtask: { message: "do X", todos: [] } }) + // AttemptCompletionTool reads todoList; leave it undefined so the open-todos guard is skipped. + ;(task as unknown as { todoList?: TodoItem[] }).todoList = undefined + + const askFinishSubTaskApproval = vi.fn().mockResolvedValue(true) + const pushToolResult = vi.fn() + const say = vi.fn().mockResolvedValue({ response: "yesButtonClicked" }) + ;(task as unknown as { say: typeof say }).say = say + + await attemptCompletionTool.execute({ result: "done with the subtask" }, task, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult, + askFinishSubTaskApproval, + toolDescription: () => "attempt_completion", + }) + + // Marker cleared. + expect(task.inlineSubtask).toBeUndefined() + // No subtask-finish approval popup (user is already in this conversation). + expect(askFinishSubTaskApproval).not.toHaveBeenCalled() + // The loop continues with a tool_result summarizing the inline completion. + const pushed = pushToolResult.mock.calls[0][0] as string + expect(pushed).toContain("[inline subtask completed]") + expect(pushed).toContain("done with the subtask") + }) + + it("does NOT take the inline branch when no marker is set (falls through to normal flow)", async () => { + const provider = makeProvider() + const task = makeTask({ depth: 2, provider }) // no inlineSubtask + ;(task as unknown as { todoList?: TodoItem[] }).todoList = undefined + + const askFinishSubTaskApproval = vi.fn().mockResolvedValue(true) + const pushToolResult = vi.fn() + // Normal flow reaches task.ask("completion_result", ...); stub it to return a decline. + ;(task as unknown as { ask: ReturnType }).ask = vi + .fn() + .mockResolvedValue({ response: "noButtonClicked" }) + const say = vi.fn().mockResolvedValue(undefined) + ;(task as unknown as { say: typeof say }).say = say + + await attemptCompletionTool.execute({ result: "normal completion" }, task, { + askApproval: vi.fn().mockResolvedValue(true), + handleError: vi.fn(), + pushToolResult, + askFinishSubTaskApproval, + toolDescription: () => "attempt_completion", + }) + + // No inline marker was set, so the inline branch must not have fired. + expect(task.inlineSubtask).toBeUndefined() + const pushed = pushToolResult.mock.calls.map((c) => c[0]).join("\n") as string + expect(pushed).not.toContain("[inline subtask completed]") + }) +}) diff --git a/src/core/tools/inlineSubtask.ts b/src/core/tools/inlineSubtask.ts new file mode 100644 index 0000000000..ac7835eb8f --- /dev/null +++ b/src/core/tools/inlineSubtask.ts @@ -0,0 +1,82 @@ +import type { TodoItem } from "@roo-code/types" + +/** + * Inputs for the auto-flatten inline decision. + * + * The decision is a pure function so it can be unit-tested without a live Task or + * vscode host. `childDepth` is the depth a child Task would have if opened as a real + * task (`parent.depth + 1`). + */ +export interface InlineFlattenInput { + /** Depth the subtask would occupy if opened as a real child Task. */ + childDepth: number + /** Configured maximum nesting depth (root = 0). `0` disables delegation entirely. */ + maxNestingDepth: number + /** When true, an over-limit subtask runs inline instead of being rejected. */ + autoFlattenOnLimit: boolean + /** True when this task is already executing an inline subtask phase. */ + inlineActive: boolean + /** The subtask instruction (used to build the flatten directive). */ + message: string + /** Parsed todos for the subtask (empty when none were provided). */ + todos: TodoItem[] +} + +export type InlineFlattenDecision = + | { action: "reject-nested"; message: string } + | { action: "flatten"; directive: string } + | { action: "reject-limit"; message: string } + | { action: "delegate" } + +/** + * Decide how a `new_task` call should be handled given the current nesting depth and + * settings. Pure — no side effects, no Task/vscode access. + * + * Precedence: + * 1. A nested `new_task` while an inline phase is already active is rejected (P1 forbids + * recursion into a second inline subtask). + * 2. Within the limit → normal delegation flow (`delegate`). + * 3. Over the limit + `autoFlattenOnLimit` → flatten inline (`flatten`). + * 4. Over the limit + `!autoFlattenOnLimit` → reject so work continues directly. + */ +export function decideInlineFlatten(input: InlineFlattenInput): InlineFlattenDecision { + const { childDepth, maxNestingDepth, autoFlattenOnLimit, inlineActive, message, todos } = input + + if (inlineActive) { + return { + action: "reject-nested", + message: + "Cannot start a nested subtask while an inline subtask is already in progress. " + + "Complete the current inline subtask with attempt_completion first.", + } + } + + const overLimit = childDepth > maxNestingDepth + if (!overLimit) { + return { action: "delegate" } + } + + if (autoFlattenOnLimit) { + return { action: "flatten", directive: buildInlineDirective(message, todos, maxNestingDepth) } + } + + return { + action: "reject-limit", + message: + `Nesting limit ${maxNestingDepth} reached and auto-flatten is disabled. ` + + "Continue working directly in the current conversation instead of delegating.", + } +} + +/** Build the inline directive that doubles as the subtask prompt (zero synthetic messages). */ +export function buildInlineDirective(message: string, todos: TodoItem[], maxNestingDepth: number): string { + const todoText = todos.length > 0 ? `\nTodos:\n${todos.map((t) => `- [ ] ${t.content}`).join("\n")}` : "" + return ( + `[auto-flattened: nesting limit ${maxNestingDepth} reached — executing inline]\n` + + "You are now executing this subtask INLINE in the current conversation.\n" + + `Subtask instruction: ${message}` + + todoText + + "\nExecute it with your available tools. When done, call attempt_completion " + + "with a summary of what you did." + ) +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6f70a19946..8e654ad6d2 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -44,6 +44,8 @@ import { DEFAULT_WRITE_DELAY_MS, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + DEFAULT_MAX_NESTING_DEPTH, + DEFAULT_AUTO_FLATTEN_ON_LIMIT, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, @@ -104,6 +106,7 @@ import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" +import { computeTaskDepth } from "../task/taskDepth" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" @@ -709,6 +712,58 @@ export class ClineProvider } } + /** + * Cancel cascade: interrupt every LIVE child of the given parent task. + * + * When a task is cancelled, any children it spawned that are still running in the + * registry would otherwise keep streaming as orphans. This aborts each live child + * instance (which also clears its inlineSubtask phase marker) and marks its persisted + * status "interrupted" so the user can resume it later. The parent's delegation link is + * left intact — this mirrors markDelegatedChildInterrupted() on the parent side. + * + * No-op when the task has no live children (the common single-open case, where the child + * is itself the current task and is handled by cancelTaskInternal's own interruption path). + */ + private async interruptLiveChildren(parentTaskId: string): Promise { + const parentHistory = this.taskHistoryStore.get(parentTaskId) + const childIds = parentHistory?.childIds ?? [] + + for (const childId of childIds) { + // Skip children already in a terminal state — never overwrite completed/interrupted. + const existingStatus = this.taskHistoryStore.get(childId)?.status + if (existingStatus === "interrupted" || existingStatus === "completed") { + continue + } + + // Only cascade to children that are actually running (not already aborted/abandoned). + const liveChild = this.taskRegistry.getById(childId) + if (!liveChild || liveChild.abort || liveChild.abandoned) { + continue + } + + this.log(`[interruptLiveChildren] Cancelling parent ${parentTaskId} — interrupting live child ${childId}`) + try { + // Abort the live instance (clears inlineSubtask, stops its stream). Fire-and-forget is + // acceptable: abortTask settles asynchronously and cancelTaskInternal awaits the parent's + // own abort promise before persisting "interrupted". + void liveChild.abortTask().catch((err) => { + this.log( + `[interruptLiveChildren] Failed to abort child ${childId}: ${err instanceof Error ? err.message : String(err)}`, + ) + }) + + const childHistory = this.taskHistoryStore.get(childId) + if (childHistory && childHistory.status !== "interrupted") { + await this.updateTaskHistory({ ...childHistory, status: "interrupted" }) + } + } catch (err) { + this.log( + `[interruptLiveChildren] Failed to interrupt child ${childId}: ${err instanceof Error ? err.message : String(err)}`, + ) + } + } + } + getTaskStackSize(): number { return this.taskRegistry.length } @@ -1301,6 +1356,12 @@ export class ClineProvider diffFuzzyThreshold, }) + // Backfill the nesting depth for legacy tasks that lack a persisted value and were + // resumed without their live parent, so their first save persists a correct depth. + if (!task.depthAuthoritative) { + await this.backfillTaskDepth(task) + } + if (isRehydratingCurrentTask) { // Replace the current task in-place to avoid UI flicker const oldTask = this.taskRegistry.current @@ -1396,6 +1457,41 @@ export class ClineProvider return task } + /** + * Backfill the nesting depth for a resumed legacy task that lacks a persisted value. + * + * The Task constructor cannot derive depth when the live parent is not available (e.g. + * reopening from history), so it marks such tasks non-authoritative. Here we walk the + * `parentTaskId` chain through the in-memory store / global state and persist the + * computed depth so subsequent saves carry a correct value. A cycle or dangling parent + * reference yields no depth, leaving the task as-is rather than persisting a bogus one. + */ + private async backfillTaskDepth(task: Task): Promise { + if (task.depthAuthoritative) { + return + } + + const lookup = (id: string) => + this.taskHistoryStore.get(id) ?? (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === id) + const depth = computeTaskDepth(task.taskId, undefined, lookup) + if (depth === undefined) { + return + } + + // Write back the complete existing record with `depth` added so no other fields are lost. + const existing = lookup(task.taskId) + if (!existing) { + return + } + try { + await this.updateTaskHistory({ ...existing, depth }, { broadcast: false }) + } catch (error) { + this.log( + `[backfillTaskDepth] Failed to persist backfilled depth for ${task.taskId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + public async postMessageToWebview(message: ExtensionMessage) { if (this._disposed) { return @@ -2508,6 +2604,8 @@ export class ClineProvider soundVolume, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -2668,6 +2766,8 @@ export class ClineProvider soundVolume: soundVolume ?? 0.5, writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + maxNestingDepth, + autoFlattenOnLimit, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true, terminalCommandDelay: terminalCommandDelay ?? 0, @@ -2897,6 +2997,8 @@ export class ClineProvider soundVolume: stateValues.soundVolume, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + maxNestingDepth: stateValues.maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH, + autoFlattenOnLimit: stateValues.autoFlattenOnLimit ?? DEFAULT_AUTO_FLATTEN_ON_LIMIT, terminalShellIntegrationTimeout: stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, @@ -3471,6 +3573,14 @@ export class ClineProvider // before we persist "interrupted", so our write is always the last one. await abortPromise.catch(() => {}) + // Cancel cascade: interrupt any live children of this task so they don't keep streaming + // as orphans. No-op in the common single-open case (the child is the current task itself). + void this.interruptLiveChildren(task.taskId).catch((err) => { + this.log( + `[cancelTask] Failed to interrupt live children of ${task.taskId}: ${err instanceof Error ? err.message : String(err)}`, + ) + }) + // Defensive safeguard: if current instance already changed, skip rehydrate const current = this.getCurrentTask() if (current && current.instanceId !== originalInstanceId) { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index e336ac8fac..8312b28c53 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1474,6 +1474,88 @@ describe("ClineProvider", () => { expect(state.diffFuzzyThreshold).toBe(0.5) }) + describe("taskTree settings round-trip", () => { + test("getState defaults maxNestingDepth to 2 and autoFlattenOnLimit to true when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Ensure the settings are not set so their documented defaults apply. + await provider.contextProxy.setValue("maxNestingDepth", undefined) + await provider.contextProxy.setValue("autoFlattenOnLimit", undefined) + + const state = await provider.getState() + expect(state.maxNestingDepth).toBe(2) + expect(state.autoFlattenOnLimit).toBe(true) + }) + + test("handles maxNestingDepth message and clamps out-of-range values", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as ReturnType).mock + .calls[0][0] + + // In-range value persists unchanged. + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: 3 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 3) + + // Above the range clamps to 5. + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: 9 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 5) + + // Below the range clamps to 0 (delegation disabled). + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: -1 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 0) + + // Non-numeric falls back to the default. + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: "abc" } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 2) + + // Unset values are not persisted (skipped). + const before = updateGlobalStateSpy.mock.calls.length + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: undefined } }) + expect(updateGlobalStateSpy).toHaveBeenCalledTimes(before) + }) + + test("handles autoFlattenOnLimit message as a boolean", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as ReturnType).mock + .calls[0][0] + + await messageHandler({ type: "updateSettings", updatedSettings: { autoFlattenOnLimit: false } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoFlattenOnLimit", false) + + // A truthy non-boolean is normalized to true. + await messageHandler({ type: "updateSettings", updatedSettings: { autoFlattenOnLimit: 1 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoFlattenOnLimit", true) + + // Unset values are not persisted (skipped). + const before = updateGlobalStateSpy.mock.calls.length + await messageHandler({ type: "updateSettings", updatedSettings: { autoFlattenOnLimit: undefined } }) + expect(updateGlobalStateSpy).toHaveBeenCalledTimes(before) + }) + + test("getStateToPostToWebview returns saved taskTree values", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.contextProxy.setValue("maxNestingDepth", 4) + await provider.contextProxy.setValue("autoFlattenOnLimit", false) + + const state = await provider.getStateToPostToWebview() + expect(state.maxNestingDepth).toBe(4) + expect(state.autoFlattenOnLimit).toBe(false) + }) + + test("getStateToPostToWebview defaults taskTree values when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Ensure the settings are not set so their documented defaults apply. + await provider.contextProxy.setValue("maxNestingDepth", undefined) + await provider.contextProxy.setValue("autoFlattenOnLimit", undefined) + + const state = await provider.getStateToPostToWebview() + expect(state.maxNestingDepth).toBe(2) + expect(state.autoFlattenOnLimit).toBe(true) + }) + }) + it("loads saved API config when switching modes", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e88fd864cd..992ab83ea0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -28,6 +28,8 @@ import { OpenAiModelsMessageType, RouterModelsMessageType, VsCodeLmModelsMessageType, + DEFAULT_MAX_NESTING_DEPTH, + DEFAULT_AUTO_FLATTEN_ON_LIMIT, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -801,6 +803,21 @@ export const webviewMessageHandler = async ( if (!value) { continue } + } else if (key === "maxNestingDepth") { + // Normalize to an integer clamped to 0–5; skip persistence when unset. + if (value === undefined || value === null) { + continue + } + const parsed = Math.round(Number(value)) + newValue = Number.isFinite(parsed) + ? Math.min(5, Math.max(0, parsed)) + : DEFAULT_MAX_NESTING_DEPTH + } else if (key === "autoFlattenOnLimit") { + // Persist as a boolean; skip persistence when unset. + if (value === undefined || value === null) { + continue + } + newValue = Boolean(value) } await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) diff --git a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts index 8873695c62..7efbbc04e0 100644 --- a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts +++ b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts @@ -558,6 +558,45 @@ describe("buildSubtree", () => { expect(node.children[0].children[0].isExpanded).toBe(true) // grandchild expanded expect(node.children[0].children[0].children[0].isExpanded).toBe(false) // great-grandchild not expanded }) + + it("derives depth from the persisted depth field when present", () => { + const root = createMockTask({ id: "root", task: "Root", depth: 0 }) + const child = createMockTask({ id: "child", task: "Child", parentTaskId: "root", depth: 1 }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + + const node = buildSubtree(root, childrenMap, new Set()) + + expect(node.depth).toBe(0) + expect(node.children[0].depth).toBe(1) + }) + + it("falls back to tree position (parentDepth + 1) for legacy items without depth", () => { + const root = createMockTask({ id: "root", task: "Root" }) // no depth field + const child = createMockTask({ id: "child", task: "Child", parentTaskId: "root" }) + const grandchild = createMockTask({ id: "grandchild", task: "Grandchild", parentTaskId: "child" }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + + const node = buildSubtree(root, childrenMap, new Set()) + + // Isolated root resolves to depth 0; descendants derive from tree position. + expect(node.depth).toBe(0) + expect(node.children[0].depth).toBe(1) + expect(node.children[0].children[0].depth).toBe(2) + }) + + it("honors an explicit parentDepth argument over the default -1", () => { + const child = createMockTask({ id: "child", task: "Child" }) // no depth field + + // Called with parentDepth=4 (as if nested under a depth-4 node) → resolves to 5. + const node = buildSubtree(child, new Map(), new Set(), 4) + + expect(node.depth).toBe(5) + }) }) describe("countAllSubtasks", () => { diff --git a/webview-ui/src/components/history/types.ts b/webview-ui/src/components/history/types.ts index 0de5e43081..e67bbefaf0 100644 --- a/webview-ui/src/components/history/types.ts +++ b/webview-ui/src/components/history/types.ts @@ -20,6 +20,8 @@ export interface SubtaskTreeNode { children: SubtaskTreeNode[] /** Whether this node's children are expanded in the UI */ isExpanded: boolean + /** Nesting depth of this node (root = 0). From persisted `depth` when present, else parentDepth + 1. */ + depth?: number } /** diff --git a/webview-ui/src/components/history/useGroupedTasks.ts b/webview-ui/src/components/history/useGroupedTasks.ts index d3f3d4e953..8e89079b38 100644 --- a/webview-ui/src/components/history/useGroupedTasks.ts +++ b/webview-ui/src/components/history/useGroupedTasks.ts @@ -9,19 +9,25 @@ import type { DisplayHistoryItem, SubtaskTreeNode, TaskGroup, GroupedTasksResult * @param task - The task to build a tree node for * @param childrenMap - Map of parentId → direct children * @param expandedIds - Set of task IDs whose children are currently expanded + * @param parentDepth - Depth of the parent node; fallback when `task.depth` is unset (legacy data). Defaults to -1 so an isolated root resolves to depth 0. * @returns A SubtaskTreeNode with recursively built children sorted by ts (newest first) */ export function buildSubtree( task: HistoryItem, childrenMap: Map, expandedIds: Set, + parentDepth = -1, ): SubtaskTreeNode { const directChildren = (childrenMap.get(task.id) || []).slice().sort((a, b) => b.ts - a.ts) + // Prefer the persisted depth; fall back to tree position for legacy items. + const nodeDepth = task.depth ?? parentDepth + 1 + return { item: task as DisplayHistoryItem, - children: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)), + children: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds, nodeDepth)), isExpanded: expandedIds.has(task.id), + depth: nodeDepth, } } diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index c3af315d99..cf0f09e313 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -3,7 +3,7 @@ import React from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { VSCodeCheckbox, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react" import { ListChevronsDownUp } from "lucide-react" -import { DEFAULT_DIFF_FUZZY_THRESHOLD } from "@roo-code/types" +import { DEFAULT_AUTO_FLATTEN_ON_LIMIT, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_MAX_NESTING_DEPTH } from "@roo-code/types" import { supportPrompt } from "@roo/support-prompt" @@ -40,6 +40,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxDiagnosticMessages?: number writeDelayMs: number diffFuzzyThreshold?: number + maxNestingDepth?: number + autoFlattenOnLimit?: boolean includeCurrentTime?: boolean includeCurrentCost?: boolean maxGitStatusFiles?: number @@ -59,6 +61,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxDiagnosticMessages" | "writeDelayMs" | "diffFuzzyThreshold" + | "maxNestingDepth" + | "autoFlattenOnLimit" | "includeCurrentTime" | "includeCurrentCost" | "maxGitStatusFiles" @@ -81,6 +85,8 @@ export const ContextManagementSettings = ({ maxDiagnosticMessages, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, includeCurrentTime, includeCurrentCost, maxGitStatusFiles, @@ -433,6 +439,44 @@ export const ContextManagementSettings = ({ + + {t("settings:taskTree.maxNestingDepth.label")} +
+ setCachedStateField("maxNestingDepth", value)} + data-testid="max-nesting-depth-slider" + /> + {maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH} +
+
+ {t("settings:taskTree.maxNestingDepth.description")} +
+
+ + + setCachedStateField("autoFlattenOnLimit", e.target.checked)} + data-testid="auto-flatten-on-limit-checkbox"> + + +
+ {t("settings:taskTree.autoFlattenOnLimit.description")} +
+
+ (({ onDone, t terminalProfile, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, showRooIgnoredFiles, enableSubfolderRules, maxImageFileSize, @@ -408,6 +410,8 @@ const SettingsView = forwardRef(({ onDone, t checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -873,6 +877,8 @@ const SettingsView = forwardRef(({ onDone, t maxDiagnosticMessages={maxDiagnosticMessages} writeDelayMs={writeDelayMs} diffFuzzyThreshold={diffFuzzyThreshold} + maxNestingDepth={maxNestingDepth} + autoFlattenOnLimit={autoFlattenOnLimit} includeCurrentTime={includeCurrentTime} includeCurrentCost={includeCurrentCost} maxGitStatusFiles={maxGitStatusFiles} diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index 10d5d0fe4f..b7fae7802b 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -529,4 +529,61 @@ describe("ContextManagementSettings", () => { }) }) }) + + describe("taskTree settings", () => { + it("renders max nesting depth slider with default value when unset", () => { + render() + + const slider = screen.getByTestId("max-nesting-depth-slider") + expect(slider).toBeInTheDocument() + // Default is 2 (DEFAULT_MAX_NESTING_DEPTH) when the prop is unset. + expect(slider).toHaveValue("2") + }) + + it("renders max nesting depth slider with an explicit value", () => { + render() + + const slider = screen.getByTestId("max-nesting-depth-slider") + expect(slider).toHaveValue("4") + }) + + it("calls setCachedStateField when the max nesting depth slider changes", async () => { + const setCachedStateField = vi.fn() + render() + + const slider = screen.getByTestId("max-nesting-depth-slider") + fireEvent.change(slider, { target: { value: "3" } }) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("maxNestingDepth", 3) + }) + }) + + it("renders the auto-flatten checkbox checked by default when unset", () => { + render() + + const checkbox = screen.getByTestId("auto-flatten-on-limit-checkbox") + expect(checkbox.querySelector("input")).toBeChecked() + }) + + it("renders the auto-flatten checkbox unchecked when explicitly false", () => { + render() + + const checkbox = screen.getByTestId("auto-flatten-on-limit-checkbox") + expect(checkbox.querySelector("input")).not.toBeChecked() + }) + + it("calls setCachedStateField when the auto-flatten checkbox is toggled", async () => { + const setCachedStateField = vi.fn() + render() + + // Default (true) → clicking produces false. + const checkbox = screen.getByTestId("auto-flatten-on-limit-checkbox").querySelector("input")! + fireEvent.click(checkbox) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("autoFlattenOnLimit", false) + }) + }) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index fa5cc11d65..a1eab76fb8 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -767,6 +767,16 @@ "description": "Els llindars més baixos fan que les edicions de fitxer siguin més resistents a variacions de format i espais en blanc. Un llindar del 100% requereix una coincidència exacta." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Profunditat màxima d'encadenament de tasques", + "description": "Quants nivells de subtasques es poden encadenar (arrel = 0). Un valor de 0 desactiva completament la delegació — cada new_task s'executa en línia a la conversa actual. Interval: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Aplanar automàticament subtasques al límit d'encadenament", + "description": "Quan una subtasca superaria la profunditat màxima d'encadenament, s'executa en línia a la conversa actual en lloc d'obrir una nova pestanya. Quan està desactivat, aquestes sol·licituds es rebutgen perquè continueu treballant directament." + } + }, "condensingThreshold": { "label": "Llindar d'activació de condensació", "selectProfile": "Configura el llindar per al perfil", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 2cb83f7893..f4b0bc7e4c 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -767,6 +767,16 @@ "description": "Niedrigere Schwellenwerte machen Dateibearbeitungen widerstandsfähiger gegen Formatierungs- und Leerzeichenunterschiede. Ein Schwellenwert von 100% erfordert eine exakte Übereinstimmung." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Maximale Aufgaben-Nestungstiefe", + "description": "Wie viele Ebenen von Unteraufgaben verschachtelt werden können (Wurzel = 0). Ein Wert von 0 deaktiviert die Delegation vollständig — jedes new_task wird inline im aktuellen Gespräch ausgeführt. Bereich: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Unteraufgaben am Nestungslimit automatisch aplanen", + "description": "Wenn eine Unteraufgabe die maximale Nestungstiefe überschreiten würde, wird sie stattdessen inline im aktuellen Gespräch ausgeführt und es wird kein neuer Tab geöffnet. Wenn deaktiviert, werden solche Anfragen abgelehnt, damit Sie direkt weiterarbeiten." + } + }, "condensingThreshold": { "label": "Schwellenwert für Kontextkomprimierung", "selectProfile": "Profil für Schwellenwert konfigurieren", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a5967792a1..4022e4c861 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -857,6 +857,16 @@ "description": "Lower thresholds make file edits more resilient to formatting and whitespace variations. A threshold of 100% requires an exact match." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Maximum task nesting depth", + "description": "How many levels of subtasks can be nested (root = 0). A value of 0 disables delegation entirely — every new_task runs inline in the current conversation. Range: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Auto-flatten subtasks at nesting limit", + "description": "When a subtask would exceed the maximum nesting depth, execute it inline in the current conversation instead of opening a new tab. When disabled, such requests are rejected so you continue working directly." + } + }, "condensingThreshold": { "label": "Condensing Trigger Threshold", "selectProfile": "Configure threshold for profile", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 305a8dd5d7..f9cbc06206 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -777,6 +777,16 @@ "description": "Los umbrales más bajos hacen que las ediciones de archivos sean más resistentes a variaciones de formato y espacios en blanco. Un umbral del 100% requiere una coincidencia exacta." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Profundidad máxima de anidación de tareas", + "description": "Cuántos niveles de subtareas pueden anidarse (raíz = 0). Un valor de 0 desactiva por completo la delegación — cada new_task se ejecuta en línea en la conversación actual. Rango: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Aplanar automáticamente subtareas al límite de anidación", + "description": "Cuando una subtarea superaría la profundidad máxima de anidación, se ejecuta en línea en la conversación actual en lugar de abrir una nueva pestaña. Cuando está desactivado, dichas solicitudes se rechazan para que continúes trabajando directamente." + } + }, "condensingThreshold": { "label": "Umbral de condensación de contexto", "selectProfile": "Configurar umbral para perfil", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index d5728833dd..02f01f0c8f 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -777,6 +777,16 @@ "description": "Des seuils plus bas rendent les modifications de fichiers plus résistantes aux variations de formatage et d'espaces blancs. Un seuil de 100% nécessite une correspondance exacte." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Profondeur d'imbrication maximale des tâches", + "description": "Nombre de niveaux de sous-tâches pouvant être imbriqués (racine = 0). Une valeur de 0 désactive entièrement la délégation — chaque new_task s'exécute en ligne dans la conversation actuelle. Plage : 0–5." + }, + "autoFlattenOnLimit": { + "label": "Aplatir automatiquement les sous-tâches à la limite d'imbrication", + "description": "Lorsqu'une sous-tâche dépasserait la profondeur maximale d'imbrication, elle s'exécute en ligne dans la conversation actuelle au lieu d'ouvrir un nouvel onglet. Désactivé, ces demandes sont rejetées afin de continuer à travailler directement." + } + }, "condensingThreshold": { "label": "Seuil de condensation du contexte", "selectProfile": "Configurer le seuil pour le profil", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 3fce97a378..a639479391 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -767,6 +767,16 @@ "description": "कम थ्रेशोल्ड फ़ाइल संपादनों को फ़ॉर्मेटिंग और व्हाइटस्पेस भिन्नताओं के प्रति अधिक लचीला बनाते हैं। 100% का थ्रेशोल्ड सटीक मिलान की आवश्यकता रखता है।" } }, + "taskTree": { + "maxNestingDepth": { + "label": "अधिकतम कार्य नेस्टिंग गहराई", + "description": "कितने स्तरों की उपकार्य (subtask) को नेस्ट किया जा सकता है (रूट = 0)। मान 0 पूरा विनिर्देशन (delegation) बंद कर देता है — हर new_task वर्तमान संवाद में इनलाइन चलता है। सीमा: 0–5।" + }, + "autoFlattenOnLimit": { + "label": "नेस्टिंग सीमा पर उपकार्य स्वतः फ्लैट करें", + "description": "जब कोई उपकार्य अधिकतम नेस्टिंग गहराई से पार करे, तो उसे नया टैब खोलने के बजाय वर्तमान संवाद में इनलाइन चलाया जाता है। अक्षम होने पर ऐसे अनुरोध अस्वीकृत हो जाते हैं ताकि आप सीधे काम जारी रख सकें।" + } + }, "condensingThreshold": { "label": "संघनन ट्रिगर सीमा", "selectProfile": "प्रोफ़ाइल के लिए सीमा कॉन्फ़िगर करें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index bcdd0ae76d..f07646e8db 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -745,6 +745,16 @@ "description": "Ambang batas yang lebih rendah membuat pengeditan file lebih tahan terhadap variasi format dan spasi. Ambang batas 100% memerlukan kecocokan yang tepat." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Kedalaman penempatan tugas maksimum", + "description": "Berapa tingkat sub-tugas yang dapat ditumpuk (akar = 0). Nilai 0 menonaktifkan delegasi sepenuhnya — setiap new_task berjalan inline di percakapan saat ini. Rentang: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Ratakan otomatis sub-tugas pada batas penempatan", + "description": "Ketika sub-tugas akan melebihi kedalaman penempatan maksimum, jalankan secara inline di percakapan saat ini alih-alih membuka tab baru. Saat dinonaktifkan, permintaan semacam itu ditolak agar Anda terus bekerja langsung." + } + }, "condensingThreshold": { "label": "Ambang Batas Pemicu Kondensasi", "selectProfile": "Konfigurasi ambang batas untuk profil", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index b22fb4c652..efd82c4bb3 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -767,6 +767,16 @@ "description": "Soglie più basse rendono le modifiche ai file più resilienti a variazioni di formattazione e spazi bianchi. Una soglia del 100% richiede una corrispondenza esatta." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Profondità massima di annidamento dei task", + "description": "Quanti livelli di sotto-task possono essere annidati (radice = 0). Un valore di 0 disabilita completamente la delega — ogni new_task viene eseguito in linea nella conversazione attuale. Intervallo: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Appiattisci automaticamente i sotto-task al limite di annidamento", + "description": "Quando un sotto-task supererebbe la profondità massima di annidamento, viene eseguito in linea nella conversazione attuale invece di aprire una nuova scheda. Quando disattivato, tali richieste vengono rifiutate così continui a lavorare direttamente." + } + }, "condensingThreshold": { "label": "Soglia di attivazione condensazione", "selectProfile": "Configura soglia per profilo", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index bbdc5c8e8a..b868a263e6 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -767,6 +767,16 @@ "description": "しきい値を低くすると、ファイル編集が書式や空白のバリエーションに対してより柔軟になります。100%のしきい値は完全一致が必要です。" } }, + "taskTree": { + "maxNestingDepth": { + "label": "タスクの最大ネスト深度", + "description": "サブタスクを何レベルまでネストできるか(ルート = 0)。値が 0 の場合は委譲が完全に無効になり、すべての new_task が現在の会話でインライン実行されます。範囲:0–5。" + }, + "autoFlattenOnLimit": { + "label": "ネスト上限時にサブタスクを自動フラット化", + "description": "サブタスクが最大ネスト深度を超える場合、新しいタブを開く代わりに現在の会話でインライン実行します。無効にすると、そのようなリクエストは拒否され、直接作業を継続できます。" + } + }, "condensingThreshold": { "label": "圧縮トリガーしきい値", "selectProfile": "プロファイルのしきい値を設定", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index c2062a5335..0bcdb2f9f9 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -767,6 +767,16 @@ "description": "낮은 임계값은 파일 편집이 서식 및 공백 변동에 더 탄력적으로 대응하게 합니다. 100% 임계값은 정확히 일치해야 합니다." } }, + "taskTree": { + "maxNestingDepth": { + "label": "최대 작업 중첩 깊이", + "description": "서브태스크를 몇 단계까지 중첩할 수 있는지 (루트 = 0). 값이 0이면 위임(delegation)이 완전히 비활성화되어 모든 new_task가 현재 대화에서 인라인으로 실행됩니다. 범위: 0–5." + }, + "autoFlattenOnLimit": { + "label": "중첩 한도 도달 시 서브태스크 자동 평탄화", + "description": "서브태스크가 최대 중첩 깊이를 초과할 경우 새 탭을 여는 대신 현재 대화에서 인라인으로 실행합니다. 비활성화된 경우 이러한 요청이 거부되어 직접 작업을 계속할 수 있습니다." + } + }, "condensingThreshold": { "label": "압축 트리거 임계값", "selectProfile": "프로필 임계값 구성", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index cf148f5617..18a956490e 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -777,6 +777,16 @@ "description": "Lagere drempelwaarden maken bestandbewerkingen beter bestand tegen opmaak- en witruimtevariaties. Een drempelwaarde van 100% vereist een exacte overeenkomst." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Maximale nestingsdiepte van taken", + "description": "Hoeveel niveaus van subtaken genest kunnen worden (wortel = 0). Een waarde van 0 schakelt delegering volledig uit — elke new_task wordt inline uitgevoerd in het huidige gesprek. Bereik: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Subtaken automatisch platteien op nestingslimiet", + "description": "Wanneer een subtaak de maximale nestingsdiepte zou overschrijden, wordt deze inline uitgevoerd in het huidige gesprek in plaats van een nieuw tabblad te openen. Wanneer uitgeschakeld, worden dergelijke verzoeken afgewezen zodat u direct blijft werken." + } + }, "condensingThreshold": { "label": "Compressie trigger drempelwaarde", "selectProfile": "Drempelwaarde voor profiel configureren", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index ace780f529..b369bd0354 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -767,6 +767,16 @@ "description": "Niższe progi sprawiają, że edycje plików są bardziej odporne na różnice w formatowaniu i białych znakach. Próg 100% wymaga dokładnego dopasowania." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Maksymalna głębokość zagnieżdżania zadań", + "description": "Ile poziomów podzadań może być zagnieżdżonych (korzeń = 0). Wartość 0 całkowicie wyłącza delegowanie — każde new_task jest wykonywane inline w bieżącej rozmowie. Zakres: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Automatyczne spłaszczanie podzadań przy limicie zagnieżdżania", + "description": "Gdy podzadanie przekroczyłoby maksymalną głębokość zagnieżdżania, jest wykonywane inline w bieżącej rozmowie zamiast otwierania nowej karty. Gdy wyłączone, takie żądania są odrzucane, abyś mógł kontynuować pracę bezpośrednio." + } + }, "condensingThreshold": { "label": "Próg wyzwalania kondensacji", "selectProfile": "Skonfiguruj próg dla profilu", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 446aa8ac02..f159b4d507 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -767,6 +767,16 @@ "description": "Limites mais baixos tornam as edições de arquivo mais resistentes a variações de formatação e espaços em branco. Um limite de 100% exige uma correspondência exata." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Profundidade máxima de aninhamento de tarefas", + "description": "Quantos níveis de subtarefas podem ser aninhados (raiz = 0). Um valor de 0 desativa completamente a delegação — cada new_task é executado em linha na conversa atual. Intervalo: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Achatar automaticamente subtarefas no limite de aninhamento", + "description": "Quando uma subtarefa excederia a profundidade máxima de aninhamento, ela é executada em linha na conversa atual em vez de abrir uma nova aba. Quando desativado, essas solicitações são rejeitadas para que você continue trabalhando diretamente." + } + }, "condensingThreshold": { "label": "Limite de Ativação de Condensação", "selectProfile": "Configurar limite para perfil", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index f2719ad06e..c474e16c59 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -767,6 +767,16 @@ "description": "Более низкие пороги делают редактирование файлов более устойчивым к изменениям форматирования и пробелов. Порог 100% требует точного совпадения." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Максимальная глубина вложенности задач", + "description": "Сколько уровней подзадач можно вложить (корень = 0). Значение 0 полностью отключает делегирование — каждый new_task выполняется inline в текущем диалоге. Диапазон: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Автоматически сглаживать подзадачи при лимите вложенности", + "description": "Если подзадача превысила бы максимальную глубину вложенности, она выполняется inline в текущем диалоге вместо открытия новой вкладки. При отключении такие запросы отклоняются, чтобы вы могли продолжать работу напрямую." + } + }, "condensingThreshold": { "label": "Порог запуска сжатия", "selectProfile": "Настроить порог для профиля", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 08374b6d20..5e8d610949 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -767,6 +767,16 @@ "description": "Düşük eşikler, dosya düzenlemelerini biçimlendirme ve boşluk farklılıklarına karşı daha dayanıklı hale getirir. %100 eşik, tam eşleşme gerektirir." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Maksimum görev iç içe derinliği", + "description": "Kaç düzeyde alt görevin iç içe geçirilebileceği (kök = 0). 0 değeri devri tamamen devre dışı bırakır — her new_task mevcut sohbette satır içi çalıştırılır. Aralık: 0–5." + }, + "autoFlattenOnLimit": { + "label": "İç içe sınırında alt görevleri otomatik düzleştir", + "description": "Bir alt görev maksimum iç içe derinliği aşacaksa, yeni bir sekme açmak yerine mevcut sohbette satır içi çalıştırılır. Devre dışı bırakıldığında bu tür istekler reddedilir ve doğrudan çalışmaya devam edersiniz." + } + }, "condensingThreshold": { "label": "Sıkıştırma Tetikleme Eşiği", "selectProfile": "Profil için eşik yapılandır", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index a8611ca687..2d4b17cf7f 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -767,6 +767,16 @@ "description": "Ngưỡng thấp hơn làm cho việc chỉnh sửa tệp linh hoạt hơn với các biến thể định dạng và khoảng trắng. Ngưỡng 100% yêu cầu khớp chính xác." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Độ sâu lồng nhau tối đa của tác vụ", + "description": "Số mức phụ tác vụ có thể lồng vào nhau (gốc = 0). Giá trị 0 tắt hoàn toàn việc ủy quyền — mọi new_task chạy inline trong cuộc trò chuyện hiện tại. Phạm vi: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Tự động dẹt phụ tác vụ ở giới hạn lồng nhau", + "description": "Khi một phụ tác vụ vượt quá độ sâu lồng nhau tối đa, nó được thực thi inline trong cuộc trò chuyện hiện tại thay vì mở tab mới. Khi tắt, các yêu cầu như vậy bị từ chối để bạn tiếp tục làm việc trực tiếp." + } + }, "condensingThreshold": { "label": "Ngưỡng kích hoạt nén", "selectProfile": "Cấu hình ngưỡng cho hồ sơ", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 9f3913e872..9a7f958bb8 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -767,6 +767,16 @@ "description": "较低的阈值使文件编辑对格式和空白变化更具弹性。100%的阈值要求完全匹配。" } }, + "taskTree": { + "maxNestingDepth": { + "label": "任务最大嵌套深度", + "description": "子任务可以嵌套多少层(根 = 0)。值为 0 时完全禁用委派——每个 new_task 都在当前对话中内联执行。范围:0–5。" + }, + "autoFlattenOnLimit": { + "label": "达到嵌套上限时自动压平子任务", + "description": "当子任务将超过最大嵌套深度时,在当前对话中内联执行,而不是打开新标签页。禁用后,此类请求将被拒绝,以便你直接继续工作。" + } + }, "condensingThreshold": { "label": "压缩触发阈值", "selectProfile": "配置配置文件阈值", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 80d0d18735..751e6c5fb8 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -804,6 +804,16 @@ "description": "較低的閾值使檔案編輯對格式和空白變化更具彈性。100%的閾值要求完全匹配。" } }, + "taskTree": { + "maxNestingDepth": { + "label": "任務最大巢狀深度", + "description": "子任務可以巢狀多少層(根 = 0)。值為 0 時完全停用委派——每個 new_task 都在目前對話中內聯執行。範圍:0–5。" + }, + "autoFlattenOnLimit": { + "label": "達到巢狀上限時自動壓平子任務", + "description": "當子任務將超過最大巢狀深度時,在目前對話中內聯執行,而不是開啟新分頁。停用後,此類請求將被拒絕,以便你直接繼續工作。" + } + }, "condensingThreshold": { "label": "壓縮觸發閾值", "selectProfile": "設定檔的閾值",