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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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