Metadata‑driven subtasks (no UI changes): automatic parent resume and single‑open safety (#9090)

This commit is contained in:
Hannes Rudolph 2025-11-27 21:18:42 -07:00 committed by GitHub
parent 3f0a6971ca
commit 9b5f6392df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2117 additions and 377 deletions

View file

@ -188,18 +188,46 @@ export class ExtensionChannel extends BaseChannel<
{ from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused },
{ from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused },
{ from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned },
{ from: RooCodeEventName.TaskDelegated, to: ExtensionBridgeEventName.TaskDelegated },
{ from: RooCodeEventName.TaskDelegationCompleted, to: ExtensionBridgeEventName.TaskDelegationCompleted },
{ from: RooCodeEventName.TaskDelegationResumed, to: ExtensionBridgeEventName.TaskDelegationResumed },
{ from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage },
{ from: RooCodeEventName.TaskTokenUsageUpdated, to: ExtensionBridgeEventName.TaskTokenUsageUpdated },
] as const
eventMapping.forEach(({ from, to }) => {
// Create and store the listener function for cleanup.
const listener = async (..._args: unknown[]) => {
this.publish(ExtensionSocketEvents.EVENT, {
const listener = async (...args: unknown[]) => {
const baseEvent: {
type: ExtensionBridgeEventName
instance: ExtensionInstance
timestamp: number
} = {
type: to,
instance: await this.updateInstance(),
timestamp: Date.now(),
})
}
let eventToPublish: ExtensionBridgeEvent
// Add payload for delegation events while avoiding `any`
if (to === ExtensionBridgeEventName.TaskDelegationCompleted) {
const [parentTaskId, childTaskId, summary] = args as [string, string, string]
eventToPublish = {
...(baseEvent as unknown as ExtensionBridgeEvent),
payload: { parentTaskId, childTaskId, summary },
} as unknown as ExtensionBridgeEvent
} else if (to === ExtensionBridgeEventName.TaskDelegationResumed) {
const [parentTaskId, childTaskId] = args as [string, string]
eventToPublish = {
...(baseEvent as unknown as ExtensionBridgeEvent),
payload: { parentTaskId, childTaskId },
} as unknown as ExtensionBridgeEvent
} else {
eventToPublish = baseEvent as unknown as ExtensionBridgeEvent
}
this.publish(ExtensionSocketEvents.EVENT, eventToPublish)
}
this.eventListeners.set(from, listener)

View file

@ -120,6 +120,10 @@ describe("ExtensionChannel", () => {
RooCodeEventName.TaskPaused,
RooCodeEventName.TaskUnpaused,
RooCodeEventName.TaskSpawned,
RooCodeEventName.TaskDelegated,
RooCodeEventName.TaskDelegationCompleted,
RooCodeEventName.TaskDelegationResumed,
RooCodeEventName.TaskUserMessage,
RooCodeEventName.TaskTokenUsageUpdated,
]
@ -246,6 +250,116 @@ describe("ExtensionChannel", () => {
undefined,
)
})
it("should forward delegation events to socket", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
const delegatedListeners = eventListeners.get(RooCodeEventName.TaskDelegated)
expect(delegatedListeners).toBeDefined()
expect(delegatedListeners!.size).toBe(1)
const listener = Array.from(delegatedListeners!)[0]
if (listener) {
await (listener as any)("parent-id", "child-id")
}
expect(mockSocket.emit).toHaveBeenCalledWith(
ExtensionSocketEvents.EVENT,
expect.objectContaining({
type: ExtensionBridgeEventName.TaskDelegated,
instance: expect.any(Object),
timestamp: expect.any(Number),
}),
undefined,
)
})
it("should forward TaskDelegationCompleted with correct payload", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
const completedListeners = eventListeners.get(RooCodeEventName.TaskDelegationCompleted)
expect(completedListeners).toBeDefined()
const listener = Array.from(completedListeners!)[0]
if (listener) {
await (listener as any)("parent-1", "child-1", "Summary text")
}
expect(mockSocket.emit).toHaveBeenCalledWith(
ExtensionSocketEvents.EVENT,
expect.objectContaining({
type: ExtensionBridgeEventName.TaskDelegationCompleted,
instance: expect.any(Object),
timestamp: expect.any(Number),
payload: expect.objectContaining({
parentTaskId: "parent-1",
childTaskId: "child-1",
summary: "Summary text",
}),
}),
undefined,
)
})
it("should forward TaskDelegationResumed with correct payload", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
const resumedListeners = eventListeners.get(RooCodeEventName.TaskDelegationResumed)
expect(resumedListeners).toBeDefined()
const listener = Array.from(resumedListeners!)[0]
if (listener) {
await (listener as any)("parent-2", "child-2")
}
expect(mockSocket.emit).toHaveBeenCalledWith(
ExtensionSocketEvents.EVENT,
expect.objectContaining({
type: ExtensionBridgeEventName.TaskDelegationResumed,
instance: expect.any(Object),
timestamp: expect.any(Number),
payload: expect.objectContaining({
parentTaskId: "parent-2",
childTaskId: "child-2",
}),
}),
undefined,
)
})
it("should propagate all three delegation events in order", async () => {
await extensionChannel.onConnect(mockSocket)
;(mockSocket.emit as any).mockClear()
// Trigger TaskDelegated
const delegatedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegated)!)[0]
await (delegatedListener as any)("p1", "c1")
// Trigger TaskDelegationCompleted
const completedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationCompleted)!)[0]
await (completedListener as any)("p1", "c1", "result")
// Trigger TaskDelegationResumed
const resumedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationResumed)!)[0]
await (resumedListener as any)("p1", "c1")
// Verify all three events were emitted
const emittedEvents = (mockSocket.emit as any).mock.calls.map((call: any[]) => call[1]?.type)
expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegated)
expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationCompleted)
expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationResumed)
// Verify correct order: Delegated → Completed → Resumed
const delegatedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegated)
const completedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationCompleted)
const resumedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationResumed)
expect(delegatedIdx).toBeLessThan(completedIdx)
expect(completedIdx).toBeLessThan(resumedIdx)
})
})
describe("Memory Leak Prevention", () => {
@ -257,7 +371,7 @@ describe("ExtensionChannel", () => {
}
// Listeners should still be the same count (not accumulated)
expect(eventListeners.size).toBe(15)
expect(eventListeners.size).toBe(18)
// Each event should have exactly 1 listener
eventListeners.forEach((listeners) => {

View file

@ -440,6 +440,9 @@ export enum ExtensionBridgeEventName {
TaskPaused = RooCodeEventName.TaskPaused,
TaskUnpaused = RooCodeEventName.TaskUnpaused,
TaskSpawned = RooCodeEventName.TaskSpawned,
TaskDelegated = RooCodeEventName.TaskDelegated,
TaskDelegationCompleted = RooCodeEventName.TaskDelegationCompleted,
TaskDelegationResumed = RooCodeEventName.TaskDelegationResumed,
TaskUserMessage = RooCodeEventName.TaskUserMessage,
@ -520,6 +523,21 @@ export const extensionBridgeEventSchema = z.discriminatedUnion("type", [
instance: extensionInstanceSchema,
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.TaskDelegated),
instance: extensionInstanceSchema,
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.TaskDelegationCompleted),
instance: extensionInstanceSchema,
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.TaskDelegationResumed),
instance: extensionInstanceSchema,
timestamp: z.number(),
}),
z.object({
type: z.literal(ExtensionBridgeEventName.TaskUserMessage),

View file

@ -26,6 +26,9 @@ export enum RooCodeEventName {
TaskPaused = "taskPaused",
TaskUnpaused = "taskUnpaused",
TaskSpawned = "taskSpawned",
TaskDelegated = "taskDelegated",
TaskDelegationCompleted = "taskDelegationCompleted",
TaskDelegationResumed = "taskDelegationResumed",
// Task Execution
Message = "message",
@ -73,6 +76,19 @@ export const rooCodeEventsSchema = z.object({
[RooCodeEventName.TaskPaused]: z.tuple([z.string()]),
[RooCodeEventName.TaskUnpaused]: z.tuple([z.string()]),
[RooCodeEventName.TaskSpawned]: z.tuple([z.string(), z.string()]),
[RooCodeEventName.TaskDelegated]: z.tuple([
z.string(), // parentTaskId
z.string(), // childTaskId
]),
[RooCodeEventName.TaskDelegationCompleted]: z.tuple([
z.string(), // parentTaskId
z.string(), // childTaskId
z.string(), // completionResultSummary
]),
[RooCodeEventName.TaskDelegationResumed]: z.tuple([
z.string(), // parentTaskId
z.string(), // childTaskId
]),
[RooCodeEventName.Message]: z.tuple([
z.object({
@ -169,6 +185,21 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskSpawned],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskDelegated),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegated],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskDelegationCompleted),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegationCompleted],
taskId: z.number().optional(),
}),
z.object({
eventName: z.literal(RooCodeEventName.TaskDelegationResumed),
payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskDelegationResumed],
taskId: z.number().optional(),
}),
// Task Execution
z.object({

View file

@ -19,6 +19,12 @@ export const historyItemSchema = z.object({
size: z.number().optional(),
workspace: z.string().optional(),
mode: z.string().optional(),
status: z.enum(["active", "completed", "delegated"]).optional(),
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)
completedByChildId: z.string().optional(), // Child that completed and resumed this parent
completionResultSummary: z.string().optional(), // Summary from completed child
})
export type HistoryItem = z.infer<typeof historyItemSchema>

View file

@ -72,6 +72,9 @@ export type TaskProviderEvents = {
[RooCodeEventName.TaskPaused]: [taskId: string]
[RooCodeEventName.TaskUnpaused]: [taskId: string]
[RooCodeEventName.TaskSpawned]: [taskId: string]
[RooCodeEventName.TaskDelegated]: [parentTaskId: string, childTaskId: string]
[RooCodeEventName.TaskDelegationCompleted]: [parentTaskId: string, childTaskId: string, summary: string]
[RooCodeEventName.TaskDelegationResumed]: [parentTaskId: string, childTaskId: string]
[RooCodeEventName.TaskUserMessage]: [taskId: string]
@ -92,6 +95,8 @@ export interface CreateTaskOptions {
consecutiveMistakeLimit?: number
experiments?: Record<string, boolean>
initialTodos?: TodoItem[]
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
}
export enum TaskStatus {

View file

@ -0,0 +1,48 @@
// npx vitest run __tests__/delegation-events.spec.ts
import { RooCodeEventName, rooCodeEventsSchema, taskEventSchema } from "@roo-code/types"
describe("delegation event schemas", () => {
test("rooCodeEventsSchema validates tuples", () => {
expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegated].parse(["p", "c"])).not.toThrow()
expect(() =>
(rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationCompleted].parse(["p", "c", "s"]),
).not.toThrow()
expect(() =>
(rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationResumed].parse(["p", "c"]),
).not.toThrow()
// invalid shapes
expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegated].parse(["p"])).toThrow()
expect(() =>
(rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationCompleted].parse(["p", "c"]),
).toThrow()
expect(() => (rooCodeEventsSchema.shape as any)[RooCodeEventName.TaskDelegationResumed].parse(["p"])).toThrow()
})
test("taskEventSchema discriminated union includes delegation events", () => {
expect(() =>
taskEventSchema.parse({
eventName: RooCodeEventName.TaskDelegated,
payload: ["p", "c"],
taskId: 1,
}),
).not.toThrow()
expect(() =>
taskEventSchema.parse({
eventName: RooCodeEventName.TaskDelegationCompleted,
payload: ["p", "c", "s"],
taskId: 1,
}),
).not.toThrow()
expect(() =>
taskEventSchema.parse({
eventName: RooCodeEventName.TaskDelegationResumed,
payload: ["p", "c"],
taskId: 1,
}),
).not.toThrow()
})
})

View file

@ -0,0 +1,494 @@
// npx vitest run __tests__/history-resume-delegation.spec.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { RooCodeEventName } from "@roo-code/types"
/* vscode mock for Task/Provider imports */
vi.mock("vscode", () => {
const window = {
createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })),
showErrorMessage: vi.fn(),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
}
const workspace = {
getConfiguration: vi.fn(() => ({
get: vi.fn((_key: string, defaultValue: any) => defaultValue),
update: vi.fn(),
})),
workspaceFolders: [],
}
const env = { machineId: "test-machine", uriScheme: "vscode", appName: "VSCode", language: "en", sessionId: "sess" }
const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }) }
const commands = { executeCommand: vi.fn() }
const ExtensionMode = { Development: 2 }
const version = "1.0.0-test"
return { window, workspace, env, Uri, commands, ExtensionMode, version }
})
// Mock persistence BEFORE importing provider
vi.mock("../core/task-persistence/taskMessages", () => ({
readTaskMessages: vi.fn().mockResolvedValue([]),
}))
vi.mock("../core/task-persistence", () => ({
readApiMessages: vi.fn().mockResolvedValue([]),
saveApiMessages: vi.fn().mockResolvedValue(undefined),
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
}))
import { ClineProvider } from "../core/webview/ClineProvider"
import { readTaskMessages } from "../core/task-persistence/taskMessages"
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence"
describe("History resume delegation - parent metadata transitions", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("reopenParentFromDelegation persists parent metadata (delegated → active) before reopen", async () => {
const providerEmit = vi.fn()
const getTaskWithId = vi.fn().mockResolvedValue({
historyItem: {
id: "parent-1",
status: "delegated",
delegatedToId: "child-1",
awaitingChildId: "child-1",
childIds: ["child-1"],
ts: Date.now(),
task: "Parent task",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
mode: "code",
workspace: "/tmp",
},
})
const updateTaskHistory = vi.fn().mockResolvedValue([])
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTaskWithHistoryItem = vi.fn().mockResolvedValue({
taskId: "parent-1",
skipPrevResponseIdOnce: false,
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
})
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId,
emit: providerEmit,
getCurrentTask: vi.fn(() => ({ taskId: "child-1" })),
removeClineFromStack,
createTaskWithHistoryItem,
updateTaskHistory,
} as unknown as ClineProvider
// Mock persistence reads to return empty arrays
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-1",
childTaskId: "child-1",
completionResultSummary: "Child done",
})
// Assert: metadata updated BEFORE createTaskWithHistoryItem
expect(updateTaskHistory).toHaveBeenCalledWith(
expect.objectContaining({
id: "parent-1",
status: "active",
completedByChildId: "child-1",
completionResultSummary: "Child done",
awaitingChildId: undefined,
childIds: ["child-1"],
}),
)
// Verify call ordering: updateTaskHistory before createTaskWithHistoryItem
const updateCall = updateTaskHistory.mock.invocationCallOrder[0]
const createCall = createTaskWithHistoryItem.mock.invocationCallOrder[0]
expect(updateCall).toBeLessThan(createCall)
// Verify child closed and parent reopened with updated metadata
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
expect(createTaskWithHistoryItem).toHaveBeenCalledWith(
expect.objectContaining({
status: "active",
completedByChildId: "child-1",
}),
{ startTask: false },
)
})
it("reopenParentFromDelegation injects subtask_result into both UI and API histories", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p1",
status: "delegated",
awaitingChildId: "c1",
childIds: [],
ts: 100,
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}),
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "c1" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
taskId: "p1",
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
} as unknown as ClineProvider
// Start with existing messages in history
const existingUiMessages = [{ type: "ask", ask: "tool", text: "Old tool", ts: 50 }]
const existingApiMessages = [{ role: "user", content: [{ type: "text", text: "Old request" }], ts: 50 }]
vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any)
vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any)
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "p1",
childTaskId: "c1",
completionResultSummary: "Subtask completed successfully",
})
// Verify UI history injection (say: subtask_result)
expect(saveTaskMessages).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
type: "say",
say: "subtask_result",
text: "Subtask completed successfully",
}),
]),
taskId: "p1",
globalStoragePath: "/storage",
}),
)
// Verify API history injection (user role message)
expect(saveApiMessages).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: expect.arrayContaining([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Subtask c1 completed"),
}),
]),
}),
]),
taskId: "p1",
globalStoragePath: "/storage",
}),
)
// Verify both include original messages
const uiCall = vi.mocked(saveTaskMessages).mock.calls[0][0]
expect(uiCall.messages).toHaveLength(2) // 1 original + 1 injected
const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0]
expect(apiCall.messages).toHaveLength(2) // 1 original + 1 injected
})
it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p-tool",
status: "delegated",
awaitingChildId: "c-tool",
childIds: [],
ts: 100,
task: "Parent with tool_use",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}),
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "c-tool" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
taskId: "p-tool",
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
} as unknown as ClineProvider
// Include an assistant message with new_task tool_use to exercise the tool_result path
const existingUiMessages = [{ type: "ask", ask: "tool", text: "new_task request", ts: 50 }]
const existingApiMessages = [
{ role: "user", content: [{ type: "text", text: "Create a subtask" }], ts: 40 },
{
role: "assistant",
content: [
{
type: "tool_use",
name: "new_task",
id: "toolu_abc123",
input: { mode: "code", message: "Do something" },
},
],
ts: 50,
},
]
vi.mocked(readTaskMessages).mockResolvedValue(existingUiMessages as any)
vi.mocked(readApiMessages).mockResolvedValue(existingApiMessages as any)
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "p-tool",
childTaskId: "c-tool",
completionResultSummary: "Subtask completed via tool_result",
})
// Verify API history injection uses tool_result (not text fallback)
expect(saveApiMessages).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: expect.arrayContaining([
expect.objectContaining({
type: "tool_result",
tool_use_id: "toolu_abc123",
content: expect.stringContaining("Subtask c-tool completed"),
}),
]),
}),
]),
taskId: "p-tool",
globalStoragePath: "/storage",
}),
)
// Verify total message count: 2 original + 1 injected user message with tool_result
const apiCall = vi.mocked(saveApiMessages).mock.calls[0][0]
expect(apiCall.messages).toHaveLength(3)
// Verify the injected message is a user message with tool_result type
const injectedMsg = apiCall.messages[2]
expect(injectedMsg.role).toBe("user")
expect((injectedMsg.content[0] as any).type).toBe("tool_result")
expect((injectedMsg.content[0] as any).tool_use_id).toBe("toolu_abc123")
})
it("reopenParentFromDelegation sets skipPrevResponseIdOnce via resumeAfterDelegation", async () => {
const parentInstance: any = {
skipPrevResponseIdOnce: false,
resumeAfterDelegation: vi.fn().mockImplementation(async function (this: any) {
// Simulate what the real resumeAfterDelegation does
this.skipPrevResponseIdOnce = true
}),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "parent-2",
status: "delegated",
awaitingChildId: "child-2",
childIds: [],
ts: 200,
task: "P",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}),
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "child-2" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
updateTaskHistory: vi.fn().mockResolvedValue([]),
} as unknown as ClineProvider
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-2",
childTaskId: "child-2",
completionResultSummary: "Done",
})
// Critical: verify skipPrevResponseIdOnce set to true by resumeAfterDelegation
expect(parentInstance.skipPrevResponseIdOnce).toBe(true)
expect(parentInstance.resumeAfterDelegation).toHaveBeenCalledTimes(1)
})
it("reopenParentFromDelegation emits events in correct order: TaskDelegationCompleted → TaskDelegationResumed", async () => {
const emitSpy = vi.fn()
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p3",
status: "delegated",
awaitingChildId: "c3",
childIds: [],
ts: 300,
task: "P3",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}),
emit: emitSpy,
getCurrentTask: vi.fn(() => ({ taskId: "c3" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
} as unknown as ClineProvider
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "p3",
childTaskId: "c3",
completionResultSummary: "Summary",
})
// Verify both events emitted
const eventNames = emitSpy.mock.calls.map((c) => c[0])
expect(eventNames).toContain(RooCodeEventName.TaskDelegationCompleted)
expect(eventNames).toContain(RooCodeEventName.TaskDelegationResumed)
// CRITICAL: verify ordering (TaskDelegationCompleted before TaskDelegationResumed)
const completedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationCompleted)
const resumedIdx = emitSpy.mock.calls.findIndex((c) => c[0] === RooCodeEventName.TaskDelegationResumed)
expect(completedIdx).toBeGreaterThanOrEqual(0)
expect(resumedIdx).toBeGreaterThan(completedIdx)
})
it("reopenParentFromDelegation does NOT emit TaskPaused or TaskUnpaused (new flow only)", async () => {
const emitSpy = vi.fn()
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p4",
status: "delegated",
awaitingChildId: "c4",
childIds: [],
ts: 400,
task: "P4",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}),
emit: emitSpy,
getCurrentTask: vi.fn(() => ({ taskId: "c4" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
} as unknown as ClineProvider
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "p4",
childTaskId: "c4",
completionResultSummary: "S",
})
// CRITICAL: verify legacy pause/unpause events NOT emitted
const eventNames = emitSpy.mock.calls.map((c) => c[0])
expect(eventNames).not.toContain(RooCodeEventName.TaskPaused)
expect(eventNames).not.toContain(RooCodeEventName.TaskUnpaused)
expect(eventNames).not.toContain(RooCodeEventName.TaskSpawned)
})
it("handles empty history gracefully when injecting synthetic messages", async () => {
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId: vi.fn().mockResolvedValue({
historyItem: {
id: "p5",
status: "delegated",
awaitingChildId: "c5",
childIds: [],
ts: 500,
task: "P5",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}),
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "c5" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
} as unknown as ClineProvider
// Mock read failures or empty returns
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
await expect(
(ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, {
parentTaskId: "p5",
childTaskId: "c5",
completionResultSummary: "Result",
}),
).resolves.toBeUndefined()
// Verify saves still occurred with just the injected message
expect(saveTaskMessages).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
expect.objectContaining({
type: "say",
say: "subtask_result",
}),
],
}),
)
expect(saveApiMessages).toHaveBeenCalledWith(
expect.objectContaining({
messages: [
expect.objectContaining({
role: "user",
}),
],
}),
)
})
})

