fix: respect final flag in mergeMessages and handle user-llm-text event - #211
Conversation
| break; | ||
| } | ||
| case RTVIMessageType.USER_LLM_TEXT: { | ||
| const llmTextData = ev.data as TranscriptData; |
There was a problem hiding this comment.
the data in a user-llm-text events does not match TranscriptData. It simply includes a text entry (much like bot-llm-text)
There was a problem hiding this comment.
You're right — fixed in the follow-up commit. Defined a proper UserLlmTextData = { text: string } type in messages.ts and used it throughout (callback declaration, RTVIEvents map, handleMessage cast). TranscriptData carries final, timestamp, and user_id which aren't part of the user-llm-text payload.
|
|
||
| // Chat mode: server emits user-llm-text instead of user-transcription when text | ||
| // input bypasses STT. Treat it as a finalized user message. | ||
| useRTVIClientEvent( |
There was a problem hiding this comment.
i haven't had a chance to test this, but I do not think this is what we want. user-llm-text will fire for EVERY user entry to the llm, spoken or not. If I'm reading this right, the result is that we will duplicate the user's spoken text. Even if not, I'm not sure this is the correct approach to filling in the missing user text. cc @Regaddi for thoughts.
There was a problem hiding this comment.
Agreed and reverted in the follow-up commit. You're right that the wiring would cause duplication in voice mode. The exact path:
user-transcriptionfires withfinal: true→upsertUserTranscriptupdates the last part tofinal: trueuser-llm-textfires →upsertUserTranscriptchecksmessages[lastUserIndex].final— stillfalseat the message level (finalization only happens on nextUserStartedSpeaking) — andlastPart.final === true, so it pushes a new part with the same text. The user message ends up with the transcript text twice.
Removed the useConversationEventWiring change entirely. RTVIEvent.UserLlmText and onUserLlmText are still exposed so callers can handle the event themselves, but we leave proper chat-mode conversation integration for a follow-up that can avoid this duplication path (e.g. an opt-in flag or a dedicated action that checks whether a user-transcription already exists for the current turn).
There was a problem hiding this comment.
This is great. and a good catch. Before I approve, I'd like @Regaddi to weigh in on the client-react change.
| text: string; | ||
| }; | ||
|
|
||
| export type UserLlmTextData = { |
There was a problem hiding this comment.
I hate writing nits to people i don't know, but this should be UserLLMTextData for consistency
| export type UserLlmTextData = { | |
| export type UserLLMTextData = { |
There was a problem hiding this comment.
Good catch, applied — renamed to UserLLMTextData everywhere to match BotLLMTextData.
|
I think the client-react change is fine: it avoids separate messages being merged into a single text bubble. |
mergeMessages was merging bot turns that were already finalized (final: true) into subsequent bot turns within the 30-second window, causing text from a completed turn to accumulate into the next turn's bubble. Add !lastMerged.final guard to the shouldMerge condition. RTVIMessageType.USER_LLM_TEXT had no corresponding RTVIEvent, no handler in PipecatClient.handleMessage, and no callback type. Add RTVIEvent.UserLlmText, UserLLMTextData type, onUserLlmText callback, and a USER_LLM_TEXT case in handleMessage so callers can react to user LLM text events. Conversation hook integration is intentionally left for a follow-up: wiring user-llm-text -> upsertUserTranscript unconditionally would duplicate user text in voice mode because user-transcription already finalizes a part before user-llm-text fires. Fixes pipecat-ai#210
9d3e48e to
6119ada
Compare
|
Rebased onto current main in a single squashed commit — the intermediate fixup commits are now gone. Resolved the |
|
Thanks so much for the thorough review and the quick turnaround — really appreciated! 🙏 On the topic of the intentionally-deferred part (chat-mode user messages in useRTVIClientEvent(
RTVIEvent.UserLlmText,
useAtomCallback(
useCallback((get, set, data) => {
const messages = get(messagesAtom);
const lastUserIdx = findLastIndex(
messages,
(m: ConversationMessage) => m.role === "user"
);
if (lastUserIdx === -1 || messages[lastUserIdx].final) {
// Chat mode: no in-progress user message — create one from the LLM text
upsertUserTranscript(get, set, data.text ?? "", true);
}
// Finalize in both cases:
// voice mode → finalizes the existing message from user-transcription
// chat mode → finalizes the one we just created
finalizeLastMessage(get, set, "user");
}, [])
)
);The key insight: instead of unconditionally calling Happy to open a PR if this direction looks right to you both! |
|
@rahulsolanki001 Feel free to open a follow-up PR. Thank you! |
|
Opened the follow-up as #225 — wires |
|
i'm going ahead and merging this one in the meantime |

Fixes #210
Summary
mergeMessagesfinal guard:shouldMergeinconversationActions.tsnow checks!lastMerged.final, so a finalized bot turn is never merged with the next turn's messages regardless of the 30-second window.user-llm-textevent wiring: AddedRTVIEvent.UserLlmTextto the event enum, aUSER_LLM_TEXTcase inPipecatClient.handleMessage,onUserLlmTextto the callbacks type, and a handler inuseConversationEventWiringthat callsupsertUserTranscript— making chat-mode user messages visible in the conversation array.Changes
client-js/rtvi/events.tsUserLlmText = "userLlmText"toRTVIEvent; adduserLlmTexttoRTVIEventstypeclient-js/client/client.tsonUserLlmTextcallback; addcase USER_LLM_TEXT:inhandleMessageclient-react/src/conversation/conversationActions.ts!lastMerged.final &&toshouldMergeclient-react/src/conversation/useConversationEventWiring.tsRTVIEvent.UserLlmText→upsertUserTranscript(..., true)Test plan
usePipecatConversationuser-llm-textstill fires and is now surfaced asRTVIEvent.UserLlmTextUserTranscriptbehavior is unchangedtsc --noEmit)