mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: getPendingSubtasksFromContent now checks both in-memory and persisted tool_results
When resuming after delegation, tool_results are already in the API history. The function now looks at user messages AFTER the last assistant message, not just in-memory pendingToolResults. This prevents the infinite loop where the same subtask was being created repeatedly on resume.
This commit is contained in:
parent
56f7be6c7f
commit
ff77e7c990
9 changed files with 487 additions and 412 deletions
|
|
@ -33,6 +33,19 @@ vi.mock("../core/task-persistence", () => ({
|
|||
readApiMessages: vi.fn().mockResolvedValue([]),
|
||||
saveApiMessages: vi.fn().mockResolvedValue(undefined),
|
||||
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
|
||||
getPendingSubtasks: vi.fn().mockReturnValue([]),
|
||||
// appendToolResult should actually add the tool_result to the messages
|
||||
appendToolResult: vi.fn().mockImplementation((msgs, toolUseId, result) => {
|
||||
const newMsgs = [...msgs]
|
||||
newMsgs.push({
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }],
|
||||
ts: Date.now(),
|
||||
})
|
||||
return newMsgs
|
||||
}),
|
||||
getOtherToolResults: vi.fn().mockReturnValue([]),
|
||||
hasPendingSubtasksInHistory: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
|
|
@ -69,7 +82,6 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
taskId: "parent-1",
|
||||
skipPrevResponseIdOnce: false,
|
||||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
})
|
||||
|
||||
|
|
@ -81,9 +93,6 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
removeClineFromStack,
|
||||
createTaskWithHistoryItem,
|
||||
updateTaskHistory,
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
// Mock persistence reads to return empty arrays
|
||||
|
|
@ -148,13 +157,9 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
// Start with existing messages in history
|
||||
|
|
@ -219,13 +224,9 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
// Include an assistant message with new_task tool_use to exercise the tool_result path
|
||||
|
|
@ -295,7 +296,6 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
}),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
}
|
||||
|
||||
|
|
@ -319,9 +319,6 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
|
||||
createTaskWithHistoryItem: vi.fn().mockResolvedValue(parentInstance),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([])
|
||||
|
|
@ -363,13 +360,9 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([])
|
||||
|
|
@ -418,13 +411,9 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
vi.mocked(readTaskMessages).mockResolvedValue([])
|
||||
|
|
@ -466,13 +455,9 @@ describe("History resume delegation - parent metadata transitions", () => {
|
|||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
updateTaskHistory: vi.fn().mockResolvedValue([]),
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
// Mock read failures or empty returns
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ vi.mock("../core/task-persistence", () => ({
|
|||
readApiMessages: vi.fn().mockResolvedValue([]),
|
||||
saveApiMessages: vi.fn().mockResolvedValue(undefined),
|
||||
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
|
||||
getPendingSubtasks: vi.fn().mockReturnValue([]),
|
||||
// appendToolResult should actually add the tool_result to the messages
|
||||
appendToolResult: vi.fn().mockImplementation((msgs, toolUseId, result) => {
|
||||
const newMsgs = [...msgs]
|
||||
newMsgs.push({
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }],
|
||||
ts: Date.now(),
|
||||
})
|
||||
return newMsgs
|
||||
}),
|
||||
getOtherToolResults: vi.fn().mockReturnValue([]),
|
||||
hasPendingSubtasksInHistory: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
import { attemptCompletionTool } from "../core/tools/AttemptCompletionTool"
|
||||
|
|
@ -57,7 +70,7 @@ import { readApiMessages, saveApiMessages, saveTaskMessages } from "../core/task
|
|||
|
||||
describe("Nested delegation resume (A → B → C)", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("C completes → reopens B; then B completes → reopens A; emits correct events; no resume_task asks", async () => {
|
||||
|
|
@ -128,7 +141,6 @@ describe("Nested delegation resume (A → B → C)", () => {
|
|||
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteClineMessages: vi.fn().mockResolvedValue(undefined),
|
||||
overwriteApiConversationHistory: vi.fn().mockResolvedValue(undefined),
|
||||
loadPendingSubtasks: vi.fn(),
|
||||
hasPendingSubtasks: vi.fn().mockReturnValue(false),
|
||||
}
|
||||
})
|
||||
|
|
@ -158,9 +170,6 @@ describe("Nested delegation resume (A → B → C)", () => {
|
|||
removeClineFromStack,
|
||||
createTaskWithHistoryItem,
|
||||
updateTaskHistory,
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
setSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
clearSubtaskState: vi.fn().mockResolvedValue(undefined),
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@
|
|||
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { RooCodeEventName } from "@roo-code/types"
|
||||
|
||||
// Mock task-persistence to avoid undefined apiMessages error
|
||||
vi.mock("../core/task-persistence", () => ({
|
||||
readApiMessages: vi.fn().mockResolvedValue([]),
|
||||
saveApiMessages: vi.fn().mockResolvedValue(undefined),
|
||||
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
|
||||
getPendingSubtasks: vi.fn().mockReturnValue([]),
|
||||
appendToolResult: vi.fn().mockImplementation((msgs) => msgs),
|
||||
getOtherToolResults: vi.fn().mockReturnValue([]),
|
||||
hasPendingSubtasksInHistory: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
|
||||
describe("ClineProvider.delegateParentAndOpenChild()", () => {
|
||||
|
|
@ -47,7 +59,6 @@ describe("ClineProvider.delegateParentAndOpenChild()", () => {
|
|||
updateTaskHistory,
|
||||
handleModeSwitch,
|
||||
log: vi.fn(),
|
||||
getSubtaskState: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ClineProvider
|
||||
|
||||
const params = {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
export { type ApiMessage, readApiMessages, saveApiMessages } from "./apiMessages"
|
||||
export { readTaskMessages, saveTaskMessages } from "./taskMessages"
|
||||
export { taskMetadata } from "./taskMetadata"
|
||||
export {
|
||||
type PendingSubtask,
|
||||
getPendingSubtasks,
|
||||
getPendingSubtasksFromContent,
|
||||
getFirstPendingSubtaskId,
|
||||
hasPendingSubtasksInHistory,
|
||||
appendToolResult,
|
||||
getOtherToolResults,
|
||||
areAllSubtasksComplete,
|
||||
getCompletedSubtaskCount,
|
||||
getTotalSubtaskCount,
|
||||
} from "./subtaskState"
|
||||
|
|
|
|||
305
src/core/task-persistence/subtaskState.ts
Normal file
305
src/core/task-persistence/subtaskState.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { TodoItem } from "@roo-code/types"
|
||||
import { parseMarkdownChecklist } from "../tools/UpdateTodoListTool"
|
||||
|
||||
import { type ApiMessage } from "./apiMessages"
|
||||
|
||||
/**
|
||||
* Represents a pending subtask derived from the API conversation history.
|
||||
*/
|
||||
export interface PendingSubtask {
|
||||
toolCallId: string
|
||||
message: string
|
||||
mode: string
|
||||
todoItems: TodoItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract pending subtasks from assistant content and existing tool results.
|
||||
* Used internally by both getPendingSubtasks and getPendingSubtasksFromContent.
|
||||
*/
|
||||
function extractPendingSubtasks(
|
||||
assistantContent: Array<Anthropic.Messages.ContentBlockParam | Anthropic.ToolResultBlockParam>,
|
||||
completedToolIds: Set<string>,
|
||||
): PendingSubtask[] {
|
||||
const newTaskToolUses = assistantContent.filter(
|
||||
(block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.name === "new_task",
|
||||
)
|
||||
|
||||
return newTaskToolUses
|
||||
.filter((toolUse) => !completedToolIds.has(toolUse.id))
|
||||
.map((toolUse) => {
|
||||
const input = toolUse.input as Record<string, unknown>
|
||||
let todoItems: TodoItem[] = []
|
||||
|
||||
if (input.todos && typeof input.todos === "string") {
|
||||
try {
|
||||
todoItems = parseMarkdownChecklist(input.todos)
|
||||
} catch {
|
||||
todoItems = []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolCallId: toolUse.id,
|
||||
message: (input.message as string) || "",
|
||||
mode: (input.mode as string) || "",
|
||||
todoItems,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets pending new_task tool calls that don't have tool_results yet.
|
||||
* This derives the pending subtasks by comparing tool_use blocks (with name: "new_task")
|
||||
* in the last assistant message against tool_result blocks in the last user message.
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @returns Array of pending subtasks with their parameters
|
||||
*/
|
||||
export function getPendingSubtasks(apiMessages: ApiMessage[]): PendingSubtask[] {
|
||||
if (apiMessages.length < 2) return []
|
||||
|
||||
const lastAssistant = apiMessages[apiMessages.length - 2]
|
||||
const lastUser = apiMessages[apiMessages.length - 1]
|
||||
|
||||
if (lastAssistant?.role !== "assistant" || lastUser?.role !== "user") return []
|
||||
|
||||
const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : []
|
||||
const userContent = Array.isArray(lastUser.content) ? lastUser.content : []
|
||||
const completedIds = new Set(
|
||||
userContent
|
||||
.filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result")
|
||||
.map((block) => block.tool_use_id),
|
||||
)
|
||||
|
||||
return extractPendingSubtasks(assistantContent, completedIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets pending new_task tool calls by examining both API history and in-memory state.
|
||||
* This handles two scenarios:
|
||||
*
|
||||
* 1. DURING streaming: user message not in history yet, tool_results in pendingToolResults
|
||||
* - apiMessages ends with assistant message
|
||||
* - pendingToolResults has executed tool results
|
||||
*
|
||||
* 2. AFTER delegation resume: user message already in history with tool_results
|
||||
* - apiMessages ends with user message (containing tool_result)
|
||||
* - pendingToolResults is empty
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @param pendingToolResults - In-memory tool_result blocks not yet saved to history
|
||||
* @returns Array of pending subtasks with their parameters
|
||||
*/
|
||||
export function getPendingSubtasksFromContent(
|
||||
apiMessages: ApiMessage[],
|
||||
pendingToolResults: Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam>,
|
||||
): PendingSubtask[] {
|
||||
if (apiMessages.length < 1) return []
|
||||
|
||||
// Find the last assistant message
|
||||
let lastAssistant: ApiMessage | undefined
|
||||
let lastAssistantIndex = -1
|
||||
for (let i = apiMessages.length - 1; i >= 0; i--) {
|
||||
if (apiMessages[i].role === "assistant") {
|
||||
lastAssistant = apiMessages[i]
|
||||
lastAssistantIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistant) return []
|
||||
|
||||
const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : []
|
||||
|
||||
// Collect completed IDs from multiple sources:
|
||||
// 1. In-memory pendingToolResults (during streaming)
|
||||
// 2. Any user message that comes AFTER the last assistant message (after delegation resume)
|
||||
const completedIds = new Set<string>()
|
||||
|
||||
// Source 1: In-memory tool results
|
||||
for (const block of pendingToolResults) {
|
||||
if (block.type === "tool_result") {
|
||||
completedIds.add(block.tool_use_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Source 2: User messages after the last assistant message
|
||||
for (let i = lastAssistantIndex + 1; i < apiMessages.length; i++) {
|
||||
const msg = apiMessages[i]
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "tool_result") {
|
||||
completedIds.add((block as Anthropic.ToolResultBlockParam).tool_use_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extractPendingSubtasks(assistantContent, completedIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tool_use ID of the first pending new_task subtask.
|
||||
* Used to determine which subtask is currently executing.
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @returns The tool_use ID of the first pending subtask, or undefined if none
|
||||
*/
|
||||
export function getFirstPendingSubtaskId(apiMessages: ApiMessage[]): string | undefined {
|
||||
const pending = getPendingSubtasks(apiMessages)
|
||||
return pending.length > 0 ? pending[0].toolCallId : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are any pending new_task subtasks.
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @returns True if there are pending subtasks
|
||||
*/
|
||||
export function hasPendingSubtasksInHistory(apiMessages: ApiMessage[]): boolean {
|
||||
return getPendingSubtasks(apiMessages).length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a tool_result to the last user message in the API conversation history.
|
||||
* If the last message is not a user message, creates a new user message.
|
||||
*
|
||||
* This function modifies the input array in place and returns it.
|
||||
*
|
||||
* @param apiMessages - The API conversation history (modified in place)
|
||||
* @param toolUseId - The tool_use_id to reference in the tool_result
|
||||
* @param result - The result content for the tool_result
|
||||
* @returns The modified apiMessages array
|
||||
*/
|
||||
export function appendToolResult(apiMessages: ApiMessage[], toolUseId: string, result: string): ApiMessage[] {
|
||||
if (apiMessages.length === 0) {
|
||||
apiMessages.push({
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }],
|
||||
ts: Date.now(),
|
||||
})
|
||||
return apiMessages
|
||||
}
|
||||
|
||||
const lastMsg = apiMessages[apiMessages.length - 1]
|
||||
|
||||
if (lastMsg?.role !== "user") {
|
||||
apiMessages.push({
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content: result }],
|
||||
ts: Date.now(),
|
||||
})
|
||||
} else {
|
||||
if (!Array.isArray(lastMsg.content)) {
|
||||
lastMsg.content = lastMsg.content ? [{ type: "text", text: lastMsg.content }] : []
|
||||
}
|
||||
;(lastMsg.content as Anthropic.ToolResultBlockParam[]).push({
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUseId,
|
||||
content: result,
|
||||
})
|
||||
}
|
||||
|
||||
return apiMessages
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets tool_result blocks from the last user message that are NOT for new_task tool calls.
|
||||
* These are "other" tool results (like update_todo_list) that were called in the same turn.
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @returns Array of tool_result blocks for non-new_task tools
|
||||
*/
|
||||
export function getOtherToolResults(apiMessages: ApiMessage[]): Anthropic.ToolResultBlockParam[] {
|
||||
if (apiMessages.length < 2) return []
|
||||
|
||||
const lastAssistant = apiMessages[apiMessages.length - 2]
|
||||
const lastUser = apiMessages[apiMessages.length - 1]
|
||||
|
||||
if (lastAssistant?.role !== "assistant" || lastUser?.role !== "user") return []
|
||||
|
||||
const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : []
|
||||
const newTaskToolIds = new Set(
|
||||
assistantContent
|
||||
.filter(
|
||||
(block): block is Anthropic.Messages.ToolUseBlock =>
|
||||
block.type === "tool_use" && block.name === "new_task",
|
||||
)
|
||||
.map((block) => block.id),
|
||||
)
|
||||
|
||||
const userContent = Array.isArray(lastUser.content) ? lastUser.content : []
|
||||
return userContent.filter(
|
||||
(block): block is Anthropic.ToolResultBlockParam =>
|
||||
block.type === "tool_result" && !newTaskToolIds.has(block.tool_use_id),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if all new_task tool calls have corresponding tool_results.
|
||||
* This indicates all subtasks have completed.
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @returns True if all new_task tool calls have tool_results
|
||||
*/
|
||||
export function areAllSubtasksComplete(apiMessages: ApiMessage[]): boolean {
|
||||
return getPendingSubtasks(apiMessages).length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of completed new_task subtasks (those with tool_results).
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @returns Number of completed subtasks
|
||||
*/
|
||||
export function getCompletedSubtaskCount(apiMessages: ApiMessage[]): number {
|
||||
if (apiMessages.length < 2) return 0
|
||||
|
||||
const lastAssistant = apiMessages[apiMessages.length - 2]
|
||||
const lastUser = apiMessages[apiMessages.length - 1]
|
||||
|
||||
if (lastAssistant?.role !== "assistant" || lastUser?.role !== "user") return 0
|
||||
|
||||
const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : []
|
||||
const newTaskToolIds = new Set(
|
||||
assistantContent
|
||||
.filter(
|
||||
(block): block is Anthropic.Messages.ToolUseBlock =>
|
||||
block.type === "tool_use" && block.name === "new_task",
|
||||
)
|
||||
.map((block) => block.id),
|
||||
)
|
||||
|
||||
const userContent = Array.isArray(lastUser.content) ? lastUser.content : []
|
||||
return userContent.filter(
|
||||
(block): block is Anthropic.ToolResultBlockParam =>
|
||||
block.type === "tool_result" && newTaskToolIds.has(block.tool_use_id),
|
||||
).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total count of new_task tool calls in the last assistant message.
|
||||
*
|
||||
* @param apiMessages - The API conversation history
|
||||
* @returns Total number of new_task tool calls
|
||||
*/
|
||||
export function getTotalSubtaskCount(apiMessages: ApiMessage[]): number {
|
||||
if (apiMessages.length < 1) return 0
|
||||
|
||||
let lastAssistant: ApiMessage | undefined
|
||||
for (let i = apiMessages.length - 1; i >= 0; i--) {
|
||||
if (apiMessages[i].role === "assistant") {
|
||||
lastAssistant = apiMessages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastAssistant) return 0
|
||||
|
||||
const assistantContent = Array.isArray(lastAssistant.content) ? lastAssistant.content : []
|
||||
return assistantContent.filter(
|
||||
(block): block is Anthropic.Messages.ToolUseBlock => block.type === "tool_use" && block.name === "new_task",
|
||||
).length
|
||||
}
|
||||
|
|
@ -109,6 +109,9 @@ import {
|
|||
readTaskMessages,
|
||||
saveTaskMessages,
|
||||
taskMetadata,
|
||||
getPendingSubtasks,
|
||||
getPendingSubtasksFromContent,
|
||||
getOtherToolResults,
|
||||
} from "../task-persistence"
|
||||
import { getEnvironmentDetails } from "../environment/getEnvironmentDetails"
|
||||
import { checkContextWindowExceededError } from "../context/context-management/context-error-handling"
|
||||
|
|
@ -161,27 +164,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
childTaskId?: string
|
||||
pendingNewTaskToolCallId?: string
|
||||
|
||||
/** Queue of subtasks to execute sequentially when multiple new_task calls are made in parallel. */
|
||||
pendingSubtasks: Array<{
|
||||
toolCallId: string
|
||||
message: string
|
||||
mode: string
|
||||
todoItems: TodoItem[]
|
||||
}> = []
|
||||
|
||||
/** Results from completed subtasks, added to conversation only after ALL subtasks finish. */
|
||||
completedSubtaskResults: Array<{
|
||||
toolCallId: string
|
||||
result: string
|
||||
}> = []
|
||||
|
||||
/** Pending tool results from OTHER tools called in the same turn.
|
||||
* Saved with subtask state so they're combined with subtask results at the end. */
|
||||
pendingOtherToolResults: Array<Anthropic.ToolResultBlockParam> = []
|
||||
|
||||
/** Tool call ID of the currently-executing subtask. */
|
||||
currentSubtaskToolCallId?: string
|
||||
|
||||
readonly instanceId: string
|
||||
readonly metadata: TaskMetadata
|
||||
|
||||
|
|
@ -842,9 +824,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.userMessageContent = []
|
||||
}
|
||||
|
||||
/** Execute pending subtasks sequentially. Returns empty array since delegation suspends the parent. */
|
||||
/**
|
||||
* Execute pending subtasks sequentially, deriving pending subtasks from api_conversation_history
|
||||
* and in-memory userMessageContent.
|
||||
* Returns empty array since delegation suspends the parent.
|
||||
*/
|
||||
public async executePendingSubtasks(): Promise<Array<{ toolCallId: string; result: string }>> {
|
||||
if (this.pendingSubtasks.length === 0) {
|
||||
// Derive pending subtasks - use the in-memory version since user message may not be in history yet
|
||||
const pendingSubtasks = getPendingSubtasksFromContent(this.apiConversationHistory, this.userMessageContent)
|
||||
if (pendingSubtasks.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
|
|
@ -853,35 +841,31 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
throw new Error("Provider not available for subtask execution")
|
||||
}
|
||||
|
||||
// Save other tool results (like update_todo_list) that were called in the same turn.
|
||||
// Don't flush them to history yet - they'll be combined with subtask results later.
|
||||
// Only save non-new_task tool results (filter out new_task tool results which are pending subtasks).
|
||||
const pendingSubtaskToolIds = new Set(this.pendingSubtasks.map((s) => s.toolCallId))
|
||||
if (this.currentSubtaskToolCallId) {
|
||||
pendingSubtaskToolIds.add(this.currentSubtaskToolCallId)
|
||||
// Before delegating first subtask, flush other tool results to api_conversation_history.
|
||||
// We need to create a user message with ALL tool_results including a placeholder for this new_task.
|
||||
// This ensures the API conversation is valid (every tool_use has a tool_result).
|
||||
|
||||
// Get all new_task tool IDs from the last assistant message
|
||||
const newTaskToolIds = new Set(pendingSubtasks.map((s) => s.toolCallId))
|
||||
|
||||
// Find non-new_task tool results in userMessageContent
|
||||
const nonNewTaskToolResults = this.userMessageContent.filter(
|
||||
(block): block is Anthropic.ToolResultBlockParam =>
|
||||
block.type === "tool_result" && !newTaskToolIds.has(block.tool_use_id),
|
||||
)
|
||||
|
||||
// Flush all pending tool results (for non-new_task tools) to history
|
||||
if (nonNewTaskToolResults.length > 0) {
|
||||
await this.flushPendingToolResultsToHistory()
|
||||
} else {
|
||||
// Clear userMessageContent since we're about to delegate
|
||||
this.userMessageContent = []
|
||||
}
|
||||
|
||||
// Extract tool_result blocks from userMessageContent that are NOT subtask-related
|
||||
const otherToolResults = this.userMessageContent.filter((block): block is Anthropic.ToolResultBlockParam => {
|
||||
if (block.type !== "tool_result") return false
|
||||
return !pendingSubtaskToolIds.has(block.tool_use_id)
|
||||
})
|
||||
|
||||
// If this is the first subtask, save the other tool results
|
||||
if (this.pendingOtherToolResults.length === 0 && otherToolResults.length > 0) {
|
||||
this.pendingOtherToolResults = otherToolResults
|
||||
}
|
||||
|
||||
// Clear userMessageContent since we're about to delegate (don't flush to history)
|
||||
this.userMessageContent = []
|
||||
|
||||
const currentSubtask = this.pendingSubtasks.shift()!
|
||||
this.currentSubtaskToolCallId = currentSubtask.toolCallId
|
||||
// Get the first pending subtask to execute
|
||||
const currentSubtask = pendingSubtasks[0]
|
||||
|
||||
try {
|
||||
// State must be saved before delegating since a new parent instance is created on resume
|
||||
await this.savePendingSubtasks()
|
||||
|
||||
await (provider as any).delegateParentAndOpenChild({
|
||||
parentTaskId: this.taskId,
|
||||
message: currentSubtask.message,
|
||||
|
|
@ -890,91 +874,21 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.completedSubtaskResults.push({
|
||||
toolCallId: currentSubtask.toolCallId,
|
||||
result: `Failed to execute subtask: ${errorMessage}`,
|
||||
})
|
||||
this.currentSubtaskToolCallId = undefined
|
||||
await this.savePendingSubtasks()
|
||||
console.error(`[Task#executePendingSubtasks] Failed to execute subtask: ${errorMessage}`)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are pending subtasks by examining api_conversation_history
|
||||
* and in-memory userMessageContent.
|
||||
* Pending = new_task tool_use blocks without corresponding tool_result blocks.
|
||||
*/
|
||||
public hasPendingSubtasks(): boolean {
|
||||
return this.pendingSubtasks.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Save subtask state to VSCode's workspaceState via the provider.
|
||||
* The workspaceState persists across extension restarts, so this state survives
|
||||
* when the parent is disposed and recreated after a child task completes.
|
||||
*/
|
||||
public async savePendingSubtasks(): Promise<void> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
console.error(`[Task#savePendingSubtasks] Provider not available`)
|
||||
return
|
||||
}
|
||||
|
||||
const hasState =
|
||||
this.pendingSubtasks.length > 0 ||
|
||||
this.completedSubtaskResults.length > 0 ||
|
||||
this.currentSubtaskToolCallId ||
|
||||
this.pendingOtherToolResults.length > 0
|
||||
|
||||
if (hasState) {
|
||||
await provider.setSubtaskState(this.taskId, {
|
||||
pendingSubtasks: this.pendingSubtasks,
|
||||
completedSubtaskResults: this.completedSubtaskResults,
|
||||
currentSubtaskToolCallId: this.currentSubtaskToolCallId,
|
||||
pendingOtherToolResults: this.pendingOtherToolResults as any,
|
||||
})
|
||||
} else {
|
||||
// Clean up state if nothing to store
|
||||
await provider.clearSubtaskState(this.taskId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load subtask state from workspaceState via the provider.
|
||||
* Called when the parent resumes after a child task completes.
|
||||
*/
|
||||
public loadPendingSubtasks(): void {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = provider.getSubtaskState(this.taskId)
|
||||
if (state) {
|
||||
if (Array.isArray(state.pendingSubtasks)) {
|
||||
this.pendingSubtasks = state.pendingSubtasks
|
||||
}
|
||||
if (Array.isArray(state.completedSubtaskResults)) {
|
||||
this.completedSubtaskResults = state.completedSubtaskResults
|
||||
}
|
||||
if (state.currentSubtaskToolCallId) {
|
||||
this.currentSubtaskToolCallId = state.currentSubtaskToolCallId
|
||||
}
|
||||
if (Array.isArray(state.pendingOtherToolResults)) {
|
||||
this.pendingOtherToolResults = state.pendingOtherToolResults as Anthropic.ToolResultBlockParam[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear subtask state from workspaceState after all subtasks are complete.
|
||||
*/
|
||||
public async clearSubtaskState(): Promise<void> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (provider) {
|
||||
await provider.clearSubtaskState(this.taskId)
|
||||
}
|
||||
this.pendingSubtasks = []
|
||||
this.completedSubtaskResults = []
|
||||
this.currentSubtaskToolCallId = undefined
|
||||
this.pendingOtherToolResults = []
|
||||
// Use the in-memory version since user message may not be in history yet
|
||||
// This is called after streaming completes but before the user message is saved
|
||||
return getPendingSubtasksFromContent(this.apiConversationHistory, this.userMessageContent).length > 0
|
||||
}
|
||||
|
||||
private async saveApiConversationHistory() {
|
||||
|
|
@ -2202,11 +2116,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Save the updated history
|
||||
await this.saveApiConversationHistory()
|
||||
|
||||
// Load any pending subtasks from the provider's in-memory storage.
|
||||
// When multiple new_task tools are called in parallel, remaining subtasks are
|
||||
// saved before each delegation. We load them here on resume.
|
||||
this.loadPendingSubtasks()
|
||||
|
||||
// Check if there are pending subtasks to execute sequentially.
|
||||
// This happens when multiple new_task tools were called in parallel -
|
||||
// they are queued and executed one at a time.
|
||||
|
|
|
|||
|
|
@ -147,44 +147,25 @@ export class NewTaskTool extends BaseTool<"new_task"> {
|
|||
task.checkpointSave(true)
|
||||
}
|
||||
|
||||
// Queue this new_task if using native tool protocol AND there are:
|
||||
// 1. Multiple new_task blocks (to execute sequentially), OR
|
||||
// 2. Any remaining tool blocks after this one (so they can execute before delegation)
|
||||
// For native tool protocol with multiple new_task blocks or remaining tools:
|
||||
// Don't execute immediately - let the tool result accumulate in userMessageContent.
|
||||
// After all tools process, executePendingSubtasks() will derive pending tasks from
|
||||
// api_conversation_history (comparing tool_use vs tool_result blocks).
|
||||
// NOTE: XML protocol processes tools one at a time, so this condition is always false for XML.
|
||||
// We add an explicit check for clarity and defensive safety.
|
||||
const isNativeToolProtocol = toolProtocol === "native"
|
||||
const newTaskBlockCount = countNewTaskBlocks(task)
|
||||
const hasRemainingTools = hasRemainingToolBlocks(task)
|
||||
|
||||
if (isNativeToolProtocol && (newTaskBlockCount > 1 || hasRemainingTools)) {
|
||||
task.pendingSubtasks.push({
|
||||
toolCallId: toolCallId ?? "",
|
||||
message: unescapedMessage,
|
||||
mode,
|
||||
todoItems,
|
||||
})
|
||||
// Don't push to pendingSubtasks - the info is already in the assistant message's tool_use block.
|
||||
// Just return without executing, and executePendingSubtasks() will handle it later.
|
||||
return
|
||||
}
|
||||
|
||||
// Save other tool results (e.g., update_todo_list) that were called in the same turn.
|
||||
// This prevents them from being lost or incorrectly ordered when the parent resumes.
|
||||
const currentToolCallId = toolCallId ?? ""
|
||||
const otherToolResults = task.userMessageContent.filter(
|
||||
(block): block is Anthropic.ToolResultBlockParam =>
|
||||
block.type === "tool_result" && block.tool_use_id !== currentToolCallId,
|
||||
)
|
||||
|
||||
if (otherToolResults.length > 0) {
|
||||
task.pendingOtherToolResults = otherToolResults
|
||||
}
|
||||
|
||||
// Track this subtask and clear userMessageContent to prevent incorrect flushing
|
||||
task.currentSubtaskToolCallId = currentToolCallId
|
||||
// For single new_task call or XML protocol, delegate immediately.
|
||||
// Clear userMessageContent to prevent incorrect flushing during delegation.
|
||||
task.userMessageContent = []
|
||||
|
||||
// Save state before delegation so it survives parent disposal
|
||||
await task.savePendingSubtasks()
|
||||
|
||||
const child = await (provider as any).delegateParentAndOpenChild({
|
||||
parentTaskId: task.taskId,
|
||||
message: unescapedMessage,
|
||||
|
|
|
|||
|
|
@ -99,9 +99,6 @@ const mockCline = {
|
|||
checkpointSave: mockCheckpointSave,
|
||||
startSubtask: mockStartSubtask,
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => ({
|
||||
getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
|
||||
|
|
@ -656,9 +653,6 @@ describe("newTaskTool delegation flow", () => {
|
|||
checkpointSave: mockCheckpointSave,
|
||||
startSubtask: localStartSubtask,
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => providerSpy),
|
||||
},
|
||||
|
|
@ -707,7 +701,10 @@ describe("newTaskTool delegation flow", () => {
|
|||
})
|
||||
|
||||
describe("newTaskTool parallel execution", () => {
|
||||
it("should queue subtasks when multiple new_task blocks are detected", async () => {
|
||||
it("should defer delegation when multiple new_task blocks are detected (native protocol)", async () => {
|
||||
// With the new approach, when multiple new_task blocks exist in native protocol,
|
||||
// the tool just returns without executing - the subtask info is in the assistant message's
|
||||
// tool_use blocks and will be derived by executePendingSubtasks() later.
|
||||
const providerSpy = {
|
||||
getState: vi.fn().mockResolvedValue({
|
||||
mode: "ask",
|
||||
|
|
@ -717,7 +714,6 @@ describe("newTaskTool parallel execution", () => {
|
|||
handleModeSwitch: vi.fn(),
|
||||
} as any
|
||||
|
||||
const pendingSubtasks: any[] = []
|
||||
const localCline = {
|
||||
ask: vi.fn(),
|
||||
sayAndCreateMissingParamError: vi.fn(),
|
||||
|
|
@ -731,9 +727,6 @@ describe("newTaskTool parallel execution", () => {
|
|||
checkpointSave: vi.fn(),
|
||||
startSubtask: vi.fn(),
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => providerSpy),
|
||||
},
|
||||
|
|
@ -753,7 +746,7 @@ describe("newTaskTool parallel execution", () => {
|
|||
partial: false,
|
||||
},
|
||||
],
|
||||
pendingSubtasks,
|
||||
currentStreamingContentIndex: 0,
|
||||
}
|
||||
|
||||
const mockPushToolResult = vi.fn()
|
||||
|
|
@ -779,38 +772,10 @@ describe("newTaskTool parallel execution", () => {
|
|||
toolCallId: "tool-1",
|
||||
})
|
||||
|
||||
expect(pendingSubtasks.length).toBe(1)
|
||||
expect(pendingSubtasks[0].mode).toBe("code")
|
||||
expect(pendingSubtasks[0].message).toBe("First task")
|
||||
expect(pendingSubtasks[0].toolCallId).toBe("tool-1")
|
||||
expect(mockPushToolResult).not.toHaveBeenCalled()
|
||||
expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled()
|
||||
|
||||
const block2: ToolUse = {
|
||||
type: "tool_use",
|
||||
id: "tool-2",
|
||||
name: "new_task",
|
||||
params: {
|
||||
mode: "code",
|
||||
message: "Second task",
|
||||
},
|
||||
partial: false,
|
||||
}
|
||||
|
||||
await newTaskTool.handle(localCline as any, block2 as ToolUse<"new_task">, {
|
||||
askApproval: mockAskApproval,
|
||||
handleError: vi.fn(),
|
||||
pushToolResult: mockPushToolResult,
|
||||
removeClosingTag: vi.fn((_: string, v?: string) => v ?? ""),
|
||||
toolProtocol: "native", // Native protocol is required for parallel tool execution
|
||||
toolCallId: "tool-2",
|
||||
})
|
||||
|
||||
expect(pendingSubtasks.length).toBe(2)
|
||||
expect(pendingSubtasks[1].mode).toBe("code")
|
||||
expect(pendingSubtasks[1].message).toBe("Second task")
|
||||
expect(pendingSubtasks[1].toolCallId).toBe("tool-2")
|
||||
// With new approach: tool returns without delegating when multiple new_task blocks exist
|
||||
// No pushToolResult call (deferred)
|
||||
expect(mockPushToolResult).not.toHaveBeenCalled()
|
||||
// No delegation yet (deferred to executePendingSubtasks)
|
||||
expect(providerSpy.delegateParentAndOpenChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
|
@ -824,7 +789,6 @@ describe("newTaskTool parallel execution", () => {
|
|||
handleModeSwitch: vi.fn(),
|
||||
} as any
|
||||
|
||||
const pendingSubtasks: any[] = []
|
||||
const localCline = {
|
||||
ask: vi.fn(),
|
||||
sayAndCreateMissingParamError: vi.fn(),
|
||||
|
|
@ -838,9 +802,6 @@ describe("newTaskTool parallel execution", () => {
|
|||
checkpointSave: vi.fn(),
|
||||
startSubtask: vi.fn(),
|
||||
userMessageContent: [] as any[],
|
||||
pendingOtherToolResults: [] as any[],
|
||||
currentSubtaskToolCallId: undefined as string | undefined,
|
||||
savePendingSubtasks: vi.fn().mockResolvedValue(undefined),
|
||||
providerRef: {
|
||||
deref: vi.fn(() => providerSpy),
|
||||
},
|
||||
|
|
@ -853,7 +814,7 @@ describe("newTaskTool parallel execution", () => {
|
|||
partial: false,
|
||||
},
|
||||
],
|
||||
pendingSubtasks,
|
||||
currentStreamingContentIndex: 0,
|
||||
}
|
||||
|
||||
const mockPushToolResult = vi.fn()
|
||||
|
|
@ -879,7 +840,6 @@ describe("newTaskTool parallel execution", () => {
|
|||
toolCallId: "tool-1",
|
||||
})
|
||||
|
||||
expect(pendingSubtasks.length).toBe(0)
|
||||
expect(providerSpy.delegateParentAndOpenChild).toHaveBeenCalledWith({
|
||||
parentTaskId: "mock-parent-task-id",
|
||||
message: "Single task",
|
||||
|
|
|
|||
|
|
@ -93,7 +93,15 @@ import { getSystemPromptFilePath } from "../prompts/sections/custom-system-promp
|
|||
|
||||
import { webviewMessageHandler } from "./webviewMessageHandler"
|
||||
import type { ClineMessage, TodoItem } from "@roo-code/types"
|
||||
import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence"
|
||||
import {
|
||||
readApiMessages,
|
||||
saveApiMessages,
|
||||
saveTaskMessages,
|
||||
getPendingSubtasks,
|
||||
appendToolResult,
|
||||
getOtherToolResults,
|
||||
hasPendingSubtasksInHistory,
|
||||
} from "../task-persistence"
|
||||
import { readTaskMessages } from "../task-persistence/taskMessages"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
|
|
@ -118,32 +126,6 @@ interface PendingEditOperation {
|
|||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* State for pending subtasks during parallel new_task execution.
|
||||
* Stored in VSCode's workspaceState for persistence across extension restarts.
|
||||
*/
|
||||
export interface SubtaskState {
|
||||
pendingSubtasks: Array<{
|
||||
toolCallId: string
|
||||
message: string
|
||||
mode: string
|
||||
todoItems: TodoItem[]
|
||||
}>
|
||||
completedSubtaskResults: Array<{
|
||||
toolCallId: string
|
||||
result: string
|
||||
}>
|
||||
currentSubtaskToolCallId?: string
|
||||
/** Pending tool results from OTHER tools called in the same turn as new_task.
|
||||
* These are saved before the first subtask executes so they can be combined
|
||||
* with subtask results into a single user message when all complete. */
|
||||
pendingOtherToolResults?: Array<{
|
||||
type: "tool_result"
|
||||
tool_use_id: string
|
||||
content: string | Array<{ type: "text"; text: string } | { type: "image"; source: any }>
|
||||
}>
|
||||
}
|
||||
|
||||
export class ClineProvider
|
||||
extends EventEmitter<TaskProviderEvents>
|
||||
implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike
|
||||
|
|
@ -172,9 +154,6 @@ export class ClineProvider
|
|||
private pendingOperations: Map<string, PendingEditOperation> = new Map()
|
||||
private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds
|
||||
|
||||
// Storage key prefix for subtask state in VSCode workspaceState
|
||||
private static readonly SUBTASK_STATE_KEY_PREFIX = "subtaskState:"
|
||||
|
||||
private cloudOrganizationsCache: CloudOrganizationMembership[] | null = null
|
||||
private cloudOrganizationsCacheTimestamp: number | null = null
|
||||
private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds
|
||||
|
|
@ -579,47 +558,6 @@ export class ClineProvider
|
|||
this.log(`[clearAllPendingEditOperations] Cleared all pending operations`)
|
||||
}
|
||||
|
||||
// Subtask State Management
|
||||
// These methods manage state for parallel new_task execution using VSCode's workspaceState.
|
||||
// workspaceState persists data across extension restarts for the current workspace.
|
||||
|
||||
/**
|
||||
* Gets the subtask state key for a given task ID.
|
||||
*/
|
||||
private getSubtaskStateKey(taskId: string): string {
|
||||
return `${ClineProvider.SUBTASK_STATE_KEY_PREFIX}${taskId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subtask state for a given task ID from workspaceState.
|
||||
*/
|
||||
public getSubtaskState(taskId: string): SubtaskState | undefined {
|
||||
const key = this.getSubtaskStateKey(taskId)
|
||||
return this.context.workspaceState.get<SubtaskState>(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subtask state for a given task ID in workspaceState.
|
||||
*/
|
||||
public async setSubtaskState(taskId: string, state: SubtaskState): Promise<void> {
|
||||
const key = this.getSubtaskStateKey(taskId)
|
||||
await this.context.workspaceState.update(key, state)
|
||||
this.log(`[setSubtaskState] Set subtask state for task ${taskId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the subtask state for a given task ID from workspaceState.
|
||||
* Should be called after all subtasks complete.
|
||||
*/
|
||||
public async clearSubtaskState(taskId: string): Promise<void> {
|
||||
const key = this.getSubtaskStateKey(taskId)
|
||||
const exists = this.context.workspaceState.get(key) !== undefined
|
||||
await this.context.workspaceState.update(key, undefined)
|
||||
if (exists) {
|
||||
this.log(`[clearSubtaskState] Cleared subtask state for task ${taskId}`)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
|
||||
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
|
||||
|
|
@ -3061,16 +2999,16 @@ export class ClineProvider
|
|||
)
|
||||
}
|
||||
// 2) DON'T flush pending tool results to history when we're in subtask execution flow.
|
||||
// The executePendingSubtasks() method saves other tool results (like update_todo_list)
|
||||
// to pendingOtherToolResults, which will be combined with subtask results into a
|
||||
// SINGLE user message when all subtasks complete. If we flush here, we'd create
|
||||
// The executePendingSubtasks() method handles saving other tool results that were
|
||||
// called in the same turn as new_task. These will be combined with subtask results
|
||||
// into a SINGLE user message when all subtasks complete. If we flush here, we'd create
|
||||
// a separate user message that breaks the conversation structure (tool results
|
||||
// MUST follow the assistant message that called them in a single user message).
|
||||
//
|
||||
// Only flush if this is a direct delegation (no pending subtask state), which means
|
||||
// Only flush if this is a direct delegation (no pending subtasks in history), which means
|
||||
// the parent called new_task without the parallel subtask execution flow.
|
||||
const hasSubtaskState = this.getSubtaskState(parentTaskId) !== undefined
|
||||
if (!hasSubtaskState) {
|
||||
const hasParallelSubtasks = hasPendingSubtasksInHistory(parent.apiConversationHistory)
|
||||
if (!hasParallelSubtasks) {
|
||||
try {
|
||||
await parent.flushPendingToolResultsToHistory()
|
||||
} catch (error) {
|
||||
|
|
@ -3151,6 +3089,12 @@ export class ClineProvider
|
|||
|
||||
/**
|
||||
* Reopen parent task from delegation with write-back and events.
|
||||
*
|
||||
* This method derives pending subtask state directly from api_conversation_history.json
|
||||
* instead of using workspaceState. The state is determined by comparing:
|
||||
* - tool_use blocks with name: "new_task" in the last assistant message
|
||||
* - tool_result blocks in the last user message
|
||||
* - Pending = tool_use.id NOT in tool_results
|
||||
*/
|
||||
public async reopenParentFromDelegation(params: {
|
||||
parentTaskId: string
|
||||
|
|
@ -3183,17 +3127,6 @@ export class ClineProvider
|
|||
parentApiMessages = []
|
||||
}
|
||||
|
||||
// Load subtask state from workspaceState
|
||||
const subtaskState = this.getSubtaskState(parentTaskId)
|
||||
let pendingSubtasks: Array<{ toolCallId: string; message: string; mode: string; todoItems: any[] }> =
|
||||
subtaskState?.pendingSubtasks ?? []
|
||||
let completedSubtaskResults: Array<{ toolCallId: string; result: string }> =
|
||||
subtaskState?.completedSubtaskResults ?? []
|
||||
let currentSubtaskToolCallId: string | undefined = subtaskState?.currentSubtaskToolCallId
|
||||
// Other tool results (like update_todo_list) that were called in the same turn as new_task
|
||||
const pendingOtherToolResults: Array<{ type: string; tool_use_id: string; content: any }> =
|
||||
(subtaskState?.pendingOtherToolResults as any) ?? []
|
||||
|
||||
const ts = Date.now()
|
||||
if (!Array.isArray(parentClineMessages)) parentClineMessages = []
|
||||
if (!Array.isArray(parentApiMessages)) parentApiMessages = []
|
||||
|
|
@ -3208,12 +3141,26 @@ export class ClineProvider
|
|||
parentClineMessages.push(subtaskUiMessage)
|
||||
await saveTaskMessages({ messages: parentClineMessages, taskId: parentTaskId, globalStoragePath })
|
||||
|
||||
// Determine the toolUseId for this subtask result
|
||||
// Priority: 1) currentSubtaskToolCallId from state, 2) fallback to finding the last one
|
||||
let toolUseId: string | undefined = currentSubtaskToolCallId
|
||||
// Derive pending subtasks from the api_conversation_history
|
||||
// The getPendingSubtasks function compares tool_use blocks vs tool_result blocks
|
||||
const pendingSubtasksFromHistory = getPendingSubtasks(parentApiMessages)
|
||||
|
||||
if (!toolUseId) {
|
||||
// Fallback: find the tool_use_id from the last assistant message's new_task tool_use
|
||||
// Get other tool results (non-new_task tools) from the last assistant message
|
||||
// that haven't been added to history yet
|
||||
const otherToolResults = getOtherToolResults(parentApiMessages)
|
||||
|
||||
// Find the toolUseId for the child that just completed
|
||||
// First, check if there's a pending subtask matching the child's details
|
||||
// Since we execute subtasks in order, the first pending one should be the one that just completed
|
||||
let toolUseId: string | undefined
|
||||
|
||||
// Look through assistant message to find the new_task tool_use that matches
|
||||
// We need to find which one was being executed - look for the first pending one
|
||||
if (pendingSubtasksFromHistory.length > 0) {
|
||||
// The first pending subtask is the one we just completed
|
||||
toolUseId = pendingSubtasksFromHistory[0].toolCallId
|
||||
} else {
|
||||
// Fallback: find the last new_task tool_use in the assistant message
|
||||
for (let i = parentApiMessages.length - 1; i >= 0; i--) {
|
||||
const msg = parentApiMessages[i]
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
|
|
@ -3228,83 +3175,41 @@ export class ClineProvider
|
|||
}
|
||||
}
|
||||
|
||||
// Store this subtask's result
|
||||
// Append the completed subtask's tool_result to the conversation history
|
||||
if (toolUseId) {
|
||||
completedSubtaskResults.push({
|
||||
toolCallId: toolUseId,
|
||||
result: `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`,
|
||||
})
|
||||
const resultContent = `Subtask ${childTaskId} completed.\n\nResult:\n${completionResultSummary}`
|
||||
parentApiMessages = appendToolResult(parentApiMessages, toolUseId, resultContent)
|
||||
}
|
||||
|
||||
// Clear currentSubtaskToolCallId since this subtask is complete
|
||||
currentSubtaskToolCallId = undefined
|
||||
// Check if there are more pending subtasks to execute after adding this result
|
||||
const remainingPendingSubtasks = getPendingSubtasks(parentApiMessages)
|
||||
const hasMoreSubtasks = remainingPendingSubtasks.length > 0
|
||||
|
||||
// Check if there are more pending subtasks to execute
|
||||
const hasMoreSubtasks = pendingSubtasks.length > 0
|
||||
|
||||
if (!hasMoreSubtasks && completedSubtaskResults.length > 0) {
|
||||
// All subtasks complete - add ALL tool_results to the conversation now
|
||||
// The API expects: user → assistant (with tool_use) → user (with tool_result)
|
||||
|
||||
// Check if the last message is already a user message we can append to
|
||||
// If no more subtasks and we have other tool results that need to be flushed,
|
||||
// add them to the conversation (they come before subtask results)
|
||||
if (!hasMoreSubtasks && otherToolResults.length > 0) {
|
||||
// The other tool results should be added to the last user message
|
||||
const lastMsg = parentApiMessages[parentApiMessages.length - 1]
|
||||
let userMessageContent: any[] = []
|
||||
|
||||
if (lastMsg?.role === "user" && Array.isArray(lastMsg.content)) {
|
||||
// Filter out any existing tool_results for our subtask IDs (to avoid duplicates)
|
||||
const allToolResultIds = new Set([
|
||||
...completedSubtaskResults.map((r) => r.toolCallId),
|
||||
...pendingOtherToolResults.map((r) => r.tool_use_id),
|
||||
])
|
||||
userMessageContent = lastMsg.content.filter(
|
||||
(block: any) => !(block.type === "tool_result" && allToolResultIds.has(block.tool_use_id)),
|
||||
// Check if these results are already in the message
|
||||
const existingToolResultIds = new Set(
|
||||
lastMsg.content.filter((b: any) => b.type === "tool_result").map((b: any) => b.tool_use_id),
|
||||
)
|
||||
// Remove the last message so we can replace it with an updated one
|
||||
parentApiMessages.pop()
|
||||
|
||||
// Add missing other tool results at the beginning (before subtask results)
|
||||
const newContent = [...otherToolResults.filter((r) => !existingToolResultIds.has(r.tool_use_id))]
|
||||
|
||||
if (newContent.length > 0) {
|
||||
// Insert other tool results at the beginning of the content array
|
||||
// to maintain original tool call order
|
||||
lastMsg.content = [...newContent, ...lastMsg.content]
|
||||
}
|
||||
}
|
||||
|
||||
// FIRST: Add other tool results (e.g., update_todo_list) that were called in the same turn
|
||||
// These should come before subtask results to maintain the original tool call order
|
||||
for (const toolResult of pendingOtherToolResults) {
|
||||
userMessageContent.push({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: toolResult.tool_use_id,
|
||||
content: toolResult.content,
|
||||
})
|
||||
}
|
||||
|
||||
// THEN: Add all completed subtask results as tool_result blocks
|
||||
for (const result of completedSubtaskResults) {
|
||||
userMessageContent.push({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: result.toolCallId,
|
||||
content: result.result,
|
||||
})
|
||||
}
|
||||
|
||||
parentApiMessages.push({
|
||||
role: "user",
|
||||
content: userMessageContent,
|
||||
ts,
|
||||
})
|
||||
|
||||
// Clear completed results since they're now in the conversation
|
||||
completedSubtaskResults = []
|
||||
|
||||
// Clean up the subtask state from workspaceState
|
||||
await this.clearSubtaskState(parentTaskId)
|
||||
} else {
|
||||
// More subtasks remain - save state for the next resumption
|
||||
await this.setSubtaskState(parentTaskId, {
|
||||
pendingSubtasks,
|
||||
completedSubtaskResults,
|
||||
currentSubtaskToolCallId,
|
||||
})
|
||||
}
|
||||
|
||||
await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath })
|
||||
|
||||
// 3) Update child metadata to "completed" status
|
||||
// 2) Update child metadata to "completed" status
|
||||
try {
|
||||
const { historyItem: childHistory } = await this.getTaskWithId(childTaskId)
|
||||
await this.updateTaskHistory({
|
||||
|
|
@ -3319,7 +3224,7 @@ export class ClineProvider
|
|||
)
|
||||
}
|
||||
|
||||
// 4) Update parent metadata and persist BEFORE emitting completion event
|
||||
// 3) Update parent metadata and persist BEFORE emitting completion event
|
||||
const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId]))
|
||||
const updatedHistory: typeof historyItem = {
|
||||
...historyItem,
|
||||
|
|
@ -3331,24 +3236,24 @@ export class ClineProvider
|
|||
}
|
||||
await this.updateTaskHistory(updatedHistory)
|
||||
|
||||
// 5) Emit TaskDelegationCompleted (provider-level)
|
||||
// 4) 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)
|
||||
// 5) 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)
|
||||
// 6) 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
|
||||
// 7) Inject restored histories into the in-memory instance before resuming
|
||||
if (parentInstance) {
|
||||
try {
|
||||
await parentInstance.overwriteClineMessages(parentClineMessages)
|
||||
|
|
@ -3361,15 +3266,13 @@ export class ClineProvider
|
|||
// non-fatal
|
||||
}
|
||||
|
||||
// Load subtask state into the parent instance (synchronous - reads from provider's in-memory state)
|
||||
parentInstance.loadPendingSubtasks()
|
||||
|
||||
// Check if there are more pending subtasks to execute
|
||||
if (parentInstance.hasPendingSubtasks()) {
|
||||
// Check if there are more pending subtasks to execute (derived from history)
|
||||
const pendingFromHistory = getPendingSubtasks(parentApiMessages)
|
||||
if (pendingFromHistory.length > 0) {
|
||||
// Execute the next pending subtask
|
||||
// This will cause the parent to be "paused" again and a new child will run
|
||||
this.log(
|
||||
`[reopenParentFromDelegation] Parent ${parentTaskId} has ${parentInstance.pendingSubtasks.length} more subtasks, executing next one`,
|
||||
`[reopenParentFromDelegation] Parent ${parentTaskId} has ${pendingFromHistory.length} more subtasks, executing next one`,
|
||||
)
|
||||
await parentInstance.executePendingSubtasks()
|
||||
} else {
|
||||
|
|
@ -3378,7 +3281,7 @@ export class ClineProvider
|
|||
}
|
||||
}
|
||||
|
||||
// 9) Emit TaskDelegationResumed (provider-level)
|
||||
// 8) Emit TaskDelegationResumed (provider-level)
|
||||
try {
|
||||
this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId)
|
||||
} catch {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue