diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index bc3f6bd6ef..ce8120e82e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1684,10 +1684,24 @@ export class ClineProvider apiConversationHistory, } } + + // EXT-696: historyItem exists but API conversation file doesn't. + // This can happen during race conditions (e.g., delegation before file is flushed). + // Return with empty apiConversationHistory instead of deleting the task. + // The task's metadata is still valid and should be preserved. + this.log( + `[getTaskWithId] Task ${id} exists in history but API conversation file not found. Returning with empty history.`, + ) + return { + historyItem, + taskDirPath, + apiConversationHistoryFilePath, + uiMessagesFilePath, + apiConversationHistory: [], + } } - // if we tried to get a task that doesn't exist, remove it from state - // FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason + // Only delete from state if historyItem truly doesn't exist await this.deleteTaskFromState(id) throw new Error("Task not found") } diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index f5e6afa7f0..a88d2b0967 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -592,4 +592,58 @@ describe("ClineProvider Task History Synchronization", () => { expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/different/workspace")).toBe(true) }) }) + + describe("getTaskWithId", () => { + it("returns task with empty apiConversationHistory when file does not exist but historyItem exists (EXT-696 fix)", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + const historyItem = createHistoryItem({ + id: "delegated-parent-task", + task: "Parent task that was delegated", + }) + + // Add the task to history + await provider.updateTaskHistory(historyItem) + + // Mock fileExistsAtPath to return false (simulating race condition during delegation) + const fsUtils = await import("../../../utils/fs") + const fileExistsSpy = vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(false) + + // Track if deleteTaskFromState was called + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState") + + // Call getTaskWithId + const result = await provider.getTaskWithId("delegated-parent-task") + + // Should return the task with empty API conversation history, NOT delete it + expect(result.historyItem.id).toBe("delegated-parent-task") + expect(result.historyItem.task).toBe("Parent task that was delegated") + expect(result.apiConversationHistory).toEqual([]) + + // Should NOT have called deleteTaskFromState + expect(deleteTaskSpy).not.toHaveBeenCalled() + + // Clean up + fileExistsSpy.mockRestore() + deleteTaskSpy.mockRestore() + }) + + it("throws error and deletes task when historyItem does not exist", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + // Track if deleteTaskFromState was called + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState").mockResolvedValue() + + // Call getTaskWithId with a task that doesn't exist + await expect(provider.getTaskWithId("non-existent-task")).rejects.toThrow("Task not found") + + // Should have called deleteTaskFromState + expect(deleteTaskSpy).toHaveBeenCalledWith("non-existent-task") + + // Clean up + deleteTaskSpy.mockRestore() + }) + }) })