View file

@ -0,0 +1,264 @@
// npx vitest run __tests__/nested-delegation-resume.spec.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { RooCodeEventName } from "@roo-code/types"
// Mock safe-stable-stringify to avoid runtime error
vi.mock("safe-stable-stringify", () => ({
default: (obj: any) => JSON.stringify(obj),
}))
// Mock TelemetryService
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureTaskCompleted: vi.fn(),
},
},
}))
// vscode mock for Task/Provider imports
vi.mock("vscode", () => {
const window = {
createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })),
showErrorMessage: vi.fn(),
onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
}
const workspace = {
getConfiguration: vi.fn(() => ({
get: vi.fn((_key: string, defaultValue: any) => defaultValue),
update: vi.fn(),
})),
workspaceFolders: [],
}
const env = { machineId: "test-machine", uriScheme: "vscode", appName: "VSCode", language: "en", sessionId: "sess" }
const Uri = { file: (p: string) => ({ fsPath: p, toString: () => p }) }
const commands = { executeCommand: vi.fn() }
const ExtensionMode = { Development: 2 }
const version = "1.0.0-test"
return { window, workspace, env, Uri, commands, ExtensionMode, version }
})
// Mock persistence helpers used by provider reopen flow BEFORE importing provider
vi.mock("../core/task-persistence/taskMessages", () => ({
readTaskMessages: vi.fn().mockResolvedValue([]),
}))
vi.mock("../core/task-persistence", () => ({
readApiMessages: vi.fn().mockResolvedValue([]),
saveApiMessages: vi.fn().mockResolvedValue(undefined),
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
}))
import { attemptCompletionTool } from "../core/tools/AttemptCompletionTool"
import { ClineProvider } from "../core/webview/ClineProvider"
import type { Task } from "../core/task/Task"
import { readTaskMessages } from "../core/task-persistence/taskMessages"
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task-persistence"
describe("Nested delegation resume (A → B → C)", () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it("C completes → reopens B; then B completes → reopens A; emits correct events; no resume_task asks", async () => {
// Track which task is "current" to satisfy provider.reopenParentFromDelegation() child-close logic
let currentActiveId: string | undefined = "C"
// History index: A is parent of B, B is parent of C
const historyIndex: Record<string, any> = {
A: {
id: "A",
status: "delegated",
delegatedToId: "B",
awaitingChildId: "B",
childIds: ["B"],
parentTaskId: undefined,
ts: 1,
task: "Task A",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
mode: "code",
workspace: "/tmp",
},
B: {
id: "B",
status: "delegated",
delegatedToId: "C",
awaitingChildId: "C",
childIds: ["C"],
parentTaskId: "A",
ts: 2,
task: "Task B",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
mode: "code",
workspace: "/tmp",
},
C: {
id: "C",
status: "active",
parentTaskId: "B",
ts: 3,
task: "Task C",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
mode: "code",
workspace: "/tmp",
},
}
const emitSpy = vi.fn()
const removeClineFromStack = vi.fn().mockImplementation(async () => {
// Simulate closing current child
currentActiveId = undefined
})
const createTaskWithHistoryItem = vi
.fn()
.mockImplementation(async (historyItem: any, opts?: { startTask?: boolean }) => {
// Assert startTask:false to avoid resume asks
expect(opts).toEqual(expect.objectContaining({ startTask: false }))
// Reopen the parent
currentActiveId = historyItem.id
// Return minimal parent instance with resumeAfterDelegation
return {
taskId: historyItem.id,
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}
})
const getTaskWithId = vi.fn(async (id: string) => {
if (!historyIndex[id]) throw new Error("Task not found")
return {
historyItem: historyIndex[id],
apiConversationHistory: [],
taskDirPath: "/tmp",
apiConversationHistoryFilePath: "/tmp/api.json",
uiMessagesFilePath: "/tmp/ui.json",
}
})
const updateTaskHistory = vi.fn(async (updated: any) => {
// Persist updated history back into index (simulate)
historyIndex[updated.id] = updated
return Object.values(historyIndex)
})
const provider = {
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
getTaskWithId,
emit: emitSpy,
getCurrentTask: vi.fn(() => (currentActiveId ? ({ taskId: currentActiveId } as any) : undefined)),
removeClineFromStack,
createTaskWithHistoryItem,
updateTaskHistory,
// Wire through provider method so attemptCompletionTool can call it
reopenParentFromDelegation: vi.fn(async (params: any) => {
return await (ClineProvider.prototype as any).reopenParentFromDelegation.call(provider, params)
}),
} as unknown as ClineProvider
// Empty histories for simplicity
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
// Step 1: C completes -> should reopen B automatically
const clineC = {
taskId: "C",
parentTask: undefined, // parent ref may or may not exist; metadata path should still work
parentTaskId: "B",
historyItem: { parentTaskId: "B" },
providerRef: { deref: () => provider },
say: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
getTokenUsage: vi.fn(() => ({})),
toolUsage: {},
clineMessages: [],
userMessageContent: [],
consecutiveMistakeCount: 0,
} as unknown as Task
const blockC = {
type: "tool_use",
name: "attempt_completion",
params: { result: "C finished" },
partial: false,
} as any
const askFinishSubTaskApproval = vi.fn(async () => true)
await attemptCompletionTool.handle(clineC, blockC, {
askApproval: vi.fn(),
handleError: vi.fn(),
pushToolResult: vi.fn(),
removeClosingTag: vi.fn((_, v?: string) => v ?? ""),
askFinishSubTaskApproval,
toolProtocol: "xml",
toolDescription: () => "desc",
} as any)
// After C completes, B must be current
expect(currentActiveId).toBe("B")
// Events emitted: C -> B hop
const eventNamesAfterC = emitSpy.mock.calls.map((c: any[]) => c[0])
expect(eventNamesAfterC).toContain(RooCodeEventName.TaskDelegationCompleted)
expect(eventNamesAfterC).toContain(RooCodeEventName.TaskDelegationResumed)
// Step 2: B completes -> should reopen A automatically (parent reference missing, must use parentTaskId path)
const clineB = {
taskId: "B",
parentTask: undefined, // simulate missing live parent reference
parentTaskId: "A", // persisted parent id
historyItem: { parentTaskId: "A" },
providerRef: { deref: () => provider },
say: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
getTokenUsage: vi.fn(() => ({})),
toolUsage: {},
clineMessages: [],
userMessageContent: [],
consecutiveMistakeCount: 0,
} as unknown as Task
const blockB = {
type: "tool_use",
name: "attempt_completion",
params: { result: "B finished" },
partial: false,
} as any
await attemptCompletionTool.handle(clineB, blockB, {
askApproval: vi.fn(),
handleError: vi.fn(),
pushToolResult: vi.fn(),
removeClosingTag: vi.fn((_, v?: string) => v ?? ""),
askFinishSubTaskApproval,
toolProtocol: "xml",
toolDescription: () => "desc",
} as any)
// After B completes, A must be current
expect(currentActiveId).toBe("A")
// Ensure no resume_task asks were scheduled: verified indirectly by startTask:false on both hops
// (asserted in createTaskWithHistoryItem mock)
// Provider emitted TaskDelegationCompleted/Resumed twice across both hops
const completedEvents = emitSpy.mock.calls.filter(
(c: any[]) => c[0] === RooCodeEventName.TaskDelegationCompleted,
)
const resumedEvents = emitSpy.mock.calls.filter((c: any[]) => c[0] === RooCodeEventName.TaskDelegationResumed)
expect(completedEvents.length).toBeGreaterThanOrEqual(2)
expect(resumedEvents.length).toBeGreaterThanOrEqual(2)
// Verify second hop used parentId = A
// Find a TaskDelegationCompleted matching A <- B
const hasAfromB = completedEvents.some(([, parentId, childId]: any[]) => parentId === "A" && childId === "B")
expect(hasAfromB).toBe(true)
})
})

