fix(desktop): stop the voice-live toasts leaking machine strings (#111987)

The session-end toast printed the raw wire reason (`connection_lost (127s)`,
`closed`) and a denied `getUserMedia` reached the toast as bare `DOMException`
text, while the recorder path already had friendly copy for those names.

- `liveEndedMessage` (new, exported for the tests) maps our own close reasons —
  `connection_lost`, `closed`, plus a blank reason — to i18n copy; server-sent
  reasons stay verbatim (unbounded, no redaction claim).
- `micError` is exported and reused on the live start path, so a mic
  DOMException gets the recorder's copy; an unmapped DOMException name now falls
  back to `microphoneStartFailed` instead of its raw text.
- The start catch maps DOMExceptions only: non-mic failures ('GPT-Live session
  already started', 'Missing local SDP offer', API errors) keep their message.

New i18n keys: `notifications.voice.liveEndedConnectionLost` / `liveEndedClosed`
(en base + zh translation; the other locales fall back to en).

Tests: `use-voice-live-conversation.test.ts` drives the real toast store through
a fake transport — reason copy, blank/unknown reasons, `close_requested` filter,
mic DOMException names, and untouched non-mic failures.
This commit is contained in:
finn763
2026-09-16 00:02:37 +08:00
committed by Teknium
parent 5a4c3b32d0
commit c2625370d7
6 changed files with 229 additions and 15 deletions
@@ -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
}
@@ -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<string, unknown>
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<void> {
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<VoiceLiveHandlers> {
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<void> {
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', () => {
@@ -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
+2
View File
@@ -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.`
+2
View File
@@ -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
+2
View File
@@ -242,6 +242,8 @@ export const zh = defineLocale({
tryRecordingAgain: '请再录一次。',
unavailable: '语音不可用',
liveEnded: '实时语音会话已结束',
liveEndedConnectionLost: '实时语音会话连接已断开。',
liveEndedClosed: '实时语音会话已被服务端关闭。',
liveError: '实时语音',
liveDelegationFailed: '无法将请求交给 Hermes',
liveUnavailable: reason => `GPT-Live 语音聊天不可用:${reason}。已改用语音转文字。`