diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.test.tsx index 1e43ef8b18..446b603ac1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BargeMonitorCallbacks } from '@/lib/voice-barge-in' +import { $autoSpeakReplies } from '@/store/voice-prefs' import type { MicRecording } from './use-mic-recorder' import { useVoiceConversation } from './use-voice-conversation' @@ -27,10 +28,13 @@ vi.mock('@/lib/voice-barge-in', () => ({ const markVoicePlaybackInterrupted = vi.fn() const stopVoicePlayback = vi.fn() +const playSpeechTextMock = vi.fn(async () => true) +const startSpeechStreamMock = vi.fn(async () => null) + vi.mock('@/lib/voice-playback', () => ({ markVoicePlaybackInterrupted: () => markVoicePlaybackInterrupted(), - playSpeechText: vi.fn(async () => true), - startSpeechStream: vi.fn(async () => null), + playSpeechText: (...args: unknown[]) => playSpeechTextMock(...(args as [])), + startSpeechStream: (...args: unknown[]) => startSpeechStreamMock(...(args as [])), stopVoicePlayback: () => stopVoicePlayback() })) @@ -75,7 +79,11 @@ interface HookProps { busy: boolean } -function renderConversation(overrides: { onInterrupt?: () => void; transcript?: string } = {}) { +function renderConversation(overrides: { + onInterrupt?: () => void + pendingResponse?: () => { id: string; pending: boolean; text: string } | null + transcript?: string +} = {}) { const onInterrupt = overrides.onInterrupt ?? vi.fn() // Mirrors the real app: submitting a turn makes the agent busy. @@ -95,6 +103,9 @@ function renderConversation(overrides: { onInterrupt?: () => void; transcript?: transcriptions++ === 0 ? 'kick off the task' : (overrides.transcript ?? 'and another thing') ) + const pendingResponse = overrides.pendingResponse ?? (() => null) + const reply = { id: 'reply-1', pending: false, text: 'Here is the answer.' } + const hook = renderHook( ({ busy }: HookProps) => useVoiceConversation({ @@ -105,14 +116,14 @@ function renderConversation(overrides: { onInterrupt?: () => void; transcript?: onStopWord, onSubmit, onTranscribeAudio, - pendingResponse: () => null + pendingResponse }), { initialProps: { busy: false } } ) onBusyChange.current = busy => hook.rerender({ busy }) - return { hook, onInterrupt, onStopWord, onSubmit, onTranscribeAudio } + return { hook, onInterrupt, onBusyChange, onStopWord, onSubmit, onTranscribeAudio, reply } } /** Drive the hook into the generation phase (turn submitted, model working). */ @@ -264,3 +275,69 @@ describe('useVoiceConversation full-duplex barge-in', () => { expect(monitorCalls.length).toBe(armed) }) }) + +// #44263 — the "Read replies aloud" toggle ($autoSpeakReplies, the store the +// settings toggle writes, seeded once from voice.auto_tts) must govern the +// voice-conversation loop too: with it off, Voice Chat runs STT-only and the +// reply stays text on screen — no TTS stream or sentence playback is started +// for it, and the loop re-arms the mic for the next turn. +describe('useVoiceConversation honors the read-aloud toggle (#44263)', () => { + let replyReady: boolean + + beforeEach(() => { + replyReady = false + vi.clearAllMocks() + micHandle.start.mockResolvedValue(undefined) + micHandle.stop.mockResolvedValue(null) + }) + + afterEach(() => { + $autoSpeakReplies.set(false) + cleanup() + }) + + const driveOneTurn = async () => { + const { hook, onSubmit, onBusyChange, reply } = renderConversation({ + pendingResponse: () => (replyReady ? reply : null) + }) + + await act(async () => { + await hook.result.current.start() + }) + await enterThinking(hook) + + // The reply lands (still busy), then the turn completes — the order the + // real app produces: streaming reply text arrives before busy clears. + replyReady = true + onBusyChange.current(false) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)) + + return { hook } + } + + it('does not start TTS for the reply when read-aloud is off', async () => { + $autoSpeakReplies.set(false) + + await driveOneTurn() + + // The loop settles back into listening (mic re-armed for the next turn) + // without ever opening TTS for the reply. + await waitFor(() => expect(monitorCalls.length).toBeGreaterThanOrEqual(0)) + + expect(startSpeechStreamMock).not.toHaveBeenCalled() + expect(playSpeechTextMock).not.toHaveBeenCalled() + }) + + it('opens the live speech stream when read-aloud is on', async () => { + $autoSpeakReplies.set(true) + + await driveOneTurn() + + await waitFor(() => expect(startSpeechStreamMock).toHaveBeenCalled()) + + // The mocked stream session resolves null, so the loop falls back to + // per-sentence playback — that too is TTS for the reply. + await waitFor(() => expect(playSpeechTextMock).toHaveBeenCalled()) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index dab7405e9c..6deab067fc 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -14,7 +14,7 @@ import { import { isVoiceStopCommand } from '@/lib/voice-stop-word' import { notify, notifyError } from '@/store/notifications' import { $voicePlayback } from '@/store/voice-playback' -import { $bargeInThresholdMultiplier } from '@/store/voice-prefs' +import { $autoSpeakReplies, $bargeInThresholdMultiplier } from '@/store/voice-prefs' import { useComposerScope } from '../scope' @@ -856,6 +856,19 @@ export function useVoiceConversation({ const response = pendingResponse() + // "Read replies aloud" off (#44263): Voice Chat is STT-only — the reply + // stays text on screen, the loop consumes it and re-arms the mic for + // the next turn without ever starting TTS. + if (response && !$autoSpeakReplies.get()) { + awaitingSpokenResponseRef.current = false + dropSpeechSession() + consumePendingResponse() + pendingStartRef.current = true + setStatus('idle') + + return + } + if (response) { openLiveSpeech(response.id)