What
Two comparisons in health-chat.js decide whether a turn recovered from the
server is new. Both are proxies for identity, and both are wrong in a reachable
case.
1. _refreshConversation compares counts.
const localCount = this._messages.filter(m => m.role !== 'error').length;
if (convo.messages.length <= localCount) return false;
The local transcript can hold messages the server never stored. The
voice-unconfigured notice is one: it is role: 'assistant', and
_flushConversation deliberately filters it out when persisting (a reload would
otherwise replay it as a real reply). So a client carrying that notice counts one
higher than the server, and a server that is genuinely one message ahead compares
as not ahead. _refreshConversation returns false, _reattachIfRunning returns
'none', and the caller takes 'none' as proof there is nothing to collect and
writes a "failed to connect" bubble over a reply that is sitting on the server.
2. _reattachIfRunning dedupes by content.
const last = this._messages.filter(m => m.role === 'assistant').at(-1);
if (!last || last.content !== (this._turnReply.reply || '')) {
await this._applyTurnOutcome(false);
}
Drops a real reply when the model legitimately repeats itself: two "Done." replies
in a row, or the same answer to a re-asked question, and the second is discarded
as an echo of the first.
Duplicates a reply in voice mode. _applyTurnOutcome stores displayText
(data.reply || data.display || data.speak) as content, but the comparison
reads this._turnReply.reply. For a reply whose text arrived under display or
speak, stored content never equals reply, so every reattach appends the same
reply again.
Why
Both are the same mistake as the bug #696 fixed, one level down: inferring "did
we already see this" from something that is not an identity. The server ids every
message and every turn event; the client already tracks _lastEventId for exactly
this reason after #696. Comparing ids removes both failure modes at once, which
is why these are one issue and not two.
Acceptance criteria
What
Two comparisons in
health-chat.jsdecide whether a turn recovered from theserver is new. Both are proxies for identity, and both are wrong in a reachable
case.
1.
_refreshConversationcompares counts.The local transcript can hold messages the server never stored. The
voice-unconfigured notice is one: it is
role: 'assistant', and_flushConversationdeliberately filters it out when persisting (a reload wouldotherwise replay it as a real reply). So a client carrying that notice counts one
higher than the server, and a server that is genuinely one message ahead compares
as not ahead.
_refreshConversationreturns false,_reattachIfRunningreturns'none', and the caller takes'none'as proof there is nothing to collect andwrites a "failed to connect" bubble over a reply that is sitting on the server.
2.
_reattachIfRunningdedupes by content.Drops a real reply when the model legitimately repeats itself: two "Done." replies
in a row, or the same answer to a re-asked question, and the second is discarded
as an echo of the first.
Duplicates a reply in voice mode.
_applyTurnOutcomestoresdisplayText(
data.reply || data.display || data.speak) ascontent, but the comparisonreads
this._turnReply.reply. For a reply whose text arrived underdisplayorspeak, stored content never equalsreply, so every reattach appends the samereply again.
Why
Both are the same mistake as the bug #696 fixed, one level down: inferring "did
we already see this" from something that is not an identity. The server ids every
message and every turn event; the client already tracks
_lastEventIdfor exactlythis reason after #696. Comparing ids removes both failure modes at once, which
is why these are one issue and not two.
Acceptance criteria
refresh, and no other local-only message can either
by comparing its text to the last assistant bubble
display/speak, noreply) is notduplicated by a reattach
reattach, each proven to fail before the fix