From 9a8bd954bff7dcc4ce7bb4caa1d57591b3706155 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Thu, 19 Jun 2025 12:08:24 -0600 Subject: [PATCH 1/3] fix: resolve phantom subtask results on cancel during API retry (#4602) - Fixed clearTask handler to check for actual parent task existence - Changed condition from getClineStackSize() > 1 to checking currentTask.parentTask - Prevents finishSubTask() from being called on tasks without parents - Added comprehensive test coverage for clearTask message handler - Resolves infinite checkpoint initialization loop issue --- .../webview/__tests__/ClineProvider.spec.ts | 113 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 9 +- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index efa49f268d8..a7c0a32b0b5 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -569,6 +569,119 @@ describe("ClineProvider", () => { expect(stackSizeBeforeAbort - stackSizeAfterAbort).toBe(1) }) + describe("clearTask message handler", () => { + beforeEach(async () => { + await provider.resolveWebviewView(mockWebviewView) + }) + + test("calls clearTask when there is no parent task", async () => { + // Setup a single task without parent + const mockCline = new Task(defaultTaskOptions) + // Ensure no parentTask property + mockCline.parentTask = undefined + + // Mock the provider methods + const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) + const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) + const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // Add task to stack + await provider.addClineToStack(mockCline) + + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + + // Trigger clearTask message + await messageHandler({ type: "clearTask" }) + + // Verify clearTask was called (not finishSubTask) + expect(clearTaskSpy).toHaveBeenCalled() + expect(finishSubTaskSpy).not.toHaveBeenCalled() + expect(postStateToWebviewSpy).toHaveBeenCalled() + }) + + test("calls finishSubTask when there is a parent task", async () => { + // Setup parent and child tasks + const parentTask = new Task(defaultTaskOptions) + const childTask = new Task(defaultTaskOptions) + + // Set up parent-child relationship + childTask.parentTask = parentTask + childTask.rootTask = parentTask + + // Mock the provider methods + const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) + const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) + const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // Add both tasks to stack (parent first, then child) + await provider.addClineToStack(parentTask) + await provider.addClineToStack(childTask) + + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + + // Trigger clearTask message + await messageHandler({ type: "clearTask" }) + + // Verify finishSubTask was called (not clearTask) + expect(finishSubTaskSpy).toHaveBeenCalledWith(expect.stringContaining("canceled")) + expect(clearTaskSpy).not.toHaveBeenCalled() + expect(postStateToWebviewSpy).toHaveBeenCalled() + }) + + test("handles case when no current task exists", async () => { + // Don't add any tasks to the stack + + // Mock the provider methods + const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) + const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) + const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + + // Trigger clearTask message + await messageHandler({ type: "clearTask" }) + + // When there's no current task, clearTask is still called (it handles the no-task case internally) + expect(clearTaskSpy).toHaveBeenCalled() + expect(finishSubTaskSpy).not.toHaveBeenCalled() + // State should still be posted + expect(postStateToWebviewSpy).toHaveBeenCalled() + }) + + test("correctly identifies subtask scenario for issue #4602", async () => { + // This test specifically validates the fix for issue #4602 + // where canceling during API retry was incorrectly treating a single task as a subtask + + const mockCline = new Task(defaultTaskOptions) + // Explicitly set no parent task + mockCline.parentTask = undefined + mockCline.rootTask = undefined + + // Mock the provider methods + const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) + const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined) + + // Add only one task to stack + await provider.addClineToStack(mockCline) + + // Verify stack size is 1 + expect(provider.getClineStackSize()).toBe(1) + + // Get the message handler + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + + // Trigger clearTask message (simulating cancel during API retry) + await messageHandler({ type: "clearTask" }) + + // The fix ensures clearTask is called, not finishSubTask + expect(clearTaskSpy).toHaveBeenCalled() + expect(finishSubTaskSpy).not.toHaveBeenCalled() + }) + }) + test("addClineToStack adds multiple Cline instances to the stack", async () => { // Setup Cline instance with auto-mock from the top of the file const mockCline1 = new Task(defaultTaskOptions) // Create a new mocked instance diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c5433497dc8..720e5cda0da 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -201,7 +201,14 @@ export const webviewMessageHandler = async ( break case "clearTask": // clear task resets the current session and allows for a new task to be started, if this session is a subtask - it allows the parent task to be resumed - await provider.finishSubTask(t("common:tasks.canceled")) + // Check if the current task actually has a parent task + const currentTask = provider.getCurrentCline() + if (currentTask && currentTask.parentTask) { + await provider.finishSubTask(t("common:tasks.canceled")) + } else { + // Regular task - just clear it + await provider.clearTask() + } await provider.postStateToWebview() break case "didShowAnnouncement": From 486ef9c62058035592abb8705a7e5aa3ff0b8a7a Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Thu, 19 Jun 2025 12:11:52 -0600 Subject: [PATCH 2/3] fix: resolve phantom subtask display on cancel during API retry (#4602) - Modified clearTask handler to check for parentTask property instead of stack size - This prevents single tasks from being incorrectly treated as subtasks - Added comprehensive test coverage for the fix - Fixes issue where canceling during API retry would show phantom 'Subtask Results' and create checkpoint initialization loop --- src/core/webview/ClineProvider.ts | 7 +++++++ src/core/webview/__tests__/ClineProvider.spec.ts | 14 ++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index aa182c3de41..805f964baf4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -232,6 +232,13 @@ export class ClineProvider await this.getCurrentCline()?.resumePausedTask(lastMessage) } + // Clear the current task without treating it as a subtask + // This is used when the user cancels a task that is not a subtask + async clearTask() { + console.log(`[clearTask] clearing current task`) + await this.removeClineFromStack() + } + /* 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/ diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index a7c0a32b0b5..de4f34a12ac 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -577,8 +577,7 @@ describe("ClineProvider", () => { test("calls clearTask when there is no parent task", async () => { // Setup a single task without parent const mockCline = new Task(defaultTaskOptions) - // Ensure no parentTask property - mockCline.parentTask = undefined + // No need to set parentTask - it's undefined by default // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) @@ -605,9 +604,10 @@ describe("ClineProvider", () => { const parentTask = new Task(defaultTaskOptions) const childTask = new Task(defaultTaskOptions) - // Set up parent-child relationship - childTask.parentTask = parentTask - childTask.rootTask = parentTask + // Set up parent-child relationship by setting the parentTask property + // The mock allows us to set properties directly + ;(childTask as any).parentTask = parentTask + ;(childTask as any).rootTask = parentTask // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) @@ -656,9 +656,7 @@ describe("ClineProvider", () => { // where canceling during API retry was incorrectly treating a single task as a subtask const mockCline = new Task(defaultTaskOptions) - // Explicitly set no parent task - mockCline.parentTask = undefined - mockCline.rootTask = undefined + // No parent task by default - no need to explicitly set // Mock the provider methods const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined) From 0cc9eca8cf620c1e4d85b0be32aad2c5dd36f7ed Mon Sep 17 00:00:00 2001 From: Daniel Riccio Date: Thu, 19 Jun 2025 14:11:11 -0500 Subject: [PATCH 3/3] fix: remove debug log from clearTask method --- src/core/webview/ClineProvider.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 805f964baf4..d99aa644b21 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -235,7 +235,6 @@ export class ClineProvider // Clear the current task without treating it as a subtask // This is used when the user cancels a task that is not a subtask async clearTask() { - console.log(`[clearTask] clearing current task`) await this.removeClineFromStack() }