diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts index e65f19eee3..7cd533a1cb 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.test.ts @@ -416,6 +416,117 @@ describe("SdkInteractionCoordinator", () => { await expect(answerPromise).resolves.toBe("") }) + it("accepts an ask_question response as soon as the ask is emitted", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const messages = new SdkMessageCoordinator({ getTask: () => task }) + const setTurnPhase = vi.fn() + const coordinator = new SdkInteractionCoordinator({ + messages, + getSessionId: () => "session-123", + postStateToWebview: vi.fn(() => new Promise(() => {})), + setTurnPhase, + }) + let accepted = false + messages.onSessionEvent(() => { + if (task.messageStateHandler.getClineMessages().length === 1) { + accepted = coordinator.resolvePendingAskQuestion("yes") + } + }) + + const answer = coordinator.handleAskQuestion("Continue?", ["Yes"], undefined) + + expect(accepted).toBe(true) + await expect(answer).resolves.toBe("yes") + expect(setTurnPhase).toHaveBeenLastCalledWith("streaming") + }) + + it.each(["Task cancelled", "Task switched"])("settles ask_question during its state post on %s", async (reason) => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + let releasePost!: () => void + const postStateToWebview = vi.fn( + () => + new Promise((resolve) => { + releasePost = resolve + }), + ) + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview, + }) + let result: Awaited> | undefined + const answer = coordinator.handleAskQuestion("Continue?", [], undefined).then((value) => { + result = value + }) + await vi.waitFor(() => expect(postStateToWebview).toHaveBeenCalledOnce()) + + coordinator.clearPending(reason) + await vi.waitFor(() => expect(result).toBe("")) + expect(task.messageStateHandler.getClineMessages()).toHaveLength(1) + + releasePost() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(coordinator.resolvePendingAskQuestion("new task input")).toBe(false) + await answer + }) + + it.each(["resolves", "rejects"])("keeps a later ask when a cleared ask's state post %s", async (outcome) => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + let releaseFirstPost!: () => void + let rejectFirstPost!: (error: Error) => void + const postStateToWebview = vi + .fn<() => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve, reject) => { + releaseFirstPost = resolve + rejectFirstPost = reject + }), + ) + .mockResolvedValue(undefined) + const setTurnPhase = vi.fn() + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview, + setTurnPhase, + }) + const firstAnswer = coordinator.handleAskQuestion("Old question?", [], undefined) + await vi.waitFor(() => expect(postStateToWebview).toHaveBeenCalledTimes(1)) + coordinator.clearPending("Task switched") + await expect(firstAnswer).resolves.toBe("") + + const secondAnswer = coordinator.handleAskQuestion("New question?", [], undefined) + await vi.waitFor(() => expect(postStateToWebview).toHaveBeenCalledTimes(2)) + const secondAskTs = task.messageStateHandler.getClineMessages()[1].ts + if (outcome === "resolves") { + releaseFirstPost() + } else { + rejectFirstPost(new Error("Task changed while building webview state")) + } + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(setTurnPhase).toHaveBeenLastCalledWith("awaiting_followup", secondAskTs) + expect(coordinator.resolvePendingAskQuestion("new answer")).toBe(true) + await expect(secondAnswer).resolves.toBe("new answer") + }) + + it("rejects and clears the ask whose state post fails", async () => { + const task = createTaskProxy("session-123", vi.fn(), vi.fn()) + const error = new Error("Failed to build webview state") + const setTurnPhase = vi.fn() + const coordinator = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => task }), + getSessionId: () => "session-123", + postStateToWebview: vi.fn().mockRejectedValue(error), + setTurnPhase, + }) + + await expect(coordinator.handleAskQuestion("Continue?", [], undefined)).rejects.toBe(error) + expect(coordinator.resolvePendingAskQuestion("late answer")).toBe(false) + expect(setTurnPhase).toHaveBeenLastCalledWith("streaming") + }) + it("shows an error row and stops immediately when the mistake limit is reached", async () => { const task = createTaskProxy("session-123", vi.fn(), vi.fn()) const setTurnPhase = vi.fn() diff --git a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts index 4d176f579e..bca66b0a3a 100644 --- a/apps/vscode/src/sdk/sdk-interaction-coordinator.ts +++ b/apps/vscode/src/sdk/sdk-interaction-coordinator.ts @@ -187,16 +187,36 @@ export class SdkInteractionCoordinator { partial: false, } + // Install before emitting the ask so responses and task teardown can settle it + // even while the full webview state is still being built. + let resolveAnswer!: (answer: AskQuestionAnswer) => void + let rejectAnswer!: (reason: unknown) => void + const answer = new Promise((resolve, reject) => { + resolveAnswer = resolve + rejectAnswer = reject + }) + this.pendingAskResolve = resolveAnswer + this.options.setTurnPhase?.("awaiting_followup", askMessage.ts) + this.options.messages.appendAndEmit([askMessage], { type: "status", payload: { sessionId: this.options.getSessionId(), status: "running" }, }) - this.options.setTurnPhase?.("awaiting_followup", askMessage.ts) - await this.options.postStateToWebview() - return new Promise((resolve) => { - this.pendingAskResolve = resolve - }) + void Promise.resolve() + .then(() => this.options.postStateToWebview()) + .catch((error) => { + Logger.warn(`[SdkController] Failed to display question: ${error}`) + // A cleared/answered ask may already have been replaced by a new one. + if (this.pendingAskResolve !== resolveAnswer) { + return + } + this.pendingAskResolve = undefined + this.options.setTurnPhase?.("streaming") + rejectAnswer(error) + }) + + return answer } resolvePendingToolApproval( diff --git a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts index 42aaf2e99b..d217ffb1fa 100644 --- a/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts +++ b/apps/vscode/src/sdk/sdk-task-control-coordinator.test.ts @@ -35,6 +35,33 @@ describe("SdkTaskControlCoordinator", () => { expect(options.postStateToWebview).toHaveBeenCalledOnce() }) + it("settles a question on cancel even while its state post is still in flight", async () => { + const pendingTask = createTaskProxy("session-123", vi.fn(), vi.fn()) + let releaseQuestionPost!: () => void + const questionPost = vi.fn( + () => + new Promise((resolve) => { + releaseQuestionPost = resolve + }), + ) + const interactions = new SdkInteractionCoordinator({ + messages: new SdkMessageCoordinator({ getTask: () => pendingTask }), + getSessionId: () => pendingTask.taskId, + postStateToWebview: questionPost, + }) + const { options } = makeCoordinator({ activeSession: makeActiveSession() }) + const coordinator = new SdkTaskControlCoordinator({ ...options, interactions }) + const questionPromise = interactions.handleAskQuestion("Which option?", ["A", "B"], undefined) + await vi.waitFor(() => expect(questionPost).toHaveBeenCalledOnce()) + + await coordinator.cancelTask() + + await expect(questionPromise).resolves.toBe("") + releaseQuestionPost() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(interactions.resolvePendingAskQuestion("input after cancel")).toBe(false) + }) + it("cancels a running Cline task when the user signs out", async () => { const activeSession = makeActiveSession() const { coordinator, options } = makeCoordinator({ activeSession })