fix: resolve phantom subtask display on cancel during API retry (#4602) (#4893)

This commit is contained in:
Hannes Rudolph 2025-06-19 13:41:24 -06:00 committed by GitHub
parent a879f247f3
commit 9e96fcabc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 125 additions and 1 deletions

View file

@ -232,6 +232,12 @@ 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() {
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/

View file

@ -569,6 +569,117 @@ 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)
// No need to set parentTask - it's undefined by default
// 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 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)
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)
// No parent task by default - no need to explicitly set
// 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

View file

@ -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":