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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ 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"
/** Whether to start the task loop immediately (default: true).
* When false, the caller must invoke `task.start()` manually. */
startTask?: boolean
Expand Down
9 changes: 9 additions & 0 deletions src/__tests__/history-resume-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ vi.mock("../core/task-persistence", async (importOriginal) => {
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
}
})
vi.mock("../core/task-persistence/delegationMeta", () => ({
readDelegationMeta: vi.fn().mockResolvedValue(null),
saveDelegationMeta: vi.fn().mockResolvedValue(undefined),
}))

import { ClineProvider } from "../core/webview/ClineProvider"
import { readTaskMessages } from "../core/task-persistence/taskMessages"
Expand Down Expand Up @@ -149,6 +153,7 @@ describe("History resume delegation - parent metadata transitions", () => {
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
log: vi.fn(),
} as unknown as ClineProvider

// Start with existing messages in history
Expand Down Expand Up @@ -232,6 +237,7 @@ describe("History resume delegation - parent metadata transitions", () => {
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
log: vi.fn(),
} as unknown as ClineProvider

// Include an assistant message with new_task tool_use to exercise the tool_result path
Expand Down Expand Up @@ -320,6 +326,7 @@ describe("History resume delegation - parent metadata transitions", () => {
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
log: vi.fn(),
} as unknown as ClineProvider

// No assistant tool_use in history
Expand Down Expand Up @@ -555,6 +562,7 @@ describe("History resume delegation - parent metadata transitions", () => {
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
log: vi.fn(),
} as unknown as ClineProvider

vi.mocked(readTaskMessages).mockResolvedValue([])
Expand Down Expand Up @@ -754,6 +762,7 @@ describe("History resume delegation - parent metadata transitions", () => {
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
}),
updateTaskHistory: vi.fn().mockResolvedValue([]),
log: vi.fn(),
} as unknown as ClineProvider

// Mock read failures or empty returns
Expand Down
39 changes: 29 additions & 10 deletions src/__tests__/provider-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@

import { describe, it, expect, vi } from "vitest"
import { RooCodeEventName } from "@roo-code/types"

vi.mock("../core/task-persistence/delegationMeta", () => ({
readDelegationMeta: vi.fn().mockResolvedValue(null),
saveDelegationMeta: vi.fn().mockResolvedValue(undefined),
}))

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 childStart = vi.fn()
const childStart = vi.fn().mockResolvedValue(undefined)
const updateTaskHistory = vi.fn()
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const createTask = vi.fn().mockResolvedValue({ taskId: "child-1", start: childStart })
Expand Down Expand Up @@ -48,6 +54,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
updateTaskHistory,
handleModeSwitch,
log: vi.fn(),
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
} as unknown as ClineProvider

const params = {
Expand All @@ -63,18 +70,26 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {

// Invariant: parent closed before child creation
expect(removeClineFromStack).toHaveBeenCalledTimes(1)
// Child task is created with startTask: false and initialStatus: "active"
// Child task is created with startTask: false
expect(createTask).toHaveBeenCalledWith("Do something", undefined, parentTask, {
initialTodos: [],
initialStatus: "active",
startTask: false,
})

// Metadata persistence - parent gets "delegated" status (child status is set at creation via initialStatus)
expect(updateTaskHistory).toHaveBeenCalledTimes(1)
// Metadata persistence - child gets "active" status, parent gets "delegated" status
expect(updateTaskHistory).toHaveBeenCalledTimes(2)

// Parent set to "delegated"
const parentSaved = updateTaskHistory.mock.calls[0][0]
// Child set to "active" (first call)
const childSaved = updateTaskHistory.mock.calls[0][0]
expect(childSaved).toEqual(
expect.objectContaining({
id: "child-1",
status: "active",
}),
)

// Parent set to "delegated" (second call)
const parentSaved = updateTaskHistory.mock.calls[1][0]
expect(parentSaved).toEqual(
expect.objectContaining({
id: "parent-1",
Expand All @@ -99,7 +114,10 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
const callOrder: string[] = []

const parentTask = { taskId: "parent-1", emit: vi.fn() } as any
const childStart = vi.fn(() => callOrder.push("child.start"))
const childStart = vi.fn(() => {
callOrder.push("child.start")
return Promise.resolve()
})

const updateTaskHistory = vi.fn(async () => {
callOrder.push("updateTaskHistory")
Expand Down Expand Up @@ -130,6 +148,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
updateTaskHistory,
handleModeSwitch,
log: vi.fn(),
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
} as unknown as ClineProvider

await (ClineProvider.prototype as any).delegateParentAndOpenChild.call(provider, {
Expand All @@ -139,7 +158,7 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
mode: "code",
})

// Verify ordering: createTask → updateTaskHistory → child.start
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "child.start"])
// Verify ordering: createTask → updateTaskHistory (child) → updateTaskHistory (parent) → child.start
expect(callOrder).toEqual(["createTask", "updateTaskHistory", "updateTaskHistory", "child.start"])
})
})
9 changes: 9 additions & 0 deletions src/__tests__/removeClineFromStack-delegation.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// npx vitest run __tests__/removeClineFromStack-delegation.spec.ts

import { describe, it, expect, vi } from "vitest"

vi.mock("../core/task-persistence/delegationMeta", () => ({
readDelegationMeta: vi.fn().mockResolvedValue(null),
saveDelegationMeta: vi.fn().mockResolvedValue(undefined),
}))

import { ClineProvider } from "../core/webview/ClineProvider"

describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
Expand Down Expand Up @@ -38,6 +44,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
log: vi.fn(),
getTaskWithId,
updateTaskHistory,
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
}

return { provider, childTask, updateTaskHistory, getTaskWithId }
Expand Down Expand Up @@ -183,6 +190,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
log: vi.fn(),
getTaskWithId: vi.fn(),
updateTaskHistory: vi.fn(),
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
}

