test(desktop): pin auto-speak silence across an Edge TTS fallback id rewrite

#93515 reports auto-speak reading each reply twice when the Edge TTS
streaming attempt falls back to the POST endpoint and the reply's
renderer id gets rewritten to its durable id mid-flight. That was true
before 63565fa26b, but resolveSpokenReply()'s ordinal-anchored dedupe
(landed 2026-08-19, five days before this issue was filed) already
follows the rewrite. No source change — this pins the behavior with a
regression test at the hook/store integration level, one layer above
the existing spoken-reply.ts unit tests.
This commit is contained in:
chelsealong
2026-08-24 04:12:05 +00:00
committed by Teknium
parent 9fbe3cc040
commit 21914880ee
@@ -0,0 +1,132 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { assistantTextPart, type ChatMessage, chatMessageText } from '@/lib/chat-messages'
import { clearSpokenRepliesForTests, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply'
import { playSpeechText } from '@/lib/voice-playback'
import { $voicePlayback, setVoicePlaybackState } from '@/store/voice-playback'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { ComposerScopeProvider, MAIN_COMPOSER_SCOPE } from '../scope'
import { useAutoSpeakReplies } from './use-auto-speak-replies'
vi.mock('@/lib/voice-playback', () => ({
playSpeechText: vi.fn()
}))
const SESSION_ID = 'session-under-test'
const IDLE_STATE = { audioElement: null, messageId: null, sequence: 0, source: null, status: 'idle' as const }
function assistantMessage(id: string, text: string): ChatMessage {
return { id, parts: [assistantTextPart(text)], role: 'assistant' }
}
// #93515 — Edge TTS has no chunked-PCM API, so the WS attempt in
// playSpeechText's fallback ladder settles 'fallback' before any audio plays
// and the client retries over the POST endpoint. While that POST round-trip
// is in flight, the backend can rewrite the just-completed reply's renderer
// id (`assistant-stream-*`) to its durable id. The issue claims
// `resolveSpokenReply()` fails to follow that rewrite and the reply gets
// spoken a second time once `$voicePlayback` goes idle.
describe('useAutoSpeakReplies — Edge TTS fallback chain (#93515)', () => {
afterEach(() => {
cleanup()
clearSpokenRepliesForTests()
$autoSpeakReplies.set(false)
setVoicePlaybackState({ ...IDLE_STATE })
vi.clearAllMocks()
})
it('does not re-speak the reply once playback goes idle after an id rewrite mid-fallback', async () => {
$autoSpeakReplies.set(true)
const $messages = atom<ChatMessage[]>([])
// The exact pendingReply/markSpoken contract use-composer-voice.ts wires
// up for this hook, backed by the real ordinal-anchored dedupe.
const pendingReply = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
const spoken = resolveSpokenReply(SESSION_ID, messages)
if (!last || last.id === spoken?.id) {
return null
}
return { id: last.id, pending: Boolean(last.pending), text: chatMessageText(last) }
}
const markSpoken = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
if (last) {
markAssistantIdSpoken(SESSION_ID, messages, last.id)
}
}
let settleFallback: (() => void) | null = null
vi.mocked(playSpeechText).mockImplementation(async () => {
setVoicePlaybackState({
audioElement: null,
messageId: 'assistant-stream-1',
sequence: 0,
source: 'read-aloud',
status: 'preparing'
})
// Holds mid-ladder — the WS-fallback-then-POST round trip the issue
// describes — until the test rewrites the message id underneath it.
await new Promise<void>(resolve => {
settleFallback = resolve
})
$messages.set([assistantMessage('durable-42', 'hello there')])
setVoicePlaybackState({
audioElement: null,
messageId: 'durable-42',
sequence: 0,
source: 'read-aloud',
status: 'idle'
})
return true
})
renderHook(
() =>
useAutoSpeakReplies({
conversationActive: false,
failureLabel: 'read-aloud failed',
markSpoken,
pendingReply,
sessionId: SESSION_ID
}),
{
wrapper: ({ children }) => (
<ComposerScopeProvider value={{ ...MAIN_COMPOSER_SCOPE, $messages }}>{children}</ComposerScopeProvider>
)
}
)
act(() => {
$messages.set([assistantMessage('assistant-stream-1', 'hello there')])
})
await waitFor(() => expect(playSpeechText).toHaveBeenCalledTimes(1))
await act(async () => {
settleFallback?.()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect($voicePlayback.get().status).toBe('idle')
expect(playSpeechText).toHaveBeenCalledTimes(1)
})
})