Skip to content

Commit e57c772

Browse files
0xMinkedelauna
authored andcommitted
fix(Task): catch updateClineMessage rejections on partial-message paths
The four fire-and-forget updateClineMessage calls in say()/ask() partial-message branches were void-prefixed without .catch arms. The callee's webview post is internally guarded, but its synchronous RooCodeEventName.Message emit can throw via a consumer-attached listener — so the void sites can produce unhandled rejections. Replace each `void this.updateClineMessage(...)` with an explicit .catch arm that logs the error, matching the other rejection-handling sites in this file. Two new tests pin the say() and ask() catch arms. Also pin the void on getCheckpointService with a rationale comment: its top-level try/catch returns undefined on failure, so .catch is not needed there.
1 parent c5d8d4a commit e57c772

2 files changed

Lines changed: 91 additions & 8 deletions

File tree

src/core/task/Task.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,8 +1197,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11971197
// data or one whole message at a time so ignore partial for
11981198
// saves, and only post parts of partial message instead of
11991199
// whole array in new listener.
1200-
/* v8 ignore next -- fire-and-forget webview update; rejection is benign */
1201-
void this.updateClineMessage(lastMessage)
1200+
// Fire-and-forget: the webview post is internally guarded, but
1201+
// the `RooCodeEventName.Message` emit can synchronously throw
1202+
// if any consumer-attached listener does, which would surface
1203+
// here as an unhandled rejection. Log it instead.
1204+
this.updateClineMessage(lastMessage).catch((error) => {
1205+
console.error("[Task#ask] updateClineMessage failed:", error)
1206+
})
12021207
// console.log("Task#ask: current ask promise was ignored (#1)")
12031208
throw new AskIgnoredError("updating existing partial")
12041209
} else {
@@ -1239,8 +1244,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12391244
lastMessage.isAnswered = true
12401245
}
12411246
await this.saveClineMessages()
1242-
/* v8 ignore next -- fire-and-forget webview update; rejection is benign */
1243-
void this.updateClineMessage(lastMessage)
1247+
// Fire-and-forget: see updateClineMessage call above for the
1248+
// rationale on the .catch arm.
1249+
this.updateClineMessage(lastMessage).catch((error) => {
1250+
console.error("[Task#ask] updateClineMessage failed:", error)
1251+
})
12441252
} else {
12451253
// This is a new and complete message, so add it like normal.
12461254
this.askResponse = undefined
@@ -1700,8 +1708,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
17001708
lastMessage.images = images
17011709
lastMessage.partial = partial
17021710
lastMessage.progressStatus = progressStatus
1703-
// Fire-and-forget: see updateClineMessage call above for the
1704-
// rationale on the .catch arm.
1711+
// Fire-and-forget: webview post is internally guarded, but the
1712+
// `RooCodeEventName.Message` emit can synchronously throw via a
1713+
// consumer-attached listener. Surface that as a log, not an
1714+
// unhandled rejection.
17051715
this.updateClineMessage(lastMessage).catch((error) => {
17061716
console.error("[Task#say] updateClineMessage failed:", error)
17071717
})

src/core/task/__tests__/Task.spec.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2486,7 +2486,7 @@ describe("Cline", () => {
24862486

24872487
expect(presentSpy).toHaveBeenCalledTimes(1)
24882488
const presentErrors = consoleErrorSpy.mock.calls.filter(
2489-
(call) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"),
2489+
(call: unknown[]) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"),
24902490
)
24912491
expect(presentErrors).toHaveLength(0)
24922492
})
@@ -2570,10 +2570,83 @@ describe("Cline", () => {
25702570

25712571
expect(presentSpy).toHaveBeenCalledTimes(1)
25722572
const presentErrors = consoleErrorSpy.mock.calls.filter(
2573-
(call) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"),
2573+
(call: unknown[]) => typeof call[0] === "string" && call[0].includes("[Task#presentAssistantMessage]"),
25742574
)
25752575
expect(presentErrors).toHaveLength(0)
25762576
})
2577+
2578+
it("logs (instead of crashing) when updateClineMessage rejects from the say() partial-update path", async () => {
2579+
// Pins the symmetric .catch arm on the fire-and-forget
2580+
// updateClineMessage call in say(). The callee's webview post is
2581+
// internally guarded, but its synchronous emit can throw via a
2582+
// consumer-attached listener — that path must surface as a log,
2583+
// not an unhandled rejection.
2584+
const boom = new Error("updateClineMessage boom")
2585+
const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => {
2586+
throw boom
2587+
})
2588+
2589+
const task = new Task({
2590+
provider: mockProvider,
2591+
apiConfiguration: mockApiConfig,
2592+
task: "test task",
2593+
startTask: false,
2594+
})
2595+
2596+
// Seed a prior partial "say" so the partial-update branch fires.
2597+
task.clineMessages.push({
2598+
ts: Date.now() - 1,
2599+
type: "say",
2600+
say: "text",
2601+
text: "partial",
2602+
partial: true,
2603+
})
2604+
2605+
await task.say("text", "updated partial", undefined, true)
2606+
await flushMicrotasks()
2607+
2608+
expect(updateSpy).toHaveBeenCalled()
2609+
expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#say] updateClineMessage failed:", boom)
2610+
updateSpy.mockRestore()
2611+
})
2612+
2613+
it("logs (instead of crashing) when updateClineMessage rejects from the ask() complete-partial path", async () => {
2614+
// Pins the symmetric .catch arm on the fire-and-forget
2615+
// updateClineMessage call in ask() when finalizing a partial.
2616+
const boom = new Error("updateClineMessage boom")
2617+
const updateSpy = vi.spyOn(Task.prototype as any, "updateClineMessage").mockImplementation(async () => {
2618+
throw boom
2619+
})
2620+
const saveSpy = vi.spyOn(Task.prototype as any, "saveClineMessages").mockResolvedValue(true)
2621+
2622+
const task = new Task({
2623+
provider: mockProvider,
2624+
apiConfiguration: mockApiConfig,
2625+
task: "test task",
2626+
startTask: false,
2627+
})
2628+
2629+
// Seed a prior partial "ask" of type "tool" so the complete-partial
2630+
// branch fires when ask("tool", ..., false) is called.
2631+
task.clineMessages.push({
2632+
ts: Date.now() - 1,
2633+
type: "ask",
2634+
ask: "tool",
2635+
text: "partial",
2636+
partial: true,
2637+
})
2638+
2639+
// ask() resolves only after a response — fire-and-forget so the
2640+
// promise the suite awaits stays bounded. The .catch on the
2641+
// pending ask handles the never-resolved promise.
2642+
void task.ask("tool", "complete", false).catch(() => {})
2643+
await flushMicrotasks()
2644+
2645+
expect(updateSpy).toHaveBeenCalled()
2646+
expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#ask] updateClineMessage failed:", boom)
2647+
updateSpy.mockRestore()
2648+
saveSpy.mockRestore()
2649+
})
25772650
})
25782651
})
25792652

0 commit comments

Comments
 (0)