// Should not throw
Expand Down Expand Up @@ -263,6 +271,7 @@ describe("ClineProvider.removeClineFromStack() delegation awareness", () => {
log: vi.fn(),
getTaskWithId,
updateTaskHistory,
contextProxy: { globalStorageUri: { fsPath: "/tmp" } },
}

// Simulate what delegateParentAndOpenChild does: pop B with skipDelegationRepair
Expand Down
23 changes: 20 additions & 3 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ import { sanitizeToolUseId } from "../../utils/tool-id"
*/

export async function presentAssistantMessage(cline: Task) {
if (cline.abort) {
if (cline.abort || cline.abandoned) {
throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`)
}

Expand Down Expand Up @@ -938,6 +938,15 @@ export async function presentAssistantMessage(cline: Task) {
// locked.
cline.presentAssistantMessageLocked = false

// Early exit if task was aborted/abandoned during tool execution (e.g., new_task delegation).
// Prevents unhandled promise rejections from recursive calls hitting the abort check.
if (cline.abort || cline.abandoned) {
if (cline.didCompleteReadingStream) {
cline.userMessageContentReady = true
}
return
}

// NOTE: When tool is rejected, iterator stream is interrupted and it waits
// for `userMessageContentReady` to be true. Future calls to present will
// skip execution since `didRejectTool` and iterate until `contentIndex` is
Expand Down Expand Up @@ -965,7 +974,11 @@ export async function presentAssistantMessage(cline: Task) {
if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) {
// There are already more content blocks to stream, so we'll call
// this function ourselves.
presentAssistantMessage(cline)
presentAssistantMessage(cline).catch((err) => {
if (!cline.abort) {
console.error("[presentAssistantMessage] Unhandled error:", err)
}
})
return
} else {
// CRITICAL FIX: If we're out of bounds and the stream is complete, set userMessageContentReady
Expand All @@ -978,7 +991,11 @@ export async function presentAssistantMessage(cline: Task) {

// Block is partial, but the read stream may have finished.
if (cline.presentAssistantMessageHasPendingUpdates) {
presentAssistantMessage(cline)
presentAssistantMessage(cline).catch((err) => {
if (!cline.abort) {
console.error("[presentAssistantMessage] Unhandled error:", err)
}
})
}
}

Expand Down
Loading
Loading