Skip to content
Closed
160 changes: 160 additions & 0 deletions apps/vscode-e2e/src/fixtures/inline-flatten.ts
Original file line number Diff line number Diff line change
@@ -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 <user_message>...</user_message>,
// so anchoring on `<user_message>` + 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 <childId> completed.\n\nResult:\n<summary>` 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<typeof LLMock>) {
// Root (depth 0), first turn only: delegate to the child.
mock.addFixture({
match: {
predicate: (req) => lastUserMessageContains(req, `<user_message>\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, `<user_message>\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, `<user_message>\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",
},
],
},
})
}
2 changes: 2 additions & 0 deletions apps/vscode-e2e/src/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -139,6 +140,7 @@ async function main() {
addListFilesResultFixtures(mock)
addReadFileResultFixtures(mock)
addSearchFilesResultFixtures(mock)
addInlineFlattenFixtures(mock)
addSubtaskFixtures(mock)
addUseMcpToolResultFixtures(mock)
addWriteToFileResultFixtures(mock)
Expand Down
107 changes: 107 additions & 0 deletions apps/vscode-e2e/src/suite/inline-flatten.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ClineMessage[]> = {}
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)
}
})
})
31 changes: 31 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading