Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
114 changes: 107 additions & 7 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// deallocated. (Although we set Cline = undefined in provider, that
// simply removes the reference to this instance, but the instance is
// still alive until this promise resolves or rejects.)
if (this.abort) {
// Exception: Allow resume asks even when aborted for soft-interrupt UX
if (this.abort && type !== "resume_task" && type !== "resume_completed_task") {
throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`)
}

Expand Down Expand Up @@ -1255,7 +1256,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
])
}

private async resumeTaskFromHistory() {
public async resumeTaskFromHistory() {
if (this.enableBridge) {
try {
await BridgeOrchestrator.subscribeToTask(this)
Expand Down Expand Up @@ -1347,6 +1348,30 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`.

// Reset abort flags AFTER user responds to resume ask.
// This is critical for the cancel → resume flow: when a task is soft-aborted
// (abandoned = false), we keep the instance alive but set abort = true.
// We only clear these flags after the user confirms they want to resume,
// preventing the old stream from continuing if abort was set.
this.abort = false
this.abandoned = false
this.abortReason = undefined
this.didFinishAbortingStream = false
this.isStreaming = false

// Reset streaming-local fields to avoid stale state from previous stream
this.currentStreamingContentIndex = 0
this.currentStreamingDidCheckpoint = false
this.assistantMessageContent = []
this.didCompleteReadingStream = false
this.userMessageContent = []
this.userMessageContentReady = false
this.didRejectTool = false
this.didAlreadyUseTool = false
this.presentAssistantMessageLocked = false
this.presentAssistantMessageHasPendingUpdates = false
this.assistantMessageParser.reset()

let responseText: string | undefined
let responseImages: string[] | undefined

Expand Down Expand Up @@ -1525,6 +1550,76 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.initiateTaskLoop(newUserContent)
}

/**
* Present a resumable ask on an aborted task without rehydrating.
* Used by soft-interrupt (cancelTask) to show Resume/Terminate UI.
* Selects the appropriate ask type based on the last relevant message.
* If the user clicks Resume, resets abort flags and continues the task loop.
*/
public async presentResumableAsk(): Promise<void> {
const lastClineMessage = this.clineMessages
.slice()
.reverse()
.find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"))

let askType: ClineAsk
if (lastClineMessage?.ask === "completion_result") {
askType = "resume_completed_task"
} else {
askType = "resume_task"
}

const { response, text, images } = await this.ask(askType)

// If user clicked Resume (not Terminate), reset abort flags and continue
if (response === "yesButtonClicked" || response === "messageResponse") {
// Reset abort flags to allow the loop to continue
this.abort = false
this.abandoned = false
this.abortReason = undefined
this.didFinishAbortingStream = false
this.isStreaming = false

// Reset streaming-local fields to avoid stale state from previous stream
this.currentStreamingContentIndex = 0
this.currentStreamingDidCheckpoint = false
this.assistantMessageContent = []
this.didCompleteReadingStream = false
this.userMessageContent = []
this.userMessageContentReady = false
this.didRejectTool = false
this.didAlreadyUseTool = false
this.presentAssistantMessageLocked = false
this.presentAssistantMessageHasPendingUpdates = false
this.assistantMessageParser.reset()

// Prepare content for resuming the task loop
let userContent: Anthropic.Messages.ContentBlockParam[] = []

if (response === "messageResponse" && text) {
// User provided additional instructions
await this.say("user_feedback", text, images)
userContent.push({
type: "text",
text: `\n\nNew instructions for task continuation:\n<user_message>\n${text}\n</user_message>`,
})
if (images && images.length > 0) {
userContent.push(...formatResponse.imageBlocks(images))
}
} else {
// Simple resume with no new instructions
userContent.push({
type: "text",
text: "[TASK RESUMPTION] Resuming task...",
})
}

// Continue the task loop
await this.initiateTaskLoop(userContent)
}
// If user clicked Terminate (noButtonClicked), do nothing - task stays aborted
}

public async abortTask(isAbandoned = false) {
// Aborting task

Expand All @@ -1536,12 +1631,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.abort = true
this.emit(RooCodeEventName.TaskAborted)

try {
this.dispose() // Call the centralized dispose method
} catch (error) {
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
// Don't rethrow - we want abort to always succeed
// Only dispose if this is a hard abort (abandoned)
// For soft abort (user cancel), keep the instance alive so we can present a resumable ask
if (isAbandoned) {
try {
this.dispose() // Call the centralized dispose method
} catch (error) {
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
// Don't rethrow - we want abort to always succeed
}
}

// Save the countdown message in the automatic retry or other content.
try {
// Save the countdown message in the automatic retry or other content.
Expand Down
146 changes: 146 additions & 0 deletions src/core/task/__tests__/Task.presentResumableAsk.abort-reset.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { ProviderSettings } from "@roo-code/types"

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

// Mocks similar to Task.dispose.test.ts
vi.mock("../../webview/ClineProvider")
vi.mock("../../../integrations/terminal/TerminalRegistry", () => ({
TerminalRegistry: {
releaseTerminalsForTask: vi.fn(),
},
}))
vi.mock("../../ignore/RooIgnoreController")
vi.mock("../../protect/RooProtectedController")
vi.mock("../../context-tracking/FileContextTracker")
vi.mock("../../../services/browser/UrlContentFetcher")
vi.mock("../../../services/browser/BrowserSession")
vi.mock("../../../integrations/editor/DiffViewProvider")
vi.mock("../../tools/ToolRepetitionDetector")
vi.mock("../../../api", () => ({
buildApiHandler: vi.fn(() => ({
getModel: () => ({ info: {}, id: "test-model" }),
})),
}))
vi.mock("../AutoApprovalHandler")

// Mock TelemetryService
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureTaskCreated: vi.fn(),
captureTaskRestarted: vi.fn(),
captureConversationMessage: vi.fn(),
},
},
}))