View file

@ -0,0 +1,44 @@
// npx vitest run __tests__/new-task-delegation.spec.ts
import { describe, it, expect, vi } from "vitest"
import { RooCodeEventName } from "@roo-code/types"
import { Task } from "../core/task/Task"
describe("Task.startSubtask() metadata-driven delegation", () => {
it("Routes to provider.delegateParentAndOpenChild without pausing parent", async () => {
const provider = {
getState: vi.fn().mockResolvedValue({
experiments: {},
}),
delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }),
createTask: vi.fn(),
handleModeSwitch: vi.fn(),
} as any
// Create a minimal Task-like instance with only fields used by startSubtask
const parent = Object.create(Task.prototype) as Task
;(parent as any).taskId = "parent-1"
;(parent as any).providerRef = { deref: () => provider }
;(parent as any).emit = vi.fn()
const child = await (Task.prototype as any).startSubtask.call(parent, "Do something", [], "code")
expect(provider.delegateParentAndOpenChild).toHaveBeenCalledWith({
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
})
expect(child.taskId).toBe("child-1")
// Parent should not be paused and no paused/unpaused events should be emitted
expect((parent as any).isPaused).not.toBe(true)
expect((parent as any).childTaskId).toBeUndefined()
const emittedEvents = (parent.emit as any).mock.calls.map((c: any[]) => c[0])
expect(emittedEvents).not.toContain(RooCodeEventName.TaskPaused)
expect(emittedEvents).not.toContain(RooCodeEventName.TaskUnpaused)
// Legacy path not used
expect(provider.createTask).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,92 @@
// npx vitest run __tests__/provider-delegation.spec.ts
import { describe, it, expect, vi } from "vitest"
import { RooCodeEventName } from "@roo-code/types"
import { ClineProvider } from "../core/webview/ClineProvider"
describe("ClineProvider.delegateParentAndOpenChild()", () => {
it("persists parent delegation metadata and emits TaskDelegated", async () => {
const providerEmit = vi.fn()
const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
const updateTaskHistory = vi.fn()
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1" })
const handleModeSwitch = vi.fn().mockResolvedValue(undefined)
const getTaskWithId = vi.fn().mockImplementation(async (id: string) => {
if (id === "parent-1") {
return {
historyItem: {
id: "parent-1",
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
childIds: [],
},
}
}
// child-1
return {
historyItem: {
id: "child-1",
task: "Do something",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
},
}
})
const provider = {
emit: providerEmit,
getCurrentTask: vi.fn(() => parentTask),
removeClineFromStack,
createTask,
getTaskWithId,
updateTaskHistory,
handleModeSwitch,
log: vi.fn(),
} as unknown as ClineProvider
const params = {
parentTaskId: "parent-1",
message: "Do something",
initialTodos: [],
mode: "code",
}
const child = await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, params)
expect(child.taskId).toBe("child-1")
// Invariant: parent closed before child creation
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
// Child task is created with initialStatus: "active" to avoid race conditions
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
initialTodos: [],
initialStatus: "active",
})
// Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus)
expect(updateTaskHistory).toHaveBeenCalledTimes(1)
// Parent set to "delegated"
const parentSaved = updateTaskHistory.mock.calls[0][0]
expect(parentSaved).toEqual(
expect.objectContaining({
id: "parent-1",
status: "delegated",
delegatedToId: "child-1",
awaitingChildId: "child-1",
childIds: expect.arrayContaining(["child-1"]),
}),
)
// Event emission (provider-level)
expect(providerEmit).toHaveBeenCalledWith(RooCodeEventName.TaskDelegated, "parent-1", "child-1")
// Mode switch
expect(handleModeSwitch).toHaveBeenCalledWith("code")
})
})

View file

@ -0,0 +1,169 @@
// npx vitest run __tests__/single-open-invariant.spec.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
import { ClineProvider } from "../core/webview/ClineProvider"
import { API } from "../extension/api"
import * as ProfileValidatorMod from "../shared/ProfileValidator"
// Mock Task class used by ClineProvider to avoid heavy startup
vi.mock("../core/task/Task", () => {
class TaskStub {
public taskId: string
public instanceId = "inst"
public parentTask?: any
public apiConfiguration: any
public rootTask?: any
public enableBridge?: boolean
constructor(opts: any) {
this.taskId = opts.historyItem?.id ?? `task-${Math.random().toString(36).slice(2, 8)}`
this.parentTask = opts.parentTask
this.apiConfiguration = opts.apiConfiguration ?? { apiProvider: "anthropic" }
opts.onCreated?.(this)
}
on() {}
off() {}
emit() {}
}
return { Task: TaskStub }
})
describe("Single-open-task invariant", () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it("User-initiated create: closes existing before opening new", async () => {
// Allow profile
vi.spyOn(ProfileValidatorMod.ProfileValidator, "isProfileAllowed").mockReturnValue(true)
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const addClineToStack = vi.fn().mockResolvedValue(undefined)
const provider = {
// Simulate an existing task present in stack
clineStack: [{ taskId: "existing-1" }],
setValues: vi.fn(),
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },
organizationAllowList: "*",
diffEnabled: false,
enableCheckpoints: true,
checkpointTimeout: 60,
fuzzyMatchThreshold: 1.0,
cloudUserInfo: null,
remoteControlEnabled: false,
}),
removeClineFromStack,
addClineToStack,
setProviderProfile: vi.fn(),
log: vi.fn(),
getStateToPostToWebview: vi.fn(),
providerSettingsManager: { getModeConfigId: vi.fn(), listConfig: vi.fn() },
customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) },
taskCreationCallback: vi.fn(),
contextProxy: {
extensionUri: {},
setValue: vi.fn(),
getValue: vi.fn(),
setProviderSettings: vi.fn(),
getProviderSettings: vi.fn(() => ({})),
},
} as unknown as ClineProvider
await (ClineProvider.prototype as any).createTask.call(provider, "New task")
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
expect(addClineToStack).toHaveBeenCalledTimes(1)
})
it("History resume path always closes current before rehydration (non-rehydrating case)", async () => {
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const addClineToStack = vi.fn().mockResolvedValue(undefined)
const updateGlobalState = vi.fn().mockResolvedValue(undefined)
const provider = {
getCurrentTask: vi.fn(() => undefined), // ensure not rehydrating
removeClineFromStack,
addClineToStack,
updateGlobalState,
log: vi.fn(),
customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) },
providerSettingsManager: {
getModeConfigId: vi.fn().mockResolvedValue(undefined),
listConfig: vi.fn().mockResolvedValue([]),
},
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },
diffEnabled: false,
enableCheckpoints: true,
checkpointTimeout: 60,
fuzzyMatchThreshold: 1.0,
experiments: {},
cloudUserInfo: null,
taskSyncEnabled: false,
}),
// Methods used by createTaskWithHistoryItem for pending edit cleanup
getPendingEditOperation: vi.fn().mockReturnValue(undefined),
clearPendingEditOperation: vi.fn(),
context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } },
contextProxy: {
extensionUri: {},
getValue: vi.fn(),
setValue: vi.fn(),
setProviderSettings: vi.fn(),
getProviderSettings: vi.fn(() => ({})),
},
postStateToWebview: vi.fn(),
} as unknown as ClineProvider
const historyItem = {
id: "hist-1",
number: 1,
ts: Date.now(),
task: "Task",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
workspace: "/tmp",
}
const task = await (ClineProvider.prototype as any).createTaskWithHistoryItem.call(provider, historyItem)
expect(task).toBeTruthy()
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
expect(addClineToStack).toHaveBeenCalledTimes(1)
})
it("IPC StartNewTask path closes current before new task", async () => {
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTask = vi.fn().mockResolvedValue({ taskId: "ipc-1" })
const provider = {
context: {} as any,
removeClineFromStack,
postStateToWebview: vi.fn(),
postMessageToWebview: vi.fn(),
createTask,
getValues: vi.fn(() => ({})),
providerSettingsManager: { saveConfig: vi.fn() },
on: vi.fn((ev: any, cb: any) => {
if (ev === "taskCreated") {
// no-op for this test
}
return provider
}),
} as unknown as ClineProvider
const output = { appendLine: vi.fn() } as any
const api = new API(output, provider, undefined, false)
const taskId = await api.startNewTask({
configuration: {},
text: "hello",
images: undefined,
newTab: false,
})
expect(taskId).toBe("ipc-1")
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
expect(createTask).toHaveBeenCalled()
})
})

View file

@ -21,6 +21,8 @@ export type TaskMetadataOptions = {
globalStoragePath: string
workspace: string
mode?: string
/** Initial status for the task (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
}
export async function taskMetadata({
@ -32,6 +34,7 @@ export async function taskMetadata({
globalStoragePath,
workspace,
mode,
initialStatus,
}: TaskMetadataOptions) {
const taskDir = await getTaskDirectoryPath(globalStoragePath, id)
@ -84,6 +87,9 @@ export async function taskMetadata({
}
// Create historyItem once with pre-calculated values.
// initialStatus is included when provided (e.g., "active" for child tasks)
// to ensure the status is set from the very first save, avoiding race conditions
// where attempt_completion might run before a separate status update.
const historyItem: HistoryItem = {
id,
rootTaskId,
@ -101,6 +107,7 @@ export async function taskMetadata({
size: taskDirSize,
workspace,
mode,
...(initialStatus && { status: initialStatus }),
}
return { historyItem, tokenUsage }

View file

@ -150,6 +150,8 @@ export interface TaskOptions extends CreateTaskOptions {
onCreated?: (task: Task) => void
initialTodos?: TodoItem[]
workspacePath?: string
/** Initial status for the task's history item (e.g., "active" for child tasks) */
initialStatus?: "active" | "delegated" | "completed"
}
export class Task extends EventEmitter<TaskEvents> implements TaskLike {
@ -217,6 +219,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private readonly globalStoragePath: string
abort: boolean = false
currentRequestAbortController?: AbortController
skipPrevResponseIdOnce: boolean = false
// TaskStatus
idleAsk?: ClineMessage
@ -228,8 +231,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
abortReason?: ClineApiReqCancelReason
isInitialized = false
isPaused: boolean = false
pausedModeSlug: string = defaultModeSlug
private pauseInterval: NodeJS.Timeout | undefined
// API
apiConfiguration: ProviderSettings
@ -322,6 +323,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Cloud Sync Tracking
private cloudSyncedMessageTimestamps: Set<number> = new Set()
// Initial status for the task's history item (set at creation time to avoid race conditions)
private readonly initialStatus?: "active" | "delegated" | "completed"
constructor({
provider,
apiConfiguration,
@ -342,6 +346,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
onCreated,
initialTodos,
workspacePath,
initialStatus,
}: TaskOptions) {
super()
@ -428,6 +433,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.parentTask = parentTask
this.taskNumber = taskNumber
this.initialStatus = initialStatus
// Store the task's mode when it's created.
// For history items, use the stored mode; for new tasks, we'll set it
@ -869,6 +875,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
workspace: this.cwd,
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
initialStatus: this.initialStatus,
})
if (hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)) {
@ -1495,7 +1502,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
text: `<task>\n${task}\n</task>`,
},
...imageBlocks,
])
]).catch((error) => {
// Swallow loop rejection when the task was intentionally abandoned/aborted
// during delegation or user cancellation to prevent unhandled rejections.
if (this.abandoned === true || this.abortReason === "user_cancelled") {
return
}
throw error
})
}
private async resumeTaskFromHistory() {
@ -1843,12 +1857,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
console.error("Error removing event listeners:", error)
}
// Stop waiting for child task completion.
if (this.pauseInterval) {
clearInterval(this.pauseInterval)
this.pauseInterval = undefined
}
if (this.enableBridge) {
BridgeOrchestrator.getInstance()
?.unsubscribeFromTask(this.taskId)
@ -1925,79 +1933,87 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
throw new Error("Provider not available")
}
const newTask = await provider.createTask(message, undefined, this, { initialTodos })
if (newTask) {
this.isPaused = true // Pause parent.
this.childTaskId = newTask.taskId
await provider.handleModeSwitch(mode) // Set child's mode.
await delay(500) // Allow mode change to take effect.
this.emit(RooCodeEventName.TaskPaused, this.taskId)
this.emit(RooCodeEventName.TaskSpawned, newTask.taskId)
}
return newTask
}
// Used when a sub-task is launched and the parent task is waiting for it to
// finish.
// TBD: Add a timeout to prevent infinite waiting.
public async waitForSubtask() {
await new Promise<void>((resolve) => {
this.pauseInterval = setInterval(() => {
if (!this.isPaused) {
clearInterval(this.pauseInterval)
this.pauseInterval = undefined
resolve()
}
}, 1000)
const child = await (provider as any).delegateParentAndOpenChild({
parentTaskId: this.taskId,
message,
initialTodos,
mode,
})
return child
}
public async completeSubtask(lastMessage: string) {
this.isPaused = false
this.childTaskId = undefined
/**
* Resume parent task after delegation completion without showing resume ask.
* Used in metadata-driven subtask flow.
*
* This method:
* - Clears any pending ask states
* - Resets abort and streaming flags
* - Ensures next API call includes full context
* - Immediately continues task loop without user interaction
*/
public async resumeAfterDelegation(): Promise<void> {
// Clear any ask states that might have been set during history load
this.idleAsk = undefined
this.resumableAsk = undefined
this.interactiveAsk = undefined
this.emit(RooCodeEventName.TaskUnpaused, this.taskId)
// Reset abort and streaming state to ensure clean continuation
this.abort = false
this.abandoned = false
this.abortReason = undefined
this.didFinishAbortingStream = false
this.isStreaming = false
this.isWaitingForFirstChunk = false
// Fake an answer from the subtask that it has completed running and
// this is the result of what it has done add the message to the chat
// history and to the webview ui.
try {
await this.say("subtask_result", lastMessage)
// Ensure next API call includes full context after delegation
this.skipPrevResponseIdOnce = true
// Check if using native protocol to determine how to add the subtask result
const modelInfo = this.api.getModel().info
const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
// Mark as initialized and active
this.isInitialized = true
this.emit(RooCodeEventName.TaskActive, this.taskId)
if (toolProtocol === "native" && this.pendingNewTaskToolCallId) {
// For native protocol, push the actual tool_result with the subtask's real result.
// NewTaskTool deferred pushing the tool_result until now so that the parent task
// gets useful information about what the subtask actually accomplished.
this.userMessageContent.push({
type: "tool_result",
tool_use_id: this.pendingNewTaskToolCallId,
content: `[new_task completed] Result: ${lastMessage}`,
} as Anthropic.ToolResultBlockParam)
// Clear the pending tool call ID
this.pendingNewTaskToolCallId = undefined
} else {
// For XML protocol (or if no pending tool call ID), add as a separate user message
await this.addToApiConversationHistory({
role: "user",
content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }],
})
}
} catch (error) {
this.providerRef
.deref()
?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`)
throw error
// Load conversation history if not already loaded
if (this.apiConversationHistory.length === 0) {
this.apiConversationHistory = await this.getSavedApiConversationHistory()
}
// Add environment details to the existing last user message (which contains the tool_result)
// This avoids creating a new user message which would cause consecutive user messages
const environmentDetails = await getEnvironmentDetails(this, true)
let lastUserMsgIndex = -1
for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) {
if (this.apiConversationHistory[i].role === "user") {
lastUserMsgIndex = i
break
}
}
if (lastUserMsgIndex >= 0) {
const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex]
if (Array.isArray(lastUserMsg.content)) {
// Remove any existing environment_details blocks before adding fresh ones
const contentWithoutEnvDetails = lastUserMsg.content.filter(
(block: Anthropic.Messages.ContentBlockParam) => {
if (block.type === "text" && typeof block.text === "string") {
const isEnvironmentDetailsBlock =
block.text.trim().startsWith("<environment_details>") &&
block.text.trim().endsWith("</environment_details>")
return !isEnvironmentDetailsBlock
}
return true
},
)
// Add fresh environment details
lastUserMsg.content = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }]
}
}
// Save the updated history
await this.saveApiConversationHistory()
// Continue task loop - pass empty array to signal no new user content needed
// The initiateTaskLoop will handle this by skipping user message addition
await this.initiateTaskLoop([])
}
// Task Loop
@ -2085,37 +2101,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.consecutiveMistakeCount = 0
}
// In this Cline request loop, we need to check if this task instance
// has been asked to wait for a subtask to finish before continuing.
const provider = this.providerRef.deref()
if (this.isPaused && provider) {
provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`)
await this.waitForSubtask()
provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`)
// After subtask completes, completeSubtask has pushed content to userMessageContent.
// Copy it to currentUserContent so it gets sent to the API in this iteration.
if (this.userMessageContent.length > 0) {
currentUserContent.push(...this.userMessageContent)
this.userMessageContent = []
}
const currentMode = (await provider.getState())?.mode ?? defaultModeSlug
if (currentMode !== this.pausedModeSlug) {
// The mode has changed, we need to switch back to the paused mode.
await provider.handleModeSwitch(this.pausedModeSlug)
// Delay to allow mode change to take effect before next tool is executed.
await delay(500)
provider.log(
`[subtasks] task ${this.taskId}.${this.instanceId} has switched back to '${this.pausedModeSlug}' from '${currentMode}'`,
)
}
}
// Getting verbose details is an expensive operation, it uses ripgrep to
// top-down build file structure of project which for large projects can
// take a few seconds. For the best UX we show a placeholder api_req_started
@ -2175,10 +2160,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }]
// Only add user message to conversation history if:
// 1. This is the first attempt (retryAttempt === 0), OR
// 2. The message was removed in a previous iteration (userMessageWasRemoved === true)
// 1. This is the first attempt (retryAttempt === 0), AND
// 2. The original userContent was not empty (empty signals delegation resume where
// the user message with tool_result and env details is already in history), OR
// 3. The message was removed in a previous iteration (userMessageWasRemoved === true)
// This prevents consecutive user messages while allowing re-add when needed
if ((currentItem.retryAttempt ?? 0) === 0 || currentItem.userMessageWasRemoved) {
const isEmptyUserContent = currentUserContent.length === 0
const shouldAddUserMessage =
((currentItem.retryAttempt ?? 0) === 0 && !isEmptyUserContent) || currentItem.userMessageWasRemoved
if (shouldAddUserMessage) {
await this.addToApiConversationHistory({ role: "user", content: finalUserContent })
TelemetryService.instance.captureConversationMessage(this.taskId, "user")
}
@ -2194,7 +2184,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
} satisfies ClineApiReqInfo)
await this.saveClineMessages()
await provider?.postStateToWebview()
await this.providerRef.deref()?.postStateToWebview()
try {
let cacheWriteTokens = 0
@ -3492,6 +3482,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const metadata: ApiHandlerCreateMessageMetadata = {
mode: mode,
taskId: this.taskId,
suppressPreviousResponseId: this.skipPrevResponseIdOnce,
// Include tools and tool protocol when using native protocol and model supports it
...(shouldIncludeTools
? { tools: allTools, tool_choice: "auto", toolProtocol, parallelToolCalls: parallelToolCallsEnabled }
@ -3501,6 +3492,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Create an AbortController to allow cancelling the request mid-stream
this.currentRequestAbortController = new AbortController()
const abortSignal = this.currentRequestAbortController.signal
// Reset the flag after using it
this.skipPrevResponseIdOnce = false
// The provider accepts reasoning items alongside standard messages; cast to the expected parameter type.
const stream = this.api.createMessage(

View file

@ -1966,153 +1966,4 @@ describe("Queued message processing after condense", () => {
expect(spyB).toHaveBeenCalledWith("B message", undefined)
expect(taskB.messageQueueService.isEmpty()).toBe(true)
})
describe("completeSubtask native protocol handling", () => {
let mockProvider: any
let mockApiConfig: any
beforeEach(() => {
vi.clearAllMocks()
if (!TelemetryService.hasInstance()) {
TelemetryService.createInstance([])
}
mockApiConfig = {
apiProvider: "anthropic",
apiKey: "test-key",
}
mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/storage" },
},
getState: vi.fn().mockResolvedValue({
apiConfiguration: mockApiConfig,
}),
say: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
}
})
it("should push tool_result to userMessageContent for native protocol with pending tool call ID", async () => {
// Create a task with a model that supports native tools
const task = new Task({
provider: mockProvider,
apiConfiguration: {
...mockApiConfig,
apiProvider: "anthropic",
toolProtocol: "native", // Explicitly set native protocol
},
task: "parent task",
startTask: false,
})
// Mock the API to return a native protocol model
vi.spyOn(task.api, "getModel").mockReturnValue({
id: "claude-3-5-sonnet-20241022",
info: {
contextWindow: 200000,
maxTokens: 8192,
supportsPromptCache: true,
supportsNativeTools: true,
defaultToolProtocol: "native",
} as ModelInfo,
})
// For native protocol, NewTaskTool does NOT push tool_result immediately.
// It only sets the pending tool call ID. The actual tool_result is pushed by completeSubtask.
task.pendingNewTaskToolCallId = "test-tool-call-id"
// Call completeSubtask
await task.completeSubtask("Subtask completed successfully")
// For native protocol, should push the actual tool_result with the subtask's result
expect(task.userMessageContent).toHaveLength(1)
expect(task.userMessageContent[0]).toEqual({
type: "tool_result",
tool_use_id: "test-tool-call-id",
content: "[new_task completed] Result: Subtask completed successfully",
})
// Should NOT have added a user message to apiConversationHistory
expect(task.apiConversationHistory).toHaveLength(0)
// pending tool call ID should be cleared
expect(task.pendingNewTaskToolCallId).toBeUndefined()
})
it("should add user message to apiConversationHistory for XML protocol", async () => {
// Create a task with a model that doesn't support native tools
const task = new Task({
provider: mockProvider,
apiConfiguration: {
...mockApiConfig,
apiProvider: "anthropic",
},
task: "parent task",
startTask: false,
})
// Mock the API to return an XML protocol model (no native tool support)
vi.spyOn(task.api, "getModel").mockReturnValue({
id: "claude-2",
info: {
contextWindow: 100000,
maxTokens: 4096,
supportsPromptCache: false,
supportsNativeTools: false,
} as ModelInfo,
})
// Call completeSubtask
await task.completeSubtask("Subtask completed successfully")
// For XML protocol, should add to apiConversationHistory
expect(task.apiConversationHistory).toHaveLength(1)
expect(task.apiConversationHistory[0]).toEqual(
expect.objectContaining({
role: "user",
content: [{ type: "text", text: "[new_task completed] Result: Subtask completed successfully" }],
}),
)
// Should NOT have added to userMessageContent
expect(task.userMessageContent).toHaveLength(0)
})
it("should set isPaused to false after completeSubtask", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "parent task",
startTask: false,
})
// Mock the API to return an XML protocol model
vi.spyOn(task.api, "getModel").mockReturnValue({
id: "claude-2",
info: {
contextWindow: 100000,
maxTokens: 4096,
supportsPromptCache: false,
supportsNativeTools: false,
} as ModelInfo,
})
// Set isPaused to true (simulating waiting for subtask)
task.isPaused = true
task.childTaskId = "child-task-id"
// Call completeSubtask
await task.completeSubtask("Subtask completed")
// Should reset paused state
expect(task.isPaused).toBe(false)
expect(task.childTaskId).toBeUndefined()
})
})
})

View file

@ -1,7 +1,6 @@
import Anthropic from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { RooCodeEventName } from "@roo-code/types"
import { RooCodeEventName, type HistoryItem } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { Task } from "../task/Task"
@ -21,6 +20,18 @@ export interface AttemptCompletionCallbacks extends ToolCallbacks {
toolDescription: () => string
}
/**
* Interface for provider methods needed by AttemptCompletionTool for delegation handling.
*/
interface DelegationProvider {
getTaskWithId(id: string): Promise<{ historyItem: HistoryItem }>
reopenParentFromDelegation(params: {
parentTaskId: string
childTaskId: string
completionResultSummary: string
}): Promise<void>
}
export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
readonly name = "attempt_completion" as const
@ -33,7 +44,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
async execute(params: AttemptCompletionParams, task: Task, callbacks: AttemptCompletionCallbacks): Promise<void> {
const { result } = params
const { handleError, pushToolResult, askFinishSubTaskApproval, toolDescription, toolProtocol } = callbacks
const { handleError, pushToolResult, askFinishSubTaskApproval } = callbacks
// Prevent attempt_completion if any tool failed in the current turn
if (task.didToolFailInCurrentTurn) {
@ -77,17 +88,50 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
TelemetryService.instance.captureTaskCompleted(task.taskId)
task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage)
if (task.parentTask) {
const didApprove = await askFinishSubTaskApproval()
// Check for subtask using parentTaskId (metadata-driven delegation)
if (task.parentTaskId) {
// Check if this subtask has already completed and returned to parent
// to prevent duplicate tool_results when user revisits from history
const provider = task.providerRef.deref() as DelegationProvider | undefined
if (provider) {
try {
const { historyItem } = await provider.getTaskWithId(task.taskId)
const status = historyItem?.status
if (!didApprove) {
pushToolResult(formatResponse.toolDenied())
return
if (status === "completed") {
// Subtask already completed - skip delegation flow entirely
// Fall through to normal completion ask flow below (outside this if block)
// This shows the user the completion result and waits for acceptance
// without injecting another tool_result to the parent
} else if (status === "active") {
// Normal subtask completion - do delegation
const delegated = await this.delegateToParent(
task,
result,
provider,
askFinishSubTaskApproval,
pushToolResult,
)
if (delegated) return
} else {
// Unexpected status (undefined or "delegated") - log error and skip delegation
// undefined indicates a bug in status persistence during child creation
// "delegated" would mean this child has its own grandchild pending (shouldn't reach attempt_completion)
console.error(
`[AttemptCompletionTool] Unexpected child task status "${status}" for task ${task.taskId}. ` +
`Expected "active" or "completed". Skipping delegation to prevent data corruption.`,
)
// Fall through to normal completion ask flow
}
} catch (err) {
// If we can't get the history, log error and skip delegation
console.error(
`[AttemptCompletionTool] Failed to get history for task ${task.taskId}: ${(err as Error)?.message ?? String(err)}. ` +
`Skipping delegation.`,
)
// Fall through to normal completion ask flow
}
}
pushToolResult("")
await task.providerRef.deref()?.finishSubTask(result)
return
}
const { response, text, images } = await task.ask("completion_result", "", false)
@ -106,6 +150,35 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
}
}
/**
* Handles the common delegation flow when a subtask completes.
* Returns true if delegation was performed and the caller should return early.
*/
private async delegateToParent(
task: Task,
result: string,
provider: DelegationProvider,
askFinishSubTaskApproval: () => Promise<boolean>,
pushToolResult: (result: string) => void,
): Promise<boolean> {
const didApprove = await askFinishSubTaskApproval()
if (!didApprove) {
pushToolResult(formatResponse.toolDenied())
return true
}
pushToolResult("")
await provider.reopenParentFromDelegation({
parentTaskId: task.parentTaskId!,
childTaskId: task.taskId,
completionResultSummary: result,
})
return true
}
override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise<void> {
const result: string | undefined = block.params.result
const command: string | undefined = block.params.command

View file

@ -3,7 +3,7 @@ import * as vscode from "vscode"
import { TodoItem } from "@roo-code/types"
import { Task } from "../task/Task"
import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { getModeBySlug } from "../../shared/modes"
import { formatResponse } from "../prompts/responses"
import { t } from "../../i18n"
import { parseMarkdownChecklist } from "./UpdateTodoListTool"
@ -123,40 +123,16 @@ export class NewTaskTool extends BaseTool<"new_task"> {
task.checkpointSave(true)
}
// Preserve the current mode so we can resume with it later.
task.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug
// For native protocol, set the pending tool call ID BEFORE starting the subtask.
// This prevents a race condition where the subtask completes (during the delay
// in startSubtask) before we set the ID, which would cause completeSubtask to
// not push the tool_result, breaking the API conversation structure.
if (toolProtocol === "native" && toolCallId) {
task.pendingNewTaskToolCallId = toolCallId
}
const newTask = await task.startSubtask(unescapedMessage, todoItems, mode)
if (!newTask) {
// Clear the pending ID since the subtask wasn't created
if (toolProtocol === "native" && toolCallId) {
task.pendingNewTaskToolCallId = undefined
}
pushToolResult(t("tools:newTask.errors.policy_restriction"))
return
}
// For native protocol with toolCallId, don't push tool_result here.
// The actual result (including what the subtask accomplished) will be pushed
// by completeSubtask. This gives the parent task useful information about
// what the subtask actually did.
// The task loop will stay alive because isPaused is true (see Task.ts stack push condition).
if (toolProtocol !== "native" || !toolCallId) {
// For XML protocol, push the result immediately (existing behavior)
pushToolResult(
`Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage} and ${todoItems.length} todo items`,
)
}
// Delegate parent and open child as sole active task
const child = await (provider as any).delegateParentAndOpenChild({
parentTaskId: task.taskId,
message: unescapedMessage,
initialTodos: todoItems,
mode,
})
// Reflect delegation in tool result (no pause/unpause, no wait)
pushToolResult(`Delegated to child task ${child.taskId}`)
return
} catch (error) {
await handleError("creating new task", error)

View file

@ -74,6 +74,15 @@ const mockSayAndCreateMissingParamError = vi.fn()
const mockStartSubtask = vi
.fn<(message: string, todoItems: any[], mode: string) => Promise<MockClineInstance>>()
.mockResolvedValue({ taskId: "mock-subtask-id" })
// Adapter to satisfy legacy expectations while exercising new delegation path
const mockDelegateParentAndOpenChild = vi.fn(
async (args: { parentTaskId: string; message: string; initialTodos: any[]; mode: string }) => {
// Call legacy spy so existing expectations still pass
await mockStartSubtask(args.message, args.initialTodos, args.mode)
return { taskId: "child-1" }
},
)
const mockCheckpointSave = vi.fn()
// Mock the Cline instance and its methods/properties
@ -93,6 +102,7 @@ const mockCline = {
deref: vi.fn(() => ({
getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
handleModeSwitch: vi.fn(),
delegateParentAndOpenChild: mockDelegateParentAndOpenChild,
})),
},
}
@ -157,7 +167,7 @@ describe("newTaskTool", () => {
)
// Verify side effects
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task"))
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
})
it("should not un-escape single escaped \@", async () => {
@ -270,7 +280,7 @@ describe("newTaskTool", () => {
expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code")
// Should complete successfully
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task"))
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
})
it("should work with todos parameter when provided", async () => {
@ -303,7 +313,7 @@ describe("newTaskTool", () => {
"code",
)
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task"))
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
})
it("should error when mode parameter is missing", async () => {
@ -423,7 +433,7 @@ describe("newTaskTool", () => {
expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code")
// Should complete successfully
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task"))
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
})
it("should REQUIRE todos when VSCode setting is enabled", async () => {
@ -505,7 +515,7 @@ describe("newTaskTool", () => {
)
// Should complete successfully
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task"))
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
})
it("should work with empty todos string when VSCode setting is enabled", async () => {
@ -542,7 +552,7 @@ describe("newTaskTool", () => {
expect(mockStartSubtask).toHaveBeenCalledWith("Test message", [], "code")
// Should complete successfully
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully created new task"))
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task"))
})
it("should check VSCode setting with Package.name configuration key", async () => {
@ -613,3 +623,77 @@ describe("newTaskTool", () => {
// Add more tests for error handling (invalid mode, approval denied) if needed
})
describe("newTaskTool delegation flow", () => {
it("delegates to provider and does not call legacy startSubtask", async () => {
// Arrange: stub provider delegation
const providerSpy = {
getState: vi.fn().mockResolvedValue({
mode: "ask",
experiments: {},
}),
delegateParentAndOpenChild: vi.fn().mockResolvedValue({ taskId: "child-1" }),
handleModeSwitch: vi.fn(),
} as any
// Use a fresh local cline instance to avoid cross-test interference
const localStartSubtask = vi.fn()
const localEmit = vi.fn()
const localCline = {
ask: vi.fn(),
sayAndCreateMissingParamError: mockSayAndCreateMissingParamError,
emit: localEmit,
recordToolError: mockRecordToolError,
consecutiveMistakeCount: 0,
isPaused: false,
pausedModeSlug: "ask",
taskId: "mock-parent-task-id",
enableCheckpoints: false,
checkpointSave: mockCheckpointSave,
startSubtask: localStartSubtask,
providerRef: {
deref: vi.fn(() => providerSpy),
},
}
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Do something",
// no todos -> should default to []
},
partial: false,
}
// Act
await newTaskTool.handle(localCline as any, block as ToolUse<"new_task">, {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
removeClosingTag: mockRemoveClosingTag,
toolProtocol: "xml",
})
// Assert: provider method called with correct params
expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({
parentTaskId: "mock-parent-task-id",
message: "Do something",
initialTodos: [],
mode: "code",
})
// Assert: legacy path not used
expect(localStartSubtask).not.toHaveBeenCalled()
// Assert: no pause/unpause events emitted in delegation path
const pauseEvents = (localEmit as any).mock.calls.filter(
(c: any[]) => c[0] === "taskPaused" || c[0] === "taskUnpaused",
)
expect(pauseEvents.length).toBe(0)
// Assert: tool result reflects delegation
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Delegated to child task child-1"))
})
})

View file

@ -92,8 +92,9 @@ import { Task } from "../task/Task"
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"
import { webviewMessageHandler } from "./webviewMessageHandler"
import type { ClineMessage } from "@roo-code/types"
import type { ClineMessage, TodoItem } from "@roo-code/types"
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence"
import { readTaskMessages } from "../task-persistence/taskMessages"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { REQUESTY_BASE_URL } from "../../shared/utils/requesty"
@ -483,19 +484,6 @@ export class ClineProvider
return this.clineStack.map((cline) => cline.taskId)
}
// Remove the current task/cline instance (at the top of the stack), so this
// task is finished and resume the previous task/cline instance (if it
// exists).
// This is used when a subtask is finished and the parent task needs to be
// resumed.
async finishSubTask(lastMessage: string) {
// Remove the last cline instance from the stack (this is the finished
// subtask).
await this.removeClineFromStack()
// Resume the last cline instance in the stack (if it exists - this is
// the 'parent' calling task).
await this.getCurrentTask()?.completeSubtask(lastMessage)
}
// Pending Edit Operations Management
/**
@ -862,7 +850,10 @@ export class ClineProvider
await this.removeClineFromStack()
}
public async createTaskWithHistoryItem(historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task }) {
public async createTaskWithHistoryItem(
historyItem: HistoryItem & { rootTask?: Task; parentTask?: Task },
options?: { startTask?: boolean },
) {
// Check if we're rehydrating the current task to avoid flicker
const currentTask = this.getCurrentTask()
const isRehydratingCurrentTask = currentTask && currentTask.taskId === historyItem.id
@ -940,7 +931,10 @@ export class ClineProvider
taskNumber: historyItem.number,
workspacePath: historyItem.workspace,
onCreated: this.taskCreationCallback,
startTask: options?.startTask ?? true,
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled),
// Preserve the status from the history item to avoid overwriting it when the task saves messages
initialStatus: historyItem.status,
})
if (isRehydratingCurrentTask) {
@ -1666,9 +1660,8 @@ export class ClineProvider
// remove task from stack if it's the current task
if (id === this.getCurrentTask()?.taskId) {
// if we found the taskid to delete - call finish to abort this task and allow a new task to be started,
// if we are deleting a subtask and parent task is still waiting for subtask to finish - it allows the parent to resume (this case should neve exist)
await this.finishSubTask(t("common:tasks.deleted"))
// Close the current task instance; delegation flows will be handled via metadata if applicable.
await this.removeClineFromStack()
}
// delete task from the task history state
@ -2359,7 +2352,13 @@ export class ClineProvider
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
history[existingItemIndex] = item
// Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten.
// This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened,
// terminated, or when routine message persistence occurs.
history[existingItemIndex] = {
...history[existingItemIndex],
...item,
}
} else {
history.push(item)
}
@ -2699,6 +2698,15 @@ export class ClineProvider
remoteControlEnabled,
} = await this.getState()
// Single-open-task invariant: always enforce for user-initiated top-level tasks
if (!parentTask) {
try {
await this.removeClineFromStack()
} catch {
// Non-fatal
}
}
if (!ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList)) {
throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist"))
}
@ -2919,11 +2927,11 @@ export class ClineProvider
language,
mode,
taskId: task?.taskId,
parentTaskId: task?.parentTask?.taskId,
parentTaskId: task?.parentTaskId,
apiProvider: apiConfiguration?.apiProvider,
modelId: task?.api?.getModel().id,
diffStrategy: task?.diffStrategy?.getName(),
isSubtask: task ? !!task.parentTask : undefined,
isSubtask: task ? !!task.parentTaskId : undefined,
...(todos && { todos }),
}
}
@ -2953,6 +2961,286 @@ export class ClineProvider
return this.currentWorkspacePath || getWorkspacePath()
}
/**
* Delegate parent task and open child task.
*
* - Enforce single-open invariant
* - Persist parent delegation metadata
* - Emit TaskDelegated (task-level; API forwards to provider/bridge)
* - Create child as sole active and switch mode to child's mode
*/
public async delegateParentAndOpenChild(params: {
parentTaskId: string
message: string
initialTodos: TodoItem[]
mode: string
}): Promise<Task> {
const { parentTaskId, message, initialTodos, mode } = params
// Metadata-driven delegation is always enabled
// 1) Get parent (must be current task)
const parent = this.getCurrentTask()
if (!parent) {
throw new Error("[delegateParentAndOpenChild] No current task")
}
if (parent.taskId !== parentTaskId) {
throw new Error(
`[delegateParentAndOpenChild] Parent mismatch: expected ${parentTaskId}, current ${parent.taskId}`,
)
}
// 2) Enforce single-open invariant by closing/disposing the parent first
// This ensures we never have >1 tasks open at any time during delegation.
// Await abort completion to ensure clean disposal and prevent unhandled rejections.
try {
await this.removeClineFromStack()
} catch (error) {
this.log(
`[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${
error instanceof Error ? error.message : String(error)
}`,
)
// Non-fatal: proceed with child creation even if parent cleanup had issues
}
// 3) Switch provider mode to child's requested mode BEFORE creating the child task
// This ensures the child's system prompt and configuration are based on the correct mode.
// The mode switch must happen before createTask() because the Task constructor
// initializes its mode from provider.getState() during initializeTaskMode().
try {
await this.handleModeSwitch(mode as any)
} catch (e) {
this.log(
`[delegateParentAndOpenChild] handleModeSwitch failed for mode '${mode}': ${
(e as Error)?.message ?? String(e)
}`,
)
}
// 4) Create child as sole active (parent reference preserved for lineage)
// Pass initialStatus: "active" to ensure the child task's historyItem is created
// with status from the start, avoiding race conditions where the task might
// call attempt_completion before status is persisted separately.
const child = await this.createTask(message, undefined, parent as any, {
initialTodos,
initialStatus: "active",
})
// 5) Persist parent delegation metadata
try {
const { historyItem } = await this.getTaskWithId(parentTaskId)
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId]))
const updatedHistory: typeof historyItem = {
...historyItem,
status: "delegated",
delegatedToId: child.taskId,
awaitingChildId: child.taskId,
childIds,
}
await this.updateTaskHistory(updatedHistory)
} catch (err) {
this.log(
`[delegateParentAndOpenChild] Failed to persist parent metadata for ${parentTaskId} -> ${child.taskId}: ${
(err as Error)?.message ?? String(err)
}`,
)
}
// 6) Emit TaskDelegated (provider-level)
try {
this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId)
} catch {
// non-fatal
}
return child
}
/**
* Reopen parent task from delegation with write-back and events.
*/
public async reopenParentFromDelegation(params: {
parentTaskId: string
childTaskId: string
completionResultSummary: string
}): Promise<void> {
const { parentTaskId, childTaskId, completionResultSummary } = params
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
// 1) Load parent from history and current persisted messages
const { historyItem } = await this.getTaskWithId(parentTaskId)
let parentClineMessages: ClineMessage[] = []
try {
parentClineMessages = await readTaskMessages({
taskId: parentTaskId,
globalStoragePath,
})
} catch {
parentClineMessages = []
}
let parentApiMessages: any[] = []
try {
parentApiMessages = (await readApiMessages({
taskId: parentTaskId,
globalStoragePath,
})) as any[]
} catch {
parentApiMessages = []
}
// 2) Inject synthetic records: UI subtask_result and update API tool_result
const ts = Date.now()
// Defensive: ensure arrays
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
const subtaskUiMessage: ClineMessage = {
type: "say",
say: "subtask_result",
text: completionResultSummary,
ts,
}
parentClineMessages.push(subtaskUiMessage)
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
// Find the tool_use_id from the last assistant message's new_task tool_use
let toolUseId: string | undefined
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
const msg = parentApiMessages[i]
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && block.name === "new_task") {
toolUseId = block.id
break
}
}
if (toolUseId) break
}
}
// The API expects: user → assistant (with tool_use) → user (with tool_result)
// We need to add a NEW user message with the tool_result AFTER the assistant's tool_use
// NOT add it to an existing user message
if (toolUseId) {
// Check if the last message is already a user message with a tool_result for this tool_use_id
// (in case this is a retry or the history was already updated)
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
let alreadyHasToolResult = false
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
for (const block of lastMsg.content) {
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
// Update the existing tool_result content
block.content = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
alreadyHasToolResult = true
break
}
}
}
// If no existing tool_result found, create a NEW user message with the tool_result
if (!alreadyHasToolResult) {
parentApiMessages.push({
role: "user",
content: [
{
type: "tool_result" as const,
tool_use_id: toolUseId,
content: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
},
],
ts,
})
}
} else {
// Fallback for XML protocol or when toolUseId couldn't be found:
// Add a text block (not ideal but maintains backward compatibility)
parentApiMessages.push({
role: "user",
content: [
{
type: "text",
text: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
},
],
ts,
})
}
await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath })
// 3) Update child metadata to "completed" status
try {
const { historyItem: childHistory } = await this.getTaskWithId(childTaskId)
await this.updateTaskHistory({
...childHistory,
status: "completed",
})
} catch (err) {
this.log(
`[reopenParentFromDelegation] Failed to persist child completed status for ${childTaskId}: ${
(err as Error)?.message ?? String(err)
}`,
)
}
// 4) Update parent metadata and persist BEFORE emitting completion event
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId]))
const updatedHistory: typeof historyItem = {
...historyItem,
status: "active",
completedByChildId: childTaskId,
completionResultSummary,
awaitingChildId: undefined,
childIds,
}
await this.updateTaskHistory(updatedHistory)
// 5) Emit TaskDelegationCompleted (provider-level)
try {
this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary)
} catch {
// non-fatal
}
// 6) Close child instance if still open (single-open-task invariant)
const current = this.getCurrentTask()
if (current?.taskId === childTaskId) {
await this.removeClineFromStack()
}
// 7) Reopen the parent from history as the sole active task (restores saved mode)
// IMPORTANT: startTask=false to suppress resume-from-history ask scheduling
const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false })
// 8) Inject restored histories into the in-memory instance before resuming
if (parentInstance) {
try {
await parentInstance.overwriteClineMessages(parentClineMessages)
} catch {
// non-fatal
}
try {
await parentInstance.overwriteApiConversationHistory(parentApiMessages as any)
} catch {
// non-fatal
}
// Auto-resume parent without ask("resume_task")
await parentInstance.resumeAfterDelegation()
}
// 9) Emit TaskDelegationResumed (provider-level)
try {
this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId)
} catch {
// non-fatal
}
}
/**
* Convert a file path to a webview-accessible URI
* This method safely converts file paths to URIs that can be loaded in the webview

View file

@ -615,14 +615,12 @@ describe("ClineProvider", () => {
await provider.resolveWebviewView(mockWebviewView)
})
test("calls clearTask when there is no parent task", async () => {
test("calls clearTask (delegation handled via metadata)", async () => {
// Setup a single task without parent
const mockCline = new Task(defaultTaskOptions)
// No need to set parentTask - it's undefined by default
// Mock the provider methods
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
// Add task to stack
@ -634,25 +632,22 @@ describe("ClineProvider", () => {
// Trigger clearTask message
await messageHandler({ type: "clearTask" })
// Verify clearTask was called (not finishSubTask)
// Verify clearTask was called
expect(clearTaskSpy).toHaveBeenCalled()
expect(finishSubTaskSpy).not.toHaveBeenCalled()
expect(postStateToWebviewSpy).toHaveBeenCalled()
})
test("calls finishSubTask when there is a parent task", async () => {
test("calls clearTask even with parent task (delegation via metadata)", async () => {
// Setup parent and child tasks
const parentTask = new Task(defaultTaskOptions)
const childTask = new Task(defaultTaskOptions)
// Set up parent-child relationship by setting the parentTask property
// The mock allows us to set properties directly
// Set up parent-child relationship
;(childTask as any).parentTask = parentTask
;(childTask as any).rootTask = parentTask
// Mock the provider methods
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
// Add both tasks to stack (parent first, then child)
@ -665,9 +660,8 @@ describe("ClineProvider", () => {
// Trigger clearTask message
await messageHandler({ type: "clearTask" })
// Verify finishSubTask was called (not clearTask)
expect(finishSubTaskSpy).toHaveBeenCalledWith(expect.stringContaining("canceled"))
expect(clearTaskSpy).not.toHaveBeenCalled()
// Verify clearTask was called (delegation happens via metadata, not finishSubTask)
expect(clearTaskSpy).toHaveBeenCalled()
expect(postStateToWebviewSpy).toHaveBeenCalled()
})
@ -676,7 +670,6 @@ describe("ClineProvider", () => {
// Mock the provider methods
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
// Get the message handler
@ -687,21 +680,17 @@ describe("ClineProvider", () => {
// When there's no current task, clearTask is still called (it handles the no-task case internally)
expect(clearTaskSpy).toHaveBeenCalled()
expect(finishSubTaskSpy).not.toHaveBeenCalled()
// State should still be posted
expect(postStateToWebviewSpy).toHaveBeenCalled()
})
test("correctly identifies subtask scenario for issue #4602", async () => {
// This test specifically validates the fix for issue #4602
// where canceling during API retry was incorrectly treating a single task as a subtask
test("correctly identifies task scenario for issue #4602", async () => {
// This test validates the fix for issue #4602
// where canceling during API retry correctly uses clearTask
const mockCline = new Task(defaultTaskOptions)
// No parent task by default - no need to explicitly set
// Mock the provider methods
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
// Add only one task to stack
await provider.addClineToStack(mockCline)
@ -715,9 +704,8 @@ describe("ClineProvider", () => {
// Trigger clearTask message (simulating cancel during API retry)
await messageHandler({ type: "clearTask" })
// The fix ensures clearTask is called, not finishSubTask
// clearTask should be called (delegation handled via metadata)
expect(clearTaskSpy).toHaveBeenCalled()
expect(finishSubTaskSpy).not.toHaveBeenCalled()
})
})

View file

@ -634,19 +634,10 @@ export const webviewMessageHandler = async (
}
break
case "clearTask":
// Clear task resets the current session and allows for a new task
// to be started, if this session is a subtask - it allows the
// parent task to be resumed.
// Check if the current task actually has a parent task.
const currentTask = provider.getCurrentTask()
if (currentTask && currentTask.parentTask) {
await provider.finishSubTask(t("common:tasks.canceled"))
} else {
// Regular task - just clear it
await provider.clearTask()
}
// Clear task resets the current session. Delegation flows are
// handled via metadata; parent resumption occurs through
// reopenParentFromDelegation, not via finishSubTask.
await provider.clearTask()
await provider.postStateToWebview()
break
case "didShowAnnouncement":

View file

@ -171,8 +171,9 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
return this.sidebarProvider.getCurrentTaskStack()
}
public async clearCurrentTask(lastMessage?: string) {
await this.sidebarProvider.finishSubTask(lastMessage ?? "")
public async clearCurrentTask(_lastMessage?: string) {
// Legacy finishSubTask removed; clear current by closing active task instance.
await this.sidebarProvider.removeClineFromStack()
await this.sidebarProvider.postStateToWebview()
}
@ -270,6 +271,18 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
this.emit(RooCodeEventName.TaskSpawned, task.taskId, childTaskId)
})
task.on(RooCodeEventName.TaskDelegated as any, (childTaskId: string) => {
;(this.emit as any)(RooCodeEventName.TaskDelegated, task.taskId, childTaskId)
})
task.on(RooCodeEventName.TaskDelegationCompleted as any, (childTaskId: string, summary: string) => {
;(this.emit as any)(RooCodeEventName.TaskDelegationCompleted, task.taskId, childTaskId, summary)
})
task.on(RooCodeEventName.TaskDelegationResumed as any, (childTaskId: string) => {
;(this.emit as any)(RooCodeEventName.TaskDelegationResumed, task.taskId, childTaskId)
})
// Task Execution
task.on(RooCodeEventName.Message, async (message) => {

View file

@ -355,8 +355,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setSendingDisabled(false)
setClineAsk("resume_task")
setEnableButtons(true)
setPrimaryButtonText(t("chat:resumeTask.title"))
setSecondaryButtonText(t("chat:terminate.title"))
// For completed subtasks, show "Start New Task" instead of "Resume"
// A subtask is considered completed if:
// - It has a parentTaskId AND
// - Its messages contain a completion_result (either ask or say)
const isCompletedSubtask =
currentTaskItem?.parentTaskId &&
messages.some(
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
)
if (isCompletedSubtask) {
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
} else {
setPrimaryButtonText(t("chat:resumeTask.title"))
setSecondaryButtonText(t("chat:terminate.title"))
}
setDidClickCancel(false) // special case where we reset the cancel button state
break
case "resume_completed_task":
@ -400,6 +414,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}, [lastMessage, secondLastMessage])
// Update button text when messages change (e.g., completion_result is added) for subtasks in resume_task state
useEffect(() => {
if (clineAsk === "resume_task" && currentTaskItem?.parentTaskId) {
const hasCompletionResult = messages.some(
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
)
if (hasCompletionResult) {
setPrimaryButtonText(t("chat:startNewTask.title"))
setSecondaryButtonText(undefined)
}
}
}, [clineAsk, currentTaskItem?.parentTaskId, messages, t])
useEffect(() => {
if (messages.length === 0) {
setSendingDisabled(false)
@ -638,7 +665,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "tool":
case "browser_action_launch":
case "use_mcp_server":
case "resume_task":
case "mistake_limit_reached":
// Only send text/images if they exist
if (trimmedInput || (images && images.length > 0)) {
@ -655,6 +681,33 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
}
break
case "resume_task":
// For completed subtasks (tasks with a parentTaskId and a completion_result),
// start a new task instead of resuming since the subtask is done
const isCompletedSubtaskForClick =
currentTaskItem?.parentTaskId &&
messagesRef.current.some(
(msg) => msg.ask === "completion_result" || msg.say === "completion_result",
)
if (isCompletedSubtaskForClick) {
startNewTask()
} else {
// Only send text/images if they exist
if (trimmedInput || (images && images.length > 0)) {
vscode.postMessage({
type: "askResponse",
askResponse: "yesButtonClicked",
text: trimmedInput,
images: images,
})
// Clear input state after sending
setInputValue("")
setSelectedImages([])
} else {
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
}
}
break
case "completion_result":
case "resume_completed_task":
// Waiting for feedback, but we can just present a new task button
@ -669,7 +722,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
setClineAsk(undefined)
setEnableButtons(false)
},
[clineAsk, startNewTask],
[clineAsk, startNewTask, currentTaskItem?.parentTaskId],
)
const handleSecondaryButtonClick = useCallback(

View file

@ -47,7 +47,8 @@ const TaskItem = ({
key={item.id}
data-testid={`task-item-${item.id}`}
className={cn(
"cursor-pointer group bg-vscode-editor-background rounded-xl relative overflow-hidden border border-transparent hover:bg-vscode-editor-foreground/10 transition-colors",
"cursor-pointer group bg-vscode-editor-background rounded-xl relative overflow-hidden border hover:bg-vscode-editor-foreground/10 transition-colors",
"border-transparent",
className,
)}
onClick={handleClick}>
@ -80,6 +81,7 @@ const TaskItem = ({
{...(item.highlight ? { dangerouslySetInnerHTML: { __html: item.highlight } } : {})}>
{item.highlight ? undefined : item.task}
</div>
<TaskItemFooter
item={item}
variant={variant}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Has esperat {{timeout}} segons per inicialitzar el punt de control. Si no necessites aquesta funció, desactiva-la a <settingsLink>la configuració del punt de control</settingsLink>.",
"init_checkpoint_fail_long_time": "La inicialització del punt de control ha trigat més de {{timeout}} segons, per això els punts de control estan desactivats per a aquesta tasca. Pots desactivar els punts de control o augmentar el temps d'espera a <settingsLink>la configuració del punt de control</settingsLink>.",
"attempt_completion_tool_failed": "No es pot executar attempt_completion perquè una crida d'eina anterior ha fallat en aquest torn. Si us plau, resol el problema de l'eina abans d'intentar completar."
},
"tasks": {
"delegated": "Delegat a subtasca",
"delegated_to": "Delegat a la tasca {{childId}}",
"delegation_completed": "Subtasca completada, reprenent la tasca principal",
"awaiting_child": "Esperant la tasca filla {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Du hast {{timeout}} Sekunden auf die Initialisierung des Checkpoints gewartet. Wenn du die Checkpoint-Funktion nicht brauchst, kannst du sie in den <settingsLink>Checkpoint-Einstellungen</settingsLink> ausschalten.",
"init_checkpoint_fail_long_time": "Die Initialisierung des Checkpoints dauert länger als {{timeout}} Sekunden, deshalb sind Checkpoints für diese Aufgabe deaktiviert. Du kannst Checkpoints ausschalten oder die Wartezeit in den <settingsLink>Checkpoint-Einstellungen</settingsLink> verlängern.",
"attempt_completion_tool_failed": "Du kannst attempt_completion nicht ausführen, weil ein vorheriger Tool-Aufruf in diesem Durchgang fehlgeschlagen ist. Behebe den Tool-Fehler, bevor du versuchst, abzuschließen."
},
"tasks": {
"delegated": "An Unteraufgabe delegiert",
"delegated_to": "An Aufgabe {{childId}} delegiert",
"delegation_completed": "Unteraufgabe abgeschlossen, übergeordnete Aufgabe wird fortgesetzt",
"awaiting_child": "Warte auf Unteraufgabe {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Waited {{timeout}} seconds for checkpoint initialization. If you don't need the checkpoint feature, please turn it off in <settingsLink>the checkpoint settings</settingsLink>.",
"init_checkpoint_fail_long_time": "Checkpoint initialization has taken more than {{timeout}} seconds, so checkpoints are disabled for this task. You can disable checkpoints or extend the waiting time in <settingsLink>the checkpoint settings</settingsLink>.",
"attempt_completion_tool_failed": "Cannot execute attempt_completion because a previous tool call failed in this turn. Please address the tool failure before attempting completion."
},
"tasks": {
"delegated": "Delegated to subtask",
"delegated_to": "Delegated to task {{childId}}",
"delegation_completed": "Subtask completed, resuming parent",
"awaiting_child": "Awaiting child task {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Has esperado {{timeout}} segundos para la inicialización del punto de control. Si no necesitas esta función, desactívala en <settingsLink>la configuración del punto de control</settingsLink>.",
"init_checkpoint_fail_long_time": "La inicialización del punto de control ha tardado más de {{timeout}} segundos, por lo que los puntos de control están desactivados para esta tarea. Puedes desactivar los puntos de control o aumentar el tiempo de espera en <settingsLink>la configuración del punto de control</settingsLink>.",
"attempt_completion_tool_failed": "No se puede ejecutar attempt_completion porque una llamada de herramienta anterior falló en este turno. Por favor, resuelve el error de la herramienta antes de intentar completar."
},
"tasks": {
"delegated": "Delegado a subtarea",
"delegated_to": "Delegado a la tarea {{childId}}",
"delegation_completed": "Subtarea completada, reanudando tarea principal",
"awaiting_child": "Esperando tarea secundaria {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Tu as attendu {{timeout}} secondes pour l'initialisation du checkpoint. Si tu n'as pas besoin de cette fonction, désactive-la dans <settingsLink>les paramètres du checkpoint</settingsLink>.",
"init_checkpoint_fail_long_time": "L'initialisation du checkpoint a pris plus de {{timeout}} secondes, donc les checkpoints sont désactivés pour cette tâche. Tu peux désactiver les checkpoints ou prolonger le délai dans <settingsLink>les paramètres du checkpoint</settingsLink>.",
"attempt_completion_tool_failed": "Tu ne peux pas exécuter attempt_completion car un appel d'outil précédent a échoué dans ce tour. Résous l'échec de l'outil avant de tenter de terminer."
},
"tasks": {
"delegated": "Délégué à la sous-tâche",
"delegated_to": "Délégué à la tâche {{childId}}",
"delegation_completed": "Sous-tâche terminée, reprise de la tâche parent",
"awaiting_child": "En attente de la tâche enfant {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "तुमने {{timeout}} सेकंड तक चेकपॉइंट इनिशियलाइज़ेशन का इंतजार किया। अगर तुम्हें यह फ़ीचर नहीं चाहिए, तो <settingsLink>चेकपॉइंट सेटिंग्स</settingsLink> में बंद कर दो।",
"init_checkpoint_fail_long_time": "चेकपॉइंट इनिशियलाइज़ेशन {{timeout}} सेकंड से ज़्यादा समय ले रहा है, इसलिए इस कार्य के लिए चेकपॉइंट बंद कर दिए गए हैं। तुम चेकपॉइंट बंद कर सकते हो या <settingsLink>चेकपॉइंट सेटिंग्स</settingsLink> में इंतजार का समय बढ़ा सकते हो।",
"attempt_completion_tool_failed": "attempt_completion निष्पादित नहीं किया जा सकता क्योंकि इस टर्न में पिछली टूल कॉल विफल रही है। कृपया पूरा करने का प्रयास करने से पहले टूल विफलता को ठीक करें।"
},
"tasks": {
"delegated": "उप-कार्य को सौंपा गया",
"delegated_to": "कार्य {{childId}} को सौंपा गया",
"delegation_completed": "उप-कार्य पूर्ण, मुख्य कार्य फिर से शुरू हो रहा है",
"awaiting_child": "चाइल्ड कार्य {{childId}} की प्रतीक्षा में"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Kamu sudah menunggu {{timeout}} detik untuk inisialisasi checkpoint. Kalau tidak butuh fitur ini, matikan saja di <settingsLink>pengaturan checkpoint</settingsLink>.",
"init_checkpoint_fail_long_time": "Inisialisasi checkpoint sudah lebih dari {{timeout}} detik, jadi checkpoint dinonaktifkan untuk tugas ini. Kamu bisa mematikan checkpoint atau menambah waktu tunggu di <settingsLink>pengaturan checkpoint</settingsLink>.",
"attempt_completion_tool_failed": "Tidak dapat mengeksekusi attempt_completion karena panggilan alat sebelumnya gagal dalam giliran ini. Harap atasi kegagalan alat sebelum mencoba menyelesaikan."
},
"tasks": {
"delegated": "Didelegasikan ke subtugas",
"delegated_to": "Didelegasikan ke tugas {{childId}}",
"delegation_completed": "Subtugas selesai, melanjutkan tugas induk",
"awaiting_child": "Menunggu tugas anak {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Hai aspettato {{timeout}} secondi per l'inizializzazione del checkpoint. Se non ti serve questa funzione, disattivala nelle <settingsLink>impostazioni del checkpoint</settingsLink>.",
"init_checkpoint_fail_long_time": "L'inizializzazione del checkpoint ha impiegato più di {{timeout}} secondi, quindi i checkpoint sono disabilitati per questa attività. Puoi disattivare i checkpoint o aumentare il tempo di attesa nelle <settingsLink>impostazioni del checkpoint</settingsLink>.",
"attempt_completion_tool_failed": "Non puoi eseguire attempt_completion perché una chiamata di strumento precedente è fallita in questo turno. Risolvi il problema dello strumento prima di tentare di completare."
},
"tasks": {
"delegated": "Delegato a sottoattività",
"delegated_to": "Delegato all'attività {{childId}}",
"delegation_completed": "Sottoattività completata, ripresa attività padre",
"awaiting_child": "In attesa dell'attività figlia {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "{{timeout}} 秒間チェックポイントの初期化を待機しました。チェックポイント機能が不要な場合は、<settingsLink>チェックポイント設定</settingsLink>でオフにしてください。",
"init_checkpoint_fail_long_time": "チェックポイントの初期化が {{timeout}} 秒以上かかったため、このタスクではチェックポイントが無効化されました。チェックポイントをオフにするか、<settingsLink>チェックポイント設定</settingsLink>で待機時間を延長できます。",
"attempt_completion_tool_failed": "前回のツール呼び出しがこのターンで失敗したため、attempt_completionを実行できません。完了を試みる前にツールの失敗に対処してください。"
},
"tasks": {
"delegated": "サブタスクに委任",
"delegated_to": "タスク{{childId}}に委任",
"delegation_completed": "サブタスク完了、親タスクを再開",
"awaiting_child": "子タスク{{childId}}を待機中"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "{{timeout}}초 동안 체크포인트 초기화를 기다렸어. 체크포인트 기능이 필요 없다면 <settingsLink>체크포인트 설정</settingsLink>에서 꺼 줘.",
"init_checkpoint_fail_long_time": "체크포인트 초기화가 {{timeout}}초 이상 걸려서 이 작업에 대해 체크포인트가 꺼졌어. 체크포인트를 끄거나 <settingsLink>체크포인트 설정</settingsLink>에서 대기 시간을 늘릴 수 있어.",
"attempt_completion_tool_failed": "이전 도구 호출이 이 턴에서 실패했기 때문에 attempt_completion을 실행할 수 없습니다. 완료를 시도하기 전에 도구 실패를 해결하세요."
},
"tasks": {
"delegated": "하위 작업에 위임됨",
"delegated_to": "작업 {{childId}}에 위임됨",
"delegation_completed": "하위 작업 완료, 상위 작업 재개",
"awaiting_child": "하위 작업 {{childId}} 대기 중"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Je hebt {{timeout}} seconden gewacht op de initialisatie van de checkpoint. Als je deze functie niet nodig hebt, schakel hem dan uit in de <settingsLink>checkpoint-instellingen</settingsLink>.",
"init_checkpoint_fail_long_time": "De initialisatie van de checkpoint duurde meer dan {{timeout}} seconden, dus checkpoints zijn uitgeschakeld voor deze taak. Je kunt checkpoints uitschakelen of de wachttijd in de <settingsLink>checkpoint-instellingen</settingsLink> verhogen.",
"attempt_completion_tool_failed": "Je kunt attempt_completion niet uitvoeren omdat een eerdere tool-aanroep in deze beurt is mislukt. Los het tool-probleem op voordat je probeert te voltooien."
},
"tasks": {
"delegated": "Gedelegeerd naar subtaak",
"delegated_to": "Gedelegeerd naar taak {{childId}}",
"delegation_completed": "Subtaak voltooid, hoofdtaak wordt hervat",
"awaiting_child": "Wachten op kindtaak {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Czekałeś {{timeout}} sekund na inicjalizację punktu kontrolnego. Jeśli nie potrzebujesz tej funkcji, wyłącz ją w <settingsLink>ustawieniach punktu kontrolnego</settingsLink>.",
"init_checkpoint_fail_long_time": "Inicjalizacja punktu kontrolnego trwała ponad {{timeout}} sekund, więc punkty kontrolne zostały wyłączone dla tego zadania. Możesz wyłączyć punkty kontrolne lub wydłużyć czas oczekiwania w <settingsLink>ustawieniach punktu kontrolnego</settingsLink>.",
"attempt_completion_tool_failed": "Nie można wykonać attempt_completion, ponieważ poprzednie wywołanie narzędzia nie powiodło się w tym cyklu. Rozwiąż błąd narzędzia przed próbą zakończenia."
},
"tasks": {
"delegated": "Przekazano do podzadania",
"delegated_to": "Przekazano do zadania {{childId}}",
"delegation_completed": "Podzadanie ukończone, wznowienie zadania nadrzędnego",
"awaiting_child": "Oczekiwanie na zadanie podrzędne {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Você esperou {{timeout}} segundos para inicializar o checkpoint. Se não precisa dessa função, desative nas <settingsLink>configurações do checkpoint</settingsLink>.",
"init_checkpoint_fail_long_time": "A inicialização do checkpoint levou mais de {{timeout}} segundos, então os checkpoints foram desativados para esta tarefa. Você pode desativar os checkpoints ou aumentar o tempo de espera nas <settingsLink>configurações do checkpoint</settingsLink>.",
"attempt_completion_tool_failed": "Não é possível executar attempt_completion porque uma chamada de ferramenta anterior falhou neste turno. Por favor, resolva a falha da ferramenta antes de tentar concluir."
},
"tasks": {
"delegated": "Delegado para subtarefa",
"delegated_to": "Delegado para tarefa {{childId}}",
"delegation_completed": "Subtarefa concluída, retomando tarefa pai",
"awaiting_child": "Aguardando tarefa filha {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Ожидание инициализации контрольной точки заняло {{timeout}} секунд. Если тебе не нужна эта функция, отключи её в <settingsLink>настройках контрольных точек</settingsLink>.",
"init_checkpoint_fail_long_time": "Инициализация контрольной точки заняла более {{timeout}} секунд, поэтому контрольные точки отключены для этой задачи. Ты можешь отключить контрольные точки или увеличить время ожидания в <settingsLink>настройках контрольных точек</settingsLink>.",
"attempt_completion_tool_failed": "Невозможно выполнить attempt_completion, потому что предыдущий вызов инструмента не удался в этом повороте. Пожалуйста, устрани сбой инструмента перед попыткой завершения."
},
"tasks": {
"delegated": "Делегировано подзадаче",
"delegated_to": "Делегировано задаче {{childId}}",
"delegation_completed": "Подзадача завершена, возобновление родительской задачи",
"awaiting_child": "Ожидание дочерней задачи {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "{{timeout}} saniye boyunca kontrol noktası başlatılması beklendi. Bu özelliğe ihtiyacın yoksa <settingsLink>kontrol noktası ayarlarından</settingsLink> kapatabilirsin.",
"init_checkpoint_fail_long_time": "Kontrol noktası başlatılması {{timeout}} saniyeden fazla sürdü, bu yüzden bu görev için kontrol noktaları devre dışı bırakıldı. Kontrol noktalarını kapatabilir veya <settingsLink>kontrol noktası ayarlarından</settingsLink> bekleme süresini artırabilirsin.",
"attempt_completion_tool_failed": "attempt_completion çalıştırılamıyor çünkü bu turda önceki bir araç çağrısı başarısız oldu. Lütfen tamamlamayı denemeden önce araç hatasını giderin."
},
"tasks": {
"delegated": "Alt göreve devredildi",
"delegated_to": "{{childId}} görevine devredildi",
"delegation_completed": "Alt görev tamamlandı, üst görev devam ediyor",
"awaiting_child": "{{childId}} alt görevi bekleniyor"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "Bạn đã chờ {{timeout}} giây để khởi tạo điểm kiểm tra. Nếu không cần chức năng này, hãy tắt nó trong <settingsLink>cài đặt điểm kiểm tra</settingsLink>.",
"init_checkpoint_fail_long_time": "Khởi tạo điểm kiểm tra mất hơn {{timeout}} giây, vì vậy các điểm kiểm tra đã bị vô hiệu hóa cho tác vụ này. Bạn có thể tắt các điểm kiểm tra hoặc tăng thời gian chờ trong <settingsLink>cài đặt điểm kiểm tra</settingsLink>.",
"attempt_completion_tool_failed": "Không thể thực thi attempt_completion vì một lệnh gọi công cụ trước đó đã thất bại trong lượt này. Vui lòng giải quyết lỗi công cụ trước khi cố gắng hoàn thành."
},
"tasks": {
"delegated": "Ủy quyền cho nhiệm vụ con",
"delegated_to": "Ủy quyền cho nhiệm vụ {{childId}}",
"delegation_completed": "Nhiệm vụ con hoàn thành, tiếp tục nhiệm vụ cha",
"awaiting_child": "Đang chờ nhiệm vụ con {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "初始化存档点已等待 {{timeout}} 秒。如果你不需要存档点功能,请在<settingsLink>存档点设置</settingsLink>中关闭。",
"init_checkpoint_fail_long_time": "存档点初始化已超过 {{timeout}} 秒,因此本任务已禁用存档点。你可以关闭存档点或在<settingsLink>存档点设置</settingsLink>中延长等待时间。",
"attempt_completion_tool_failed": "无法执行 attempt_completion因为本轮中先前的工具调用失败了。请在尝试完成前解决工具失败问题。"
},
"tasks": {
"delegated": "已委托给子任务",
"delegated_to": "已委托给任务 {{childId}}",
"delegation_completed": "子任务已完成,恢复父任务",
"awaiting_child": "等待子任务 {{childId}}"
}
}

View file

@ -100,5 +100,11 @@
"wait_checkpoint_long_time": "初始化存檔點已等待 {{timeout}} 秒。如果你不需要存檔點功能,請在<settingsLink>存檔點設定</settingsLink>中關閉。",
"init_checkpoint_fail_long_time": "存檔點初始化已超過 {{timeout}} 秒,因此此工作已停用存檔點。你可以關閉存檔點或在<settingsLink>存檔點設定</settingsLink>中延長等待時間。",
"attempt_completion_tool_failed": "無法執行 attempt_completion因為本輪中先前的工具呼叫失敗了。請在嘗試完成前解決工具失敗問題。"
},
"tasks": {
"delegated": "已委派給子工作",
"delegated_to": "已委派給工作 {{childId}}",
"delegation_completed": "子工作已完成,繼續父工作",
"awaiting_child": "等待子工作 {{childId}}"
}
}