From 81c7be95fd34bb403268c86f1c89c7bfef45556b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 21 Aug 2026 17:56:01 +0800 Subject: [PATCH] fix(task): mark interrupted tool calls as errors in persisted history When a task is interrupted while an assistant turn's tool calls (e.g. attempt_completion) are unanswered, resumeTaskFromHistory synthesizes tool_result blocks to keep the API conversation valid. Those synthetic blocks lacked is_error: true, so the persisted api_conversation_history.json ends with a success-looking completion while the task list records the task as interrupted - any consumer inferring completion from history is fooled. Set is_error: true on both synthetic tool_result sites (assistant-last and missing-results cases), matching the existing error tool_result pattern used for new_task truncation. Adds two regression tests covering both code paths. --- src/core/task/Task.ts | 9 ++ .../task/__tests__/Task.persistence.spec.ts | 136 ++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4be087394e..571c988fb5 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2101,10 +2101,16 @@ export class Task extends EventEmitter implements TaskLike { const toolUseBlocks = content.filter( (block) => block.type === "tool_use", ) as Anthropic.Messages.ToolUseBlock[] + // Mark the synthetic results as errors (is_error: true) so the persisted + // history cannot be misread as a successful completion of the interrupted + // tool calls (e.g. attempt_completion). The task list already records the + // task as interrupted; the API history must agree. + // See: https://github.com/Zoo-Code-Org/Zoo-Code/issues/1283 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.", + is_error: true, })) modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes modifiedOldUserContent = [...toolResponses] @@ -2140,10 +2146,13 @@ export class Task extends EventEmitter implements TaskLike { (toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id), ) + // is_error: true — same rationale as the assistant-last case above. + // See: https://github.com/Zoo-Code-Org/Zoo-Code/issues/1283 .map((toolUse) => ({ type: "tool_result", tool_use_id: toolUse.id, content: "Task was interrupted before this tool call could be completed.", + is_error: true, })) modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 19bd0c7f34..3b1f3f709a 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -6,6 +6,7 @@ import * as vscode from "vscode" import type { ClineMessage, GlobalState, ProviderSettings } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import type { Anthropic } from "@anthropic-ai/sdk" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" @@ -15,6 +16,7 @@ import { providerIdentifiers } from "@roo-code/types/provider-identifiers" type TaskPersistenceAccess = { resumeTaskFromHistory: () => Promise saveClineMessages: () => Promise + initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise } function getTaskPersistenceAccess(task: Task): TaskPersistenceAccess { @@ -584,6 +586,140 @@ describe("Task persistence", () => { }) }) + // ── resumeTaskFromHistory — interrupted tool calls must be recorded as errors ── + + describe("resumeTaskFromHistory interrupted tool calls", () => { + const interruptedToolResultContent = "Task was interrupted before this tool call could be completed." + + it("marks synthetic tool_results from an interrupted assistant turn as errors", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "interrupted-subtask", + number: 1, + ts: Date.now(), + task: "Interrupted subtask", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + initialStatus: "interrupted", + }) + // Stop the resume flow right before the agentic loop so the test only + // exercises history reconstruction; the loop would make a real API call. + const initiateTaskLoopSpy = vi + .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") + .mockResolvedValue(undefined) + vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + + // The persisted history ends with an assistant turn whose tool calls + // (attempt_completion) were never answered because the task was + // interrupted. See: https://github.com/Zoo-Code-Org/Zoo-Code/issues/1283 + mockReadApiMessages.mockResolvedValue([ + { + role: "assistant", + content: [ + { type: "text", text: "Wrapping up" }, + { + type: "tool_use", + id: "toolu_interrupted_1", + name: "attempt_completion", + input: { result: "done" }, + }, + ], + }, + ]) + + await getTaskPersistenceAccess(task).resumeTaskFromHistory() + + expect(initiateTaskLoopSpy).toHaveBeenCalledTimes(1) + const newUserContent = initiateTaskLoopSpy.mock.calls[0][0] + const toolResults = newUserContent.filter((block) => block.type === "tool_result") + // The synthetic tool_result must be recorded as an error so the history + // cannot be misread as a successful completion of the interrupted call. + expect(toolResults).toEqual([ + { + type: "tool_result", + tool_use_id: "toolu_interrupted_1", + content: interruptedToolResultContent, + is_error: true, + }, + ]) + }) + + it("marks missing tool_results for an interrupted trailing user turn as errors", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "interrupted-subtask-2", + number: 2, + ts: Date.now(), + task: "Interrupted subtask 2", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + initialStatus: "interrupted", + }) + const initiateTaskLoopSpy = vi + .spyOn(getTaskPersistenceAccess(task), "initiateTaskLoop") + .mockResolvedValue(undefined) + vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + + // The persisted history ends with a user turn that only answered the + // first of two parallel tool calls. + mockReadApiMessages.mockResolvedValue([ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_int_a", + name: "execute_command", + input: { command: "ls" }, + }, + { type: "tool_use", id: "toolu_int_b", name: "read_file", input: { path: "a.txt" } }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_int_a", + content: "partial result", + }, + ], + }, + ]) + + await getTaskPersistenceAccess(task).resumeTaskFromHistory() + + expect(initiateTaskLoopSpy).toHaveBeenCalledTimes(1) + const newUserContent = initiateTaskLoopSpy.mock.calls[0][0] + const toolResults = newUserContent.filter((block) => block.type === "tool_result") + // The pre-existing result is preserved untouched; the synthesized one + // for the unanswered tool call is marked as an error. + expect(toolResults).toEqual([ + { + type: "tool_result", + tool_use_id: "toolu_int_a", + content: "partial result", + }, + { + type: "tool_result", + tool_use_id: "toolu_int_b", + content: interruptedToolResultContent, + is_error: true, + }, + ]) + }) + }) + // ── flushPendingToolResultsToHistory — save failure/success ─────────── describe("flushPendingToolResultsToHistory persistence", () => {