diff --git a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts index 5389d9f4d5..c560ef9ad2 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-mic-recorder.ts @@ -33,7 +33,11 @@ interface MicRecorderHandle { cancel: () => void } -function micError(error: unknown, copy: MicRecorderErrorCopy): Error { +/** Recorder + live-start mic failures → the same friendly copy: a DOMException + * name is mapped, an unrecognized DOMException falls back to the generic start + * copy, and anything else keeps its own message (non-mic failures must not be + * mislabeled as microphone problems). */ +export function micError(error: unknown, copy: MicRecorderErrorCopy): Error { const name = error instanceof DOMException ? error.name : '' if (name === 'NotAllowedError' || name === 'SecurityError') { @@ -52,6 +56,10 @@ function micError(error: unknown, copy: MicRecorderErrorCopy): Error { return new Error(copy.microphoneConstraintsUnsupported) } + if (error instanceof DOMException) { + return new Error(copy.microphoneStartFailed) + } + if (error instanceof Error) { return error } diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.test.ts index 5046a8f59e..089c661f2e 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.test.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.test.ts @@ -1,9 +1,190 @@ // @vitest-environment jsdom -import { describe, expect, it } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { chunkForCommentary, toLiveHistory } from '@/lib/voice-live' +import { en } from '@/i18n/en' +import { chunkForCommentary, toLiveHistory, type VoiceLiveHandlers } from '@/lib/voice-live' +import { $notifications, clearNotifications } from '@/store/notifications' -import { delegationPrompt } from './use-voice-live-conversation' +import { delegationPrompt, useVoiceLiveConversation } from './use-voice-live-conversation' + +// Issue #111987: the live-voice toasts must not show machine strings — neither +// the wire close reasons (`connection_lost`, `closed`) nor the raw +// `DOMException` text a denied `getUserMedia` throws. +// +// The transport is the only seam in the live hook, so the fake session records +// the handlers it registers (tests drive the close/error paths directly) and +// lets `start` reject with exactly what the real `getUserMedia` would throw. +const transport = vi.hoisted(() => ({ failure: null as unknown, handlers: [] as VoiceLiveHandlers[] })) + +vi.mock('@/lib/voice-live', async importOriginal => { + const actual = (await importOriginal()) as Record + + return { + ...actual, + VoiceLiveSession: class { + close = vi.fn() + instruct = vi.fn() + setMuted = vi.fn() + speak = vi.fn() + think = vi.fn() + + constructor(handlers: VoiceLiveHandlers) { + transport.handlers.push(handlers) + } + + async start(): Promise { + if (transport.failure) { + throw transport.failure + } + } + } + } +}) + +const voice = en.notifications.voice + +function mountLive() { + return renderHook(() => + useVoiceLiveConversation({ + busy: false, + consumePendingResponse: vi.fn(), + enabled: true, + onSubmit: vi.fn(), + pendingResponse: () => null, + seedHistory: () => [] + }) + ) +} + +/** Mount, start, and hand back the handlers the started session registered. */ +async function openSession(): Promise { + const hook = mountLive() + + await act(async () => { + await hook.result.current.start() + }) + + return transport.handlers.at(-1) as VoiceLiveHandlers +} + +/** Start a session whose transport fails the way the real one can. */ +async function failedStart(failure: unknown): Promise { + transport.failure = failure + const hook = mountLive() + + await act(async () => { + await hook.result.current.start() + }) +} + +function resetToasts() { + clearNotifications() + transport.failure = null + transport.handlers.length = 0 +} + +describe('Voice-live session-end toast copy (#111987)', () => { + beforeEach(resetToasts) + afterEach(cleanup) + + it('names the lost connection in copy instead of the internal reason code', async () => { + const handlers = await openSession() + + act(() => { + handlers.onClosed('connection_lost', 127) + }) + + const toast = $notifications.get()[0] + + expect(toast.title).toBe(voice.liveEnded) + expect(toast.message).toBe(`${voice.liveEndedConnectionLost} (127s)`) + expect(toast.message).not.toContain('connection_lost') + }) + + it('names the vendor-side close in copy instead of the "closed" fallback code', async () => { + const handlers = await openSession() + + act(() => { + handlers.onClosed('closed', null) + }) + + const toast = $notifications.get()[0] + + expect(toast.kind).toBe('warning') + expect(toast.message).toBe(voice.liveEndedClosed) + }) + + it('falls back to the ended copy when the wire reason is blank', async () => { + const handlers = await openSession() + + act(() => { + handlers.onClosed('', null) + }) + + expect($notifications.get()[0].message).toBe(voice.liveEndedClosed) + }) + + it('passes an unrecognized server-sent reason through verbatim (unbounded by contract)', async () => { + const handlers = await openSession() + + act(() => { + handlers.onClosed('quota_exhausted', 12) + }) + + expect($notifications.get()[0].message).toBe('quota_exhausted (12s)') + }) + + it('still drops close_requested (our own close) without a toast', async () => { + const handlers = await openSession() + + act(() => { + handlers.onClosed('close_requested', 30) + }) + + expect($notifications.get()).toHaveLength(0) + }) +}) + +describe('Voice-live mic failure toast copy (#111987)', () => { + beforeEach(resetToasts) + afterEach(cleanup) + + it('reuses the recorder mic copy for a getUserMedia DOMException', async () => { + const cases: Array<[string, string]> = [ + ['NotAllowedError', voice.microphonePermissionDenied], + ['NotFoundError', voice.noMicrophone], + ['NotReadableError', voice.microphoneInUse], + ['OverconstrainedError', voice.microphoneConstraintsUnsupported] + ] + + for (const [name, copy] of cases) { + await failedStart( + new DOMException('The request is not allowed by the user agent or the platform in the current context.', name) + ) + + const toast = $notifications.get()[0] + + expect(toast.title).toBe(voice.couldNotStartSession) + expect(toast.message).toBe(copy) + expect(toast.message).not.toContain('user agent') + + cleanup() + resetToasts() + } + }) + + it('keeps a non-mic start failure message untouched', async () => { + for (const message of ['GPT-Live session already started', 'Missing local SDP offer']) { + await failedStart(new Error(message)) + + expect($notifications.get()[0].message).toBe(message) + + cleanup() + resetToasts() + } + }) +}) describe('GPT-Live delegation → Hermes turn', () => { it('sends the latest user words as the turn and the exchange as model-only context', () => { diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.ts index f9677709a6..3e45ead577 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-live-conversation.ts @@ -6,6 +6,7 @@ import { type LiveHistoryMessage, type LiveTranscriptFragment, VoiceLiveSession import { isVoiceStopCommand } from '@/lib/voice-stop-word' import { notify, notifyError } from '@/store/notifications' +import { micError } from './use-mic-recorder' import type { ConversationStatus } from './use-voice-conversation' /** How long an accepted delegation may sit before the gateway shows the turn running. */ @@ -67,6 +68,29 @@ export function delegationPrompt(context: LiveTranscriptFragment[]): { context: return { context: transcript, prompt: prompt || transcript.slice(-400) } } +/** + * Body of the session-end toast. `connection_lost` and `closed` are our own + * machine reasons (`lib/voice-live.ts`) and get i18n copy; so does a blank + * reason, which has no wording of its own. Any other reason is server-sent and + * unbounded, so it passes through verbatim (issue #111987 — no redaction claim + * for vendor strings). + */ +export function liveEndedMessage( + reason: string, + usageSeconds: null | number, + copy: { liveEndedClosed: string; liveEndedConnectionLost: string } +): string { + let text = reason?.trim() ?? '' + + if (text === 'connection_lost') { + text = copy.liveEndedConnectionLost + } else if (text === 'closed' || !text) { + text = copy.liveEndedClosed + } + + return usageSeconds != null ? `${text} (${Math.round(usageSeconds)}s)` : text +} + /** * GPT-Live conversation engine — same public shape as `useVoiceConversation` * so the composer can mount either from `voice.voice_chat_mode`. @@ -252,7 +276,7 @@ export function useVoiceLiveConversation({ if (reason !== 'close_requested') { notify({ kind: 'warning', - message: usageSeconds != null ? `${reason} (${Math.round(usageSeconds)}s)` : reason, + message: liveEndedMessage(reason, usageSeconds, voiceCopy), title: voiceCopy.liveEnded }) latest.current.onFatalError?.() @@ -330,19 +354,14 @@ export function useVoiceLiveConversation({ return } - notifyError(error, voiceCopy.couldNotStartSession) + // Only a mic DOMException gets the recorder's copy: this catch also + // takes non-mic start failures ('GPT-Live session already started', + // 'Missing local SDP offer', API errors) — those keep their own message. + notifyError(error instanceof DOMException ? micError(error, voiceCopy) : error, voiceCopy.couldNotStartSession) setStatus('idle') latest.current.onFatalError?.() } - }, [ - end, - refreshStatus, - setDelegation, - voiceCopy.couldNotStartSession, - voiceCopy.liveDelegationFailed, - voiceCopy.liveEnded, - voiceCopy.liveError - ]) + }, [end, refreshStatus, setDelegation, voiceCopy]) // Drive the reply back into the voice: stream commentary as Hermes writes // it (sentence-chunked), quiet tool progress as thinking appends, and clear diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 61ba7795c0..0ce996a314 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -278,6 +278,8 @@ export const en: Translations = { tryRecordingAgain: 'Try recording again.', unavailable: 'Voice unavailable', liveEnded: 'Live voice session ended', + liveEndedConnectionLost: 'The live voice session lost its connection.', + liveEndedClosed: 'The live voice session was closed by the service.', liveError: 'Live voice', liveDelegationFailed: 'Could not hand the request to Hermes', liveUnavailable: reason => `GPT-Live voice chat is not available: ${reason}. Using speech-to-text instead.` diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b36257e660..62fdf475e0 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -322,6 +322,8 @@ export interface Translations { tryRecordingAgain: string unavailable: string liveEnded: string + liveEndedConnectionLost: string + liveEndedClosed: string liveError: string liveDelegationFailed: string liveUnavailable: (reason: string) => string diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 1a0277d410..b31f823cc2 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -242,6 +242,8 @@ export const zh = defineLocale({ tryRecordingAgain: '请再录一次。', unavailable: '语音不可用', liveEnded: '实时语音会话已结束', + liveEndedConnectionLost: '实时语音会话连接已断开。', + liveEndedClosed: '实时语音会话已被服务端关闭。', liveError: '实时语音', liveDelegationFailed: '无法将请求交给 Hermes', liveUnavailable: reason => `GPT-Live 语音聊天不可用:${reason}。已改用语音转文字。`