Skip to content
Open
158 changes: 131 additions & 27 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2750,6 +2750,40 @@
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.
*
* 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" || text === "") {
return -1
}
return findLastIndex(
this.clineMessages,
(m) =>
m.partial === true &&
m.type === "say" &&

Check warning on line 2779 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2779: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
m.say === type &&
typeof m.text === "string" &&

Check warning on line 2781 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2781: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
m.text !== "" &&

Check warning on line 2782 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2782: 2 mutation test gaps; example: Survived ConditionalExpression mutant (replacement: true). See the job summary for the complete list and resolution guidance.
(text.startsWith(m.text) || m.text.startsWith(text)),

Check warning on line 2783 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2783: Survived MethodExpression mutant (replacement: m.text.endsWith(text)). See the job summary for the complete list and resolution guidance.
Comment thread
myk1yt marked this conversation as resolved.
Resolved
Comment thread
myk1yt marked this conversation as resolved.
Resolved
)
}

async say(
type: ClineSay,
text?: string,
Expand Down Expand Up @@ -2789,22 +2823,57 @@
})
} 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
// 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.
const saved = await this.saveClineMessages()
Comment thread
myk1yt marked this conversation as resolved.
Resolved
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) => {

Check warning on line 2856 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2856: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
console.error("[Task#say] updateClineMessage failed:", error)

Check warning on line 2857 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2857: NoCoverage StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
})
} else {
const sayTs = Date.now()

if (!options.isNonInteractive) {
this.lastMessageTs = sayTs
}
if (!options.isNonInteractive) {

Check warning on line 2862 in src/core/task/Task.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/task/Task.ts:2862: 3 mutation test gaps; example: Survived BooleanLiteral mutant (replacement: options.isNonInteractive). See the job summary for the complete list and resolution guidance.
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.
Expand Down Expand Up @@ -2832,21 +2901,56 @@
})
} 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.
const saved = await this.saveClineMessages()

if (!saved) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early return skips all of updateClineMessage, not just the webview post. That also skips the Message "updated" emit that api.ts forwards to IPC/CLI consumers, and the cloud capture. The existing complete branch is ungated and still emits. Is it intended that these consumers never see the completed row when the save fails?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and I am not going to change it in this round: this is a policy question and it should be your call, not mine.

The asymmetry is real and it is wider than the two branches you pointed at. Three sibling branches in say() currently have three different durability policies:

branch line save result used? post gated?
partial, in-place update (at(-1)) Task.ts:2811-2823 no save at all no
partial, stale merge Task.ts:2840-2857 yes yes (return)
complete, in-place update (at(-1)) Task.ts:2888-2895 discarded no
complete, stale merge Task.ts:2917-2926 yes yes (return)
finalizePartialToolAsk (precedent) Task.ts:3024-3030 yes yes

So the gate you are asking about is also present in the in-repo precedent I copied it from. Fixing it properly means deciding the policy once, not patching one branch.

The design I would propose: split the gate.

updateClineMessage does three things (Task.ts:1746-1763): postMessageToWebview({ type: "messageUpdated" }), emit(RooCodeEventName.Message, { action: "updated" }), and the cloud capture. Only the first one is a rendered view of durable state. The other two are notifications that in-memory state changed, and the in-memory merge happened unconditionally in both branches.

  • Webview post: stays gated on the save result. A reload would restore the stale disk text, so posting the merged row would let the rendered view disagree with disk.
  • Message emit + cloud capture: ungated. api.ts:531-537 forwards this to IPC/CLI consumers, and those consumers never reload clineMessages from disk. Suppressing the emit makes their view of in-memory truth wrong, which is a worse failure than the one the gate prevents. The cloud capture is keyed by ts in cloudSyncedMessageTimestamps, so an ungated capture does not double-report.

That is a net deletion: both branches lose the return and keep one if around the post. It also makes the two stale-merge branches agree with the complete in-place branch on the emit, which is the asymmetry you named.

The counter-argument, which is why I am not pre-empting you: it inverts two existing specs that currently assert updateSpy was not called at all (Task.spec.ts "skips the webview post and logs when the partial-merge save fails" and the complete-merge sibling). Those specs pin the current behavior, so changing the policy means rewriting them, and a reviewer could reasonably read that as tests being written to the implementation. If you prefer durability-first, the current gate stands and this thread resolves as answered-by-design.

Say the word and I will implement the split gate in a follow-up commit on this branch, with the two specs inverted to assert "emit fired, post did not" and a red-on-head proof that the emit is currently suppressed when saveClineMessages returns false.

// 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)
})
} 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 {
Expand Down
Loading
Loading