describe("Task.presentResumableAsk abort reset", () => {
let mockProvider: any
let mockApiConfiguration: ProviderSettings
let task: Task

beforeEach(() => {
vi.clearAllMocks()

mockProvider = {
context: {
globalStorageUri: { fsPath: "/test/path" },
},
getState: vi.fn().mockResolvedValue({ mode: "code" }),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
postMessageToWebview: vi.fn().mockResolvedValue(undefined),
updateTaskHistory: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
}

mockApiConfiguration = {
apiProvider: "anthropic",
apiKey: "test-key",
} as ProviderSettings

task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
startTask: false,
})
})

afterEach(() => {
// Ensure we don't leave event listeners dangling
task.dispose()
})

it("resets abort flags and continues the loop on yesButtonClicked", async () => {
// Arrange aborted state
task.abort = true
task.abortReason = "user_cancelled"
task.didFinishAbortingStream = true
task.isStreaming = true

// minimal message history
task.clineMessages = [{ ts: Date.now() - 1000, type: "say", say: "text", text: "prev" } as any]

// Spy and stub ask + loop
const askSpy = vi.spyOn(task as any, "ask").mockResolvedValue({ response: "yesButtonClicked" })
const loopSpy = vi.spyOn(task as any, "initiateTaskLoop").mockResolvedValue(undefined)

// Act
await task.presentResumableAsk()

// Assert ask was presented
expect(askSpy).toHaveBeenCalled()

// Abort flags cleared
expect(task.abort).toBe(false)
expect(task.abandoned).toBe(false)
expect(task.abortReason).toBeUndefined()
expect(task.didFinishAbortingStream).toBe(false)
expect(task.isStreaming).toBe(false)

// Streaming-local state cleared
expect(task.currentStreamingContentIndex).toBe(0)
expect(task.assistantMessageContent).toEqual([])
expect(task.userMessageContentReady).toBe(false)
expect(task.didRejectTool).toBe(false)
expect(task.presentAssistantMessageLocked).toBe(false)

// Loop resumed
expect(loopSpy).toHaveBeenCalledTimes(1)
})

it("includes user feedback when resuming with messageResponse", async () => {
task.abort = true
task.clineMessages = [{ ts: Date.now() - 1000, type: "say", say: "text", text: "prev" } as any]

const askSpy = vi
.spyOn(task as any, "ask")
.mockResolvedValue({ response: "messageResponse", text: "Continue with this", images: undefined })
const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined as any)
const loopSpy = vi.spyOn(task as any, "initiateTaskLoop").mockResolvedValue(undefined)

await task.presentResumableAsk()

expect(askSpy).toHaveBeenCalled()
expect(saySpy).toHaveBeenCalledWith("user_feedback", "Continue with this", undefined)
expect(loopSpy).toHaveBeenCalledTimes(1)
})

it("does nothing when user clicks Terminate (noButtonClicked)", async () => {
task.abort = true
task.abortReason = "user_cancelled"
task.clineMessages = [{ ts: Date.now() - 1000, type: "say", say: "text", text: "prev" } as any]

vi.spyOn(task as any, "ask").mockResolvedValue({ response: "noButtonClicked" })
const loopSpy = vi.spyOn(task as any, "initiateTaskLoop").mockResolvedValue(undefined)

await task.presentResumableAsk()

// Still aborted
expect(task.abort).toBe(true)
expect(task.abortReason).toBe("user_cancelled")
// No loop resume
expect(loopSpy).not.toHaveBeenCalled()
})
})
10 changes: 5 additions & 5 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1722,12 +1722,12 @@ describe("Cline", () => {
// Mock the dispose method to track cleanup
const disposeSpy = vi.spyOn(task, "dispose").mockImplementation(() => {})

// Call abortTask
// Call abortTask (soft cancel - same path as UI Cancel button)
await task.abortTask()

// Verify the same behavior as Cancel button
// Verify the same behavior as Cancel button: soft abort sets abort flag but does not dispose
expect(task.abort).toBe(true)
expect(disposeSpy).toHaveBeenCalled()
expect(disposeSpy).not.toHaveBeenCalled()
})

it("should work with TaskLike interface", async () => {
Expand Down Expand Up @@ -1771,8 +1771,8 @@ describe("Cline", () => {
// Spy on console.error to verify error is logged
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})

// abortTask should not throw even if dispose fails
await expect(task.abortTask()).resolves.not.toThrow()
// abortTask should not throw even if dispose fails (hard abort triggers dispose)
await expect(task.abortTask(true)).resolves.not.toThrow()

// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Error during task"), mockError)
Expand Down
Loading