Skip to content

Commit 9e96fca

Browse files
fix: resolve phantom subtask display on cancel during API retry (#4602) (#4893)
1 parent a879f24 commit 9e96fca

File tree

3 files changed

+125
-1
lines changed

3 files changed

+125
-1
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ export class ClineProvider
232232
await this.getCurrentCline()?.resumePausedTask(lastMessage)
233233
}
234234

235+
// Clear the current task without treating it as a subtask
236+
// This is used when the user cancels a task that is not a subtask
237+
async clearTask() {
238+
await this.removeClineFromStack()
239+
}
240+
235241
/*
236242
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.
237243
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,117 @@ describe("ClineProvider", () => {
569569
expect(stackSizeBeforeAbort - stackSizeAfterAbort).toBe(1)
570570
})
571571

572+
describe("clearTask message handler", () => {
573+
beforeEach(async () => {
574+
await provider.resolveWebviewView(mockWebviewView)
575+
})
576+
577+
test("calls clearTask when there is no parent task", async () => {
578+
// Setup a single task without parent
579+
const mockCline = new Task(defaultTaskOptions)
580+
// No need to set parentTask - it's undefined by default
581+
582+
// Mock the provider methods
583+
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
584+
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
585+
const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
586+
587+
// Add task to stack
588+
await provider.addClineToStack(mockCline)
589+
590+
// Get the message handler
591+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
592+
593+
// Trigger clearTask message
594+
await messageHandler({ type: "clearTask" })
595+
596+
// Verify clearTask was called (not finishSubTask)
597+
expect(clearTaskSpy).toHaveBeenCalled()
598+
expect(finishSubTaskSpy).not.toHaveBeenCalled()
599+
expect(postStateToWebviewSpy).toHaveBeenCalled()
600+
})
601+
602+
test("calls finishSubTask when there is a parent task", async () => {
603+
// Setup parent and child tasks
604+
const parentTask = new Task(defaultTaskOptions)
605+
const childTask = new Task(defaultTaskOptions)
606+
607+
// Set up parent-child relationship by setting the parentTask property
608+
// The mock allows us to set properties directly
609+
;(childTask as any).parentTask = parentTask
610+
;(childTask as any).rootTask = parentTask
611+
612+
// Mock the provider methods
613+
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
614+
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
615+
const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
616+
617+
// Add both tasks to stack (parent first, then child)
618+
await provider.addClineToStack(parentTask)
619+
await provider.addClineToStack(childTask)
620+
621+
// Get the message handler
622+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
623+
624+
// Trigger clearTask message
625+
await messageHandler({ type: "clearTask" })
626+
627+
// Verify finishSubTask was called (not clearTask)
628+
expect(finishSubTaskSpy).toHaveBeenCalledWith(expect.stringContaining("canceled"))
629+
expect(clearTaskSpy).not.toHaveBeenCalled()
630+
expect(postStateToWebviewSpy).toHaveBeenCalled()
631+
})
632+
633+
test("handles case when no current task exists", async () => {
634+
// Don't add any tasks to the stack
635+
636+
// Mock the provider methods
637+
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
638+
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
639+
const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
640+
641+
// Get the message handler
642+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
643+
644+
// Trigger clearTask message
645+
await messageHandler({ type: "clearTask" })
646+
647+
// When there's no current task, clearTask is still called (it handles the no-task case internally)
648+
expect(clearTaskSpy).toHaveBeenCalled()
649+
expect(finishSubTaskSpy).not.toHaveBeenCalled()
650+
// State should still be posted
651+
expect(postStateToWebviewSpy).toHaveBeenCalled()
652+
})
653+
654+
test("correctly identifies subtask scenario for issue #4602", async () => {
655+
// This test specifically validates the fix for issue #4602
656+
// where canceling during API retry was incorrectly treating a single task as a subtask
657+
658+
const mockCline = new Task(defaultTaskOptions)
659+
// No parent task by default - no need to explicitly set
660+
661+
// Mock the provider methods
662+
const clearTaskSpy = vi.spyOn(provider, "clearTask").mockResolvedValue(undefined)
663+
const finishSubTaskSpy = vi.spyOn(provider, "finishSubTask").mockResolvedValue(undefined)
664+
665+
// Add only one task to stack
666+
await provider.addClineToStack(mockCline)
667+
668+
// Verify stack size is 1
669+
expect(provider.getClineStackSize()).toBe(1)
670+
671+
// Get the message handler
672+
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0]
673+
674+
// Trigger clearTask message (simulating cancel during API retry)
675+
await messageHandler({ type: "clearTask" })
676+
677+
// The fix ensures clearTask is called, not finishSubTask
678+
expect(clearTaskSpy).toHaveBeenCalled()
679+
expect(finishSubTaskSpy).not.toHaveBeenCalled()
680+
})
681+
})
682+
572683
test("addClineToStack adds multiple Cline instances to the stack", async () => {
573684
// Setup Cline instance with auto-mock from the top of the file
574685
const mockCline1 = new Task(defaultTaskOptions) // Create a new mocked instance

src/core/webview/webviewMessageHandler.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,14 @@ export const webviewMessageHandler = async (
201201
break
202202
case "clearTask":
203203
// 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
204-
await provider.finishSubTask(t("common:tasks.canceled"))
204+
// Check if the current task actually has a parent task
205+
const currentTask = provider.getCurrentCline()
206+
if (currentTask && currentTask.parentTask) {
207+
await provider.finishSubTask(t("common:tasks.canceled"))
208+
} else {
209+
// Regular task - just clear it
210+
await provider.clearTask()
211+
}
205212
await provider.postStateToWebview()
206213
break
207214
case "didShowAnnouncement":

0 commit comments

Comments
 (0)