-
Notifications
You must be signed in to change notification settings - Fork 66
fix(react): Keep an assistant turn in one message on RTVI 2.0.0 #246
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,10 +83,15 @@ export function useConversationEventWiring() { | |
|
|
||
| // -- helpers --------------------------------------------------------------- | ||
|
|
||
| /** Cancel any pending delayed finalize, leaving no timer armed. */ | ||
| const cancelFinalizeTimer = useCallback(() => { | ||
| clearTimeout(botStoppedSpeakingTimeoutRef.current); | ||
| botStoppedSpeakingTimeoutRef.current = undefined; | ||
| }, []); | ||
|
|
||
| const finalizeLastAssistantMessageIfPending = useAtomCallback( | ||
| useCallback((get, set) => { | ||
| clearTimeout(botStoppedSpeakingTimeoutRef.current); | ||
| botStoppedSpeakingTimeoutRef.current = undefined; | ||
| cancelFinalizeTimer(); | ||
| const messages = get(messagesAtom); | ||
| const lastAssistant = findLast(messages, | ||
| (m: ConversationMessage) => m.role === "assistant" | ||
|
|
@@ -139,6 +144,58 @@ export function useConversationEventWiring() { | |
| }, []) | ||
| ); | ||
|
|
||
| /** | ||
| * Arms (or re-arms) the delayed finalize for the in-flight assistant turn. | ||
| * | ||
| * Finalizing is deferred rather than immediate because the bot may just be | ||
| * pausing mid-turn; BotStartedSpeaking cancels the timer when that happens. | ||
| * On RTVI 2.0.0+ this timer and UserStartedSpeaking are the *only* things | ||
| * that end an assistant turn, so any path that cancels the timer has to | ||
| * re-arm it here rather than dropping it on the floor. | ||
| */ | ||
| const armBotStoppedFinalizeTimer = useAtomCallback( | ||
| useCallback((get, set) => { | ||
| cancelFinalizeTimer(); | ||
|
|
||
| const messages = get(messagesAtom); | ||
| const lastAssistant = findLast(messages, | ||
| (m: ConversationMessage) => m.role === "assistant" | ||
| ); | ||
| if (!lastAssistant || lastAssistant.final) return; | ||
|
|
||
| botStoppedSpeakingTimeoutRef.current = setTimeout(() => { | ||
| botStoppedSpeakingTimeoutRef.current = undefined; | ||
|
|
||
| // Snap the speech-progress cursor to the end of all parts. | ||
| // The bot finished speaking normally (not interrupted), so all | ||
| // text should render as "spoken". Without this, text from the | ||
| // last sentence can remain grey if the spoken BotOutput event | ||
| // didn't match the unspoken text exactly. | ||
| const msgs = get(messagesAtom); | ||
| const cursorMap = new Map(get(botOutputMessageStateAtom)); | ||
| const last = findLast(msgs, | ||
| (m: ConversationMessage) => m.role === "assistant" | ||
| ); | ||
| if (last) { | ||
| const cursor = cursorMap.get(last.createdAt); | ||
| if (cursor && last.parts && last.parts.length > 0) { | ||
| const lastPartIdx = last.parts.length - 1; | ||
| const lastPartText = last.parts[lastPartIdx]?.text; | ||
| cursor.currentPartIndex = lastPartIdx; | ||
| cursor.currentCharIndex = | ||
| typeof lastPartText === "string" ? lastPartText.length : 0; | ||
| for (let i = 0; i <= lastPartIdx; i++) { | ||
| cursor.partFinalFlags[i] = true; | ||
| } | ||
| set(botOutputMessageStateAtom, cursorMap); | ||
| } | ||
| } | ||
|
|
||
| finalizeLastMessage(get, set, "assistant"); | ||
| }, BOT_STOPPED_FINALIZE_DELAY_MS); | ||
| }, []) | ||
| ); | ||
|
|
||
| // -- event handlers -------------------------------------------------------- | ||
|
|
||
| useRTVIClientEvent( | ||
|
|
@@ -148,10 +205,9 @@ export function useConversationEventWiring() { | |
| clearMessages(get, set); | ||
| set(botOutputSupportedAtom, null); | ||
| set(botOutputProtocolAtom, null); | ||
| clearTimeout(botStoppedSpeakingTimeoutRef.current); | ||
| botStoppedSpeakingTimeoutRef.current = undefined; | ||
| cancelFinalizeTimer(); | ||
| botOutputLastChunkRef.current = { spoken: "", unspoken: "" }; | ||
| }, []) | ||
| }, [cancelFinalizeTimer]) | ||
| ) | ||
| ); | ||
|
|
||
|
|
@@ -180,11 +236,6 @@ export function useConversationEventWiring() { | |
| useAtomCallback( | ||
| useCallback( | ||
| (get, set, data: BotOutputData) => { | ||
| // A BotOutput event means the response is still active; cancel any | ||
| // pending finalize timer from BotStoppedSpeaking. | ||
| clearTimeout(botStoppedSpeakingTimeoutRef.current); | ||
| botStoppedSpeakingTimeoutRef.current = undefined; | ||
|
|
||
| const protocol = get(botOutputProtocolAtom) ?? "legacy"; | ||
|
|
||
| if (protocol === "v2") { | ||
|
|
@@ -207,12 +258,17 @@ export function useConversationEventWiring() { | |
| segment_id: data.segment_id, | ||
| }; | ||
|
|
||
| const isFinal = data.aggregated_by === "sentence"; | ||
| // `aggregated_by` describes how the text was chunked, not whether | ||
| // the turn is over: a turn contains many sentences. Marking each | ||
| // sentence final ends the message after the first one, and the | ||
| // next segment then opens a new bubble instead of continuing the | ||
| // turn. On 2.0.0 the turn is finalized by BotStoppedSpeaking (or | ||
| // by the user starting a new turn), so leave it open here. | ||
| updateAssistantBotOutput( | ||
| get, | ||
| set, | ||
| data.text, | ||
| isFinal, | ||
| false, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the right call, and the comment above it earns its length. Confirming the third step of the chain in your description: I read One small thing: this comment explains why v2 passes
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 27607e2 — the reasoning is now a comment on the legacy branch itself, right above Thanks for independently confirming step 3. |
||
| payload, | ||
| data.aggregated_by | ||
| ); | ||
|
|
@@ -253,64 +309,37 @@ export function useConversationEventWiring() { | |
| data.aggregated_by | ||
| ); | ||
| } | ||
|
|
||
| // A BotOutput event means the response is still active, so push a | ||
| // pending finalize deadline back rather than letting it fire | ||
| // mid-turn. It is postponed, not dropped: on 2.0.0 nothing else ends | ||
| // the turn, so cancelling outright for a trailing event would leave | ||
| // the message non-final until the user speaks again. This handler is | ||
| // synchronous, so a pending timer cannot have fired before here. | ||
| // Re-arming is a no-op once the message is final (legacy path). | ||
| if (botStoppedSpeakingTimeoutRef.current) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This guard is the crux of the change and the comment is worth having. One scoping note for the follow-up. Because the timer is only re-armed when one is already pending, a v2 turn that is never spoken has no finalize path at all except Per probe A in my review comment,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch on the scoping, and I have rewritten the limitation section to match. It now separates the two symptoms explicitly: the The text-input-only-client-with-no-VAD case is called out by name, since that is the configuration where there is genuinely no finalize path at all. |
||
| armBotStoppedFinalizeTimer(); | ||
| } | ||
| }, | ||
| [ensureAssistantMessage] | ||
| [armBotStoppedFinalizeTimer, ensureAssistantMessage] | ||
| ) | ||
| ) | ||
| ); | ||
|
|
||
| useRTVIClientEvent( | ||
| RTVIEvent.BotStoppedSpeaking, | ||
| useAtomCallback( | ||
| useCallback((get, set) => { | ||
| // Don't finalize immediately; start a timer. Bot may start speaking again (pause). | ||
| clearTimeout(botStoppedSpeakingTimeoutRef.current); | ||
| const messages = get(messagesAtom); | ||
| const lastAssistant = findLast(messages, | ||
| (m: ConversationMessage) => m.role === "assistant" | ||
| ); | ||
| if (!lastAssistant || lastAssistant.final) return; | ||
| botStoppedSpeakingTimeoutRef.current = setTimeout(() => { | ||
| botStoppedSpeakingTimeoutRef.current = undefined; | ||
|
|
||
| // Snap the speech-progress cursor to the end of all parts. | ||
| // The bot finished speaking normally (not interrupted), so all | ||
| // text should render as "spoken". Without this, text from the | ||
| // last sentence can remain grey if the spoken BotOutput event | ||
| // didn't match the unspoken text exactly. | ||
| const msgs = get(messagesAtom); | ||
| const cursorMap = new Map(get(botOutputMessageStateAtom)); | ||
| const last = findLast(msgs, | ||
| (m: ConversationMessage) => m.role === "assistant" | ||
| ); | ||
| if (last) { | ||
| const cursor = cursorMap.get(last.createdAt); | ||
| if (cursor && last.parts && last.parts.length > 0) { | ||
| const lastPartIdx = last.parts.length - 1; | ||
| const lastPartText = last.parts[lastPartIdx]?.text; | ||
| cursor.currentPartIndex = lastPartIdx; | ||
| cursor.currentCharIndex = | ||
| typeof lastPartText === "string" ? lastPartText.length : 0; | ||
| for (let i = 0; i <= lastPartIdx; i++) { | ||
| cursor.partFinalFlags[i] = true; | ||
| } | ||
| set(botOutputMessageStateAtom, cursorMap); | ||
| } | ||
| } | ||
|
|
||
| finalizeLastMessage(get, set, "assistant"); | ||
| }, BOT_STOPPED_FINALIZE_DELAY_MS); | ||
| }, []) | ||
| ) | ||
| useCallback(() => { | ||
| // Don't finalize immediately; start a timer. Bot may start speaking again (pause). | ||
| armBotStoppedFinalizeTimer(); | ||
| }, [armBotStoppedFinalizeTimer]) | ||
| ); | ||
|
|
||
| useRTVIClientEvent( | ||
| RTVIEvent.BotStartedSpeaking, | ||
| useCallback(() => { | ||
| // Bot is speaking again; reset the finalize timer (bot was just pausing). | ||
| clearTimeout(botStoppedSpeakingTimeoutRef.current); | ||
| botStoppedSpeakingTimeoutRef.current = undefined; | ||
| }, []) | ||
| cancelFinalizeTimer(); | ||
| }, [cancelFinalizeTimer]) | ||
| ); | ||
|
|
||
| useRTVIClientEvent( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -135,12 +135,13 @@ export function createStoreHarness() { | |
| ensureAssistantMessage(); | ||
| } | ||
|
|
||
| const isFinal = aggregated_by === "sentence"; | ||
| // v2 never finalizes per segment; the turn is finalized by | ||
| // BotStoppedSpeaking / UserStartedSpeaking. Mirrors useConversationEventWiring. | ||
| actions.updateAssistantBotOutput( | ||
| store.get, | ||
| store.set, | ||
| text, | ||
| isFinal, | ||
| false, | ||
| { protocol: "v2", will_be_spoken, spoken_status, spoken_progress, segment_id }, | ||
| aggregated_by | ||
| ); | ||
|
|
@@ -161,6 +162,38 @@ export function createStoreHarness() { | |
| actions.finalizeLastMessage(store.get, store.set, "assistant"); | ||
| } | ||
|
|
||
| /** | ||
| * Finalize the assistant turn the way the BotStoppedSpeaking timer does: | ||
| * snap the speech-progress cursor to the end of all parts, then finalize. | ||
| * Mirrors armBotStoppedFinalizeTimer in useConversationEventWiring. | ||
| * | ||
| * Use this for a normal, uninterrupted turn boundary. `finalizeAssistant` | ||
| * models the raw UserStartedSpeaking / interruption path, which deliberately | ||
| * does not snap the cursor (unspoken text stays unspoken). | ||
| */ | ||
| function finalizeAssistantAfterBotStoppedSpeaking() { | ||
| const messages = store.get(messagesAtom); | ||
| const cursorMap = new Map(store.get(botOutputMessageStateAtom)); | ||
| const last = [...messages] | ||
| .reverse() | ||
| .find((m: ConversationMessage) => m.role === "assistant"); | ||
| if (last) { | ||
| const cursor = cursorMap.get(last.createdAt); | ||
| if (cursor && last.parts && last.parts.length > 0) { | ||
| const lastPartIdx = last.parts.length - 1; | ||
| const lastPartText = last.parts[lastPartIdx]?.text; | ||
| cursor.currentPartIndex = lastPartIdx; | ||
| cursor.currentCharIndex = | ||
| typeof lastPartText === "string" ? lastPartText.length : 0; | ||
| for (let i = 0; i <= lastPartIdx; i++) { | ||
| cursor.partFinalFlags[i] = true; | ||
| } | ||
| store.set(botOutputMessageStateAtom, cursorMap); | ||
| } | ||
| } | ||
| finalizeAssistant(); | ||
| } | ||
|
Comment on lines
+175
to
+178
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is now the second copy of the cursor-snap block, the first being inside A test helper that mirrors production logic drifts silently, and the drift shows up as tests that keep passing while the behavior changes, which is the exact failure this harness exists to catch. Extracting the snap into an exported helper in Worth doing now, while the two copies are still identical and the diff is trivial. More broadly:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 27607e2. Extracted as You were right that it was worth doing now — the copies were still byte-identical, so the diff was mechanical. Verified behavior-neutral: 265 passing both before and after the extraction (stashed and re-ran to be sure), against 253 on Agreed on the direction for |
||
|
|
||
| /** | ||
| * Finalize the last user message. | ||
| */ | ||
|
|
@@ -342,6 +375,7 @@ export function createStoreHarness() { | |
| emitBotOutputV2, | ||
| emitUserTranscript, | ||
| finalizeAssistant, | ||
| finalizeAssistantAfterBotStoppedSpeaking, | ||
| finalizeUser, | ||
| finalizeAssistantIfPending, | ||
| finalizeUserIfPending, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
react-hooks/exhaustive-depsflags this array for a missingcancelFinalizeTimer, and the same warning fires at line 102 onfinalizeLastAssistantMessageIfPending.mainis clean on this file, so both are new here.cancelFinalizeTimerisuseCallback(..., [])and therefore stable, so adding it to both arrays is behaviorally a no-op. It would also match how you already updatedConnectedandBotStartedSpeakingin this same diff, which is what made the inconsistency visible.Worth noting nothing will catch this for you: CI does not run eslint on this repo, and
npm run lintalready fails onmainwith 828no-undeferrors on jest globals in the tests, so the two real warnings are buried.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 27607e2 —
cancelFinalizeTimeradded to both arrays. It isuseCallback(..., [])so this is a no-op behaviorally, and it matches what I had already done toConnectedandBotStartedSpeaking; the inconsistency was mine.npx eslinton the two source files is now silent. Your point about nothing catching this stands and is worth its own issue: CI does not run eslint here, andnpm run lintfails onmainwith 828no-undeferrors on jest globals, so any real warning is buried. Not fixing that in this PR.