Skip to content

Commit a0af970

Browse files
ivanarifinedelauna
authored andcommitted
Add completion change actions (Zoo-Code-Org#633)
* feat: add completion change actions * fix: address completion action review feedback * test(webview): mock console output and assert on checkpoint timeout error - Mock `console.log` and `console.error` in `ClineProvider.flicker-free-cancel.spec.ts` to prevent test output pollution. - Add assertion for `vscode.window.showErrorMessage` with `"errors.checkpoint_timeout"` in `webviewMessageHandler.checkpoint.spec.ts`. * fix: address completion checkpoint review feedback * feat(ChatRow): adding key to react component --------- Co-authored-by: Elliott de Launay <edelauna@gmail.com> # Conflicts: # webview-ui/src/components/chat/ChatView.tsx
1 parent f89e9d9 commit a0af970

30 files changed

Lines changed: 739 additions & 9 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ for this exact support, so if you are having problems or if you have question, j
8787
- [简体中文](locales/zh-CN/README.md)
8888
- [繁體中文](locales/zh-TW/README.md)
8989
- ...
90-
</details>
90+
</details>
9191

9292
---
9393

packages/types/src/__tests__/message.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
// pnpm --filter @roo-code/types test src/__tests__/message.test.ts
22

3-
import { clineAsks, isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk } from "../message.js"
3+
import {
4+
clineAsks,
5+
getCompletionCheckpoint,
6+
isIdleAsk,
7+
isInteractiveAsk,
8+
isResumableAsk,
9+
isNonBlockingAsk,
10+
type ClineMessage,
11+
} from "../message.js"
412

513
describe("ask messages", () => {
614
test("all ask messages are classified", () => {
@@ -12,3 +20,46 @@ describe("ask messages", () => {
1220
}
1321
})
1422
})
23+
24+
describe("getCompletionCheckpoint", () => {
25+
it("returns the first checkpoint after the latest user prompt before completion", () => {
26+
const messages: ClineMessage[] = [
27+
{ type: "say", say: "text", ts: 1, text: "Initial task" },
28+
{ type: "say", say: "checkpoint_saved", ts: 2, text: "initial-checkpoint" },
29+
{ type: "say", say: "completion_result", ts: 3, text: "First completion" },
30+
{ type: "say", say: "user_feedback", ts: 4, text: "Change it" },
31+
{ type: "say", say: "checkpoint_saved", ts: 5, text: "latest-prompt-checkpoint" },
32+
{ type: "say", say: "checkpoint_saved", ts: 6, text: "later-edit-checkpoint" },
33+
{ type: "ask", ask: "completion_result", ts: 7, text: "", partial: false },
34+
]
35+
36+
expect(getCompletionCheckpoint(messages)).toEqual({
37+
ts: 5,
38+
commitHash: "latest-prompt-checkpoint",
39+
})
40+
})
41+
42+
it("returns the first checkpoint after an initial task row before completion", () => {
43+
const messages: ClineMessage[] = [
44+
{ type: "say", say: "task", ts: 1, text: "Initial task" },
45+
{ type: "say", say: "checkpoint_saved", ts: 2, text: "checkpoint-after-initial-task" },
46+
{ type: "ask", ask: "completion_result", ts: 3, text: "Task complete", partial: false },
47+
]
48+
49+
expect(getCompletionCheckpoint(messages)).toEqual({
50+
ts: 2,
51+
commitHash: "checkpoint-after-initial-task",
52+
})
53+
})
54+
55+
it("returns undefined when completion has no checkpoint after the latest user prompt", () => {
56+
const messages: ClineMessage[] = [
57+
{ type: "say", say: "text", ts: 1, text: "Initial task" },
58+
{ type: "say", say: "checkpoint_saved", ts: 2, text: "initial-checkpoint" },
59+
{ type: "say", say: "user_feedback", ts: 3, text: "Change it" },
60+
{ type: "ask", ask: "completion_result", ts: 4, text: "", partial: false },
61+
]
62+
63+
expect(getCompletionCheckpoint(messages)).toBeUndefined()
64+
})
65+
})

packages/types/src/message.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export const clineSays = [
150150
"api_req_rate_limit_wait",
151151
"api_req_deleted",
152152
"text",
153+
"task",
153154
"image",
154155
"reasoning",
155156
"completion_result",
@@ -275,6 +276,76 @@ export const clineMessageSchema = z.object({
275276

276277
export type ClineMessage = z.infer<typeof clineMessageSchema>
277278

279+
export interface CompletionCheckpoint {
280+
ts: number
281+
commitHash: string
282+
}
283+
284+
const isInitialTaskMessage = (message: ClineMessage | undefined): boolean => {
285+
return message?.type === "say" && (message.say === "text" || message.say === "task")
286+
}
287+
288+
const isUserFeedbackMessage = (message: ClineMessage): boolean => {
289+
return message.type === "say" && message.say === "user_feedback"
290+
}
291+
292+
const isCompletionMessage = (message: ClineMessage): boolean => {
293+
return (
294+
(message.type === "ask" && message.ask === "completion_result") ||
295+
(message.type === "say" && message.say === "completion_result")
296+
)
297+
}
298+
299+
const isCheckpointMessage = (message: ClineMessage): boolean => {
300+
return message.type === "say" && message.say === "checkpoint_saved" && typeof message.text === "string"
301+
}
302+
303+
function findLastIndexBefore(
304+
messages: ClineMessage[],
305+
beforeIndex: number,
306+
predicate: (message: ClineMessage) => boolean,
307+
): number {
308+
for (let i = beforeIndex - 1; i >= 0; i--) {
309+
const message = messages[i]
310+
311+
if (message && predicate(message)) {
312+
return i
313+
}
314+
}
315+
316+
return -1
317+
}
318+
319+
/**
320+
* Finds the checkpoint that should anchor completion-result actions.
321+
*
322+
* The baseline is the first checkpoint created after the latest user prompt in
323+
* the turn that produced the completion. Restoring to that checkpoint reverts
324+
* changes made for the latest prompt, and diffing from it shows the same scoped
325+
* changes.
326+
*/
327+
export function getCompletionCheckpoint(messages: ClineMessage[]): CompletionCheckpoint | undefined {
328+
const completionIndex = findLastIndexBefore(messages, messages.length, isCompletionMessage)
329+
const searchEnd = completionIndex === -1 ? messages.length : completionIndex
330+
const latestUserFeedbackIndex = findLastIndexBefore(messages, searchEnd, isUserFeedbackMessage)
331+
const latestUserPromptIndex =
332+
latestUserFeedbackIndex !== -1 ? latestUserFeedbackIndex : isInitialTaskMessage(messages[0]) ? 0 : -1
333+
334+
if (latestUserPromptIndex === -1) {
335+
return undefined
336+
}
337+
338+
for (let i = latestUserPromptIndex + 1; i < searchEnd; i++) {
339+
const message = messages[i]
340+
341+
if (message && isCheckpointMessage(message)) {
342+
return { ts: message.ts, commitHash: message.text! }
343+
}
344+
}
345+
346+
return undefined
347+
}
348+
278349
/**
279350
* TokenUsage
280351
*/

packages/types/src/vscode-extension-host.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,8 @@ export interface WebviewMessage {
520520
| "openCustomModesSettings"
521521
| "checkpointDiff"
522522
| "checkpointRestore"
523+
| "completionCheckpointDiff"
524+
| "completionCheckpointRestore"
523525
| "deleteMcpServer"
524526
| "codebaseIndexEnabled"
525527
| "telemetrySetting"

src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
1+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"
22
import * as vscode from "vscode"
33

44
import { ClineProvider } from "../ClineProvider"
@@ -257,12 +257,24 @@ describe("ClineProvider flicker-free cancel", () => {
257257
let mockOutputChannel: any
258258
let mockTask1: any
259259
let mockTask2: any
260+
let consoleLogSpy: ReturnType<typeof vi.spyOn>
261+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
260262

261263
const mockApiConfig: ProviderSettings = {
262264
apiProvider: "anthropic",
263265
apiKey: "test-key",
264266
} as ProviderSettings
265267

268+
beforeAll(() => {
269+
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
270+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
271+
})
272+
273+
afterAll(() => {
274+
consoleLogSpy.mockRestore()
275+
consoleErrorSpy.mockRestore()
276+
})
277+
266278
beforeEach(() => {
267279
vi.clearAllMocks()
268280

src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import pWaitFor from "p-wait-for"
23
import { webviewMessageHandler } from "../webviewMessageHandler"
34
import { saveTaskMessages } from "../../task-persistence"
45
import { handleCheckpointRestoreOperation } from "../checkpointRestoreHandler"
@@ -7,6 +8,13 @@ import { MessageManager } from "../../message-manager"
78
// Mock dependencies
89
vi.mock("../../task-persistence")
910
vi.mock("../checkpointRestoreHandler")
11+
vi.mock("p-wait-for", () => ({
12+
default: vi.fn(async (condition: () => boolean) => {
13+
if (!condition()) {
14+
throw new Error("condition not met")
15+
}
16+
}),
17+
}))
1018
vi.mock("vscode", () => ({
1119
window: {
1220
showErrorMessage: vi.fn(),
@@ -26,6 +34,7 @@ describe("webviewMessageHandler - checkpoint operations", () => {
2634
// Setup mock Cline instance
2735
mockCline = {
2836
taskId: "test-task-123",
37+
isInitialized: true,
2938
clineMessages: [
3039
{ ts: 1, type: "user", say: "user", text: "First message" },
3140
{ ts: 2, type: "assistant", say: "checkpoint_saved", text: "abc123" },
@@ -37,6 +46,7 @@ describe("webviewMessageHandler - checkpoint operations", () => {
3746
{ ts: 3, role: "user", content: [{ type: "text", text: "Message to delete" }] },
3847
{ ts: 4, role: "assistant", content: [{ type: "text", text: "After message" }] },
3948
],
49+
checkpointDiff: vi.fn(),
4050
checkpointRestore: vi.fn(),
4151
overwriteClineMessages: vi.fn(),
4252
overwriteApiConversationHistory: vi.fn(),
@@ -52,6 +62,7 @@ describe("webviewMessageHandler - checkpoint operations", () => {
5262
})),
5363
createTaskWithHistoryItem: vi.fn(),
5464
setPendingEditOperation: vi.fn(),
65+
cancelTask: vi.fn(),
5566
contextProxy: {
5667
globalStorageUri: { fsPath: "/test/storage" },
5768
},
@@ -134,4 +145,109 @@ describe("webviewMessageHandler - checkpoint operations", () => {
134145
})
135146
})
136147
})
148+
149+
describe("completion checkpoint actions", () => {
150+
beforeEach(() => {
151+
mockCline.clineMessages = [
152+
{ ts: 1, type: "say", say: "text", text: "Initial task" },
153+
{ ts: 2, type: "say", say: "checkpoint_saved", text: "initial-checkpoint" },
154+
{ ts: 3, type: "say", say: "user_feedback", text: "Latest prompt" },
155+
{ ts: 4, type: "say", say: "checkpoint_saved", text: "latest-prompt-checkpoint" },
156+
{ ts: 5, type: "say", say: "completion_result", text: "Task complete" },
157+
{ ts: 6, type: "ask", ask: "completion_result", text: "", partial: false },
158+
]
159+
})
160+
161+
it("diffs changes from the checkpoint created after the latest prompt", async () => {
162+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointDiff" })
163+
164+
expect(mockCline.checkpointDiff).toHaveBeenCalledWith({
165+
ts: 4,
166+
commitHash: "latest-prompt-checkpoint",
167+
mode: "to-current",
168+
})
169+
})
170+
171+
it("restores files and task state to the checkpoint created after the latest prompt", async () => {
172+
const callOrder: string[] = []
173+
mockProvider.cancelTask.mockImplementation(async () => callOrder.push("cancelTask"))
174+
mockCline.checkpointRestore.mockImplementation(async () => callOrder.push("checkpointRestore"))
175+
176+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointRestore" })
177+
178+
expect(mockProvider.cancelTask).toHaveBeenCalled()
179+
expect(mockCline.checkpointRestore).toHaveBeenCalledWith({
180+
ts: 4,
181+
commitHash: "latest-prompt-checkpoint",
182+
mode: "restore",
183+
})
184+
expect(callOrder).toEqual(["cancelTask", "checkpointRestore"])
185+
})
186+
187+
it("does not diff or restore when no latest-prompt checkpoint exists", async () => {
188+
mockCline.clineMessages = [
189+
{ ts: 1, type: "say", say: "text", text: "Initial task" },
190+
{ ts: 2, type: "say", say: "user_feedback", text: "Latest prompt" },
191+
{ ts: 3, type: "ask", ask: "completion_result", text: "", partial: false },
192+
]
193+
194+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointDiff" })
195+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointRestore" })
196+
197+
expect(mockCline.checkpointDiff).not.toHaveBeenCalled()
198+
expect(mockCline.checkpointRestore).not.toHaveBeenCalled()
199+
expect(mockProvider.cancelTask).not.toHaveBeenCalled()
200+
})
201+
202+
it("resolves the latest completion checkpoint in the extension host", async () => {
203+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointDiff" })
204+
205+
expect(mockCline.checkpointDiff).toHaveBeenCalledWith({
206+
ts: 4,
207+
commitHash: "latest-prompt-checkpoint",
208+
mode: "to-current",
209+
})
210+
})
211+
212+
it("does not restore when task re-initialization times out", async () => {
213+
;(pWaitFor as any).mockRejectedValueOnce(new Error("timed out"))
214+
215+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointRestore" })
216+
217+
expect(mockProvider.cancelTask).toHaveBeenCalled()
218+
expect(mockCline.checkpointRestore).not.toHaveBeenCalled()
219+
const vscode = await import("vscode")
220+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.checkpoint_timeout")
221+
})
222+
223+
it("shows an error when completion checkpoint restore fails", async () => {
224+
const restoreError = new Error("restore failed")
225+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
226+
mockCline.checkpointRestore.mockRejectedValueOnce(restoreError)
227+
228+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointRestore" })
229+
230+
const vscode = await import("vscode")
231+
expect(consoleErrorSpy).toHaveBeenCalledWith(
232+
"[completionCheckpointRestore] checkpointRestore failed:",
233+
restoreError,
234+
)
235+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.checkpoint_failed")
236+
consoleErrorSpy.mockRestore()
237+
})
238+
239+
it("does not restore when task identity changes during cancellation", async () => {
240+
mockProvider.getCurrentTask.mockReturnValueOnce(mockCline).mockReturnValue({
241+
...mockCline,
242+
taskId: "different-task-id",
243+
})
244+
245+
await webviewMessageHandler(mockProvider, { type: "completionCheckpointRestore" })
246+
247+
expect(mockProvider.cancelTask).toHaveBeenCalled()
248+
expect(mockCline.checkpointRestore).not.toHaveBeenCalled()
249+
const vscode = await import("vscode")
250+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.checkpoint_failed")
251+
})
252+
})
137253
})

0 commit comments

Comments
 (0)