fix(desktop): don't replay earlier turns in voice conversation

When lastSpokenId is missing or stale, collectUnspokenTurnSpeech used
index -1 and concatenated every prior assistant reply into one speech
string. Bound collection to the current turn (after the last user
message) and carry the spoken anchor from a null session id onto the
created session so live conversations stay turn-scoped.
This commit is contained in:
Patrick Gibbs
2026-08-27 15:27:44 -05:00
committed by kshitij
parent a166dd7b80
commit a2e0ca7fc9
5 changed files with 79 additions and 2 deletions
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { adoptSpokenReplySession, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { CONVERSATION_LEASE, READ_ALOUD_LEASE, syncTtsLease } from '@/lib/tts-lease'
import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
@@ -65,8 +65,15 @@ export function useComposerVoice({
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const ownsWakeIndicatorRef = useRef(false)
const previousSessionIdRef = useRef(sessionId)
const voiceStartRequest = useStore($voiceConversationStartRequest)
// eslint-disable-next-line no-restricted-syntax -- session-id adopt token, not an atom mirror
useEffect(() => {
adoptSpokenReplySession(previousSessionIdRef.current, sessionId)
previousSessionIdRef.current = sessionId
}, [sessionId])
const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({
focusInput,
maxRecordingSeconds,
@@ -1342,6 +1342,19 @@ describe('collectUnspokenTurnSpeech', () => {
expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull()
expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull()
})
it('does not replay earlier turns when the spoken id is missing or stale', () => {
const messages = [
user('u1', 'old question'),
assistant('a1', 'Previous output from last turn.'),
user('u2', 'new question'),
assistant('a2', 'Live reply only.')
]
expect(collectUnspokenTurnSpeech(messages, null)?.text).toBe('Live reply only.')
expect(collectUnspokenTurnSpeech(messages, 'vanished-stream-id')?.text).toBe('Live reply only.')
expect(collectUnspokenTurnSpeech(messages, 'a1')?.text).toBe('Live reply only.')
})
})
describe('stripPendingClarifyProjectionForCache', () => {
+15 -1
View File
@@ -68,12 +68,26 @@ export interface UnspokenTurnSpeech {
* selecting only one bubble silently drops everything after it. The blank-line
* join is a sentence boundary for the server's cutter, so a sealed bubble's
* tail is flushed as soon as the next bubble starts.
*
* If `lastSpokenId` is missing or stale (session id assigned mid-turn,
* live-tail rewrite missed), do **not** fall back to index -1 — that replays
* every earlier assistant turn as one speech string. Bound to the current
* turn (assistant bubbles after the last user message) instead. A slice with
* no user row (mid-turn interims only) still collects those assistants.
*/
export function collectUnspokenTurnSpeech(
messages: ChatMessage[],
lastSpokenId: string | null
): UnspokenTurnSpeech | null {
const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
let spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1
if (spokenIndex < 0) {
const lastUser = messages.findLastIndex(m => m.role === 'user' && !m.hidden)
if (lastUser >= 0) {
spokenIndex = lastUser
}
}
let id: string | null = null
let pending = false
+18
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
absorbSpokenReplyRewrite,
adoptSpokenReplySession,
assistantReplyOrdinal,
clearSpokenRepliesForTests,
isLiveTailReplyId,
@@ -90,3 +91,20 @@ describe('resolveSpokenReply', () => {
expect(spoken?.ordinal).toBe(0)
})
})
describe('adoptSpokenReplySession', () => {
it('moves the null-session anchor onto the created session id', () => {
markAssistantIdSpoken(null, [assistant('a1')], 'a1')
adoptSpokenReplySession(null, 'session-created')
expect(spokenReplyOf('session-created')?.id).toBe('a1')
})
it('does not leak a spoken anchor from one real session into another', () => {
markAssistantIdSpoken('session-a', [assistant('a1')], 'a1')
adoptSpokenReplySession('session-a', 'session-b')
expect(spokenReplyOf('session-b')).toBeNull()
expect(spokenReplyOf('session-a')?.id).toBe('a1')
})
})
+25
View File
@@ -111,6 +111,31 @@ export function markAssistantIdSpoken(
markSpokenReply(sessionId, { id, ordinal })
}
/**
* Carry the spoken anchor when a chat gets a real session id (null → created)
* mid voice-conversation. Do not copy across two real sessions — that would
* leak "already spoken" into a different transcript.
*/
export function adoptSpokenReplySession(
fromSessionId: string | null | undefined,
toSessionId: string | null | undefined
): void {
const fromKey = sessionKey(fromSessionId)
const toKey = sessionKey(toSessionId)
if (fromKey === toKey || fromKey !== NO_SESSION) {
return
}
const from = lastSpokenBySession.get(fromKey)
if (!from || lastSpokenBySession.has(toKey)) {
return
}
lastSpokenBySession.set(toKey, from)
}
/** Current spoken anchor, migrated in place when the live row was rewritten. */
export function resolveSpokenReply(
sessionId: string | null | undefined,