mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
fix(vscode): settle pending questions before posting state (#14839)
This commit is contained in:
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user