From 51e82611f3a9acdc06bb52461e87e0d7cf0c4ac4 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Sun, 20 Sep 2026 10:00:01 +0900 Subject: [PATCH 1/8] fix(task): merge stale partial reasoning snapshot instead of duplicating say() only inspected clineMessages.at(-1) when deciding whether a partial/complete write updates an in-flight partial or appends a new message. When another message (ask/tool/api_req) was appended while a reasoning stream was in flight, the final write took the append branch and persisted a second reasoning entry next to the orphaned partial:true snapshot, rendering the same reasoning twice in Code mode. In both append branches of say(), scan clineMessages backwards for a stranded partial snapshot of the same say type with prefix-related text and merge into it in place (keeping the original ts) instead of appending. Closes #1346. --- src/core/task/Task.ts | 122 +++++++++++++++++++++------ src/core/task/__tests__/Task.spec.ts | 118 ++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 27 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 55798437c3..7940b12206 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2006,6 +2006,32 @@ export class Task extends EventEmitter implements TaskLike { this.processQueuedMessages() } + /** + * Returns the index of a stranded in-flight partial "say" snapshot of the + * same type whose text is a prefix (or extension) of `text`, or -1 if none + * exists. + * + * A partial stream can be interrupted by other messages (tool use, api_req, + * ...) being appended while it is in flight, which strands its partial + * snapshot before the stream resumes or completes. Appending a fresh message + * in that case would persist — and the webview would render — the same + * content twice, so callers merge into the stranded snapshot instead. + */ + private findStalePartialSaySnapshotIndex(type: ClineSay, text: string | undefined): number { + if (typeof text !== "string") { + return -1 + } + return findLastIndex( + this.clineMessages, + (m) => + m.partial === true && + m.type === "say" && + m.say === type && + typeof m.text === "string" && + (text.startsWith(m.text) || m.text.startsWith(text)), + ) + } + async say( type: ClineSay, text?: string, @@ -2045,22 +2071,38 @@ export class Task extends EventEmitter implements TaskLike { }) } else { // This is a new partial message, so add it with partial state. - const sayTs = Date.now() + const staleSnapshotIndex = this.findStalePartialSaySnapshotIndex(type, text) + + if (staleSnapshotIndex !== -1) { + // The in-flight partial snapshot of this stream was stranded + // by an intervening message; keep streaming into it instead of + // starting a duplicate entry that would render twice. + const staleSnapshot = this.clineMessages[staleSnapshotIndex] + staleSnapshot.text = text + staleSnapshot.images = images + staleSnapshot.partial = partial + staleSnapshot.progressStatus = progressStatus + this.updateClineMessage(staleSnapshot).catch((error) => { + console.error("[Task#say] updateClineMessage failed:", error) + }) + } else { + const sayTs = Date.now() - if (!options.isNonInteractive) { - this.lastMessageTs = sayTs - } + if (!options.isNonInteractive) { + this.lastMessageTs = sayTs + } - await this.addToClineMessages({ - ts: sayTs, - type: "say", - say: type, - text, - images, - partial, - contextCondense, - contextTruncation, - }) + await this.addToClineMessages({ + ts: sayTs, + type: "say", + say: type, + text, + images, + partial, + contextCondense, + contextTruncation, + }) + } } } else { // New now have a complete version of a previously partial message. @@ -2088,21 +2130,47 @@ export class Task extends EventEmitter implements TaskLike { }) } else { // This is a new and complete message, so add it like normal. - const sayTs = Date.now() + const staleSnapshotIndex = this.findStalePartialSaySnapshotIndex(type, text) - if (!options.isNonInteractive) { - this.lastMessageTs = sayTs - } + if (staleSnapshotIndex !== -1) { + // The complete version of a stranded partial snapshot: replace + // it in place so the frozen snapshot is not persisted (and + // rendered) as a duplicate. + const staleSnapshot = this.clineMessages[staleSnapshotIndex] - await this.addToClineMessages({ - ts: sayTs, - type: "say", - say: type, - text, - images, - contextCondense, - contextTruncation, - }) + if (!options.isNonInteractive) { + this.lastMessageTs = staleSnapshot.ts + } + + staleSnapshot.text = text + staleSnapshot.images = images + staleSnapshot.partial = false + staleSnapshot.progressStatus = progressStatus + + // Instead of streaming partialMessage events, we do a save + // and post like normal to persist to disk. + await this.saveClineMessages() + + this.updateClineMessage(staleSnapshot).catch((error) => { + console.error("[Task#say] updateClineMessage failed:", error) + }) + } else { + const sayTs = Date.now() + + if (!options.isNonInteractive) { + this.lastMessageTs = sayTs + } + + await this.addToClineMessages({ + ts: sayTs, + type: "say", + say: type, + text, + images, + contextCondense, + contextTruncation, + }) + } } } } else { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index c35acf864d..2864c5f078 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -5715,6 +5715,124 @@ describe("Cline", () => { ) }) }) + + describe("say() stale partial snapshot dedup (issue #1346)", () => { + // Streaming reasoning calls say("reasoning", text, undefined, true) on + // every delta. If another message is appended while the stream is in + // flight, the final write no longer sees the partial snapshot as the + // last message and used to append a duplicate entry — the pair rendered + // twice in Code mode. These specs pin the backwards-scan merge. + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("merges a final reasoning write into a stranded partial snapshot instead of appending a duplicate", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + // Frozen streaming snapshot, then another message lands mid-stream. + await task.say("reasoning", "Let me list the .ps1 files", undefined, true) + task.clineMessages.push({ + ts: Date.now(), + type: "say", + say: "api_req_started", + text: "{}", + partial: false, + }) + // The final (complete) write replaces the stranded snapshot in place. + await task.say("reasoning", "Let me list the .ps1 files.", undefined, false) + + const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(reasoningMessages).toHaveLength(1) + expect(reasoningMessages[0].partial).toBe(false) + expect(reasoningMessages[0].text).toBe("Let me list the .ps1 files.") + // No duplicate entry was appended next to the interleaved message. + expect(task.clineMessages).toHaveLength(2) + // The completed message is persisted like a normal completion. + expect(saveSpy).toHaveBeenCalled() + }) + + it("keeps streaming partial reasoning deltas into a stranded snapshot instead of starting a duplicate", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + await task.say("reasoning", "Thinking ab", undefined, true) + // A tool approval ask interrupts the reasoning stream. + task.clineMessages.push({ + ts: Date.now(), + type: "ask", + ask: "tool", + text: "Execute command?", + partial: false, + }) + // The next delta extends the stranded snapshot rather than forking a + // second partial entry that would render as its own reasoning block. + await task.say("reasoning", "Thinking about it", undefined, true) + + const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(reasoningMessages).toHaveLength(1) + expect(reasoningMessages[0].partial).toBe(true) + expect(reasoningMessages[0].text).toBe("Thinking about it") + expect(task.clineMessages).toHaveLength(2) + }) + + it("appends a new partial message when no prefix-related stranded snapshot exists", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + // Two genuinely different reasoning segments must not be merged: the + // texts are not prefix-related, so the second stream starts fresh. + await task.say("reasoning", "Completely different topic", undefined, true) + task.clineMessages.push({ + ts: Date.now(), + type: "say", + say: "text", + text: "interleaved", + partial: false, + }) + await task.say("reasoning", "Another unrelated reasoning", undefined, true) + + const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(reasoningMessages).toHaveLength(2) + expect(reasoningMessages[1].partial).toBe(true) + expect(reasoningMessages[1].text).toBe("Another unrelated reasoning") + }) + + it("appends a complete message when only a completed same-type message exists", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + // A finalized reasoning message is not a partial snapshot, so a new + // complete reasoning write is a distinct message. + await task.say("reasoning", "First reasoning block.", undefined, false) + await task.say("reasoning", "Second reasoning block.", undefined, false) + + const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(reasoningMessages).toHaveLength(2) + expect(reasoningMessages.every((m) => m.partial === undefined)).toBe(true) + }) + }) }) describe("Queued message processing after condense", () => { From 38d0edc9dd874d0b387540c53ac9eed6392a8ed5 Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Sun, 20 Sep 2026 11:00:47 +0900 Subject: [PATCH 2/8] test(task): pin partial-snapshot boundary with prefix-related text cases --- src/core/task/__tests__/Task.spec.ts | 38 +++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 2864c5f078..8e29cb7717 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -5814,7 +5814,7 @@ describe("Cline", () => { expect(reasoningMessages[1].text).toBe("Another unrelated reasoning") }) - it("appends a complete message when only a completed same-type message exists", async () => { + it("appends a complete message when only a completed same-type message exists, even with prefix-related text", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -5823,15 +5823,45 @@ describe("Cline", () => { }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) - // A finalized reasoning message is not a partial snapshot, so a new - // complete reasoning write is a distinct message. + // A finalized reasoning message is not a partial snapshot — the + // partial===true boundary must hold even when the new text extends + // the old one, so both remain distinct messages. await task.say("reasoning", "First reasoning block.", undefined, false) - await task.say("reasoning", "Second reasoning block.", undefined, false) + await task.say("reasoning", "First reasoning block. Second reasoning block.", undefined, false) const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") expect(reasoningMessages).toHaveLength(2) + expect(reasoningMessages[0].text).toBe("First reasoning block.") + expect(reasoningMessages[1].text).toBe("First reasoning block. Second reasoning block.") expect(reasoningMessages.every((m) => m.partial === undefined)).toBe(true) }) + + it("does not replace a stranded partial snapshot when the new text belongs to a different say value", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + // Stranded reasoning snapshot… + await task.say("reasoning", "Shared prefix text", undefined, true) + // …followed by a completion_result whose text happens to extend the + // same prefix. Different say value ⇒ no merge; the partial snapshot + // stays untouched. + await task.say("completion_result", "Shared prefix text, continued", undefined, false) + + const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(reasoningMessages).toHaveLength(1) + expect(reasoningMessages[0].partial).toBe(true) + expect(reasoningMessages[0].text).toBe("Shared prefix text") + const completionMessages = task.clineMessages.filter( + (m) => m.type === "say" && m.say === "completion_result", + ) + expect(completionMessages).toHaveLength(1) + expect(completionMessages[0].text).toBe("Shared prefix text, continued") + }) }) }) From 7f4b23d2551ed7b91259baee6314dc1eec39047e Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Wed, 23 Sep 2026 06:22:33 +0900 Subject: [PATCH 3/8] test(task): cover undefined-text and timestamp paths for stale snapshot merge --- src/core/task/__tests__/Task.spec.ts | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 8e29cb7717..a3e5141a05 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -5738,6 +5738,7 @@ describe("Cline", () => { // Frozen streaming snapshot, then another message lands mid-stream. await task.say("reasoning", "Let me list the .ps1 files", undefined, true) + const originalSnapshotTs = task.clineMessages[0].ts task.clineMessages.push({ ts: Date.now(), type: "say", @@ -5752,6 +5753,11 @@ describe("Cline", () => { expect(reasoningMessages).toHaveLength(1) expect(reasoningMessages[0].partial).toBe(false) expect(reasoningMessages[0].text).toBe("Let me list the .ps1 files.") + // The merge preserves the snapshot's original timestamp instead of + // minting a fresh one, and the interactive call stamps lastMessageTs + // with that preserved timestamp. + expect(reasoningMessages[0].ts).toBe(originalSnapshotTs) + expect(task.lastMessageTs).toBe(originalSnapshotTs) // No duplicate entry was appended next to the interleaved message. expect(task.clineMessages).toHaveLength(2) // The completed message is persisted like a normal completion. @@ -5768,6 +5774,7 @@ describe("Cline", () => { vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) await task.say("reasoning", "Thinking ab", undefined, true) + const originalSnapshotTs = task.clineMessages[0].ts // A tool approval ask interrupts the reasoning stream. task.clineMessages.push({ ts: Date.now(), @@ -5784,6 +5791,10 @@ describe("Cline", () => { expect(reasoningMessages).toHaveLength(1) expect(reasoningMessages[0].partial).toBe(true) expect(reasoningMessages[0].text).toBe("Thinking about it") + // Streaming into the stranded snapshot keeps its original timestamp + // and leaves the interactive lastMessageTs pointing at it. + expect(reasoningMessages[0].ts).toBe(originalSnapshotTs) + expect(task.lastMessageTs).toBe(originalSnapshotTs) expect(task.clineMessages).toHaveLength(2) }) @@ -5862,6 +5873,47 @@ describe("Cline", () => { expect(completionMessages).toHaveLength(1) expect(completionMessages[0].text).toBe("Shared prefix text, continued") }) + + it("treats undefined completing text as a no-match append instead of throwing (rate-limit finalize)", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + // Mirrors the production rate-limit countdown: partial delay + // snapshots stream with partial=true… + await task.say("api_req_rate_limit_wait", JSON.stringify({ seconds: 2 }), undefined, true) + const strandedSnapshotTs = task.clineMessages[0].ts + // …another message lands mid-stream… + task.clineMessages.push({ + ts: Date.now(), + type: "say", + say: "api_req_started", + text: "{}", + partial: false, + }) + // …then the stream finalizes with text === undefined, exactly like + // say("api_req_rate_limit_wait", undefined, undefined, false). The + // typeof text !== "string" guard must turn the stale-snapshot lookup + // into a no-match instead of throwing on startsWith, leaving the + // stranded snapshot untouched and appending the completion. + await expect(task.say("api_req_rate_limit_wait", undefined, undefined, false)).resolves.toBeUndefined() + + const rateLimitMessages = task.clineMessages.filter( + (m) => m.type === "say" && m.say === "api_req_rate_limit_wait", + ) + expect(rateLimitMessages).toHaveLength(2) + expect(rateLimitMessages[0].partial).toBe(true) + expect(rateLimitMessages[0].text).toBe(JSON.stringify({ seconds: 2 })) + expect(rateLimitMessages[0].ts).toBe(strandedSnapshotTs) + expect(rateLimitMessages[1].partial).toBeUndefined() + expect(rateLimitMessages[1].text).toBeUndefined() + expect(task.clineMessages).toHaveLength(3) + expect(saveSpy).toHaveBeenCalled() + }) }) }) From bcd387990c10a7dd2f00da0987f0038538e2443c Mon Sep 17 00:00:00 2001 From: "Zoo (VP)" Date: Wed, 23 Sep 2026 06:49:29 +0900 Subject: [PATCH 4/8] fix(task): persist stale partial merge before webview update --- src/core/task/Task.ts | 4 ++ src/core/task/__tests__/Task.spec.ts | 61 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7940b12206..8dea57e8f3 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2082,6 +2082,10 @@ export class Task extends EventEmitter implements TaskLike { staleSnapshot.images = images staleSnapshot.partial = partial staleSnapshot.progressStatus = progressStatus + // A stranded snapshot merge has no guaranteed later save, so + // persist the merged delta before the webview update — the + // same save-then-post pattern as the complete-merge branch. + await this.saveClineMessages() this.updateClineMessage(staleSnapshot).catch((error) => { console.error("[Task#say] updateClineMessage failed:", error) }) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index a3e5141a05..6240178615 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -5798,6 +5798,67 @@ describe("Cline", () => { expect(task.clineMessages).toHaveLength(2) }) + it("persists the merged partial delta before the webview update", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + await task.say("reasoning", "Thinking ab", undefined, true) + // A tool approval ask interrupts the reasoning stream. + task.clineMessages.push({ + ts: Date.now(), + type: "ask", + ask: "tool", + text: "Execute command?", + partial: false, + }) + + // Arm a deferred save for the merge call so await-order is observable: + // the merged state must reach persistence, and the webview update must + // not fire before the save settles. + const events: string[] = [] + let resolveSave!: (saved: boolean) => void + const pendingSave = new Promise((resolve) => { + resolveSave = resolve + }) + let persistedAtSaveCall: Array<{ text?: string; partial?: boolean }> | undefined + saveSpy.mockClear() + saveSpy.mockImplementation(() => { + events.push("save") + persistedAtSaveCall = task.clineMessages.map((m) => ({ text: m.text, partial: m.partial })) + return pendingSave + }) + const updateSpy = vi.spyOn(getTaskTestAccess(task), "updateClineMessage").mockImplementation(() => { + events.push("update") + return Promise.resolve() + }) + + let saySettled = false + const sayPromise = task.say("reasoning", "Thinking about it", undefined, true).then(() => { + saySettled = true + }) + + // The merge must not return while persistence is still pending. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(saySettled).toBe(false) + expect(saveSpy).toHaveBeenCalledOnce() + expect(updateSpy).not.toHaveBeenCalled() + + resolveSave(true) + await sayPromise + + expect(events).toEqual(["save", "update"]) + // The save observed the already-merged snapshot, not the stale text. + expect(persistedAtSaveCall).toEqual([ + { text: "Thinking about it", partial: true }, + { text: "Execute command?", partial: false }, + ]) + }) + it("appends a new partial message when no prefix-related stranded snapshot exists", async () => { const task = new Task({ provider: mockProvider, From 5815295c05552aa5e194e53e14aa0763356ee7dd Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 22:49:15 +0900 Subject: [PATCH 5/8] fix(task): gate stale-snapshot webview posts on the save result What: both stale-snapshot merge branches in Task#say (the partial merge and the complete merge) now capture the boolean from `saveClineMessages()` and, when it is false, log `[Task#say] saveClineMessages failed; skipping webview update` and return without posting `messageUpdated`. Why: `saveClineMessages` returns false when `saveTaskMessages` rejects, leaving the on-disk record stale while the in-memory message is already merged. Posting the merged row anyway lets a reload restore the pre-merge text (partial branch) or a spinner over a finished message (complete branch). The method's own docstring mandates that callers gating UI updates on durable state skip them, and `finalizePartialToolAsk` already follows that shape. Impact: a failed write no longer desynchronizes the webview from disk; the in-memory merge still happens and the next `saveClineMessages()` repairs the record. Success path is unchanged. --- src/core/task/Task.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4e0613f306..4eb0bf9027 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2829,7 +2829,16 @@ export class Task extends EventEmitter implements TaskLike { // A stranded snapshot merge has no guaranteed later save, so // persist the merged delta before the webview update — the // same save-then-post pattern as the complete-merge branch. - await this.saveClineMessages() + const saved = await this.saveClineMessages() + if (!saved) { + // The persistence write failed: the on-disk record still carries + // the pre-merge snapshot while the in-memory message is updated. + // Skip the webview-only post so the two views do not diverge (a + // reload would restore the stale text). The next saveClineMessages() + // call re-persists the full message array and repairs the disk record. + console.error("[Task#say] saveClineMessages failed; skipping webview update") + return + } this.updateClineMessage(staleSnapshot).catch((error) => { console.error("[Task#say] updateClineMessage failed:", error) }) @@ -2897,7 +2906,16 @@ export class Task extends EventEmitter implements TaskLike { // Instead of streaming partialMessage events, we do a save // and post like normal to persist to disk. - await this.saveClineMessages() + const saved = await this.saveClineMessages() + + if (!saved) { + // Same durable-state gate as the partial-merge branch above: the + // on-disk record still carries the partial snapshot, so posting the + // completed row would let a reload restore a spinner over a + // finished message. The next saveClineMessages() repairs the disk. + console.error("[Task#say] saveClineMessages failed; skipping webview update") + return + } this.updateClineMessage(staleSnapshot).catch((error) => { console.error("[Task#say] updateClineMessage failed:", error) From ecce02193f116514a7fc4ba8bd67ced7952293ab Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sat, 31 Oct 2026 22:49:38 +0900 Subject: [PATCH 6/8] test(task): make the stale-snapshot merge specs discriminating What: three changes in the `say() stale partial snapshot dedup` block. - The completing-write spec clears `saveSpy` after the interleaved message and before the final `say`, then asserts `toHaveBeenCalledTimes(1)`. - The interleaved-ask spec pushes its hand-rolled ask with a distinct `askTs` and sets `lastMessageTs` to it, so the partial merge is asserted to leave `lastMessageTs` on the ask; the complete merge is then pinned to re-stamp it with the snapshot ts. - Two new specs drive `saveClineMessages` to `false` in each merge branch and assert the webview post is skipped and the failure is logged. Why: `addToClineMessages` also calls `saveClineMessages` (Task.ts:1691), so an uncleared spy was satisfied by the initial partial append and stayed green even with the completing save deleted. The ask spec likewise started with `lastMessageTs` already equal to the snapshot ts, so a regression that re-stamped it during the partial merge went unseen. Both were vacuous assertions. Impact: the specs now fail when the behavior they name is removed (verified by scratch mutations of both production lines), and the new failure-path specs are red on the previous head and green with the gate in place. --- src/core/task/__tests__/Task.spec.ts | 118 +++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 6 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 5606672cd0..6648caa15f 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -7728,6 +7728,10 @@ describe("Cline", () => { text: "{}", partial: false, }) + // Clear the initial partial save so only the completing write is observed: + // addToClineMessages (Task.ts:1691) also calls saveClineMessages, so an + // uncleared spy would be satisfied without the completing merge persisting. + saveSpy.mockClear() // The final (complete) write replaces the stranded snapshot in place. await task.say("reasoning", "Let me list the .ps1 files.", undefined, false) @@ -7742,8 +7746,8 @@ describe("Cline", () => { expect(task.lastMessageTs).toBe(originalSnapshotTs) // No duplicate entry was appended next to the interleaved message. expect(task.clineMessages).toHaveLength(2) - // The completed message is persisted like a normal completion. - expect(saveSpy).toHaveBeenCalled() + // Exactly one save: the completing merge's own write, not the partial append's. + expect(saveSpy).toHaveBeenCalledTimes(1) }) it("keeps streaming partial reasoning deltas into a stranded snapshot instead of starting a duplicate", async () => { @@ -7757,14 +7761,19 @@ describe("Cline", () => { await task.say("reasoning", "Thinking ab", undefined, true) const originalSnapshotTs = task.clineMessages[0].ts - // A tool approval ask interrupts the reasoning stream. + // A tool approval ask interrupts the reasoning stream. ask() stamps + // lastMessageTs with the ask timestamp (Task.ts:2022), so mirror that + // state: without a distinct askTs, lastMessageTs already equals the + // snapshot ts and a regression that re-stamps it would pass unseen. + const askTs = originalSnapshotTs + 1 task.clineMessages.push({ - ts: Date.now(), + ts: askTs, type: "ask", ask: "tool", text: "Execute command?", partial: false, }) + task.lastMessageTs = askTs // The next delta extends the stranded snapshot rather than forking a // second partial entry that would render as its own reasoning block. await task.say("reasoning", "Thinking about it", undefined, true) @@ -7773,9 +7782,24 @@ describe("Cline", () => { expect(reasoningMessages).toHaveLength(1) expect(reasoningMessages[0].partial).toBe(true) expect(reasoningMessages[0].text).toBe("Thinking about it") - // Streaming into the stranded snapshot keeps its original timestamp - // and leaves the interactive lastMessageTs pointing at it. + // Streaming into the stranded snapshot keeps its original timestamp and + // leaves lastMessageTs on the pending ask: the partial-merge branch does + // not touch lastMessageTs, so the ask stays the newest interactive point. expect(reasoningMessages[0].ts).toBe(originalSnapshotTs) + expect(task.lastMessageTs).toBe(askTs) + expect(task.clineMessages).toHaveLength(2) + + // The complete-merge branch is different by design and pinned here: + // finalizing the stranded snapshot re-stamps lastMessageTs with the + // snapshot ts (Task.ts:2899), because the completed row replaces + // the ask as the newest message the user can act on. + await task.say("reasoning", "Thinking about it.", undefined, false) + + const completedMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(completedMessages).toHaveLength(1) + expect(completedMessages[0].partial).toBe(false) + expect(completedMessages[0].text).toBe("Thinking about it.") + expect(completedMessages[0].ts).toBe(originalSnapshotTs) expect(task.lastMessageTs).toBe(originalSnapshotTs) expect(task.clineMessages).toHaveLength(2) }) @@ -7957,6 +7981,88 @@ describe("Cline", () => { expect(task.clineMessages).toHaveLength(3) expect(saveSpy).toHaveBeenCalled() }) + + it("skips the webview post and logs when the partial-merge save fails", async () => { + // saveClineMessages' contract (Task.ts:1757-1764) makes the boolean + // authoritative for durable state: a false result leaves the on-disk + // record stale, so the webview-only post must be skipped the way + // finalizePartialToolAsk does it (Task.ts:2998-3007). + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const updateSpy = vi.spyOn(getTaskTestAccess(task), "updateClineMessage").mockResolvedValue(undefined) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await task.say("reasoning", "Thinking ab", undefined, true) + const originalSnapshotTs = task.clineMessages[0].ts + // A tool approval ask strands the partial snapshot mid-stream. + task.clineMessages.push({ + ts: originalSnapshotTs + 1, + type: "ask", + ask: "tool", + text: "Execute command?", + partial: false, + }) + + // Only the merge's save fails; the earlier append save already succeeded. + saveSpy.mockClear() + saveSpy.mockResolvedValue(false) + updateSpy.mockClear() + + await task.say("reasoning", "Thinking about it", undefined, true) + + expect(saveSpy).toHaveBeenCalledTimes(1) + expect(updateSpy).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#say] saveClineMessages failed; skipping webview update") + // The in-memory merge still happened: only the webview post is gated. + const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(reasoningMessages).toHaveLength(1) + expect(reasoningMessages[0].text).toBe("Thinking about it") + + consoleErrorSpy.mockRestore() + }) + + it("skips the webview post and logs when the complete-merge save fails", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const updateSpy = vi.spyOn(getTaskTestAccess(task), "updateClineMessage").mockResolvedValue(undefined) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + await task.say("reasoning", "Thinking ab", undefined, true) + const originalSnapshotTs = task.clineMessages[0].ts + task.clineMessages.push({ + ts: originalSnapshotTs + 1, + type: "say", + say: "api_req_started", + text: "{}", + partial: false, + }) + + saveSpy.mockClear() + saveSpy.mockResolvedValue(false) + updateSpy.mockClear() + + await task.say("reasoning", "Thinking about it.", undefined, false) + + expect(saveSpy).toHaveBeenCalledTimes(1) + expect(updateSpy).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith("[Task#say] saveClineMessages failed; skipping webview update") + const reasoningMessages = task.clineMessages.filter((m) => m.type === "say" && m.say === "reasoning") + expect(reasoningMessages).toHaveLength(1) + expect(reasoningMessages[0].partial).toBe(false) + expect(reasoningMessages[0].text).toBe("Thinking about it.") + + consoleErrorSpy.mockRestore() + }) }) }) From a372707d78d233f4e5666959b49023a81d7e64b1 Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 07:33:39 +0900 Subject: [PATCH 7/8] fix(task): never merge into a stale partial snapshot on empty text What: `findStalePartialSaySnapshotIndex` now rejects an empty incoming text and skips stored snapshots whose text is empty, so `m.text.startsWith("")` can no longer match every same-type partial row. Why: `AttemptCompletionTool` passes `result ?? ""` with `block.partial` forwarded (AttemptCompletionTool.ts:312), and `abortStream` only clears `partial` on `.at(-1)` (Task.ts:4180). An abandoned partial `completion_result` row therefore survived, and an empty completion adopted it: the streaming write overwrote the dead row's text, and the completing write finalized it in place (`partial: false`) and re-stamped `lastMessageTs` with the dead row's ts, so an abandoned attempt was persisted and rendered as this turn's completion. Impact: empty writes now append their own row instead of resurrecting a dead one. Prefix-based dedup for non-empty text is unchanged. --- src/core/task/Task.ts | 10 ++- src/core/task/__tests__/Task.spec.ts | 106 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4eb0bf9027..a32a5ce32b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2760,9 +2760,16 @@ export class Task extends EventEmitter implements TaskLike { * snapshot before the stream resumes or completes. Appending a fresh message * in that case would persist — and the webview would render — the same * content twice, so callers merge into the stranded snapshot instead. + * + * Empty text never matches, on either side. `m.text.startsWith("")` is true + * for every stored text, so an empty write (`AttemptCompletionTool` passes + * `result ?? ""`) would otherwise adopt an abandoned snapshot from an + * earlier turn: the streaming write would overwrite its text and the + * completing write would finalize it as this turn's message, re-stamping + * `lastMessageTs` with the dead row's ts. */ private findStalePartialSaySnapshotIndex(type: ClineSay, text: string | undefined): number { - if (typeof text !== "string") { + if (typeof text !== "string" || text === "") { return -1 } return findLastIndex( @@ -2772,6 +2779,7 @@ export class Task extends EventEmitter implements TaskLike { m.type === "say" && m.say === type && typeof m.text === "string" && + m.text !== "" && (text.startsWith(m.text) || m.text.startsWith(text)), ) } diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6648caa15f..48bde0d510 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -41,6 +41,7 @@ import { McpHub } from "../../../services/mcp/McpHub" import { McpServerManager } from "../../../services/mcp/McpServerManager" import { readFileTool } from "../../tools/ReadFileTool" import { writeToFileTool } from "../../tools/WriteToFileTool" +import { attemptCompletionTool } from "../../tools/AttemptCompletionTool" type TaskTestAccess = { getSystemPrompt: (requestState: ProviderState | undefined, requestModelInfo?: ModelInfo) => Promise @@ -8063,6 +8064,111 @@ describe("Cline", () => { consoleErrorSpy.mockRestore() }) + + it("does not finalize an abandoned partial completion_result row when the completing text is empty", async () => { + // AttemptCompletionTool.handlePartial passes `result ?? ""` with + // `block.partial` forwarded (AttemptCompletionTool.ts:312), so an + // empty completion_result is reachable in production. `"".startsWith` + // is false for any non-empty stored text, but `m.text.startsWith("")` + // is true for every stored text, so without a non-empty guard the + // empty completion matches any earlier partial completion_result row. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + // A completion_result stream that was abandoned mid-flight: its row is + // still partial and no longer the tail, so nothing finalizes it. + task.clineMessages.push({ + ts: 1, + type: "say", + say: "completion_result", + text: "Abandoned completion attempt", + partial: true, + }) + task.clineMessages.push({ + ts: 2, + type: "say", + say: "api_req_started", + text: "{}", + partial: false, + }) + saveSpy.mockClear() + + // The next turn's attempt_completion streams with an empty result. + await attemptCompletionTool.handlePartial(task, { + type: "tool_use", + name: "attempt_completion", + params: { result: "" }, + partial: true, + }) + + const completionMessages = task.clineMessages.filter( + (m) => m.type === "say" && m.say === "completion_result", + ) + // The dead row must stay dead: no finalize, no hijacked lastMessageTs. + expect(completionMessages[0]).toMatchObject({ + ts: 1, + text: "Abandoned completion attempt", + partial: true, + }) + // The empty stream starts its own row instead of resurrecting the old one. + expect(completionMessages).toHaveLength(2) + expect(completionMessages[1]).toMatchObject({ text: "", partial: true }) + expect(completionMessages[1].ts).toBeGreaterThan(2) + }) + + it("does not turn an abandoned partial completion_result row into the completed row of an empty completion", async () => { + // The same degenerate match on the completing (partial=false) write, which + // is the branch that finalizes the row: the dead row's `partial` flips to + // false and `lastMessageTs` is re-stamped with the dead row's ts, so the + // abandoned attempt is persisted and rendered as this turn's completion. + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + task.clineMessages.push({ + ts: 1, + type: "say", + say: "completion_result", + text: "Abandoned completion attempt", + partial: true, + }) + task.clineMessages.push({ + ts: 2, + type: "say", + say: "api_req_started", + text: "{}", + partial: false, + }) + task.lastMessageTs = 2 + + // The empty completion is rejected by AttemptCompletionTool.handle before + // any say() call, so drive the completing write the way the tool's + // completion path does: say("completion_result", result, undefined, false). + await task.say("completion_result", "", undefined, false) + + const completionMessages = task.clineMessages.filter( + (m) => m.type === "say" && m.say === "completion_result", + ) + expect(completionMessages[0]).toMatchObject({ + ts: 1, + text: "Abandoned completion attempt", + partial: true, + }) + expect(completionMessages).toHaveLength(2) + expect(completionMessages[1].text).toBe("") + expect(completionMessages[1].partial).toBeUndefined() + // lastMessageTs must not be hijacked by the dead row's ts. + expect(task.lastMessageTs).not.toBe(1) + }) }) }) From ff56613ad153e900d565cbc207eeb7de5214cccd Mon Sep 17 00:00:00 2001 From: myk1yt Date: Sun, 1 Nov 2026 07:43:04 +0900 Subject: [PATCH 8/8] fix(task): move a merged stranded snapshot to the tail What: after the first stale-snapshot merge, the merged row is spliced out of its old position and pushed to the end of `clineMessages`. `ts` is not touched. Why: a merged row that stays in its old position is never `at(-1)`, so every later delta of the same stream re-entered the stranded-merge branch and awaited a full `saveClineMessages()` (structuredClone of the whole history plus metadata) inside the stream loop. Moving it to the tail restores the in-place fast path, so one stranding costs one save instead of one per delta. The move also restores the ordering the pre-merge code produced: on main a stranded delta appended a fresh partial row at the tail, so the tail was the row carrying the newest content. The in-place merge left that row behind the intervening message, which is the only place this PR changed array order. `ts` stays untouched because it is the webview's virtuoso key (Task.ts:4180). Impact: one awaited save per stranding instead of per delta; array order matches content recency again. --- src/core/task/Task.ts | 6 ++++ src/core/task/__tests__/Task.spec.ts | 52 +++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a32a5ce32b..594872e1c8 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2834,6 +2834,12 @@ export class Task extends EventEmitter implements TaskLike { staleSnapshot.images = images staleSnapshot.partial = partial staleSnapshot.progressStatus = progressStatus + // Move the merged row to the tail so the next delta takes the + // in-place fast path above instead of re-entering this branch + // and awaiting another full save. `ts` is untouched: it is the + // webview's virtuoso key, so re-stamping it would remount the row. + this.clineMessages.splice(staleSnapshotIndex, 1) + this.clineMessages.push(staleSnapshot) // A stranded snapshot merge has no guaranteed later save, so // persist the merged delta before the webview update — the // same save-then-post pattern as the complete-merge branch. diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 48bde0d510..f7b8ad16df 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -7860,12 +7860,62 @@ describe("Cline", () => { expect(events).toEqual(["save", "update"]) // The save observed the already-merged snapshot, not the stale text. + // The merged row is moved to the tail before the save, so the array + // order matches content recency rather than the original position. expect(persistedAtSaveCall).toEqual([ - { text: "Thinking about it", partial: true }, { text: "Execute command?", partial: false }, + { text: "Thinking about it", partial: true }, ]) }) + it("moves a merged stranded snapshot to the tail so later deltas take the in-place fast path", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + + await task.say("reasoning", "Thinking ab", undefined, true) + const snapshotTs = task.clineMessages[0].ts + const askTs = snapshotTs + 1 + task.clineMessages.push({ + ts: askTs, + type: "ask", + ask: "tool", + text: "Execute command?", + partial: false, + }) + task.lastMessageTs = askTs + const tsBefore = task.clineMessages.map((m) => m.ts) + saveSpy.mockClear() + + // Three more deltas of the same stranded stream. + await task.say("reasoning", "Thinking about", undefined, true) + await task.say("reasoning", "Thinking about the", undefined, true) + await task.say("reasoning", "Thinking about the plan", undefined, true) + + // (b) One awaited full save for the whole stranding, not one per delta: + // the first delta merges and persists, the rest update the tail in place. + expect(saveSpy).toHaveBeenCalledTimes(1) + + // (a) The splice must not mint or re-stamp a ts: the webview uses ts as + // the virtuoso list key (Task.ts:4180), so a changed key remounts the row. + expect([...task.clineMessages.map((m) => m.ts)].sort((a, b) => a - b)).toEqual( + [...tsBefore].sort((a, b) => a - b), + ) + const reasoning = task.clineMessages.find((m) => m.type === "say" && m.say === "reasoning") + expect(reasoning?.ts).toBe(snapshotTs) + + // (c) The tail is the row whose content was written most recently. + expect(task.clineMessages.at(-1)).toBe(reasoning) + expect(task.clineMessages.at(-1)?.text).toBe("Thinking about the plan") + expect(task.clineMessages.at(-1)?.partial).toBe(true) + // The partial-merge branch leaves lastMessageTs on the pending ask. + expect(task.lastMessageTs).toBe(askTs) + }) + it("appends a new partial message when no prefix-related stranded snapshot exists", async () => { const task = new Task({ provider: mockProvider,