diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 226661537a..5298dbfb03 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1241,7 +1241,37 @@ export class Task extends EventEmitter implements TaskLike { } // Wait for askResponse to be set - await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + await pWaitFor( + () => { + if (this.askResponse !== undefined || this.lastMessageTs !== askTs) { + return true + } + + // If a queued message arrives while we're blocked on an ask (e.g. a follow-up + // suggestion click that was incorrectly queued due to UI state), consume it + // immediately so the task doesn't hang. + if (!this.messageQueueService.isEmpty()) { + const message = this.messageQueueService.dequeueMessage() + if (message) { + // If this is a tool approval ask, we need to approve first (yesButtonClicked) + // and include any queued text/images. + if ( + type === "tool" || + type === "command" || + type === "browser_action_launch" || + type === "use_mcp_server" + ) { + this.handleWebviewAskResponse("yesButtonClicked", message.text, message.images) + } else { + this.handleWebviewAskResponse("messageResponse", message.text, message.images) + } + } + } + + return false + }, + { interval: 100 }, + ) if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts new file mode 100644 index 0000000000..3b4097a940 --- /dev/null +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -0,0 +1,38 @@ +import { Task } from "../Task" + +// Keep this test focused: if a queued message arrives while Task.ask() is blocked, +// it should be consumed and used to fulfill the ask. + +describe("Task.ask queued message drain", () => { + it("consumes queued message while blocked on followup ask", async () => { + const task = Object.create(Task.prototype) as Task + ;(task as any).abort = false + ;(task as any).clineMessages = [] + ;(task as any).askResponse = undefined + ;(task as any).askResponseText = undefined + ;(task as any).askResponseImages = undefined + ;(task as any).lastMessageTs = undefined + + // Message queue service exists in constructor; for unit test we can attach a real one. + const { MessageQueueService } = await import("../../message-queue/MessageQueueService") + ;(task as any).messageQueueService = new MessageQueueService() + + // Minimal stubs used by ask() + ;(task as any).addToClineMessages = vi.fn(async () => {}) + ;(task as any).saveClineMessages = vi.fn(async () => {}) + ;(task as any).updateClineMessage = vi.fn(async () => {}) + ;(task as any).cancelAutoApprovalTimeout = vi.fn(() => {}) + ;(task as any).checkpointSave = vi.fn(async () => {}) + ;(task as any).emit = vi.fn() + ;(task as any).providerRef = { deref: () => undefined } + + const askPromise = task.ask("followup", "Q?", false) + + // Simulate webview queuing the user's selection text while the ask is pending. + ;(task as any).messageQueueService.addMessage("picked answer") + + const result = await askPromise + expect(result.response).toBe("messageResponse") + expect(result.text).toBe("picked answer") + }) +})