fix(vscode): settle pending questions before posting state (#14839)

This commit is contained in:
Lucien
2026-10-19 21:10:28 +02:00
committed by GitHub
parent 41e75efc53
commit 7e8d22c789
3 changed files with 163 additions and 5 deletions
@@ -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<void>(() => {})),
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<void>((resolve) => {
releasePost = resolve
}),
)
const coordinator = new SdkInteractionCoordinator({
messages: new SdkMessageCoordinator({ getTask: () => task }),
getSessionId: () => "session-123",
postStateToWebview,
})
let result: Awaited<ReturnType<SdkInteractionCoordinator["handleAskQuestion"]>> | 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<void>>()
.mockImplementationOnce(
() =>
new Promise<void>((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()
@@ -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<AskQuestionAnswer>((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<AskQuestionAnswer>((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(
@@ -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<void>((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 })