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
9 changes: 9 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2101,10 +2101,16 @@ export class Task extends EventEmitter<TaskEvents> 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]
Expand Down Expand Up @@ -2140,10 +2146,13 @@ export class Task extends EventEmitter<TaskEvents> 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
Expand Down
136 changes: 136 additions & 0 deletions src/core/task/__tests__/Task.persistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -15,6 +16,7 @@ import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
type TaskPersistenceAccess = {
resumeTaskFromHistory: () => Promise<void>
saveClineMessages: () => Promise<boolean>
initiateTaskLoop: (userContent: Anthropic.Messages.ContentBlockParam[]) => Promise<void>
}

function getTaskPersistenceAccess(task: Task): TaskPersistenceAccess {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading