fix: handle id mismatch on multi-message assistant turns#393
Open
dariusz-did wants to merge 4 commits intomainfrom
Open
fix: handle id mismatch on multi-message assistant turns#393dariusz-did wants to merge 4 commits intomainfrom
dariusz-did wants to merge 4 commits intomainfrom
Conversation
Backends emit chat/partial keyed by sequence.id but chat/answer keyed by chat_item.id, so the SDK was treating each final answer as a brand-new message and rendering duplicates of the message it had just streamed. Greetings pushed locally via speak() also collided with the backend stream. Changes: - speak() pushes assistant messages with an empty id; the message-queue adopts the backend's id on the first partial. - Mid-stream Answer (queue non-empty) always finalises the streaming message, ignoring an id mismatch from chat_item.id vs sequence.id. - Push messages with parts already populated so the UI never receives a parts-empty assistant entry. - clearQueue mutates the queue object so the closure inside processChatEvent observes the reset (previously reassigned the outer-scope variable; the inner reference still pointed at the old object). - Clear the buffer after each Answer so the next turn's "is streaming?" check starts fresh (keeps clips/talks multi-Answer flow intact).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background — how the SDK builds an assistant message
The backend streams an assistant reply word-by-word over two kinds of events:
sequenceindex so the SDK can re-order them.The SDK keeps two structures in
processChatEvent(message-queue.ts):getMessageContent(chatEventQueue)concatenates the buffer (0 + 1 + 2 + …). When that string changes (or an Answer arrives) the SDK updates the last message and notifies the UI viaonNewMessage.The whole routing decision boils down to one boolean: is this event continuing the same message, or is it a new one? That was decided by:
i.e. compare the new event's id against the last assistant message in state.
The three bugs we hit
Bug A — backend uses two different ids for the same message
The orchestrator publishes
chat/partialkeyed bysequence.id(e.g.588571514) butchat/answerkeyed bychat_item.id(e.g.item_b997ef5e). For one logical message the SDK saw:So
isNewAssistantMessageflipped totrueon the Answer and the SDK pushed a brand-new entry instead of finalising the streaming one. Result: the message rendered twice — and because the duplicate had no streamed parts yet, the UI hid it until the avatar video finished, making the post-tool reply look like it was delayed by 20s.Bug B — the greeting duplicated
UI calls
agent.speak("Hi! I'm My Agent…")so the greeting appears in chat instantly without waiting for the backend round-trip.speak()pushes the message locally with a random id. The backend then also streams the same greeting overchat/partialwith its own id. Same id-mismatch logic fires, same duplicate result — two greetings side by side.Bug C —
chatEventQueuereset was broken by closure captureprocessChatEventreceiveschatEventQueueas a parameter — a captured reference to the original object. WhenclearQueue()reassigns the outer-scope variable to{}, the parameter insideprocessChatEventstill points at the old object. "Clearing" did nothing for the function that actually reads/writes the buffer, so finished content from the previous message could leak into the next one.The fix — change by change
1.
speak()pushes withid: ''(agent-manager/index.ts)Empty id is a signal: "this assistant entry is local — adopt the backend's id when it starts streaming the same content."
2. Greeting reclaim in
processChatEventWhen the first Partial of the greeting arrives and the last message has an empty id, take the backend id, clear the body, and let the normal streaming path repopulate it. One greeting, not two.
3.
isStreamingThisTurnheuristic (Bug A)We treat the event as a new message only when:
Partial(Partials always open a new message), orAnswerand the buffer is empty (no streaming was happening this turn).chatEventQueueis the proxy for "we're mid-stream":Answerarrives the buffer is full →isStreamingThisTurn=true→ the Answer is treated as the finaliser of the streaming message, even if its id ischat_item.idrather thansequence.id.isStreamingThisTurn=false→ an Answer with a new id is correctly treated as a new message.4.
clearQueue()after every AnswerThe
isStreamingThisTurnheuristic relies on the buffer being empty between turns. We have to clear it explicitly when a turn closes — otherwise the V1 multi-Answer path would see leftover state and misclassify the next Answer.5. Push messages with
partsalready populatedcurrentMessage = { id: …, content: initialContent, - parts: [], + parts: parseMessagePartsMemo(initialContent), … }Previously a fresh message was pushed with
parts: []. The follow-up update only ran ifcurrentMessage.content !== messageContent, which is false on the very first Partial (both equaldata.content). The UI would briefly see an assistant message with empty parts and hide it. Populatingpartsat push time means the UI never observes a parts-empty assistant entry.6.
clearQueuemutates instead of reassigning (Bug C)Mutating the same object means every captured reference (including the parameter inside
processChatEvent) observes the reset.Bug → fix map
Test plan
yarn test— 414 tests passing, including existing multi-message turn and greeting suites.