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:
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user