|
|
@@ -2011,234 +2011,246 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|
|
}
|
|
|
|
|
|
private async resumeTaskFromHistory() {
|
|
|
- if (this.enableBridge) {
|
|
|
- try {
|
|
|
- await BridgeOrchestrator.subscribeToTask(this)
|
|
|
- } catch (error) {
|
|
|
- console.error(
|
|
|
- `[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
- )
|
|
|
+ try {
|
|
|
+ if (this.enableBridge) {
|
|
|
+ try {
|
|
|
+ await BridgeOrchestrator.subscribeToTask(this)
|
|
|
+ } catch (error) {
|
|
|
+ console.error(
|
|
|
+ `[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
|
+ )
|
|
|
+ }
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- const modifiedClineMessages = await this.getSavedClineMessages()
|
|
|
+ const modifiedClineMessages = await this.getSavedClineMessages()
|
|
|
|
|
|
- // Remove any resume messages that may have been added before.
|
|
|
- const lastRelevantMessageIndex = findLastIndex(
|
|
|
- modifiedClineMessages,
|
|
|
- (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
|
|
|
- )
|
|
|
+ // Remove any resume messages that may have been added before.
|
|
|
+ const lastRelevantMessageIndex = findLastIndex(
|
|
|
+ modifiedClineMessages,
|
|
|
+ (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
|
|
|
+ )
|
|
|
|
|
|
- if (lastRelevantMessageIndex !== -1) {
|
|
|
- modifiedClineMessages.splice(lastRelevantMessageIndex + 1)
|
|
|
- }
|
|
|
+ if (lastRelevantMessageIndex !== -1) {
|
|
|
+ modifiedClineMessages.splice(lastRelevantMessageIndex + 1)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Remove any trailing reasoning-only UI messages that were not part of the persisted API conversation
|
|
|
+ while (modifiedClineMessages.length > 0) {
|
|
|
+ const last = modifiedClineMessages[modifiedClineMessages.length - 1]
|
|
|
+ if (last.type === "say" && last.say === "reasoning") {
|
|
|
+ modifiedClineMessages.pop()
|
|
|
+ } else {
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Since we don't use `api_req_finished` anymore, we need to check if the
|
|
|
+ // last `api_req_started` has a cost value, if it doesn't and no
|
|
|
+ // cancellation reason to present, then we remove it since it indicates
|
|
|
+ // an api request without any partial content streamed.
|
|
|
+ const lastApiReqStartedIndex = findLastIndex(
|
|
|
+ modifiedClineMessages,
|
|
|
+ (m) => m.type === "say" && m.say === "api_req_started",
|
|
|
+ )
|
|
|
+
|
|
|
+ if (lastApiReqStartedIndex !== -1) {
|
|
|
+ const lastApiReqStarted = modifiedClineMessages[lastApiReqStartedIndex]
|
|
|
+ const { cost, cancelReason }: ClineApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}")
|
|
|
+
|
|
|
+ if (cost === undefined && cancelReason === undefined) {
|
|
|
+ modifiedClineMessages.splice(lastApiReqStartedIndex, 1)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ await this.overwriteClineMessages(modifiedClineMessages)
|
|
|
+ this.clineMessages = await this.getSavedClineMessages()
|
|
|
+
|
|
|
+ // Now present the cline messages to the user and ask if they want to
|
|
|
+ // resume (NOTE: we ran into a bug before where the
|
|
|
+ // apiConversationHistory wouldn't be initialized when opening a old
|
|
|
+ // task, and it was because we were waiting for resume).
|
|
|
+ // This is important in case the user deletes messages without resuming
|
|
|
+ // the task first.
|
|
|
+ this.apiConversationHistory = await this.getSavedApiConversationHistory()
|
|
|
+
|
|
|
+ const lastClineMessage = this.clineMessages
|
|
|
+ .slice()
|
|
|
+ .reverse()
|
|
|
+ .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks.
|
|
|
|
|
|
- // Remove any trailing reasoning-only UI messages that were not part of the persisted API conversation
|
|
|
- while (modifiedClineMessages.length > 0) {
|
|
|
- const last = modifiedClineMessages[modifiedClineMessages.length - 1]
|
|
|
- if (last.type === "say" && last.say === "reasoning") {
|
|
|
- modifiedClineMessages.pop()
|
|
|
+ let askType: ClineAsk
|
|
|
+ if (lastClineMessage?.ask === "completion_result") {
|
|
|
+ askType = "resume_completed_task"
|
|
|
} else {
|
|
|
- break
|
|
|
+ askType = "resume_task"
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- // Since we don't use `api_req_finished` anymore, we need to check if the
|
|
|
- // last `api_req_started` has a cost value, if it doesn't and no
|
|
|
- // cancellation reason to present, then we remove it since it indicates
|
|
|
- // an api request without any partial content streamed.
|
|
|
- const lastApiReqStartedIndex = findLastIndex(
|
|
|
- modifiedClineMessages,
|
|
|
- (m) => m.type === "say" && m.say === "api_req_started",
|
|
|
- )
|
|
|
+ this.isInitialized = true
|
|
|
+
|
|
|
+ const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`.
|
|
|
|
|
|
- if (lastApiReqStartedIndex !== -1) {
|
|
|
- const lastApiReqStarted = modifiedClineMessages[lastApiReqStartedIndex]
|
|
|
- const { cost, cancelReason }: ClineApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}")
|
|
|
+ let responseText: string | undefined
|
|
|
+ let responseImages: string[] | undefined
|
|
|
|
|
|
- if (cost === undefined && cancelReason === undefined) {
|
|
|
- modifiedClineMessages.splice(lastApiReqStartedIndex, 1)
|
|
|
+ if (response === "messageResponse") {
|
|
|
+ await this.say("user_feedback", text, images)
|
|
|
+ responseText = text
|
|
|
+ responseImages = images
|
|
|
}
|
|
|
- }
|
|
|
|
|
|
- await this.overwriteClineMessages(modifiedClineMessages)
|
|
|
- this.clineMessages = await this.getSavedClineMessages()
|
|
|
-
|
|
|
- // Now present the cline messages to the user and ask if they want to
|
|
|
- // resume (NOTE: we ran into a bug before where the
|
|
|
- // apiConversationHistory wouldn't be initialized when opening a old
|
|
|
- // task, and it was because we were waiting for resume).
|
|
|
- // This is important in case the user deletes messages without resuming
|
|
|
- // the task first.
|
|
|
- this.apiConversationHistory = await this.getSavedApiConversationHistory()
|
|
|
-
|
|
|
- const lastClineMessage = this.clineMessages
|
|
|
- .slice()
|
|
|
- .reverse()
|
|
|
- .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks.
|
|
|
-
|
|
|
- let askType: ClineAsk
|
|
|
- if (lastClineMessage?.ask === "completion_result") {
|
|
|
- askType = "resume_completed_task"
|
|
|
- } else {
|
|
|
- askType = "resume_task"
|
|
|
- }
|
|
|
+ // Make sure that the api conversation history can be resumed by the API,
|
|
|
+ // even if it goes out of sync with cline messages.
|
|
|
+ let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory()
|
|
|
|
|
|
- this.isInitialized = true
|
|
|
+ // Tool blocks are always preserved; native tool calling only.
|
|
|
|
|
|
- const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`.
|
|
|
+ // if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response
|
|
|
+ // if there's no tool use and only a text block, then we can just add a user message
|
|
|
+ // (note this isn't relevant anymore since we use custom tool prompts instead of tool use blocks, but this is here for legacy purposes in case users resume old tasks)
|
|
|
|
|
|
- let responseText: string | undefined
|
|
|
- let responseImages: string[] | undefined
|
|
|
+ // if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted'
|
|
|
|
|
|
- if (response === "messageResponse") {
|
|
|
- await this.say("user_feedback", text, images)
|
|
|
- responseText = text
|
|
|
- responseImages = images
|
|
|
- }
|
|
|
+ let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message
|
|
|
+ let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message
|
|
|
+ if (existingApiConversationHistory.length > 0) {
|
|
|
+ const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
|
|
|
|
|
|
- // Make sure that the api conversation history can be resumed by the API,
|
|
|
- // even if it goes out of sync with cline messages.
|
|
|
- let existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory()
|
|
|
-
|
|
|
- // Tool blocks are always preserved; native tool calling only.
|
|
|
-
|
|
|
- // if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response
|
|
|
- // if there's no tool use and only a text block, then we can just add a user message
|
|
|
- // (note this isn't relevant anymore since we use custom tool prompts instead of tool use blocks, but this is here for legacy purposes in case users resume old tasks)
|
|
|
-
|
|
|
- // if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted'
|
|
|
-
|
|
|
- let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message
|
|
|
- let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message
|
|
|
- if (existingApiConversationHistory.length > 0) {
|
|
|
- const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1]
|
|
|
-
|
|
|
- if (lastMessage.isSummary) {
|
|
|
- // IMPORTANT: If the last message is a condensation summary, we must preserve it
|
|
|
- // intact. The summary message carries critical metadata (isSummary, condenseId)
|
|
|
- // that getEffectiveApiHistory() uses to filter out condensed messages.
|
|
|
- // Removing or merging it would destroy this metadata, causing all condensed
|
|
|
- // messages to become "orphaned" and restored to active status — effectively
|
|
|
- // undoing the condensation and sending the full history to the API.
|
|
|
- // See: https://github.com/RooCodeInc/Roo-Code/issues/11487
|
|
|
- modifiedApiConversationHistory = [...existingApiConversationHistory]
|
|
|
- modifiedOldUserContent = []
|
|
|
- } else if (lastMessage.role === "assistant") {
|
|
|
- const content = Array.isArray(lastMessage.content)
|
|
|
- ? lastMessage.content
|
|
|
- : [{ type: "text", text: lastMessage.content }]
|
|
|
- const hasToolUse = content.some((block) => block.type === "tool_use")
|
|
|
-
|
|
|
- if (hasToolUse) {
|
|
|
- const toolUseBlocks = content.filter(
|
|
|
- (block) => block.type === "tool_use",
|
|
|
- ) as Anthropic.Messages.ToolUseBlock[]
|
|
|
- const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({
|
|
|
- type: "tool_result",
|
|
|
- tool_use_id: block.id,
|
|
|
- content: "Task was interrupted before this tool call could be completed.",
|
|
|
- }))
|
|
|
- modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes
|
|
|
- modifiedOldUserContent = [...toolResponses]
|
|
|
- } else {
|
|
|
+ if (lastMessage.isSummary) {
|
|
|
+ // IMPORTANT: If the last message is a condensation summary, we must preserve it
|
|
|
+ // intact. The summary message carries critical metadata (isSummary, condenseId)
|
|
|
+ // that getEffectiveApiHistory() uses to filter out condensed messages.
|
|
|
+ // Removing or merging it would destroy this metadata, causing all condensed
|
|
|
+ // messages to become "orphaned" and restored to active status — effectively
|
|
|
+ // undoing the condensation and sending the full history to the API.
|
|
|
+ // See: https://github.com/RooCodeInc/Roo-Code/issues/11487
|
|
|
modifiedApiConversationHistory = [...existingApiConversationHistory]
|
|
|
modifiedOldUserContent = []
|
|
|
- }
|
|
|
- } else if (lastMessage.role === "user") {
|
|
|
- const previousAssistantMessage: ApiMessage | undefined =
|
|
|
- existingApiConversationHistory[existingApiConversationHistory.length - 2]
|
|
|
-
|
|
|
- const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray(lastMessage.content)
|
|
|
- ? lastMessage.content
|
|
|
- : [{ type: "text", text: lastMessage.content }]
|
|
|
- if (previousAssistantMessage && previousAssistantMessage.role === "assistant") {
|
|
|
- const assistantContent = Array.isArray(previousAssistantMessage.content)
|
|
|
- ? previousAssistantMessage.content
|
|
|
- : [{ type: "text", text: previousAssistantMessage.content }]
|
|
|
-
|
|
|
- const toolUseBlocks = assistantContent.filter(
|
|
|
- (block) => block.type === "tool_use",
|
|
|
- ) as Anthropic.Messages.ToolUseBlock[]
|
|
|
-
|
|
|
- if (toolUseBlocks.length > 0) {
|
|
|
- const existingToolResults = existingUserContent.filter(
|
|
|
- (block) => block.type === "tool_result",
|
|
|
- ) as Anthropic.ToolResultBlockParam[]
|
|
|
-
|
|
|
- const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks
|
|
|
- .filter(
|
|
|
- (toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id),
|
|
|
- )
|
|
|
- .map((toolUse) => ({
|
|
|
- type: "tool_result",
|
|
|
- tool_use_id: toolUse.id,
|
|
|
- content: "Task was interrupted before this tool call could be completed.",
|
|
|
- }))
|
|
|
-
|
|
|
- modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message
|
|
|
- modifiedOldUserContent = [...existingUserContent, ...missingToolResponses]
|
|
|
+ } else if (lastMessage.role === "assistant") {
|
|
|
+ const content = Array.isArray(lastMessage.content)
|
|
|
+ ? lastMessage.content
|
|
|
+ : [{ type: "text", text: lastMessage.content }]
|
|
|
+ const hasToolUse = content.some((block) => block.type === "tool_use")
|
|
|
+
|
|
|
+ if (hasToolUse) {
|
|
|
+ const toolUseBlocks = content.filter(
|
|
|
+ (block) => block.type === "tool_use",
|
|
|
+ ) as Anthropic.Messages.ToolUseBlock[]
|
|
|
+ const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({
|
|
|
+ type: "tool_result",
|
|
|
+ tool_use_id: block.id,
|
|
|
+ content: "Task was interrupted before this tool call could be completed.",
|
|
|
+ }))
|
|
|
+ modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes
|
|
|
+ modifiedOldUserContent = [...toolResponses]
|
|
|
+ } else {
|
|
|
+ modifiedApiConversationHistory = [...existingApiConversationHistory]
|
|
|
+ modifiedOldUserContent = []
|
|
|
+ }
|
|
|
+ } else if (lastMessage.role === "user") {
|
|
|
+ const previousAssistantMessage: ApiMessage | undefined =
|
|
|
+ existingApiConversationHistory[existingApiConversationHistory.length - 2]
|
|
|
+
|
|
|
+ const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray(
|
|
|
+ lastMessage.content,
|
|
|
+ )
|
|
|
+ ? lastMessage.content
|
|
|
+ : [{ type: "text", text: lastMessage.content }]
|
|
|
+ if (previousAssistantMessage && previousAssistantMessage.role === "assistant") {
|
|
|
+ const assistantContent = Array.isArray(previousAssistantMessage.content)
|
|
|
+ ? previousAssistantMessage.content
|
|
|
+ : [{ type: "text", text: previousAssistantMessage.content }]
|
|
|
+
|
|
|
+ const toolUseBlocks = assistantContent.filter(
|
|
|
+ (block) => block.type === "tool_use",
|
|
|
+ ) as Anthropic.Messages.ToolUseBlock[]
|
|
|
+
|
|
|
+ if (toolUseBlocks.length > 0) {
|
|
|
+ const existingToolResults = existingUserContent.filter(
|
|
|
+ (block) => block.type === "tool_result",
|
|
|
+ ) as Anthropic.ToolResultBlockParam[]
|
|
|
+
|
|
|
+ const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks
|
|
|
+ .filter(
|
|
|
+ (toolUse) =>
|
|
|
+ !existingToolResults.some((result) => result.tool_use_id === toolUse.id),
|
|
|
+ )
|
|
|
+ .map((toolUse) => ({
|
|
|
+ type: "tool_result",
|
|
|
+ tool_use_id: toolUse.id,
|
|
|
+ content: "Task was interrupted before this tool call could be completed.",
|
|
|
+ }))
|
|
|
+
|
|
|
+ modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message
|
|
|
+ modifiedOldUserContent = [...existingUserContent, ...missingToolResponses]
|
|
|
+ } else {
|
|
|
+ modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
|
|
|
+ modifiedOldUserContent = [...existingUserContent]
|
|
|
+ }
|
|
|
} else {
|
|
|
modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
|
|
|
modifiedOldUserContent = [...existingUserContent]
|
|
|
}
|
|
|
} else {
|
|
|
- modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1)
|
|
|
- modifiedOldUserContent = [...existingUserContent]
|
|
|
+ throw new Error("Unexpected: Last message is not a user or assistant message")
|
|
|
}
|
|
|
} else {
|
|
|
- throw new Error("Unexpected: Last message is not a user or assistant message")
|
|
|
+ throw new Error("Unexpected: No existing API conversation history")
|
|
|
}
|
|
|
- } else {
|
|
|
- throw new Error("Unexpected: No existing API conversation history")
|
|
|
- }
|
|
|
|
|
|
- let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent]
|
|
|
+ let newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent]
|
|
|
|
|
|
- const agoText = ((): string => {
|
|
|
- const timestamp = lastClineMessage?.ts ?? Date.now()
|
|
|
- const now = Date.now()
|
|
|
- const diff = now - timestamp
|
|
|
- const minutes = Math.floor(diff / 60000)
|
|
|
- const hours = Math.floor(minutes / 60)
|
|
|
- const days = Math.floor(hours / 24)
|
|
|
+ const agoText = ((): string => {
|
|
|
+ const timestamp = lastClineMessage?.ts ?? Date.now()
|
|
|
+ const now = Date.now()
|
|
|
+ const diff = now - timestamp
|
|
|
+ const minutes = Math.floor(diff / 60000)
|
|
|
+ const hours = Math.floor(minutes / 60)
|
|
|
+ const days = Math.floor(hours / 24)
|
|
|
|
|
|
- if (days > 0) {
|
|
|
- return `${days} day${days > 1 ? "s" : ""} ago`
|
|
|
- }
|
|
|
- if (hours > 0) {
|
|
|
- return `${hours} hour${hours > 1 ? "s" : ""} ago`
|
|
|
+ if (days > 0) {
|
|
|
+ return `${days} day${days > 1 ? "s" : ""} ago`
|
|
|
+ }
|
|
|
+ if (hours > 0) {
|
|
|
+ return `${hours} hour${hours > 1 ? "s" : ""} ago`
|
|
|
+ }
|
|
|
+ if (minutes > 0) {
|
|
|
+ return `${minutes} minute${minutes > 1 ? "s" : ""} ago`
|
|
|
+ }
|
|
|
+ return "just now"
|
|
|
+ })()
|
|
|
+
|
|
|
+ if (responseText) {
|
|
|
+ newUserContent.push({
|
|
|
+ type: "text",
|
|
|
+ text: `<user_message>\n${responseText}\n</user_message>`,
|
|
|
+ })
|
|
|
}
|
|
|
- if (minutes > 0) {
|
|
|
- return `${minutes} minute${minutes > 1 ? "s" : ""} ago`
|
|
|
+
|
|
|
+ if (responseImages && responseImages.length > 0) {
|
|
|
+ newUserContent.push(...formatResponse.imageBlocks(responseImages))
|
|
|
}
|
|
|
- return "just now"
|
|
|
- })()
|
|
|
|
|
|
- if (responseText) {
|
|
|
- newUserContent.push({
|
|
|
- type: "text",
|
|
|
- text: `<user_message>\n${responseText}\n</user_message>`,
|
|
|
- })
|
|
|
- }
|
|
|
+ // Ensure we have at least some content to send to the API.
|
|
|
+ // If newUserContent is empty, add a minimal resumption message.
|
|
|
+ if (newUserContent.length === 0) {
|
|
|
+ newUserContent.push({
|
|
|
+ type: "text",
|
|
|
+ text: "[TASK RESUMPTION] Resuming task...",
|
|
|
+ })
|
|
|
+ }
|
|
|
|
|
|
- if (responseImages && responseImages.length > 0) {
|
|
|
- newUserContent.push(...formatResponse.imageBlocks(responseImages))
|
|
|
- }
|
|
|
+ await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
|
|
|
|
|
|
- // Ensure we have at least some content to send to the API.
|
|
|
- // If newUserContent is empty, add a minimal resumption message.
|
|
|
- if (newUserContent.length === 0) {
|
|
|
- newUserContent.push({
|
|
|
- type: "text",
|
|
|
- text: "[TASK RESUMPTION] Resuming task...",
|
|
|
- })
|
|
|
+ // Task resuming from history item.
|
|
|
+ await this.initiateTaskLoop(newUserContent)
|
|
|
+ } catch (error) {
|
|
|
+ // Resume and cancellation can race when users issue repeated cancels.
|
|
|
+ // Treat intentional abort/abandon flows as expected and avoid process-level crashes.
|
|
|
+ if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ throw error
|
|
|
}
|
|
|
-
|
|
|
- await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
|
|
|
-
|
|
|
- // Task resuming from history item.
|
|
|
- await this.initiateTaskLoop(newUserContent)
|
|
|
}
|
|
|
|
|
|
/**
|