diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.test.tsx new file mode 100644 index 0000000000..d8548d1c4c --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.test.tsx @@ -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([]) + + // 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(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 }) => ( + {children} + ) + } + ) + + 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) + }) +})