From eefc3c9de0fe0373dc0635cc6fdd0e9dc6d838a1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 19:37:27 -0400 Subject: [PATCH 01/38] fix(voice): hand off calls between sessions --- src/features/chat/ui/ChatInputToolbar.tsx | 2 +- .../chat/ui/__tests__/ChatInput.test.tsx | 15 ++-- .../useVoiceConversationController.test.ts | 53 ++++++++++++- .../hooks/useVoiceConversationController.ts | 78 +++++++++++-------- 4 files changed, 109 insertions(+), 39 deletions(-) diff --git a/src/features/chat/ui/ChatInputToolbar.tsx b/src/features/chat/ui/ChatInputToolbar.tsx index 2fdc786dc..3050e94f7 100644 --- a/src/features/chat/ui/ChatInputToolbar.tsx +++ b/src/features/chat/ui/ChatInputToolbar.tsx @@ -161,7 +161,7 @@ export function ChatInputToolbar({ const voiceConversationTooltip = ownsActiveVoiceConversation ? t("toolbar.voiceConversation.hangUp") : voiceConversationRunning - ? t("toolbar.voiceConversation.buddy.openSession") + ? t("toolbar.voiceConversation.start") : voiceConversationState !== "off" ? t(`toolbar.voiceConversation.states.${voiceConversationState}`, { sessionId: voiceConversation?.boundSessionId ?? "", diff --git a/src/features/chat/ui/__tests__/ChatInput.test.tsx b/src/features/chat/ui/__tests__/ChatInput.test.tsx index cfe4bf9d7..cfab01afd 100644 --- a/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -1173,7 +1173,8 @@ describe("ChatInput", () => { expect(button.querySelector(".lucide-phone-off")).toBeInTheDocument(); }); - it("shows a non-destructive open control outside the owning session", () => { + it("shows a new-call control outside the owning session", async () => { + const onToggle = vi.fn(); render( { active: true, ownsActiveConversation: false, microphoneMuted: false, - onToggle: vi.fn(), + onToggle, onMicrophoneMuteToggle: vi.fn(), }} />, ); - const open = screen.getByRole("button", { name: "Open voice session" }); - expect(open).not.toHaveClass("bg-destructive"); - expect(open.querySelector(".lucide-phone")).toBeInTheDocument(); + const start = screen.getByRole("button", { + name: "Start voice conversation", + }); + expect(start).not.toHaveClass("bg-destructive"); + expect(start.querySelector(".lucide-phone")).toBeInTheDocument(); + await userEvent.click(start); + expect(onToggle).toHaveBeenCalledOnce(); expect( screen.queryByRole("button", { name: "Mute microphone" }), ).not.toBeInTheDocument(); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index e4518f879..0b903229b 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -22,6 +22,7 @@ import { createVoiceTranscriptDeliveryQueue, hasDeliveredVoiceTranscript, observeVoiceConversationControlVisibility, + replaceActiveVoiceConversation, resetVoiceUiWhenRunSettles, resolveActiveVoiceButtonAction, resolveVoiceRouteMount, @@ -441,15 +442,63 @@ describe("voice transcript delivery coordination", () => { expect(canClaimVoiceSendRoute(null, null, "session-2")).toBe(true); }); - it("opens the owner instead of stopping voice from another session", () => { + it("replaces the active call when starting from another session", () => { expect(resolveActiveVoiceButtonAction("session-1", "session-2")).toBe( - "open-owner", + "replace", ); expect(resolveActiveVoiceButtonAction("session-1", "session-1")).toBe( "stop", ); }); + it("starts the replacement only after the active call fully stops", async () => { + let finishStop: + | ((status: { lifecycle: string; sessionId: null }) => void) + | undefined; + const stop = vi.fn( + () => + new Promise<{ lifecycle: string; sessionId: null }>((resolve) => { + finishStop = resolve; + }), + ); + const start = vi.fn().mockResolvedValue(undefined); + + const replacement = replaceActiveVoiceConversation({ stop, start }); + await Promise.resolve(); + expect(start).not.toHaveBeenCalled(); + + finishStop?.({ lifecycle: "stopped", sessionId: null }); + await replacement; + expect(start).toHaveBeenCalledOnce(); + }); + + it("does not start a replacement when the active call remains running", async () => { + const start = vi.fn().mockResolvedValue(undefined); + + await expect( + replaceActiveVoiceConversation({ + stop: vi.fn().mockResolvedValue({ + lifecycle: "running", + sessionId: "session-1", + }), + start, + }), + ).rejects.toThrow("could not be stopped"); + expect(start).not.toHaveBeenCalled(); + }); + + it("does not start a replacement when stopping the active call fails", async () => { + const start = vi.fn().mockResolvedValue(undefined); + + await expect( + replaceActiveVoiceConversation({ + stop: vi.fn().mockRejectedValue(new Error("stop failed")), + start, + }), + ).rejects.toThrow("stop failed"); + expect(start).not.toHaveBeenCalled(); + }); + it("drains retained transcripts without stealing a stopped session route", () => { expect( resolveVoiceRouteMount({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 456927956..b6d8f702f 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -16,10 +16,7 @@ import { stopNativeAssistantSpeech, takeVoicePlaybackNotices, } from "../lib/nativeAssistantSpeech"; -import { - openVoiceConversationSession, - setVoiceConversationControlsSuppressed, -} from "../api/voiceConversation"; +import { setVoiceConversationControlsSuppressed } from "../api/voiceConversation"; interface VoiceSendRoute { sessionId: string; @@ -68,8 +65,22 @@ export function canBindVoiceSendRoute(options: { export function resolveActiveVoiceButtonAction( activeSessionId: string | null, candidateSessionId: string, -): "stop" | "open-owner" { - return activeSessionId === candidateSessionId ? "stop" : "open-owner"; +): "stop" | "replace" { + return activeSessionId === candidateSessionId ? "stop" : "replace"; +} + +export async function replaceActiveVoiceConversation(options: { + stop: () => Promise<{ lifecycle: string; sessionId: string | null }>; + start: () => Promise; +}): Promise { + const stopped = await options.stop(); + if ( + stopped.sessionId !== null || + (stopped.lifecycle !== "stopped" && stopped.lifecycle !== "unavailable") + ) { + throw new Error("The active voice conversation could not be stopped."); + } + await options.start(); } export function shouldSuppressVoiceConversationControls(options: { @@ -684,6 +695,27 @@ export function useVoiceConversationController({ }); }, [sessionId]); + const startCurrentConversation = useCallback(async () => { + // Do not rely on the mount effect racing ahead of the user's first + // click. The native recognizer can finalize quickly, so its delivery + // subscriber must exist before the microphone lifecycle starts. + ensureVoiceEventDeliveryInitialized(); + activeSendRoute = { sessionId, send: onSend }; + // Capture the history boundary before native startup can admit a + // transcript and produce the first assistant response. + startAssistantSpeech(); + try { + await start(sessionId); + } catch (startError) { + const backendStatus = useVoiceConversationStore.getState().status; + if (backendStatus.sessionId !== sessionId) { + activeSendRoute = null; + stopNativeAssistantSpeech(); + } + addErrorNotification(sessionId, errorText(startError)); + } + }, [onSend, sessionId, start, startAssistantSpeech]); + useEffect(() => { if (status.lifecycle !== "running" || status.sessionId !== sessionId) return; @@ -797,12 +829,15 @@ export function useVoiceConversationController({ const boundSessionId = currentStatus.sessionId; if ( resolveActiveVoiceButtonAction(boundSessionId, sessionId) === - "open-owner" + "replace" ) { try { - await openVoiceConversationSession(); - } catch (openError) { - addErrorNotification(boundSessionId, errorText(openError)); + await replaceActiveVoiceConversation({ + stop, + start: startCurrentConversation, + }); + } catch (replaceError) { + addErrorNotification(sessionId, errorText(replaceError)); } return; } @@ -821,35 +856,16 @@ export function useVoiceConversationController({ return; } - // Do not rely on the mount effect racing ahead of the user's first - // click. The native recognizer can finalize quickly, so its delivery - // subscriber must exist before the microphone lifecycle starts. - ensureVoiceEventDeliveryInitialized(); - activeSendRoute = { sessionId, send: onSend }; - // Capture the history boundary before native startup can admit a - // transcript and produce the first assistant response. - startAssistantSpeech(); - try { - await start(sessionId); - } catch (startError) { - const backendStatus = useVoiceConversationStore.getState().status; - if (backendStatus.sessionId !== sessionId) { - activeSendRoute = null; - stopNativeAssistantSpeech(); - } - addErrorNotification(sessionId, errorText(startError)); - } + await startCurrentConversation(); } finally { operationInFlight = false; } }, [ canToggle, onPocketSetupRequired, - onSend, pocketReady, sessionId, - start, - startAssistantSpeech, + startCurrentConversation, stop, ]); From b328b97e2f049ad6ac5b2bcbbedeab38050e9ec9 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:32:37 -0400 Subject: [PATCH 02/38] fix(voice): validate cross-session call handoff --- src-tauri/src/commands/native_voice.rs | 19 ++++ src-tauri/src/lib.rs | 1 + .../api/voiceConversation.test.ts | 34 +++++++ .../api/voiceConversation.ts | 18 ++++ .../useVoiceConversationController.test.ts | 46 ++++++++- .../hooks/useVoiceConversationController.ts | 99 +++++++++++++++---- 6 files changed, 196 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index d49660cb5..634d495b8 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1315,6 +1315,25 @@ pub async fn stop_native_voice_conversation( Ok(status(&app, &state)) } +#[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri injects four guards beside the exact lifecycle payload. +pub async fn stop_native_voice_conversation_for_replacement( + app: AppHandle, + state: State<'_, NativeVoiceState>, + capture: State<'_, VoiceCaptureState>, + webview_window: WebviewWindow, + renderer_id: String, + renderer_epoch: u64, + session_id: String, + expected_revision: u64, +) -> Result { + capture.activate_renderer(webview_window.label(), &renderer_id, renderer_epoch)?; + state + .stop_active_for_lifecycle(&app, &capture, &session_id, expected_revision) + .await?; + Ok(status(&app, &state)) +} + fn native_owner_id(session_id: &str) -> String { format!("native-voice:{session_id}") } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b01ce27d8..db72ce758 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -650,6 +650,7 @@ pub fn run() { commands::native_voice::reject_native_voice_conversation_transcript, commands::native_voice::start_native_voice_conversation, commands::native_voice::stop_native_voice_conversation, + commands::native_voice::stop_native_voice_conversation_for_replacement, commands::native_voice::push_native_voice_audio, commands::voice_buddy::open_voice_conversation_session, commands::voice_buddy::show_voice_conversation_controls, diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index 034be4b82..f58f240a9 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -38,6 +38,7 @@ import { stopActiveMicrophoneForTest, stopVoiceConversationFromBuddy, stopVoiceConversation, + stopVoiceConversationForReplacement, } from "./voiceConversation"; describe("voice conversation API", () => { @@ -343,6 +344,39 @@ describe("voice conversation API", () => { expect(mocks.stopMicrophone).not.toHaveBeenCalled(); }); + it("requests an exact lifecycle stop when replacing from another window", async () => { + const activeStatus = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-1", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 3, + }; + const stoppedStatus = { + ...activeStatus, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + revision: 4, + }; + mocks.invoke.mockResolvedValueOnce(stoppedStatus); + + await expect( + stopVoiceConversationForReplacement(activeStatus), + ).resolves.toEqual(stoppedStatus); + expect(mocks.invoke).toHaveBeenCalledWith( + "stop_native_voice_conversation_for_replacement", + { + rendererId: "renderer-test", + rendererEpoch: 7, + sessionId: "session-1", + expectedRevision: 3, + }, + ); + }); + it("reattaches browser capture when a reloaded renderer finds a running session", async () => { const status = { available: true, diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index 531b1bab6..5a5250c10 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -421,6 +421,24 @@ export async function stopVoiceConversation( return nextStatus; } +export async function stopVoiceConversationForReplacement( + status: VoiceConversationStatus, +): Promise { + resetMicrophoneMuteState(); + const { rendererId, rendererEpoch } = await getRendererInstance(); + const nextStatus = await invoke( + "stop_native_voice_conversation_for_replacement", + { + rendererId, + rendererEpoch, + sessionId: status.sessionId, + expectedRevision: status.revision, + }, + ); + await reconcileVoiceConversationMicrophone(nextStatus); + return nextStatus; +} + export function listenToVoiceConversation( onEvent: (event: VoiceConversationEvent) => void, ): Promise { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 0b903229b..ca8844582 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -17,6 +17,7 @@ vi.mock("../lib/nativeAssistantSpeech", () => ({ import { canBindVoiceSendRoute, + canReplaceActiveVoiceConversation, canClaimVoiceSendRoute, beginVoiceControlsVisibilityLease, createVoiceTranscriptDeliveryQueue, @@ -28,6 +29,7 @@ import { resolveVoiceRouteMount, resolveVoiceToggleAction, shouldSuppressVoiceConversationControls, + shouldShowVoiceConversationControl, shouldStartRequestedVoiceConversation, startPendingTranscriptRecovery, useVoiceConversationController, @@ -451,6 +453,46 @@ describe("voice transcript delivery coordination", () => { ); }); + it("keeps an ineligible foreign session from controlling the active call", () => { + expect( + canReplaceActiveVoiceConversation({ + canToggle: false, + hydrated: true, + pocketReady: true, + }), + ).toBe(false); + expect( + canReplaceActiveVoiceConversation({ + canToggle: true, + hydrated: false, + pocketReady: true, + }), + ).toBe(false); + expect( + canReplaceActiveVoiceConversation({ + canToggle: true, + hydrated: true, + pocketReady: false, + }), + ).toBe(false); + expect( + shouldShowVoiceConversationControl({ + activeConversation: true, + controlEnabled: false, + voiceEnabled: true, + isGooseSession: true, + }), + ).toBe(false); + expect( + shouldShowVoiceConversationControl({ + activeConversation: true, + controlEnabled: true, + voiceEnabled: true, + isGooseSession: true, + }), + ).toBe(true); + }); + it("starts the replacement only after the active call fully stops", async () => { let finishStop: | ((status: { lifecycle: string; sessionId: null }) => void) @@ -468,7 +510,7 @@ describe("voice transcript delivery coordination", () => { expect(start).not.toHaveBeenCalled(); finishStop?.({ lifecycle: "stopped", sessionId: null }); - await replacement; + await expect(replacement).resolves.toBe(true); expect(start).toHaveBeenCalledOnce(); }); @@ -483,7 +525,7 @@ describe("voice transcript delivery coordination", () => { }), start, }), - ).rejects.toThrow("could not be stopped"); + ).resolves.toBe(false); expect(start).not.toHaveBeenCalled(); }); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index b6d8f702f..37978cb35 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; import type { ChatInputSendHandler, @@ -16,7 +17,10 @@ import { stopNativeAssistantSpeech, takeVoicePlaybackNotices, } from "../lib/nativeAssistantSpeech"; -import { setVoiceConversationControlsSuppressed } from "../api/voiceConversation"; +import { + setVoiceConversationControlsSuppressed, + stopVoiceConversationForReplacement, +} from "../api/voiceConversation"; interface VoiceSendRoute { sessionId: string; @@ -69,18 +73,38 @@ export function resolveActiveVoiceButtonAction( return activeSessionId === candidateSessionId ? "stop" : "replace"; } +export function canReplaceActiveVoiceConversation(options: { + canToggle: boolean; + hydrated: boolean; + pocketReady: boolean; +}): boolean { + return options.canToggle && options.hydrated && options.pocketReady; +} + +export function shouldShowVoiceConversationControl(options: { + activeConversation: boolean; + controlEnabled: boolean; + voiceEnabled: boolean; + isGooseSession: boolean; +}): boolean { + return options.activeConversation + ? options.controlEnabled + : options.voiceEnabled && options.isGooseSession; +} + export async function replaceActiveVoiceConversation(options: { stop: () => Promise<{ lifecycle: string; sessionId: string | null }>; start: () => Promise; -}): Promise { +}): Promise { const stopped = await options.stop(); if ( stopped.sessionId !== null || (stopped.lifecycle !== "stopped" && stopped.lifecycle !== "unavailable") ) { - throw new Error("The active voice conversation could not be stopped."); + return false; } await options.start(); + return true; } export function shouldSuppressVoiceConversationControls(options: { @@ -562,6 +586,7 @@ export function useVoiceConversationController({ readOnly = false, disabled = false, }: UseVoiceConversationControllerOptions): ChatInputVoiceConversation { + const { t } = useTranslation("chat"); const status = useVoiceConversationStore((state) => state.status); const uiState = useVoiceConversationStore((state) => state.uiState); const error = useVoiceConversationStore((state) => state.error); @@ -808,8 +833,8 @@ export function useVoiceConversationController({ ]); const isActive = status.sessionId !== null && status.lifecycle !== "stopped"; - const controlEnabled = enabled && isGooseSession && !readOnly && !disabled; - const canToggle = controlEnabled && (!pocketReady || status.available); + const sessionEligible = enabled && isGooseSession && !readOnly && !disabled; + const canToggle = sessionEligible && (!pocketReady || status.available); const toggle = useCallback(async () => { if (operationInFlight) return; @@ -827,17 +852,36 @@ export function useVoiceConversationController({ }); if (action === "stop") { const boundSessionId = currentStatus.sessionId; - if ( - resolveActiveVoiceButtonAction(boundSessionId, sessionId) === - "replace" - ) { + const activeButtonAction = resolveActiveVoiceButtonAction( + boundSessionId, + sessionId, + ); + if (activeButtonAction === "replace") { + if ( + !canReplaceActiveVoiceConversation({ + canToggle, + hydrated, + pocketReady, + }) + ) { + return; + } try { - await replaceActiveVoiceConversation({ - stop, + const replaced = await replaceActiveVoiceConversation({ + stop: () => stopVoiceConversationForReplacement(currentStatus), start: startCurrentConversation, }); - } catch (replaceError) { - addErrorNotification(sessionId, errorText(replaceError)); + if (!replaced) { + addErrorNotification( + sessionId, + t("toolbar.voiceConversation.buddy.errors.stop"), + ); + } + } catch { + addErrorNotification( + sessionId, + t("toolbar.voiceConversation.buddy.errors.stop"), + ); } return; } @@ -862,11 +906,13 @@ export function useVoiceConversationController({ } }, [ canToggle, + hydrated, onPocketSetupRequired, pocketReady, sessionId, startCurrentConversation, stop, + t, ]); useEffect(() => { @@ -908,31 +954,46 @@ export function useVoiceConversationController({ } }, [setMicrophoneMuted, status.lifecycle, status.sessionId]); + const ownsActiveConversation = isActive && status.sessionId === sessionId; + const controlEnabled = + ownsActiveConversation || + (isActive + ? canReplaceActiveVoiceConversation({ + canToggle, + hydrated, + pocketReady, + }) + : canToggle && hydrated); + return useMemo( () => ({ - visible: isActive || (enabled && isGooseSession), + visible: shouldShowVoiceConversationControl({ + activeConversation: isActive, + controlEnabled, + voiceEnabled: enabled, + isGooseSession, + }), state: uiState, boundSessionId: status.sessionId, active: isActive, - ownsActiveConversation: isActive && status.sessionId === sessionId, + ownsActiveConversation, microphoneMuted, error: error ?? (pocketReady && !status.available ? status.unavailableReason : null), - disabled: isActive ? false : !canToggle || !hydrated, + disabled: !controlEnabled, onToggle: toggle, onMicrophoneMuteToggle: toggleMicrophoneMute, }), [ - canToggle, + controlEnabled, enabled, error, - hydrated, isActive, isGooseSession, microphoneMuted, pocketReady, - sessionId, + ownsActiveConversation, status, toggle, toggleMicrophoneMute, From 125c5516a66ed3e1e5ff5f1ce1dfb3226b6e984d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:39:50 -0400 Subject: [PATCH 03/38] fix(voice): reconcile competing handoffs --- .../hooks/useVoiceConversationController.ts | 11 +++-- .../stores/voiceConversationStore.test.ts | 22 +++++++++ .../stores/voiceConversationStore.ts | 47 +++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 37978cb35..44c5e80fd 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -17,10 +17,7 @@ import { stopNativeAssistantSpeech, takeVoicePlaybackNotices, } from "../lib/nativeAssistantSpeech"; -import { - setVoiceConversationControlsSuppressed, - stopVoiceConversationForReplacement, -} from "../api/voiceConversation"; +import { setVoiceConversationControlsSuppressed } from "../api/voiceConversation"; interface VoiceSendRoute { sessionId: string; @@ -594,6 +591,9 @@ export function useVoiceConversationController({ const init = useVoiceConversationStore((state) => state.init); const start = useVoiceConversationStore((state) => state.start); const stop = useVoiceConversationStore((state) => state.stop); + const stopForReplacement = useVoiceConversationStore( + (state) => state.stopForReplacement, + ); const microphoneMuted = useVoiceConversationStore( (state) => state.microphoneMuted, ); @@ -868,7 +868,7 @@ export function useVoiceConversationController({ } try { const replaced = await replaceActiveVoiceConversation({ - stop: () => stopVoiceConversationForReplacement(currentStatus), + stop: () => stopForReplacement(currentStatus), start: startCurrentConversation, }); if (!replaced) { @@ -912,6 +912,7 @@ export function useVoiceConversationController({ sessionId, startCurrentConversation, stop, + stopForReplacement, t, ]); diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 917d2365f..12de1ffe1 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ setMicrophoneMuted: vi.fn(), start: vi.fn(), stop: vi.fn(), + stopForReplacement: vi.fn(), })); vi.mock("../api/voiceConversation", () => ({ @@ -31,6 +32,7 @@ vi.mock("../api/voiceConversation", () => ({ setVoiceConversationMicrophoneMuted: mocks.setMicrophoneMuted, startVoiceConversation: mocks.start, stopVoiceConversation: mocks.stop, + stopVoiceConversationForReplacement: mocks.stopForReplacement, })); function status( @@ -68,6 +70,7 @@ describe("voice conversation store lifecycle ordering", () => { mocks.getStatus.mockReset().mockResolvedValue(status("stopped", 0)); mocks.start.mockReset(); mocks.stop.mockReset(); + mocks.stopForReplacement.mockReset(); mocks.listen.mockReset().mockImplementation(async (callback) => { emit = callback; return vi.fn(); @@ -418,6 +421,25 @@ describe("voice conversation store lifecycle ordering", () => { }); }); + it("adopts the winner when a concurrent replacement already changed lifecycles", async () => { + const store = await loadStore(); + const active = status("running", 2, "session-a"); + const winner = status("running", 4, "session-b"); + store.setState({ status: active, uiState: "listening" }); + mocks.stopForReplacement.mockResolvedValue(winner); + + await expect(store.getState().stopForReplacement(active)).resolves.toEqual( + winner, + ); + + expect(store.getState()).toMatchObject({ + status: winner, + uiState: "listening", + error: null, + }); + expect(mocks.stopForReplacement).toHaveBeenCalledWith(active); + }); + it("does not reconcile a delayed terminal event from an older lifecycle", async () => { const store = await loadStore(); store.setState({ diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index c561694fb..c6d0fff83 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -12,6 +12,7 @@ import { setVoiceConversationMicrophoneMuted, startVoiceConversation, stopVoiceConversation, + stopVoiceConversationForReplacement, type PendingVoiceTranscript, type VoiceConversationEvent, type VoiceConversationStatus, @@ -52,6 +53,9 @@ interface VoiceConversationStore { clearRequestedStart: (sessionId: string) => void; start: (sessionId: string) => Promise; stop: () => Promise; + stopForReplacement: ( + status: VoiceConversationStatus, + ) => Promise; setMicrophoneMuted: (muted: boolean) => Promise; setUiState: (state: VoiceConversationUiState, error?: string) => void; drainPendingTranscripts: (sessionId: string) => Promise; @@ -632,6 +636,49 @@ export const useVoiceConversationStore = create( return request; }, + stopForReplacement: async (activeStatus) => { + microphoneMuteIntent += 1; + microphoneMuteStateVersion += 1; + set({ + uiState: "stopping", + microphoneMuted: false, + error: null, + requestedStartSessionId: null, + }); + try { + const status = await stopVoiceConversationForReplacement(activeStatus); + set((state) => + shouldApplyResponseRevision(state.status, status.revision) || + (status.revision === state.status.revision && + (status.lifecycle === "stopped" || + status.lifecycle === "unavailable")) + ? { + status, + uiState: uiStateForStatus(status), + microphoneMuted: status.microphoneMuted, + error: null, + } + : state, + ); + await reconcileVoiceConversationMicrophone(get().status); + return status; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + try { + const status = await getVoiceConversationStatus(); + set((state) => + status.revision >= state.status.revision + ? { status, uiState: "error", error: message } + : state, + ); + await reconcileVoiceConversationMicrophone(get().status); + } catch { + set({ uiState: "error", error: message }); + } + throw error; + } + }, + setUiState: (uiState, error) => set((state) => { const activityFallbackState = [ From 9555a4c90e1677da674f66caa03d4620eb9fe324 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:48:40 -0400 Subject: [PATCH 04/38] fix(voice): authorize handoffs from fresh state --- src-tauri/src/commands/native_voice.rs | 46 ++++++++++++++++++ .../api/voiceConversation.test.ts | 3 +- .../api/voiceConversation.ts | 2 + .../useVoiceConversationController.test.ts | 6 +++ .../hooks/useVoiceConversationController.ts | 15 +++++- .../stores/voiceConversationStore.test.ts | 27 +++++++++-- .../stores/voiceConversationStore.ts | 47 ++++++++++++++++++- 7 files changed, 137 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index 634d495b8..a137fbd06 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1321,19 +1321,47 @@ pub async fn stop_native_voice_conversation_for_replacement( app: AppHandle, state: State<'_, NativeVoiceState>, capture: State<'_, VoiceCaptureState>, + window_sessions: State<'_, super::window_session::WindowSessionRegistry>, webview_window: WebviewWindow, renderer_id: String, renderer_epoch: u64, session_id: String, expected_revision: u64, + target_session_id: String, ) -> Result { capture.activate_renderer(webview_window.label(), &renderer_id, renderer_epoch)?; + let target_session_id = target_session_id.trim(); + if target_session_id.is_empty() || target_session_id.len() > 256 { + return Err("target session id must be between 1 and 256 bytes".to_string()); + } + let target_owner = window_sessions.label_for(target_session_id); + if !replacement_caller_matches_target(webview_window.label(), target_owner.as_deref()) { + return Err("Only the target session window can replace a voice conversation.".to_string()); + } + if !webview_window + .is_focused() + .map_err(|error| format!("Could not confirm the target session window focus: {error}"))? + { + return Err( + "Only the focused target session can replace a voice conversation.".to_string(), + ); + } state .stop_active_for_lifecycle(&app, &capture, &session_id, expected_revision) .await?; Ok(status(&app, &state)) } +fn replacement_caller_matches_target( + caller_window_label: &str, + target_owner: Option<&str>, +) -> bool { + match target_owner { + Some(owner_window_label) => owner_window_label == caller_window_label, + None => caller_window_label == "main", + } +} + fn native_owner_id(session_id: &str) -> String { format!("native-voice:{session_id}") } @@ -2074,6 +2102,24 @@ mod tests { assert!(!software_microphone_mute(false, false)); } + #[test] + fn replacement_stop_requires_the_target_session_window() { + assert!(replacement_caller_matches_target("main", None)); + assert!(!replacement_caller_matches_target( + "main", + Some("session:target"), + )); + assert!(replacement_caller_matches_target( + "session:target", + Some("session:target"), + )); + assert!(!replacement_caller_matches_target( + "session:other", + Some("session:target"), + )); + assert!(!replacement_caller_matches_target("voice-buddy", None)); + } + #[test] fn speaker_playback_blocks_vad_ingestion_until_all_guards_finish() { let state = NativeVoiceState::default(); diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index f58f240a9..972f8b637 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -364,7 +364,7 @@ describe("voice conversation API", () => { mocks.invoke.mockResolvedValueOnce(stoppedStatus); await expect( - stopVoiceConversationForReplacement(activeStatus), + stopVoiceConversationForReplacement(activeStatus, "session-2"), ).resolves.toEqual(stoppedStatus); expect(mocks.invoke).toHaveBeenCalledWith( "stop_native_voice_conversation_for_replacement", @@ -373,6 +373,7 @@ describe("voice conversation API", () => { rendererEpoch: 7, sessionId: "session-1", expectedRevision: 3, + targetSessionId: "session-2", }, ); }); diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index 5a5250c10..dbea67142 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -423,6 +423,7 @@ export async function stopVoiceConversation( export async function stopVoiceConversationForReplacement( status: VoiceConversationStatus, + targetSessionId: string, ): Promise { resetMicrophoneMuteState(); const { rendererId, rendererEpoch } = await getRendererInstance(); @@ -433,6 +434,7 @@ export async function stopVoiceConversationForReplacement( rendererEpoch, sessionId: status.sessionId, expectedRevision: status.revision, + targetSessionId, }, ); await reconcileVoiceConversationMicrophone(nextStatus); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index ca8844582..96d6f44e7 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -374,6 +374,11 @@ describe("voice transcript delivery coordination", () => { it("starts a first-run request after Pocket installation refreshes availability", async () => { const init = vi.fn().mockResolvedValue(undefined); + const refreshStatus = vi + .fn() + .mockImplementation(() => + Promise.resolve(useVoiceConversationStore.getState().status), + ); const start = vi.fn().mockResolvedValue({ available: true, unavailableReason: null, @@ -395,6 +400,7 @@ describe("voice transcript delivery coordination", () => { }, hydrated: true, init, + refreshStatus, start, requestedStartSessionId: "session-1", }); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 44c5e80fd..be3021f5b 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -589,6 +589,9 @@ export function useVoiceConversationController({ const error = useVoiceConversationStore((state) => state.error); const hydrated = useVoiceConversationStore((state) => state.hydrated); const init = useVoiceConversationStore((state) => state.init); + const refreshStatus = useVoiceConversationStore( + (state) => state.refreshStatus, + ); const start = useVoiceConversationStore((state) => state.start); const stop = useVoiceConversationStore((state) => state.stop); const stopForReplacement = useVoiceConversationStore( @@ -840,7 +843,14 @@ export function useVoiceConversationController({ if (operationInFlight) return; operationInFlight = true; try { - const currentStatus = useVoiceConversationStore.getState().status; + const currentStatus = await refreshStatus().catch(() => { + addErrorNotification( + sessionId, + t("toolbar.voiceConversation.buddy.errors.initialize"), + ); + return null; + }); + if (!currentStatus) return; const currentlyActive = currentStatus.sessionId !== null && currentStatus.lifecycle !== "stopped" && @@ -868,7 +878,7 @@ export function useVoiceConversationController({ } try { const replaced = await replaceActiveVoiceConversation({ - stop: () => stopForReplacement(currentStatus), + stop: () => stopForReplacement(currentStatus, sessionId), start: startCurrentConversation, }); if (!replaced) { @@ -909,6 +919,7 @@ export function useVoiceConversationController({ hydrated, onPocketSetupRequired, pocketReady, + refreshStatus, sessionId, startCurrentConversation, stop, diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 12de1ffe1..96137e476 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -428,16 +428,35 @@ describe("voice conversation store lifecycle ordering", () => { store.setState({ status: active, uiState: "listening" }); mocks.stopForReplacement.mockResolvedValue(winner); - await expect(store.getState().stopForReplacement(active)).resolves.toEqual( - winner, - ); + await expect( + store.getState().stopForReplacement(active, "session-c"), + ).resolves.toEqual(winner); expect(store.getState()).toMatchObject({ status: winner, uiState: "listening", error: null, }); - expect(mocks.stopForReplacement).toHaveBeenCalledWith(active); + expect(mocks.stopForReplacement).toHaveBeenCalledWith(active, "session-c"); + }); + + it("refreshes a stale foreign renderer before choosing a call action", async () => { + const store = await loadStore(); + store.setState({ + status: status("stopped", 1), + uiState: "off", + hydrated: true, + }); + const active = status("running", 2, "session-a"); + mocks.getStatus.mockResolvedValue(active); + + await expect(store.getState().refreshStatus()).resolves.toEqual(active); + + expect(store.getState()).toMatchObject({ + status: active, + uiState: "listening", + error: null, + }); }); it("does not reconcile a delayed terminal event from an older lifecycle", async () => { diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index c6d0fff83..9889422b7 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -49,12 +49,14 @@ interface VoiceConversationStore { hydrated: boolean; requestedStartSessionId: string | null; init: () => Promise; + refreshStatus: () => Promise; requestStart: (sessionId: string) => void; clearRequestedStart: (sessionId: string) => void; start: (sessionId: string) => Promise; stop: () => Promise; stopForReplacement: ( status: VoiceConversationStatus, + targetSessionId: string, ) => Promise; setMicrophoneMuted: (muted: boolean) => Promise; setUiState: (state: VoiceConversationUiState, error?: string) => void; @@ -525,6 +527,44 @@ export const useVoiceConversationStore = create( } }, + refreshStatus: async () => { + const muteStateVersion = microphoneMuteStateVersion; + const muteRequestWasPending = pendingMicrophoneMuteRequests > 0; + const status = await getVoiceConversationStatus(); + const shouldPreserveCurrentMute = ( + observedStatus: VoiceConversationStatus, + ) => + isSameRunningLifecycle(observedStatus, status) && + (muteRequestWasPending || + pendingMicrophoneMuteRequests > 0 || + muteStateVersion !== microphoneMuteStateVersion); + const preserveCurrentMute = shouldPreserveCurrentMute(get().status); + await reconcileVoiceConversationMicrophone( + preserveCurrentMute + ? { ...status, microphoneMuted: get().microphoneMuted } + : status, + ); + set((state) => { + if ( + !shouldApplyResponseRevision(state.status, status.revision) && + status.revision !== state.status.revision + ) { + return state; + } + const microphoneMuted = shouldPreserveCurrentMute(state.status) + ? state.microphoneMuted + : status.microphoneMuted; + return { + status: { ...status, microphoneMuted }, + uiState: uiStateForStatus(status), + microphoneMuted, + hydrated: true, + error: null, + }; + }); + return status; + }, + start: (sessionId) => { if (voiceStartBlocks.has(sessionId)) { return Promise.reject( @@ -636,7 +676,7 @@ export const useVoiceConversationStore = create( return request; }, - stopForReplacement: async (activeStatus) => { + stopForReplacement: async (activeStatus, targetSessionId) => { microphoneMuteIntent += 1; microphoneMuteStateVersion += 1; set({ @@ -646,7 +686,10 @@ export const useVoiceConversationStore = create( requestedStartSessionId: null, }); try { - const status = await stopVoiceConversationForReplacement(activeStatus); + const status = await stopVoiceConversationForReplacement( + activeStatus, + targetSessionId, + ); set((state) => shouldApplyResponseRevision(state.status, status.revision) || (status.revision === state.status.revision && From 3d52594e46963fd17a2b8f2d71ac7d49bd879337 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:56:29 -0400 Subject: [PATCH 05/38] test(voice): cover stale cross-window handoff --- .../useVoiceConversationController.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 96d6f44e7..a8361e238 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -36,6 +36,14 @@ import { waitForVoiceDeliveryOpportunity, } from "./useVoiceConversationController"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("voice transcript delivery coordination", () => { it("suppresses floating controls only for the focused owner session", () => { const base = { @@ -459,6 +467,72 @@ describe("voice transcript delivery coordination", () => { ); }); + it("refreshes stale status before handing a foreign call to this session", async () => { + const active = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 2, + }; + const stopped = { + ...active, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + revision: 3, + }; + const stopRequest = deferred(); + const refreshStatus = vi.fn().mockResolvedValue(active); + const stopForReplacement = vi.fn().mockReturnValue(stopRequest.promise); + const start = vi.fn().mockResolvedValue({ + ...active, + sessionId: "session-b", + ownerWindowLabel: "session-window-b", + revision: 4, + }); + useVoiceConversationStore.setState({ + status: { + ...stopped, + revision: 1, + }, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus, + stopForReplacement, + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-b", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let handoff: Promise | undefined; + act(() => { + handoff = Promise.resolve(result.current.onToggle()); + }); + await vi.waitFor(() => expect(refreshStatus).toHaveBeenCalledOnce()); + await vi.waitFor(() => + expect(stopForReplacement).toHaveBeenCalledWith(active, "session-b"), + ); + expect(start).not.toHaveBeenCalled(); + + await act(async () => { + stopRequest.resolve(stopped); + await handoff; + }); + expect(start).toHaveBeenCalledWith("session-b"); + }); + it("keeps an ineligible foreign session from controlling the active call", () => { expect( canReplaceActiveVoiceConversation({ From 3cf73db4f450884cf80e9ab31ef78d6921be99cb Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:08:16 -0400 Subject: [PATCH 06/38] fix(voice): bind call handoff to foreground session --- src-tauri/src/commands/native_voice.rs | 29 +++- src-tauri/src/commands/voice_capture.rs | 153 +++++++++++++++++- src-tauri/src/lib.rs | 1 + src/app/AppShell.berdctl.test.tsx | 1 + src/app/AppShell.navigation.test.tsx | 10 ++ src/app/AppShell.tsx | 13 +- src/app/SessionWindowApp.tsx | 11 ++ src/app/__tests__/SessionWindowApp.test.tsx | 12 ++ .../api/voiceConversation.test.ts | 72 ++++++++- .../api/voiceConversation.ts | 26 +++ 10 files changed, 318 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index a137fbd06..7cfeae05f 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1329,13 +1329,22 @@ pub async fn stop_native_voice_conversation_for_replacement( expected_revision: u64, target_session_id: String, ) -> Result { - capture.activate_renderer(webview_window.label(), &renderer_id, renderer_epoch)?; let target_session_id = target_session_id.trim(); if target_session_id.is_empty() || target_session_id.len() > 256 { return Err("target session id must be between 1 and 256 bytes".to_string()); } let target_owner = window_sessions.label_for(target_session_id); - if !replacement_caller_matches_target(webview_window.label(), target_owner.as_deref()) { + let owns_foreground_session = capture.foreground_session_matches( + webview_window.label(), + &renderer_id, + renderer_epoch, + target_session_id, + )?; + if !replacement_caller_matches_target( + webview_window.label(), + target_owner.as_deref(), + owns_foreground_session, + ) { return Err("Only the target session window can replace a voice conversation.".to_string()); } if !webview_window @@ -1355,7 +1364,11 @@ pub async fn stop_native_voice_conversation_for_replacement( fn replacement_caller_matches_target( caller_window_label: &str, target_owner: Option<&str>, + owns_foreground_session: bool, ) -> bool { + if !owns_foreground_session { + return false; + } match target_owner { Some(owner_window_label) => owner_window_label == caller_window_label, None => caller_window_label == "main", @@ -2104,20 +2117,28 @@ mod tests { #[test] fn replacement_stop_requires_the_target_session_window() { - assert!(replacement_caller_matches_target("main", None)); + assert!(replacement_caller_matches_target("main", None, true)); + assert!(!replacement_caller_matches_target("main", None, false)); assert!(!replacement_caller_matches_target( "main", Some("session:target"), + true, )); assert!(replacement_caller_matches_target( "session:target", Some("session:target"), + true, )); assert!(!replacement_caller_matches_target( "session:other", Some("session:target"), + true, + )); + assert!(!replacement_caller_matches_target( + "voice-buddy", + None, + true, )); - assert!(!replacement_caller_matches_target("voice-buddy", None)); } #[test] diff --git a/src-tauri/src/commands/voice_capture.rs b/src-tauri/src/commands/voice_capture.rs index 56d15c66c..cb071f363 100644 --- a/src-tauri/src/commands/voice_capture.rs +++ b/src-tauri/src/commands/voice_capture.rs @@ -2,6 +2,7 @@ use std::{collections::HashMap, sync::Mutex}; +use serde::Deserialize; use tauri::{State, WebviewWindow}; const MAX_ID_LEN: usize = 256; @@ -14,11 +15,29 @@ struct MicrophoneOwner { owner_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ForegroundSessionClaim { + renderer_id: String, + renderer_epoch: u64, + generation: u64, + session_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ForegroundSessionRequest { + renderer_id: String, + renderer_epoch: u64, + generation: u64, + session_id: Option, +} + #[derive(Default)] struct CaptureState { renderer_epoch: u64, pending_renderers: HashMap, current_renderers: HashMap, + foreground_sessions: HashMap, microphone_owner: Option, } @@ -67,10 +86,18 @@ impl CaptureState { _ => return Err("Voice renderer instance is not registered".to_string()), } - self.current_renderers.insert( - window_label.to_string(), - (renderer_id.to_string(), renderer_epoch), - ); + let replaced_renderer = self + .current_renderers + .insert( + window_label.to_string(), + (renderer_id.to_string(), renderer_epoch), + ) + .is_some_and(|(active_renderer, active_epoch)| { + active_renderer != renderer_id || active_epoch != renderer_epoch + }); + if replaced_renderer { + self.foreground_sessions.remove(window_label); + } if self .microphone_owner .as_ref() @@ -131,6 +158,70 @@ impl VoiceCaptureState { .activate_renderer(window_label, renderer_id, renderer_epoch) } + pub fn set_foreground_session( + &self, + window_label: &str, + renderer_id: &str, + renderer_epoch: u64, + generation: u64, + session_id: Option<&str>, + ) -> Result<(), String> { + validate_id("renderer", renderer_id)?; + if let Some(session_id) = session_id { + validate_id("session", session_id)?; + } + let mut state = self + .state + .lock() + .map_err(|_| "Voice capture state lock was poisoned".to_string())?; + state.activate_renderer(window_label, renderer_id, renderer_epoch)?; + if state + .foreground_sessions + .get(window_label) + .is_some_and(|claim| { + claim.renderer_id == renderer_id + && claim.renderer_epoch == renderer_epoch + && claim.generation >= generation + }) + { + return Ok(()); + } + state.foreground_sessions.insert( + window_label.to_string(), + ForegroundSessionClaim { + renderer_id: renderer_id.to_string(), + renderer_epoch, + generation, + session_id: session_id.map(ToString::to_string), + }, + ); + Ok(()) + } + + pub fn foreground_session_matches( + &self, + window_label: &str, + renderer_id: &str, + renderer_epoch: u64, + session_id: &str, + ) -> Result { + validate_id("renderer", renderer_id)?; + validate_id("session", session_id)?; + let mut state = self + .state + .lock() + .map_err(|_| "Voice capture state lock was poisoned".to_string())?; + state.activate_renderer(window_label, renderer_id, renderer_epoch)?; + Ok(state + .foreground_sessions + .get(window_label) + .is_some_and(|claim| { + claim.renderer_id == renderer_id + && claim.renderer_epoch == renderer_epoch + && claim.session_id.as_deref() == Some(session_id) + })) + } + pub fn claim_microphone( &self, window_label: String, @@ -209,9 +300,25 @@ impl VoiceCaptureState { } state.current_renderers.remove(window_label); state.pending_renderers.remove(window_label); + state.foreground_sessions.remove(window_label); } } +#[tauri::command] +pub fn set_voice_renderer_foreground_session( + state: State<'_, VoiceCaptureState>, + webview_window: WebviewWindow, + request: ForegroundSessionRequest, +) -> Result<(), String> { + state.set_foreground_session( + webview_window.label(), + &request.renderer_id, + request.renderer_epoch, + request.generation, + request.session_id.as_deref(), + ) +} + #[tauri::command] pub fn register_voice_renderer_instance( state: State<'_, VoiceCaptureState>, @@ -397,4 +504,42 @@ mod tests { .is_err()); assert!(!operation_ran.get()); } + + #[test] + fn foreground_session_claim_rejects_a_stale_navigation_target() { + let capture = VoiceCaptureState::default(); + let epoch = capture.register_renderer_for_test("main", "renderer-1"); + capture + .set_foreground_session("main", "renderer-1", epoch, 1, Some("session-b")) + .expect("claim session B"); + assert!(capture + .foreground_session_matches("main", "renderer-1", epoch, "session-b") + .expect("authorize session B")); + + capture + .set_foreground_session("main", "renderer-1", epoch, 2, Some("session-c")) + .expect("navigate to session C"); + assert!(!capture + .foreground_session_matches("main", "renderer-1", epoch, "session-b") + .expect("reject stale session B")); + assert!(capture + .foreground_session_matches("main", "renderer-1", epoch, "session-c") + .expect("authorize session C")); + } + + #[test] + fn foreground_session_claim_ignores_out_of_order_updates() { + let capture = VoiceCaptureState::default(); + let epoch = capture.register_renderer_for_test("main", "renderer-1"); + capture + .set_foreground_session("main", "renderer-1", epoch, 2, Some("session-c")) + .expect("claim newest session"); + capture + .set_foreground_session("main", "renderer-1", epoch, 1, Some("session-b")) + .expect("ignore stale claim"); + + assert!(capture + .foreground_session_matches("main", "renderer-1", epoch, "session-c") + .expect("retain newest session")); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db72ce758..88f073e43 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -658,6 +658,7 @@ pub fn run() { commands::voice_buddy::stop_voice_conversation_from_buddy, commands::notifications::should_suppress_completion_notification, commands::voice_capture::register_voice_renderer_instance, + commands::voice_capture::set_voice_renderer_foreground_session, commands::window_session::get_session_window_support, commands::window_session::open_session_window, commands::window_session::release_session, diff --git a/src/app/AppShell.berdctl.test.tsx b/src/app/AppShell.berdctl.test.tsx index 09ef3ccd6..24e9c7afd 100644 --- a/src/app/AppShell.berdctl.test.tsx +++ b/src/app/AppShell.berdctl.test.tsx @@ -47,6 +47,7 @@ vi.mock( releaseNativeVoiceConversationStartBlock: vi .fn() .mockResolvedValue(undefined), + setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined), }), ); diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 161e125ac..208707970 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -36,6 +36,7 @@ import { import { blockNativeVoiceConversationStarts, releaseNativeVoiceConversationStartBlock, + setVoiceConversationForegroundSession, } from "@/features/voice-conversation/api/voiceConversation"; import { dispatchOnboarding } from "@/features/onboarding/model"; import { @@ -73,6 +74,7 @@ vi.mock( releaseNativeVoiceConversationStartBlock: vi .fn() .mockResolvedValue(undefined), + setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined), }), ); @@ -962,6 +964,9 @@ describe("AppShell global navigation", () => { vi.mocked(releaseNativeVoiceConversationStartBlock) .mockReset() .mockResolvedValue(undefined); + vi.mocked(setVoiceConversationForegroundSession) + .mockReset() + .mockResolvedValue(undefined); mockListExtensions.mockReset(); mockListExtensions.mockResolvedValue([]); mockAcpCreateSession.mockReset(); @@ -1986,6 +1991,11 @@ describe("AppShell global navigation", () => { expect(screen.getByTestId("rendered-session-id")).toHaveTextContent( "session-2", ); + await waitFor(() => + expect(setVoiceConversationForegroundSession).toHaveBeenLastCalledWith( + "session-2", + ), + ); }); it("keeps archive UI active until the backend succeeds and rolls back archivedAt on failure", async () => { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 28f9463e9..a208bf02f 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -229,7 +229,10 @@ import { blockVoiceConversationStarts, useVoiceConversationStore, } from "@/features/voice-conversation/stores/voiceConversationStore"; -import { listenToVoiceConversationOpenSession } from "@/features/voice-conversation/api/voiceConversation"; +import { + listenToVoiceConversationOpenSession, + setVoiceConversationForegroundSession, +} from "@/features/voice-conversation/api/voiceConversation"; import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup"; import { useSiriVoiceSetup } from "@/features/voice-conversation/hooks/useSiriVoiceSetup"; import { useVoiceOutputPreference } from "@/features/voice-conversation/lib/voiceOutputPreference"; @@ -772,6 +775,14 @@ export function AppShell({ }, [capabilities.voiceConversation, stopVoiceConversation]); const sessions = useChatSessionStore(selectSessions); const activeSessionId = useChatSessionStore(selectActiveSessionId); + useLayoutEffect(() => { + const foregroundSessionId = activeView === "chat" ? activeSessionId : null; + void setVoiceConversationForegroundSession(foregroundSessionId).catch( + (error) => { + console.warn("Failed to publish the foreground voice session", error); + }, + ); + }, [activeSessionId, activeView]); const messagesBySession = useChatStore((state) => state.messagesBySession); const previousActiveSessionIdRef = useRef(activeSessionId); useEffect(() => { diff --git a/src/app/SessionWindowApp.tsx b/src/app/SessionWindowApp.tsx index 07969d42a..69d52c7b8 100644 --- a/src/app/SessionWindowApp.tsx +++ b/src/app/SessionWindowApp.tsx @@ -42,6 +42,7 @@ import { useWorkspaceNameRequestQueue } from "@/features/chat/hooks/useWorkspace import { ProjectWorkspaceStartupNameDialog } from "@/features/projects/ui/ProjectWorkspaceStartupNameDialog"; import { Button } from "@/shared/ui/button"; import { SecurityConfirmationFallback } from "@/features/security/ui/SecurityConfirmationPanel"; +import { setVoiceConversationForegroundSession } from "@/features/voice-conversation/api/voiceConversation"; import { useSecurityConfirmationStore } from "@/features/security/stores/securityConfirmationStore"; type Phase = "loading" | "mirror" | "recoverable" | "ready" | "missing"; @@ -278,6 +279,16 @@ export function SessionWindowApp({ }; }, [currentWindowLabelOverride, loadOwnedSession, sessionId]); + useEffect(() => { + const foregroundSessionId = + phase === "ready" || phase === "mirror" ? sessionId : null; + void setVoiceConversationForegroundSession(foregroundSessionId).catch( + (error) => { + console.warn("Failed to publish the foreground voice session", error); + }, + ); + }, [phase, sessionId]); + useEffect(() => { if (phase !== "mirror" || !currentWindowLabel) { return; diff --git a/src/app/__tests__/SessionWindowApp.test.tsx b/src/app/__tests__/SessionWindowApp.test.tsx index f8809b4f8..1c9f395d8 100644 --- a/src/app/__tests__/SessionWindowApp.test.tsx +++ b/src/app/__tests__/SessionWindowApp.test.tsx @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ buildFeatures: { securityMl: true, }, + setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/app/lib/chatRuntimeStartup", () => ({ @@ -94,6 +95,11 @@ vi.mock("@/features/chat/ui/ChatView", () => ({ ), })); +vi.mock("@/features/voice-conversation/api/voiceConversation", () => ({ + setVoiceConversationForegroundSession: + mocks.setVoiceConversationForegroundSession, +})); + import { SessionWindowApp } from "@/app/SessionWindowApp"; const session: ChatSession = { @@ -219,6 +225,7 @@ describe("SessionWindowApp", () => { vi.mocked(readSessionHandoffSnapshot).mockReset(); vi.mocked(readSessionHandoffSnapshot).mockResolvedValue(null); vi.mocked(recoverSessionHandoff).mockClear(); + mocks.setVoiceConversationForegroundSession.mockClear(); }); it("renders an error state for an unknown session after hydration", async () => { @@ -233,6 +240,11 @@ describe("SessionWindowApp", () => { seedSession(); renderSessionWindow(); await screen.findByTestId("chat-view"); + await waitFor(() => + expect(mocks.setVoiceConversationForegroundSession).toHaveBeenCalledWith( + "session-1", + ), + ); await waitFor(() => expect(handoffListeners.searchTarget).toBeDefined()); act(() => { diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index 972f8b637..cc8778d79 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -30,8 +30,10 @@ import { openVoiceConversationSession, reconcileVoiceConversationMicrophone, releaseNativeVoiceConversationStartBlock, + resetVoiceConversationForegroundSessionForTest, setVoiceConversationAssistantSpeaking, setVoiceConversationControlsSuppressed, + setVoiceConversationForegroundSession, setVoiceConversationMicrophoneMuted, startVoiceConversation, showVoiceConversationControls, @@ -44,6 +46,7 @@ import { describe("voice conversation API", () => { beforeEach(() => { stopActiveMicrophoneForTest(); + resetVoiceConversationForegroundSessionForTest(); mocks.invoke.mockReset(); mocks.listen.mockReset(); mocks.startMicrophone.mockReset().mockResolvedValue({ @@ -160,6 +163,50 @@ describe("voice conversation API", () => { ); }); + it("publishes ordered foreground-session claims for native authorization", async () => { + mocks.invoke.mockResolvedValue(undefined); + + await setVoiceConversationForegroundSession("session-b"); + await setVoiceConversationForegroundSession("session-c"); + await setVoiceConversationForegroundSession(null); + + expect(mocks.invoke.mock.calls).toEqual([ + [ + "set_voice_renderer_foreground_session", + { + request: { + rendererId: "renderer-test", + rendererEpoch: 7, + generation: 1, + sessionId: "session-b", + }, + }, + ], + [ + "set_voice_renderer_foreground_session", + { + request: { + rendererId: "renderer-test", + rendererEpoch: 7, + generation: 2, + sessionId: "session-c", + }, + }, + ], + [ + "set_voice_renderer_foreground_session", + { + request: { + rendererId: "renderer-test", + rendererEpoch: 7, + generation: 3, + sessionId: null, + }, + }, + ], + ]); + }); + it("serializes floating-control visibility updates", async () => { let releaseFirst: (() => void) | undefined; mocks.invoke @@ -361,7 +408,9 @@ describe("voice conversation API", () => { ownerWindowLabel: null, revision: 4, }; - mocks.invoke.mockResolvedValueOnce(stoppedStatus); + mocks.invoke.mockResolvedValueOnce(undefined); + await setVoiceConversationForegroundSession("session-2"); + mocks.invoke.mockReset().mockResolvedValueOnce(stoppedStatus); await expect( stopVoiceConversationForReplacement(activeStatus, "session-2"), @@ -378,6 +427,27 @@ describe("voice conversation API", () => { ); }); + it("rejects a replacement after foreground navigation changes", async () => { + const activeStatus = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-1", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 3, + }; + mocks.invoke.mockResolvedValue(undefined); + await setVoiceConversationForegroundSession("session-b"); + await setVoiceConversationForegroundSession("session-c"); + mocks.invoke.mockClear(); + + await expect( + stopVoiceConversationForReplacement(activeStatus, "session-b"), + ).rejects.toThrow("no longer in the foreground"); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + it("reattaches browser capture when a reloaded renderer finds a running session", async () => { const status = { available: true, diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index dbea67142..d657010e1 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -16,6 +16,8 @@ let microphoneMuted = false; let microphoneMuteIntent = 0; let microphoneMuteObservationVersion = 0; let microphoneMuteQueue: Promise = Promise.resolve(); +let foregroundSessionGeneration = 0; +let foregroundSessionId: string | null = null; function resetMicrophoneMuteState(): void { microphoneMuteIntent += 1; @@ -243,6 +245,27 @@ export function getVoiceConversationStatus(): Promise { ); } +export async function setVoiceConversationForegroundSession( + sessionId: string | null, +): Promise { + const generation = ++foregroundSessionGeneration; + foregroundSessionId = sessionId; + const { rendererId, rendererEpoch } = await getRendererInstance(); + await invoke("set_voice_renderer_foreground_session", { + request: { + rendererId, + rendererEpoch, + generation, + sessionId, + }, + }); +} + +export function resetVoiceConversationForegroundSessionForTest(): void { + foregroundSessionGeneration = 0; + foregroundSessionId = null; +} + export async function blockNativeVoiceConversationStarts( sessionId: string, ): Promise { @@ -425,6 +448,9 @@ export async function stopVoiceConversationForReplacement( status: VoiceConversationStatus, targetSessionId: string, ): Promise { + if (foregroundSessionId !== targetSessionId) { + throw new Error("The target session is no longer in the foreground."); + } resetMicrophoneMuteState(); const { rendererId, rendererEpoch } = await getRendererInstance(); const nextStatus = await invoke( From 5755bbec6230b47b89e1077d55f6846a567da4ab Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:16:44 -0400 Subject: [PATCH 07/38] fix(voice): await foreground claim before handoff --- .../api/voiceConversation.test.ts | 75 +++++++++++++++++-- .../api/voiceConversation.ts | 47 +++++++++--- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index cc8778d79..da50ec80f 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -21,6 +21,14 @@ vi.mock("../lib/nativeMicrophone", () => ({ startNativeMicrophone: mocks.startMicrophone, })); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + import { acknowledgeVoiceConversationTranscript, blockNativeVoiceConversationStarts, @@ -427,6 +435,49 @@ describe("voice conversation API", () => { ); }); + it("waits for the target foreground claim before requesting replacement", async () => { + const activeStatus = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-1", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 3, + }; + const stoppedStatus = { + ...activeStatus, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + revision: 4, + }; + const claim = deferred(); + mocks.invoke + .mockReturnValueOnce(claim.promise) + .mockResolvedValueOnce(stoppedStatus); + + const publish = setVoiceConversationForegroundSession("session-b"); + const replacement = stopVoiceConversationForReplacement( + activeStatus, + "session-b", + ); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(1)); + expect(mocks.invoke).toHaveBeenLastCalledWith( + "set_voice_renderer_foreground_session", + expect.anything(), + ); + + claim.resolve(); + await publish; + await expect(replacement).resolves.toEqual(stoppedStatus); + expect(mocks.invoke).toHaveBeenNthCalledWith( + 2, + "stop_native_voice_conversation_for_replacement", + expect.objectContaining({ targetSessionId: "session-b" }), + ); + }); + it("rejects a replacement after foreground navigation changes", async () => { const activeStatus = { available: true, @@ -437,15 +488,25 @@ describe("voice conversation API", () => { microphoneMuted: false, revision: 3, }; - mocks.invoke.mockResolvedValue(undefined); - await setVoiceConversationForegroundSession("session-b"); + const sessionBClaim = deferred(); + mocks.invoke + .mockReturnValueOnce(sessionBClaim.promise) + .mockResolvedValueOnce(undefined); + const publishSessionB = setVoiceConversationForegroundSession("session-b"); + const replacement = stopVoiceConversationForReplacement( + activeStatus, + "session-b", + ); await setVoiceConversationForegroundSession("session-c"); - mocks.invoke.mockClear(); + sessionBClaim.resolve(); + await publishSessionB; - await expect( - stopVoiceConversationForReplacement(activeStatus, "session-b"), - ).rejects.toThrow("no longer in the foreground"); - expect(mocks.invoke).not.toHaveBeenCalled(); + await expect(replacement).rejects.toThrow("no longer in the foreground"); + expect(mocks.invoke).toHaveBeenCalledTimes(2); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "stop_native_voice_conversation_for_replacement", + expect.anything(), + ); }); it("reattaches browser capture when a reloaded renderer finds a running session", async () => { diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index d657010e1..3337d0166 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -18,6 +18,11 @@ let microphoneMuteObservationVersion = 0; let microphoneMuteQueue: Promise = Promise.resolve(); let foregroundSessionGeneration = 0; let foregroundSessionId: string | null = null; +let foregroundSessionClaim: { + generation: number; + sessionId: string | null; + acknowledgement: Promise; +} | null = null; function resetMicrophoneMuteState(): void { microphoneMuteIntent += 1; @@ -245,25 +250,34 @@ export function getVoiceConversationStatus(): Promise { ); } -export async function setVoiceConversationForegroundSession( +export function setVoiceConversationForegroundSession( sessionId: string | null, ): Promise { const generation = ++foregroundSessionGeneration; foregroundSessionId = sessionId; - const { rendererId, rendererEpoch } = await getRendererInstance(); - await invoke("set_voice_renderer_foreground_session", { - request: { - rendererId, - rendererEpoch, - generation, - sessionId, - }, - }); + const acknowledgement = getRendererInstance().then( + ({ rendererId, rendererEpoch }) => + invoke("set_voice_renderer_foreground_session", { + request: { + rendererId, + rendererEpoch, + generation, + sessionId, + }, + }), + ); + foregroundSessionClaim = { + generation, + sessionId, + acknowledgement, + }; + return acknowledgement; } export function resetVoiceConversationForegroundSessionForTest(): void { foregroundSessionGeneration = 0; foregroundSessionId = null; + foregroundSessionClaim = null; } export async function blockNativeVoiceConversationStarts( @@ -448,7 +462,18 @@ export async function stopVoiceConversationForReplacement( status: VoiceConversationStatus, targetSessionId: string, ): Promise { - if (foregroundSessionId !== targetSessionId) { + const targetClaim = foregroundSessionClaim; + if ( + foregroundSessionId !== targetSessionId || + targetClaim?.sessionId !== targetSessionId + ) { + throw new Error("The target session is no longer in the foreground."); + } + await targetClaim.acknowledgement; + if ( + foregroundSessionClaim !== targetClaim || + foregroundSessionId !== targetSessionId + ) { throw new Error("The target session is no longer in the foreground."); } resetMicrophoneMuteState(); From 29d845471e87861a3534bba6def06bdf952beaf4 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:21:33 -0400 Subject: [PATCH 08/38] fix(voice): follow same-session handoff claims --- .../api/voiceConversation.test.ts | 44 ++++++++++++++++ .../api/voiceConversation.ts | 52 ++++++++++++++----- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index da50ec80f..a8c2f3f5f 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -478,6 +478,50 @@ describe("voice conversation API", () => { ); }); + it("follows a newer claim for the same foreground session", async () => { + const activeStatus = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-1", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 3, + }; + const stoppedStatus = { + ...activeStatus, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + revision: 4, + }; + const firstClaim = deferred(); + const secondClaim = deferred(); + mocks.invoke + .mockReturnValueOnce(firstClaim.promise) + .mockReturnValueOnce(secondClaim.promise) + .mockResolvedValueOnce(stoppedStatus); + + const publishFirst = setVoiceConversationForegroundSession("session-b"); + const replacement = stopVoiceConversationForReplacement( + activeStatus, + "session-b", + ); + const publishSecond = setVoiceConversationForegroundSession("session-b"); + firstClaim.resolve(); + await publishFirst; + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2)); + + secondClaim.resolve(); + await publishSecond; + await expect(replacement).resolves.toEqual(stoppedStatus); + expect(mocks.invoke).toHaveBeenNthCalledWith( + 3, + "stop_native_voice_conversation_for_replacement", + expect.objectContaining({ targetSessionId: "session-b" }), + ); + }); + it("rejects a replacement after foreground navigation changes", async () => { const activeStatus = { available: true, diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index 3337d0166..c9479d670 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -280,6 +280,43 @@ export function resetVoiceConversationForegroundSessionForTest(): void { foregroundSessionClaim = null; } +async function awaitForegroundSessionClaim( + targetSessionId: string, +): Promise { + let targetClaim = foregroundSessionClaim; + if ( + foregroundSessionId !== targetSessionId || + targetClaim?.sessionId !== targetSessionId + ) { + throw new Error("The target session is no longer in the foreground."); + } + + while (targetClaim) { + try { + await targetClaim.acknowledgement; + } catch (error) { + const latestClaim = foregroundSessionClaim; + if ( + latestClaim !== targetClaim && + latestClaim?.sessionId === targetSessionId + ) { + targetClaim = latestClaim; + continue; + } + throw error; + } + const latestClaim = foregroundSessionClaim; + if ( + foregroundSessionId !== targetSessionId || + latestClaim?.sessionId !== targetSessionId + ) { + throw new Error("The target session is no longer in the foreground."); + } + if (latestClaim === targetClaim) return; + targetClaim = latestClaim; + } +} + export async function blockNativeVoiceConversationStarts( sessionId: string, ): Promise { @@ -462,20 +499,7 @@ export async function stopVoiceConversationForReplacement( status: VoiceConversationStatus, targetSessionId: string, ): Promise { - const targetClaim = foregroundSessionClaim; - if ( - foregroundSessionId !== targetSessionId || - targetClaim?.sessionId !== targetSessionId - ) { - throw new Error("The target session is no longer in the foreground."); - } - await targetClaim.acknowledgement; - if ( - foregroundSessionClaim !== targetClaim || - foregroundSessionId !== targetSessionId - ) { - throw new Error("The target session is no longer in the foreground."); - } + await awaitForegroundSessionClaim(targetSessionId); resetMicrophoneMuteState(); const { rendererId, rendererEpoch } = await getRendererInstance(); const nextStatus = await invoke( From d652325c0a9d92c11146537a38caba74fd6c8e88 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:26:33 -0400 Subject: [PATCH 09/38] fix(voice): unblock superseded handoff claims --- .../api/voiceConversation.test.ts | 8 ++---- .../api/voiceConversation.ts | 26 +++++++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index a8c2f3f5f..eba1ca42d 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -502,14 +502,12 @@ describe("voice conversation API", () => { .mockReturnValueOnce(secondClaim.promise) .mockResolvedValueOnce(stoppedStatus); - const publishFirst = setVoiceConversationForegroundSession("session-b"); + void setVoiceConversationForegroundSession("session-b"); const replacement = stopVoiceConversationForReplacement( activeStatus, "session-b", ); const publishSecond = setVoiceConversationForegroundSession("session-b"); - firstClaim.resolve(); - await publishFirst; await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2)); secondClaim.resolve(); @@ -536,14 +534,12 @@ describe("voice conversation API", () => { mocks.invoke .mockReturnValueOnce(sessionBClaim.promise) .mockResolvedValueOnce(undefined); - const publishSessionB = setVoiceConversationForegroundSession("session-b"); + void setVoiceConversationForegroundSession("session-b"); const replacement = stopVoiceConversationForReplacement( activeStatus, "session-b", ); await setVoiceConversationForegroundSession("session-c"); - sessionBClaim.resolve(); - await publishSessionB; await expect(replacement).rejects.toThrow("no longer in the foreground"); expect(mocks.invoke).toHaveBeenCalledTimes(2); diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index c9479d670..74cc6311b 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -22,6 +22,8 @@ let foregroundSessionClaim: { generation: number; sessionId: string | null; acknowledgement: Promise; + superseded: Promise; + supersede: () => void; } | null = null; function resetMicrophoneMuteState(): void { @@ -266,15 +268,24 @@ export function setVoiceConversationForegroundSession( }, }), ); + let supersede!: () => void; + const superseded = new Promise((resolve) => { + supersede = resolve; + }); + const previousClaim = foregroundSessionClaim; foregroundSessionClaim = { generation, sessionId, acknowledgement, + superseded, + supersede, }; + previousClaim?.supersede(); return acknowledgement; } export function resetVoiceConversationForegroundSessionForTest(): void { + foregroundSessionClaim?.supersede(); foregroundSessionGeneration = 0; foregroundSessionId = null; foregroundSessionClaim = null; @@ -292,9 +303,14 @@ async function awaitForegroundSessionClaim( } while (targetClaim) { - try { - await targetClaim.acknowledgement; - } catch (error) { + const outcome = await Promise.race([ + targetClaim.acknowledgement.then( + () => ({ type: "acknowledged" as const }), + (error: unknown) => ({ type: "failed" as const, error }), + ), + targetClaim.superseded.then(() => ({ type: "superseded" as const })), + ]); + if (outcome.type === "failed") { const latestClaim = foregroundSessionClaim; if ( latestClaim !== targetClaim && @@ -303,7 +319,7 @@ async function awaitForegroundSessionClaim( targetClaim = latestClaim; continue; } - throw error; + throw outcome.error; } const latestClaim = foregroundSessionClaim; if ( @@ -312,7 +328,7 @@ async function awaitForegroundSessionClaim( ) { throw new Error("The target session is no longer in the foreground."); } - if (latestClaim === targetClaim) return; + if (outcome.type === "acknowledged" && latestClaim === targetClaim) return; targetClaim = latestClaim; } } From e39b2e7501c5b62d8e085d19da422b8abb13e729 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 23:04:08 -0400 Subject: [PATCH 10/38] fix(voice): bound foreground claim wait --- .../api/voiceConversation.test.ts | 37 +++++++++++++++ .../api/voiceConversation.ts | 15 ++++++- .../useVoiceConversationController.test.ts | 45 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index eba1ca42d..2d3568a50 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -33,6 +33,7 @@ import { acknowledgeVoiceConversationTranscript, blockNativeVoiceConversationStarts, drainVoiceConversationTranscripts, + FOREGROUND_SESSION_CLAIM_TIMEOUT_MS, getVoiceConversationStatus, listenToVoiceConversation, openVoiceConversationSession, @@ -549,6 +550,42 @@ describe("voice conversation API", () => { ); }); + it("times out a foreground claim without stopping the active call", async () => { + vi.useFakeTimers(); + try { + const activeStatus = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-1", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 3, + }; + const claim = deferred(); + mocks.invoke.mockReturnValueOnce(claim.promise); + void setVoiceConversationForegroundSession("session-b"); + + const replacement = stopVoiceConversationForReplacement( + activeStatus, + "session-b", + ); + const rejection = expect(replacement).rejects.toThrow( + "Foreground voice session confirmation timed out.", + ); + await vi.advanceTimersByTimeAsync(FOREGROUND_SESSION_CLAIM_TIMEOUT_MS); + + await rejection; + expect(mocks.invoke).toHaveBeenCalledOnce(); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "stop_native_voice_conversation_for_replacement", + expect.anything(), + ); + } finally { + vi.useRealTimers(); + } + }); + it("reattaches browser capture when a reloaded renderer finds a running session", async () => { const status = { available: true, diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index 74cc6311b..ba38d1bc5 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -245,6 +245,7 @@ export type VoiceConversationEvent = export const VOICE_CONVERSATION_EVENT = "voice-conversation:event"; export const VOICE_CONVERSATION_OPEN_SESSION_EVENT = "voice-conversation:open-session"; +export const FOREGROUND_SESSION_CLAIM_TIMEOUT_MS = 3_000; export function getVoiceConversationStatus(): Promise { return invoke( @@ -303,13 +304,25 @@ async function awaitForegroundSessionClaim( } while (targetClaim) { + let timeoutId: ReturnType | undefined; const outcome = await Promise.race([ targetClaim.acknowledgement.then( () => ({ type: "acknowledged" as const }), (error: unknown) => ({ type: "failed" as const, error }), ), targetClaim.superseded.then(() => ({ type: "superseded" as const })), - ]); + new Promise<{ type: "timed-out" }>((resolve) => { + timeoutId = setTimeout( + () => resolve({ type: "timed-out" }), + FOREGROUND_SESSION_CLAIM_TIMEOUT_MS, + ); + }), + ]).finally(() => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }); + if (outcome.type === "timed-out") { + throw new Error("Foreground voice session confirmation timed out."); + } if (outcome.type === "failed") { const latestClaim = foregroundSessionClaim; if ( diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index a8361e238..fb4609702 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -533,6 +533,51 @@ describe("voice transcript delivery coordination", () => { expect(start).toHaveBeenCalledWith("session-b"); }); + it("accepts a later toggle after a replacement attempt times out", async () => { + const active = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 2, + }; + const refreshStatus = vi.fn().mockResolvedValue(active); + const stopForReplacement = vi + .fn() + .mockRejectedValue(new Error("Foreground claim timed out")); + useVoiceConversationStore.setState({ + status: active, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus, + stopForReplacement, + start: vi.fn(), + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-b", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + await act(async () => { + await result.current.onToggle(); + }); + await act(async () => { + await result.current.onToggle(); + }); + + expect(refreshStatus).toHaveBeenCalledTimes(2); + expect(stopForReplacement).toHaveBeenCalledTimes(2); + }); + it("keeps an ineligible foreign session from controlling the active call", () => { expect( canReplaceActiveVoiceConversation({ From d6c93d457fab1c10b55444f3fb952d7a666503d4 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 23:06:27 -0400 Subject: [PATCH 11/38] fix(voice): preserve handoff claim deadline --- .../api/voiceConversation.test.ts | 41 +++++++++++++++++++ .../api/voiceConversation.ts | 4 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index 2d3568a50..fc43b264d 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -586,6 +586,47 @@ describe("voice conversation API", () => { } }); + it("keeps one timeout deadline across same-session claims", async () => { + vi.useFakeTimers(); + try { + const activeStatus = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-1", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 3, + }; + mocks.invoke + .mockReturnValueOnce(deferred().promise) + .mockReturnValueOnce(deferred().promise); + void setVoiceConversationForegroundSession("session-b"); + const replacement = stopVoiceConversationForReplacement( + activeStatus, + "session-b", + ); + const rejection = expect(replacement).rejects.toThrow( + "Foreground voice session confirmation timed out.", + ); + + await vi.advanceTimersByTimeAsync( + FOREGROUND_SESSION_CLAIM_TIMEOUT_MS - 1, + ); + void setVoiceConversationForegroundSession("session-b"); + await vi.advanceTimersByTimeAsync(1); + + await rejection; + expect(mocks.invoke).toHaveBeenCalledTimes(2); + expect(mocks.invoke).not.toHaveBeenCalledWith( + "stop_native_voice_conversation_for_replacement", + expect.anything(), + ); + } finally { + vi.useRealTimers(); + } + }); + it("reattaches browser capture when a reloaded renderer finds a running session", async () => { const status = { available: true, diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index ba38d1bc5..423029f42 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -302,6 +302,8 @@ async function awaitForegroundSessionClaim( ) { throw new Error("The target session is no longer in the foreground."); } + const acknowledgementDeadline = + Date.now() + FOREGROUND_SESSION_CLAIM_TIMEOUT_MS; while (targetClaim) { let timeoutId: ReturnType | undefined; @@ -314,7 +316,7 @@ async function awaitForegroundSessionClaim( new Promise<{ type: "timed-out" }>((resolve) => { timeoutId = setTimeout( () => resolve({ type: "timed-out" }), - FOREGROUND_SESSION_CLAIM_TIMEOUT_MS, + Math.max(0, acknowledgementDeadline - Date.now()), ); }), ]).finally(() => { From 85bcb6e3453dd9953a05311ba884b5151957aaf5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 23:13:00 -0400 Subject: [PATCH 12/38] fix(voice): renew timed-out foreground claim --- .../api/voiceConversation.test.ts | 27 ++++++++++++++++--- .../api/voiceConversation.ts | 17 ++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index fc43b264d..3243a0686 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -550,7 +550,7 @@ describe("voice conversation API", () => { ); }); - it("times out a foreground claim without stopping the active call", async () => { + it("renews a timed-out foreground claim for the next replacement", async () => { vi.useFakeTimers(); try { const activeStatus = { @@ -563,7 +563,17 @@ describe("voice conversation API", () => { revision: 3, }; const claim = deferred(); - mocks.invoke.mockReturnValueOnce(claim.promise); + const stoppedStatus = { + ...activeStatus, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + revision: 4, + }; + mocks.invoke + .mockReturnValueOnce(claim.promise) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(stoppedStatus); void setVoiceConversationForegroundSession("session-b"); const replacement = stopVoiceConversationForReplacement( @@ -576,11 +586,20 @@ describe("voice conversation API", () => { await vi.advanceTimersByTimeAsync(FOREGROUND_SESSION_CLAIM_TIMEOUT_MS); await rejection; - expect(mocks.invoke).toHaveBeenCalledOnce(); + expect(mocks.invoke).toHaveBeenCalledTimes(2); expect(mocks.invoke).not.toHaveBeenCalledWith( "stop_native_voice_conversation_for_replacement", expect.anything(), ); + + await expect( + stopVoiceConversationForReplacement(activeStatus, "session-b"), + ).resolves.toEqual(stoppedStatus); + expect(mocks.invoke).toHaveBeenNthCalledWith( + 3, + "stop_native_voice_conversation_for_replacement", + expect.objectContaining({ targetSessionId: "session-b" }), + ); } finally { vi.useRealTimers(); } @@ -617,7 +636,7 @@ describe("voice conversation API", () => { await vi.advanceTimersByTimeAsync(1); await rejection; - expect(mocks.invoke).toHaveBeenCalledTimes(2); + expect(mocks.invoke).toHaveBeenCalledTimes(3); expect(mocks.invoke).not.toHaveBeenCalledWith( "stop_native_voice_conversation_for_replacement", expect.anything(), diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index 423029f42..ba8d00f64 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -292,6 +292,21 @@ export function resetVoiceConversationForegroundSessionForTest(): void { foregroundSessionClaim = null; } +function renewForegroundSessionClaim( + failedClaim: NonNullable, + targetSessionId: string, +): void { + if ( + foregroundSessionClaim !== failedClaim || + foregroundSessionId !== targetSessionId + ) { + return; + } + void setVoiceConversationForegroundSession(targetSessionId).catch( + () => undefined, + ); +} + async function awaitForegroundSessionClaim( targetSessionId: string, ): Promise { @@ -323,6 +338,7 @@ async function awaitForegroundSessionClaim( if (timeoutId !== undefined) clearTimeout(timeoutId); }); if (outcome.type === "timed-out") { + renewForegroundSessionClaim(targetClaim, targetSessionId); throw new Error("Foreground voice session confirmation timed out."); } if (outcome.type === "failed") { @@ -334,6 +350,7 @@ async function awaitForegroundSessionClaim( targetClaim = latestClaim; continue; } + renewForegroundSessionClaim(targetClaim, targetSessionId); throw outcome.error; } const latestClaim = foregroundSessionClaim; From a625b57bd33bd4f2e1baa1e45ff8986f410fb44c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 20:58:51 -0400 Subject: [PATCH 13/38] fix(voice): scope handoff action latch to session --- .../useVoiceConversationController.test.ts | 90 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 8 +- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index fb4609702..70fd03ff0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -578,6 +578,96 @@ describe("voice transcript delivery coordination", () => { expect(stopForReplacement).toHaveBeenCalledTimes(2); }); + it("allows a new session to replace a running call while the prior start settles", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const runningA = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const stoppedA = { + ...stopped, + revision: 3, + }; + const runningB = { + ...runningA, + sessionId: "session-b", + revision: 4, + }; + const startA = deferred(); + const refreshStatus = vi + .fn() + .mockImplementation(() => + Promise.resolve(useVoiceConversationStore.getState().status), + ); + const start = vi + .fn() + .mockReturnValueOnce(startA.promise) + .mockResolvedValueOnce(runningB); + const stopForReplacement = vi.fn().mockResolvedValue(stoppedA); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus, + stopForReplacement, + start, + }); + const sessionA = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let startRequest!: Promise; + act(() => { + startRequest = Promise.resolve(sessionA.result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + sessionA.unmount(); + act(() => { + useVoiceConversationStore.setState({ + status: runningA, + uiState: "listening", + }); + }); + + const sessionB = renderHook(() => + useVoiceConversationController({ + sessionId: "session-b", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + await act(async () => { + await sessionB.result.current.onToggle(); + }); + + startA.resolve(runningA); + await startRequest; + expect(stopForReplacement).toHaveBeenCalledWith(runningA, "session-b"); + expect(start).toHaveBeenCalledTimes(2); + }); + it("keeps an ineligible foreign session from controlling the active call", () => { expect( canReplaceActiveVoiceConversation({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index be3021f5b..ab0abc86c 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -29,7 +29,6 @@ interface VoiceSendRoute { // view for its bound session is mounted. let activeSendRoute: VoiceSendRoute | null = null; let deliveryInitialized = false; -let operationInFlight = false; export function createVoiceTranscriptDeliveryQueue() { const queues = new Map>(); @@ -612,6 +611,7 @@ export function useVoiceConversationController({ const clearRequestedStart = useVoiceConversationStore( (state) => state.clearRequestedStart, ); + const operationInFlightRef = useRef(false); const previousPocketReady = useRef(pocketReady); useEffect(() => { @@ -840,8 +840,8 @@ export function useVoiceConversationController({ const canToggle = sessionEligible && (!pocketReady || status.available); const toggle = useCallback(async () => { - if (operationInFlight) return; - operationInFlight = true; + if (operationInFlightRef.current) return; + operationInFlightRef.current = true; try { const currentStatus = await refreshStatus().catch(() => { addErrorNotification( @@ -912,7 +912,7 @@ export function useVoiceConversationController({ await startCurrentConversation(); } finally { - operationInFlight = false; + operationInFlightRef.current = false; } }, [ canToggle, From 0b4e7bf64777c2d76b44d6af58836ed94ffac5a5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:02:13 -0400 Subject: [PATCH 14/38] fix(voice): serialize actions per session --- .../useVoiceConversationController.test.ts | 58 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 8 +-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 70fd03ff0..77cacd4f1 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -668,6 +668,64 @@ describe("voice transcript delivery coordination", () => { expect(start).toHaveBeenCalledTimes(2); }); + it("deduplicates concurrent controls for the same session", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const running = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const startRequest = deferred(); + const refreshStatus = vi.fn().mockResolvedValue(stopped); + const start = vi.fn().mockReturnValue(startRequest.promise); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus, + start, + }); + const options = { + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }; + const firstControl = renderHook(() => + useVoiceConversationController(options), + ); + const secondControl = renderHook(() => + useVoiceConversationController(options), + ); + + let firstToggle!: Promise; + act(() => { + firstToggle = Promise.resolve(firstControl.result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + await act(async () => { + await secondControl.result.current.onToggle(); + }); + + expect(refreshStatus).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledOnce(); + startRequest.resolve(running); + await firstToggle; + }); + it("keeps an ineligible foreign session from controlling the active call", () => { expect( canReplaceActiveVoiceConversation({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index ab0abc86c..e4be054c2 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -29,6 +29,7 @@ interface VoiceSendRoute { // view for its bound session is mounted. let activeSendRoute: VoiceSendRoute | null = null; let deliveryInitialized = false; +const operationInFlightBySession = new Set(); export function createVoiceTranscriptDeliveryQueue() { const queues = new Map>(); @@ -611,7 +612,6 @@ export function useVoiceConversationController({ const clearRequestedStart = useVoiceConversationStore( (state) => state.clearRequestedStart, ); - const operationInFlightRef = useRef(false); const previousPocketReady = useRef(pocketReady); useEffect(() => { @@ -840,8 +840,8 @@ export function useVoiceConversationController({ const canToggle = sessionEligible && (!pocketReady || status.available); const toggle = useCallback(async () => { - if (operationInFlightRef.current) return; - operationInFlightRef.current = true; + if (operationInFlightBySession.has(sessionId)) return; + operationInFlightBySession.add(sessionId); try { const currentStatus = await refreshStatus().catch(() => { addErrorNotification( @@ -912,7 +912,7 @@ export function useVoiceConversationController({ await startCurrentConversation(); } finally { - operationInFlightRef.current = false; + operationInFlightBySession.delete(sessionId); } }, [ canToggle, From 33d14d2ad340e879ecc06e7127014b54a12173df Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:19:39 -0400 Subject: [PATCH 15/38] fix(voice): ignore stale start cleanup --- .../useVoiceConversationController.test.ts | 22 ++++++++++++--- .../hooks/useVoiceConversationController.ts | 5 ++-- .../stores/voiceConversationStore.test.ts | 25 +++++++++++++++-- .../stores/voiceConversationStore.ts | 27 ++++++++++++++----- 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 77cacd4f1..a0134af5a 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -38,10 +38,12 @@ import { function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; + reject = rejectPromise; }); - return { promise, resolve }; + return { promise, reject, resolve }; } describe("voice transcript delivery coordination", () => { @@ -621,6 +623,7 @@ describe("voice transcript delivery coordination", () => { hydrated: true, init: vi.fn().mockResolvedValue(undefined), refreshStatus, + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), stopForReplacement, start, }); @@ -662,10 +665,23 @@ describe("voice transcript delivery coordination", () => { await sessionB.result.current.onToggle(); }); - startA.resolve(runningA); + act(() => { + useVoiceConversationStore.setState({ + status: runningB, + uiState: "listening", + error: null, + }); + }); + startA.reject(new Error("session A start tail failed")); await startRequest; expect(stopForReplacement).toHaveBeenCalledWith(runningA, "session-b"); expect(start).toHaveBeenCalledTimes(2); + expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); + expect(useVoiceConversationStore.getState()).toMatchObject({ + status: runningB, + uiState: "listening", + error: null, + }); }); it("deduplicates concurrent controls for the same session", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index e4be054c2..adc899b3f 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -728,7 +728,8 @@ export function useVoiceConversationController({ // click. The native recognizer can finalize quickly, so its delivery // subscriber must exist before the microphone lifecycle starts. ensureVoiceEventDeliveryInitialized(); - activeSendRoute = { sessionId, send: onSend }; + const route = { sessionId, send: onSend }; + activeSendRoute = route; // Capture the history boundary before native startup can admit a // transcript and produce the first assistant response. startAssistantSpeech(); @@ -736,7 +737,7 @@ export function useVoiceConversationController({ await start(sessionId); } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; - if (backendStatus.sessionId !== sessionId) { + if (backendStatus.sessionId !== sessionId && activeSendRoute === route) { activeSendRoute = null; stopNativeAssistantSpeech(); } diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 96137e476..24eac8afb 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -53,10 +53,12 @@ function status( function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((resolver) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolver, rejecter) => { resolve = resolver; + reject = rejecter; }); - return { promise, resolve }; + return { promise, reject, resolve }; } describe("voice conversation store lifecycle ordering", () => { @@ -532,6 +534,25 @@ describe("voice conversation store lifecycle ordering", () => { }); }); + it("does not let a stale start failure mark a replacement call as errored", async () => { + const store = await loadStore(); + const startA = deferred(); + const runningB = status("running", 4, "session-b"); + mocks.start.mockReturnValue(startA.promise); + mocks.getStatus.mockResolvedValue(runningB); + + const startingA = store.getState().start("session-a"); + store.setState({ status: runningB, uiState: "listening", error: null }); + startA.reject(new Error("session A start tail failed")); + + await expect(startingA).rejects.toThrow("session A start tail failed"); + expect(store.getState()).toMatchObject({ + status: runningB, + uiState: "listening", + error: null, + }); + }); + it("reconciles status after a failed stop", async () => { const store = await loadStore(); store.setState({ diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index 9889422b7..d7733d2af 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -597,14 +597,29 @@ export const useVoiceConversationStore = create( error instanceof Error ? error.message : String(error); try { const status = await getVoiceConversationStatus(); - set((state) => - status.revision >= state.status.revision - ? { status, uiState: "error", error: message } - : state, - ); + set((state) => { + if (status.revision < state.status.revision) return state; + if ( + status.lifecycle === "running" && + status.sessionId !== sessionId + ) { + return { + status, + uiState: uiStateForStatus(status), + microphoneMuted: status.microphoneMuted, + error: null, + }; + } + return { status, uiState: "error", error: message }; + }); await reconcileVoiceConversationMicrophone(get().status); } catch { - set({ uiState: "error", error: message }); + set((state) => + state.status.lifecycle === "running" && + state.status.sessionId !== sessionId + ? state + : { uiState: "error", error: message }, + ); } throw error; } From cd6c2cfec52f8d4906d3d4f13ae2c331063e085d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:33:41 -0400 Subject: [PATCH 16/38] fix(voice): preserve replacement lifecycle state --- .../stores/voiceConversationStore.test.ts | 58 +++++++++++++++++-- .../stores/voiceConversationStore.ts | 14 +++-- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 24eac8afb..6a8b783fd 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -534,22 +534,72 @@ describe("voice conversation store lifecycle ordering", () => { }); }); - it("does not let a stale start failure mark a replacement call as errored", async () => { + it.each([ + ["starting", "starting"], + ["running", "listening"], + ["stopping", "stopping"], + ] as const)("does not let a stale start failure mark a %s replacement as errored", async (lifecycle, uiState) => { const store = await loadStore(); const startA = deferred(); + const replacementB = status(lifecycle, 4, "session-b"); + mocks.start.mockReturnValue(startA.promise); + mocks.getStatus.mockResolvedValue(replacementB); + + const startingA = store.getState().start("session-a"); + store.setState({ status: replacementB, uiState, error: null }); + startA.reject(new Error("session A start tail failed")); + + await expect(startingA).rejects.toThrow("session A start tail failed"); + expect(store.getState()).toMatchObject({ + status: replacementB, + uiState, + error: null, + }); + }); + + it("preserves a local replacement when stale-start status refresh fails", async () => { + const store = await loadStore(); + const startA = deferred(); + const startingB = status("starting", 4, "session-b"); + mocks.start.mockReturnValue(startA.promise); + mocks.getStatus.mockRejectedValue(new Error("status unavailable")); + + const startingA = store.getState().start("session-a"); + store.setState({ status: startingB, uiState: "starting", error: null }); + startA.reject(new Error("session A start tail failed")); + + await expect(startingA).rejects.toThrow("session A start tail failed"); + expect(store.getState()).toMatchObject({ + status: startingB, + uiState: "starting", + error: null, + }); + }); + + it("preserves replacement activity while stale-start status refresh settles", async () => { + const store = await loadStore(); + const startA = deferred(); + const statusRefresh = deferred(); const runningB = status("running", 4, "session-b"); mocks.start.mockReturnValue(startA.promise); - mocks.getStatus.mockResolvedValue(runningB); + mocks.getStatus.mockReturnValue(statusRefresh.promise); const startingA = store.getState().start("session-a"); store.setState({ status: runningB, uiState: "listening", error: null }); startA.reject(new Error("session A start tail failed")); + await vi.waitFor(() => expect(mocks.getStatus).toHaveBeenCalledTimes(2)); + store.setState({ + status: runningB, + uiState: "agent-speaking", + error: "session B playback warning", + }); + statusRefresh.resolve(runningB); await expect(startingA).rejects.toThrow("session A start tail failed"); expect(store.getState()).toMatchObject({ status: runningB, - uiState: "listening", - error: null, + uiState: "agent-speaking", + error: "session B playback warning", }); }); diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index d7733d2af..afbe0c6fe 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -599,10 +599,14 @@ export const useVoiceConversationStore = create( const status = await getVoiceConversationStatus(); set((state) => { if (status.revision < state.status.revision) return state; - if ( - status.lifecycle === "running" && - status.sessionId !== sessionId - ) { + if (status.sessionId !== null && status.sessionId !== sessionId) { + if ( + state.status.sessionId === status.sessionId && + state.status.revision === status.revision && + state.status.lifecycle === status.lifecycle + ) { + return state; + } return { status, uiState: uiStateForStatus(status), @@ -615,7 +619,7 @@ export const useVoiceConversationStore = create( await reconcileVoiceConversationMicrophone(get().status); } catch { set((state) => - state.status.lifecycle === "running" && + state.status.sessionId !== null && state.status.sessionId !== sessionId ? state : { uiState: "error", error: message }, From 25c8e1e24f19d9e957d8f5a9b3f2c6d8c38f6c37 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:38:25 -0400 Subject: [PATCH 17/38] fix(voice): preserve competing handoff winner --- .../stores/voiceConversationStore.test.ts | 34 ++++++++++++++++++ .../stores/voiceConversationStore.ts | 35 ++++++++++++++++--- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 6a8b783fd..283eba62c 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -442,6 +442,40 @@ describe("voice conversation store lifecycle ordering", () => { expect(mocks.stopForReplacement).toHaveBeenCalledWith(active, "session-c"); }); + it.each([ + "resolves", + "rejects", + ] as const)("preserves a competing handoff winner when stale status refresh %s", async (refreshOutcome) => { + const store = await loadStore(); + const active = status("running", 2, "session-a"); + const staleReplacement = deferred(); + const winner = status("running", 4, "session-c"); + store.setState({ status: active, uiState: "listening" }); + mocks.stopForReplacement.mockReturnValue(staleReplacement.promise); + if (refreshOutcome === "resolves") { + mocks.getStatus.mockResolvedValue(winner); + } else { + mocks.getStatus.mockRejectedValue(new Error("status unavailable")); + } + + const replacingWithB = store + .getState() + .stopForReplacement(active, "session-b"); + store.setState({ + status: winner, + uiState: "agent-speaking", + error: "session C playback warning", + }); + staleReplacement.reject(new Error("session B handoff failed")); + + await expect(replacingWithB).rejects.toThrow("session B handoff failed"); + expect(store.getState()).toMatchObject({ + status: winner, + uiState: "agent-speaking", + error: "session C playback warning", + }); + }); + it("refreshes a stale foreign renderer before choosing a call action", async () => { const store = await loadStore(); store.setState({ diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index afbe0c6fe..90c95c52e 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -728,14 +728,39 @@ export const useVoiceConversationStore = create( const message = error instanceof Error ? error.message : String(error); try { const status = await getVoiceConversationStatus(); - set((state) => - status.revision >= state.status.revision + set((state) => { + const foreignWinner = + status.sessionId !== null && + status.sessionId !== activeStatus.sessionId && + status.sessionId !== targetSessionId; + if (foreignWinner) { + if ( + state.status.sessionId === status.sessionId && + state.status.revision === status.revision && + state.status.lifecycle === status.lifecycle + ) { + return state; + } + return { + status, + uiState: uiStateForStatus(status), + microphoneMuted: status.microphoneMuted, + error: null, + }; + } + return status.revision >= state.status.revision ? { status, uiState: "error", error: message } - : state, - ); + : state; + }); await reconcileVoiceConversationMicrophone(get().status); } catch { - set({ uiState: "error", error: message }); + set((state) => + state.status.sessionId !== null && + state.status.sessionId !== activeStatus.sessionId && + state.status.sessionId !== targetSessionId + ? state + : { uiState: "error", error: message }, + ); } throw error; } From 5b345d32dfbac98d52f2805dfc0a3253f0ddaa8d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:51:49 -0400 Subject: [PATCH 18/38] fix(voice): harden handoff failure recovery --- .../useVoiceConversationController.test.ts | 56 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 8 ++- .../stores/voiceConversationStore.test.ts | 48 ++++++++++++++-- .../stores/voiceConversationStore.ts | 33 ++++++++++- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index a0134af5a..b8f152807 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -684,6 +684,62 @@ describe("voice transcript delivery coordination", () => { }); }); + it("cleans up assistant speech when the current session fails to start", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const starting = { + ...stopped, + lifecycle: "starting" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockReturnValue(startRequest.promise); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + act(() => { + useVoiceConversationStore.setState({ + status: starting, + uiState: "starting", + }); + }); + startRequest.reject(new Error("start failed")); + await toggling; + + expect(nativeAssistantSpeechMocks.stop).toHaveBeenCalledOnce(); + }); + it("deduplicates concurrent controls for the same session", async () => { const stopped = { available: true, diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index adc899b3f..6f3577f72 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -737,7 +737,13 @@ export function useVoiceConversationController({ await start(sessionId); } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; - if (backendStatus.sessionId !== sessionId && activeSendRoute === route) { + const conversationStarted = + backendStatus.lifecycle === "running" && + backendStatus.sessionId === sessionId; + if ( + !conversationStarted && + activeSendRoute?.sessionId === route.sessionId + ) { activeSendRoute = null; stopNativeAssistantSpeech(); } diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 283eba62c..e509841b4 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -450,10 +450,14 @@ describe("voice conversation store lifecycle ordering", () => { const active = status("running", 2, "session-a"); const staleReplacement = deferred(); const winner = status("running", 4, "session-c"); + const observedWinner = + refreshOutcome === "resolves" + ? { ...winner, microphoneMuted: true } + : winner; store.setState({ status: active, uiState: "listening" }); mocks.stopForReplacement.mockReturnValue(staleReplacement.promise); if (refreshOutcome === "resolves") { - mocks.getStatus.mockResolvedValue(winner); + mocks.getStatus.mockResolvedValue(observedWinner); } else { mocks.getStatus.mockRejectedValue(new Error("status unavailable")); } @@ -470,9 +474,41 @@ describe("voice conversation store lifecycle ordering", () => { await expect(replacingWithB).rejects.toThrow("session B handoff failed"); expect(store.getState()).toMatchObject({ - status: winner, + status: observedWinner, uiState: "agent-speaking", error: "session C playback warning", + microphoneMuted: observedWinner.microphoneMuted, + }); + }); + + it("does not let a delayed competing-handoff refresh regress a newer winner", async () => { + const store = await loadStore(); + const active = status("running", 2, "session-a"); + const staleReplacement = deferred(); + const statusRefresh = deferred(); + const observedWinner = status("running", 4, "session-c"); + const newerWinner = status("running", 6, "session-d"); + store.setState({ status: active, uiState: "listening" }); + mocks.stopForReplacement.mockReturnValue(staleReplacement.promise); + mocks.getStatus.mockReturnValue(statusRefresh.promise); + + const replacingWithB = store + .getState() + .stopForReplacement(active, "session-b"); + staleReplacement.reject(new Error("session B handoff failed")); + await vi.waitFor(() => expect(mocks.getStatus).toHaveBeenCalledTimes(2)); + store.setState({ + status: newerWinner, + uiState: "agent-speaking", + error: "session D playback warning", + }); + statusRefresh.resolve(observedWinner); + + await expect(replacingWithB).rejects.toThrow("session B handoff failed"); + expect(store.getState()).toMatchObject({ + status: newerWinner, + uiState: "agent-speaking", + error: "session D playback warning", }); }); @@ -627,14 +663,18 @@ describe("voice conversation store lifecycle ordering", () => { uiState: "agent-speaking", error: "session B playback warning", }); - statusRefresh.resolve(runningB); + const mutedRunningB = { ...runningB, microphoneMuted: true }; + statusRefresh.resolve(mutedRunningB); await expect(startingA).rejects.toThrow("session A start tail failed"); expect(store.getState()).toMatchObject({ - status: runningB, + status: mutedRunningB, uiState: "agent-speaking", error: "session B playback warning", + microphoneMuted: true, + userSpeaking: false, }); + expect(mocks.reconcileMicrophone).toHaveBeenLastCalledWith(mutedRunningB); }); it("reconciles status after a failed stop", async () => { diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index 90c95c52e..31ca5618f 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -596,6 +596,8 @@ export const useVoiceConversationStore = create( const message = error instanceof Error ? error.message : String(error); try { + const muteStateVersion = microphoneMuteStateVersion; + const muteRequestWasPending = pendingMicrophoneMuteRequests > 0; const status = await getVoiceConversationStatus(); set((state) => { if (status.revision < state.status.revision) return state; @@ -605,7 +607,19 @@ export const useVoiceConversationStore = create( state.status.revision === status.revision && state.status.lifecycle === status.lifecycle ) { - return state; + const preserveCurrentMute = + isSameRunningLifecycle(state.status, status) && + (muteRequestWasPending || + pendingMicrophoneMuteRequests > 0 || + muteStateVersion !== microphoneMuteStateVersion); + const microphoneMuted = preserveCurrentMute + ? state.microphoneMuted + : status.microphoneMuted; + return { + status: { ...state.status, microphoneMuted }, + microphoneMuted, + userSpeaking: microphoneMuted ? false : state.userSpeaking, + }; } return { status, @@ -727,8 +741,11 @@ export const useVoiceConversationStore = create( } catch (error) { const message = error instanceof Error ? error.message : String(error); try { + const muteStateVersion = microphoneMuteStateVersion; + const muteRequestWasPending = pendingMicrophoneMuteRequests > 0; const status = await getVoiceConversationStatus(); set((state) => { + if (status.revision < state.status.revision) return state; const foreignWinner = status.sessionId !== null && status.sessionId !== activeStatus.sessionId && @@ -739,7 +756,19 @@ export const useVoiceConversationStore = create( state.status.revision === status.revision && state.status.lifecycle === status.lifecycle ) { - return state; + const preserveCurrentMute = + isSameRunningLifecycle(state.status, status) && + (muteRequestWasPending || + pendingMicrophoneMuteRequests > 0 || + muteStateVersion !== microphoneMuteStateVersion); + const microphoneMuted = preserveCurrentMute + ? state.microphoneMuted + : status.microphoneMuted; + return { + status: { ...state.status, microphoneMuted }, + microphoneMuted, + userSpeaking: microphoneMuted ? false : state.userSpeaking, + }; } return { status, From 18ec3170a0c3f0f4d1aad4fd4804db30750df661 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:59:40 -0400 Subject: [PATCH 19/38] fix(voice): reconcile muted activity state --- .../stores/voiceConversationStore.test.ts | 6 ++++-- .../stores/voiceConversationStore.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index e509841b4..0e72462ed 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -468,6 +468,7 @@ describe("voice conversation store lifecycle ordering", () => { store.setState({ status: winner, uiState: "agent-speaking", + assistantSpeaking: true, error: "session C playback warning", }); staleReplacement.reject(new Error("session B handoff failed")); @@ -660,7 +661,8 @@ describe("voice conversation store lifecycle ordering", () => { await vi.waitFor(() => expect(mocks.getStatus).toHaveBeenCalledTimes(2)); store.setState({ status: runningB, - uiState: "agent-speaking", + uiState: "user-speaking", + userSpeaking: true, error: "session B playback warning", }); const mutedRunningB = { ...runningB, microphoneMuted: true }; @@ -669,7 +671,7 @@ describe("voice conversation store lifecycle ordering", () => { await expect(startingA).rejects.toThrow("session A start tail failed"); expect(store.getState()).toMatchObject({ status: mutedRunningB, - uiState: "agent-speaking", + uiState: "listening", error: "session B playback warning", microphoneMuted: true, userSpeaking: false, diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index 31ca5618f..72d1ab7f9 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -615,10 +615,16 @@ export const useVoiceConversationStore = create( const microphoneMuted = preserveCurrentMute ? state.microphoneMuted : status.microphoneMuted; + const userSpeaking = microphoneMuted + ? false + : state.userSpeaking; return { status: { ...state.status, microphoneMuted }, microphoneMuted, - userSpeaking: microphoneMuted ? false : state.userSpeaking, + userSpeaking, + uiState: microphoneMuted + ? activityUiState({ ...state, userSpeaking }) + : state.uiState, }; } return { @@ -764,10 +770,16 @@ export const useVoiceConversationStore = create( const microphoneMuted = preserveCurrentMute ? state.microphoneMuted : status.microphoneMuted; + const userSpeaking = microphoneMuted + ? false + : state.userSpeaking; return { status: { ...state.status, microphoneMuted }, microphoneMuted, - userSpeaking: microphoneMuted ? false : state.userSpeaking, + userSpeaking, + uiState: microphoneMuted + ? activityUiState({ ...state, userSpeaking }) + : state.uiState, }; } return { From 964eeabdd23e9b37f748d46ebf4813e39065c985 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 22:21:05 -0400 Subject: [PATCH 20/38] fix(voice): preserve non-user activity on mute --- .../stores/voiceConversationStore.test.ts | 42 +++++++++++++++++++ .../stores/voiceConversationStore.ts | 14 ++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 0e72462ed..a52bc0dfe 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.test.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.test.ts @@ -679,6 +679,48 @@ describe("voice conversation store lifecycle ordering", () => { expect(mocks.reconcileMicrophone).toHaveBeenLastCalledWith(mutedRunningB); }); + it.each([ + ["stale start", "agent-speaking"], + ["stale start", "error"], + ["failed handoff", "agent-speaking"], + ["failed handoff", "error"], + ] as const)("preserves direct %s %s UI while applying authoritative mute", async (operation, uiState) => { + const store = await loadStore(); + const active = status("running", 2, "session-a"); + const winner = { + ...status("running", 4, "session-c"), + microphoneMuted: true, + }; + const request = deferred(); + mocks.getStatus.mockResolvedValue(winner); + store.setState({ status: active, uiState: "listening" }); + + let failing: Promise; + if (operation === "stale start") { + mocks.start.mockReturnValue(request.promise); + failing = store.getState().start("session-b"); + } else { + mocks.stopForReplacement.mockReturnValue(request.promise); + failing = store.getState().stopForReplacement(active, "session-b"); + } + store.setState({ + status: winner, + uiState, + error: uiState === "error" ? "session C warning" : null, + assistantSpeaking: false, + userSpeaking: false, + }); + request.reject(new Error("stale operation failed")); + + await expect(failing).rejects.toThrow("stale operation failed"); + expect(store.getState()).toMatchObject({ + status: winner, + uiState, + error: uiState === "error" ? "session C warning" : null, + microphoneMuted: true, + }); + }); + it("reconciles status after a failed stop", async () => { const store = await loadStore(); store.setState({ diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index 72d1ab7f9..bc0b173ac 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -622,9 +622,10 @@ export const useVoiceConversationStore = create( status: { ...state.status, microphoneMuted }, microphoneMuted, userSpeaking, - uiState: microphoneMuted - ? activityUiState({ ...state, userSpeaking }) - : state.uiState, + uiState: + microphoneMuted && state.uiState === "user-speaking" + ? activityUiState({ ...state, userSpeaking }) + : state.uiState, }; } return { @@ -777,9 +778,10 @@ export const useVoiceConversationStore = create( status: { ...state.status, microphoneMuted }, microphoneMuted, userSpeaking, - uiState: microphoneMuted - ? activityUiState({ ...state, userSpeaking }) - : state.uiState, + uiState: + microphoneMuted && state.uiState === "user-speaking" + ? activityUiState({ ...state, userSpeaking }) + : state.uiState, }; } return { From 0f5288204c9dbca5fcb41b7bd400c549f8ee1cd5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 22:55:48 -0400 Subject: [PATCH 21/38] fix(voice): serialize competing handoffs --- .../useVoiceConversationController.test.ts | 89 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 5 ++ 2 files changed, 94 insertions(+) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index b8f152807..fed2b98c4 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -684,6 +684,95 @@ describe("voice transcript delivery coordination", () => { }); }); + it.each([ + ["session-b", "session-c"], + ["session-c", "session-b"], + ] as const)("serializes a %s handoff ahead of competing %s", async (winnerSessionId, loserSessionId) => { + const active = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + microphoneMuted: false, + revision: 2, + }; + const stopped = { + ...active, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + revision: 3, + }; + const runningWinner = { + ...active, + sessionId: winnerSessionId, + revision: 4, + }; + const winnerRefresh = deferred(); + const loserRefresh = deferred(); + const stopRequest = deferred(); + const refreshStatus = vi + .fn() + .mockReturnValueOnce(winnerRefresh.promise) + .mockReturnValueOnce(loserRefresh.promise); + const stopForReplacement = vi.fn().mockReturnValue(stopRequest.promise); + const start = vi.fn().mockResolvedValue(runningWinner); + useVoiceConversationStore.setState({ + status: active, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus, + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + stopForReplacement, + start, + }); + const controllers = new Map( + ["session-b", "session-c"].map((candidateSessionId) => [ + candidateSessionId, + renderHook(() => + useVoiceConversationController({ + sessionId: candidateSessionId, + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ), + ]), + ); + + let winnerToggle!: Promise; + let loserToggle!: Promise; + act(() => { + winnerToggle = Promise.resolve( + controllers.get(winnerSessionId)?.result.current.onToggle(), + ); + loserToggle = Promise.resolve( + controllers.get(loserSessionId)?.result.current.onToggle(), + ); + }); + winnerRefresh.resolve(active); + await vi.waitFor(() => expect(stopForReplacement).toHaveBeenCalledOnce()); + loserRefresh.resolve(active); + await loserToggle; + expect(stopForReplacement).toHaveBeenCalledWith(active, winnerSessionId); + expect(start).not.toHaveBeenCalled(); + + stopRequest.resolve(stopped); + await winnerToggle; + + expect(start).toHaveBeenCalledOnce(); + expect(start).toHaveBeenCalledWith(winnerSessionId); + expect(nativeAssistantSpeechMocks.start).toHaveBeenLastCalledWith( + winnerSessionId, + expect.any(Function), + ); + expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); + }); + it("cleans up assistant speech when the current session fails to start", async () => { const stopped = { available: true, diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 6f3577f72..fba57b2e2 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -30,6 +30,7 @@ interface VoiceSendRoute { let activeSendRoute: VoiceSendRoute | null = null; let deliveryInitialized = false; const operationInFlightBySession = new Set(); +let replacementOperationInFlight = false; export function createVoiceTranscriptDeliveryQueue() { const queues = new Map>(); @@ -883,6 +884,8 @@ export function useVoiceConversationController({ ) { return; } + if (replacementOperationInFlight) return; + replacementOperationInFlight = true; try { const replaced = await replaceActiveVoiceConversation({ stop: () => stopForReplacement(currentStatus, sessionId), @@ -899,6 +902,8 @@ export function useVoiceConversationController({ sessionId, t("toolbar.voiceConversation.buddy.errors.stop"), ); + } finally { + replacementOperationInFlight = false; } return; } From c3243188d80754ed733a8733a1ae4299ce28c730 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 23:13:14 -0400 Subject: [PATCH 22/38] fix(voice): bind shared speech to native winner --- .../useVoiceConversationController.test.ts | 29 ++++++++--- .../hooks/useVoiceConversationController.ts | 49 ++++++++++++------- .../lib/nativeAssistantSpeech.ts | 15 ++++-- 3 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index fed2b98c4..7220c0ef4 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -4,12 +4,14 @@ import { useChatStore } from "@/features/chat/stores/chatStore"; import { useVoiceConversationStore } from "../stores/voiceConversationStore"; const nativeAssistantSpeechMocks = vi.hoisted(() => ({ + capture: vi.fn(() => []), start: vi.fn(), stop: vi.fn(), takeNotices: vi.fn<() => string | null>(() => null), })); vi.mock("../lib/nativeAssistantSpeech", () => ({ + captureNativeAssistantSpeechHistory: nativeAssistantSpeechMocks.capture, startNativeAssistantSpeech: nativeAssistantSpeechMocks.start, stopNativeAssistantSpeech: nativeAssistantSpeechMocks.stop, takeVoicePlaybackNotices: nativeAssistantSpeechMocks.takeNotices, @@ -182,6 +184,7 @@ describe("voice transcript delivery coordination", () => { }); beforeEach(() => { + nativeAssistantSpeechMocks.capture.mockClear(); nativeAssistantSpeechMocks.start.mockClear(); nativeAssistantSpeechMocks.stop.mockClear(); nativeAssistantSpeechMocks.takeNotices.mockClear(); @@ -710,14 +713,15 @@ describe("voice transcript delivery coordination", () => { revision: 4, }; const winnerRefresh = deferred(); - const loserRefresh = deferred(); + const loserRefresh = deferred(); const stopRequest = deferred(); + const startRequest = deferred(); const refreshStatus = vi .fn() .mockReturnValueOnce(winnerRefresh.promise) .mockReturnValueOnce(loserRefresh.promise); const stopForReplacement = vi.fn().mockReturnValue(stopRequest.promise); - const start = vi.fn().mockResolvedValue(runningWinner); + const start = vi.fn().mockReturnValue(startRequest.promise); useVoiceConversationStore.setState({ status: active, uiState: "listening", @@ -756,24 +760,33 @@ describe("voice transcript delivery coordination", () => { }); winnerRefresh.resolve(active); await vi.waitFor(() => expect(stopForReplacement).toHaveBeenCalledOnce()); - loserRefresh.resolve(active); + loserRefresh.resolve(stopped); await loserToggle; expect(stopForReplacement).toHaveBeenCalledWith(active, winnerSessionId); expect(start).not.toHaveBeenCalled(); stopRequest.resolve(stopped); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + act(() => { + useVoiceConversationStore.setState({ + status: runningWinner, + uiState: "listening", + }); + }); + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + startRequest.resolve(runningWinner); await winnerToggle; - expect(start).toHaveBeenCalledOnce(); expect(start).toHaveBeenCalledWith(winnerSessionId); expect(nativeAssistantSpeechMocks.start).toHaveBeenLastCalledWith( winnerSessionId, expect.any(Function), + [], ); expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); }); - it("cleans up assistant speech when the current session fails to start", async () => { + it("does not disturb assistant speech when the current session fails to start", async () => { const stopped = { available: true, unavailableReason: null, @@ -826,7 +839,11 @@ describe("voice transcript delivery coordination", () => { startRequest.reject(new Error("start failed")); await toggling; - expect(nativeAssistantSpeechMocks.stop).toHaveBeenCalledOnce(); + expect(nativeAssistantSpeechMocks.capture).toHaveBeenCalledWith( + "session-a", + ); + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); }); it("deduplicates concurrent controls for the same session", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index fba57b2e2..9a15794f0 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -13,8 +13,8 @@ import { useVoiceConversationStore, } from "../stores/voiceConversationStore"; import { + captureNativeAssistantSpeechHistory, startNativeAssistantSpeech, - stopNativeAssistantSpeech, takeVoicePlaybackNotices, } from "../lib/nativeAssistantSpeech"; import { setVoiceConversationControlsSuppressed } from "../api/voiceConversation"; @@ -708,34 +708,43 @@ export function useVoiceConversationController({ status.sessionId, ]); - const startAssistantSpeech = useCallback(() => { - startNativeAssistantSpeech(sessionId, (text, playbackError) => { - addErrorNotification( + const startAssistantSpeech = useCallback( + ( + initialMessages?: ReturnType, + ) => { + startNativeAssistantSpeech( sessionId, - `Pocket TTS could not speak the assistant response: ${errorText( - playbackError, - )}`, + (text, playbackError) => { + addErrorNotification( + sessionId, + `Pocket TTS could not speak the assistant response: ${errorText( + playbackError, + )}`, + ); + console.error("Native Pocket playback failed", { + sessionId, + textLength: text.length, + error: playbackError, + }); + }, + initialMessages, ); - console.error("Native Pocket playback failed", { - sessionId, - textLength: text.length, - error: playbackError, - }); - }); - }, [sessionId]); + }, + [sessionId], + ); const startCurrentConversation = useCallback(async () => { // Do not rely on the mount effect racing ahead of the user's first // click. The native recognizer can finalize quickly, so its delivery // subscriber must exist before the microphone lifecycle starts. ensureVoiceEventDeliveryInitialized(); + const assistantSpeechHistory = + captureNativeAssistantSpeechHistory(sessionId); const route = { sessionId, send: onSend }; activeSendRoute = route; - // Capture the history boundary before native startup can admit a - // transcript and produce the first assistant response. - startAssistantSpeech(); try { await start(sessionId); + startAssistantSpeech(assistantSpeechHistory); } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; const conversationStarted = @@ -746,7 +755,6 @@ export function useVoiceConversationController({ activeSendRoute?.sessionId === route.sessionId ) { activeSendRoute = null; - stopNativeAssistantSpeech(); } addErrorNotification(sessionId, errorText(startError)); } @@ -755,6 +763,9 @@ export function useVoiceConversationController({ useEffect(() => { if (status.lifecycle !== "running" || status.sessionId !== sessionId) return; + // The initiating operation captured the pre-start history boundary and + // activates speech after native startup succeeds. + if (operationInFlightBySession.has(sessionId)) return; startAssistantSpeech(); }, [sessionId, startAssistantSpeech, status.lifecycle, status.sessionId]); @@ -851,6 +862,7 @@ export function useVoiceConversationController({ if (operationInFlightBySession.has(sessionId)) return; operationInFlightBySession.add(sessionId); try { + if (replacementOperationInFlight) return; const currentStatus = await refreshStatus().catch(() => { addErrorNotification( sessionId, @@ -859,6 +871,7 @@ export function useVoiceConversationController({ return null; }); if (!currentStatus) return; + if (replacementOperationInFlight) return; const currentlyActive = currentStatus.sessionId !== null && currentStatus.lifecycle !== "stopped" && diff --git a/src/features/voice-conversation/lib/nativeAssistantSpeech.ts b/src/features/voice-conversation/lib/nativeAssistantSpeech.ts index d7a049b89..4028ff689 100644 --- a/src/features/voice-conversation/lib/nativeAssistantSpeech.ts +++ b/src/features/voice-conversation/lib/nativeAssistantSpeech.ts @@ -1,5 +1,9 @@ import { useChatStore } from "@/features/chat/stores/chatStore"; -import type { TextContent, VoiceSpeechState } from "@/shared/types/messages"; +import type { + Message, + TextContent, + VoiceSpeechState, +} from "@/shared/types/messages"; import { appendPocketVoiceStream, finishPocketVoiceStream, @@ -653,9 +657,16 @@ export function stopNativeAssistantSpeech(awaitTerminalDelivery = false): void { activeSpeechRevision = null; } +export function captureNativeAssistantSpeechHistory( + sessionId: string, +): Message[] { + return [...(useChatStore.getState().messagesBySession[sessionId] ?? [])]; +} + export function startNativeAssistantSpeech( sessionId: string, onFailure: SpeechFailureHandler, + initialMessages: Message[] = captureNativeAssistantSpeechHistory(sessionId), ): void { if (activeSpeechSessionId === sessionId) return; stopNativeAssistantSpeech(); @@ -691,8 +702,6 @@ export function startNativeAssistantSpeech( stopStreamSubscription = unlisten; }); - const initialMessages = - useChatStore.getState().messagesBySession[sessionId] ?? []; const toolCountByMessage = new Map(); const consumedTextBySlot = new Map(); const completedMessages = new Set(); From b18a766d9d77da779671ed1700753437e2cb0708 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 23:16:31 -0400 Subject: [PATCH 23/38] fix(voice): activate reconciled winner speech --- .../useVoiceConversationController.test.ts | 63 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 4 +- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 7220c0ef4..0b8a7ec4d 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -846,6 +846,69 @@ describe("voice transcript delivery coordination", () => { expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); }); + it("activates captured speech history when a rejected start is already running", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const running = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockReturnValue(startRequest.promise); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + act(() => { + useVoiceConversationStore.setState({ + status: running, + uiState: "listening", + }); + }); + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + startRequest.reject(new Error("renderer reconciliation failed")); + await toggling; + + expect(nativeAssistantSpeechMocks.start).toHaveBeenCalledOnce(); + expect(nativeAssistantSpeechMocks.start).toHaveBeenCalledWith( + "session-a", + expect.any(Function), + [], + ); + expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); + }); + it("deduplicates concurrent controls for the same session", async () => { const stopped = { available: true, diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 9a15794f0..d36d8a9db 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -750,7 +750,9 @@ export function useVoiceConversationController({ const conversationStarted = backendStatus.lifecycle === "running" && backendStatus.sessionId === sessionId; - if ( + if (conversationStarted) { + startAssistantSpeech(assistantSpeechHistory); + } else if ( !conversationStarted && activeSendRoute?.sessionId === route.sessionId ) { From c34608dbd5ef27c0533fb7c5830a630bb826c017 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 23:24:41 -0400 Subject: [PATCH 24/38] fix(voice): bind recovery to owner window --- .../useVoiceConversationController.test.ts | 78 ++++++++++++++++++- .../hooks/useVoiceConversationController.ts | 15 +++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 0b8a7ec4d..77bc55584 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -9,6 +9,11 @@ const nativeAssistantSpeechMocks = vi.hoisted(() => ({ stop: vi.fn(), takeNotices: vi.fn<() => string | null>(() => null), })); +const tauriWindowMocks = vi.hoisted(() => ({ label: "main" })); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ label: tauriWindowMocks.label }), +})); vi.mock("../lib/nativeAssistantSpeech", () => ({ captureNativeAssistantSpeechHistory: nativeAssistantSpeechMocks.capture, @@ -184,6 +189,7 @@ describe("voice transcript delivery coordination", () => { }); beforeEach(() => { + tauriWindowMocks.label = "main"; nativeAssistantSpeechMocks.capture.mockClear(); nativeAssistantSpeechMocks.start.mockClear(); nativeAssistantSpeechMocks.stop.mockClear(); @@ -893,12 +899,15 @@ describe("voice transcript delivery coordination", () => { act(() => { useVoiceConversationStore.setState({ status: running, - uiState: "listening", + uiState: "error", + error: "renderer reconciliation failed", }); }); expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); - startRequest.reject(new Error("renderer reconciliation failed")); - await toggling; + await act(async () => { + startRequest.reject(new Error("renderer reconciliation failed")); + await toggling; + }); expect(nativeAssistantSpeechMocks.start).toHaveBeenCalledOnce(); expect(nativeAssistantSpeechMocks.start).toHaveBeenCalledWith( @@ -907,6 +916,69 @@ describe("voice transcript delivery coordination", () => { [], ); expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); + expect(useVoiceConversationStore.getState()).toMatchObject({ + status: running, + uiState: "listening", + error: null, + }); + }); + + it("does not activate speech for another window's same-session lifecycle", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const winner = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "session-window-winner", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockReturnValue(startRequest.promise); + tauriWindowMocks.label = "session-window-loser"; + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(stopped), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + act(() => { + useVoiceConversationStore.setState({ + status: winner, + uiState: "listening", + }); + }); + startRequest.reject(new Error("lost same-session start")); + await toggling; + + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); }); it("deduplicates concurrent controls for the same session", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index d36d8a9db..66348f7ef 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import type { ChatInputSendHandler, @@ -749,8 +750,20 @@ export function useVoiceConversationController({ const backendStatus = useVoiceConversationStore.getState().status; const conversationStarted = backendStatus.lifecycle === "running" && - backendStatus.sessionId === sessionId; + backendStatus.sessionId === sessionId && + backendStatus.ownerWindowLabel === getCurrentWindow().label; if (conversationStarted) { + useVoiceConversationStore.setState((state) => + state.status.sessionId === sessionId && + state.status.ownerWindowLabel === backendStatus.ownerWindowLabel && + state.status.revision === backendStatus.revision + ? { + uiState: + state.uiState === "error" ? "listening" : state.uiState, + error: null, + } + : state, + ); startAssistantSpeech(assistantSpeechHistory); } else if ( !conversationStarted && From de1457ab9116b24d55c9611ca6e652b85506c3fa Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 23:37:15 -0400 Subject: [PATCH 25/38] fix(voice): bind recovered speech to owner --- .../useVoiceConversationController.test.ts | 37 +++++++++++++++++++ .../hooks/useVoiceConversationController.ts | 25 +++++++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 77bc55584..32a136e63 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -921,6 +921,43 @@ describe("voice transcript delivery coordination", () => { uiState: "listening", error: null, }); + expect( + useChatStore.getState().messagesBySession["session-a"], + ).toBeUndefined(); + }); + + it("does not activate speech when a non-owner mounts an already-running session", () => { + const running = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "session-window-owner", + microphoneMuted: false, + revision: 2, + }; + tauriWindowMocks.label = "session-window-mirror"; + useVoiceConversationStore.setState({ + status: running, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + }); + + renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + expect(nativeAssistantSpeechMocks.stop).not.toHaveBeenCalled(); }); it("does not activate speech for another window's same-session lifecycle", async () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 66348f7ef..81e9d2712 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -765,24 +765,33 @@ export function useVoiceConversationController({ : state, ); startAssistantSpeech(assistantSpeechHistory); - } else if ( - !conversationStarted && - activeSendRoute?.sessionId === route.sessionId - ) { - activeSendRoute = null; + } else { + if (activeSendRoute?.sessionId === route.sessionId) { + activeSendRoute = null; + } + addErrorNotification(sessionId, errorText(startError)); } - addErrorNotification(sessionId, errorText(startError)); } }, [onSend, sessionId, start, startAssistantSpeech]); useEffect(() => { - if (status.lifecycle !== "running" || status.sessionId !== sessionId) + if ( + status.lifecycle !== "running" || + status.sessionId !== sessionId || + status.ownerWindowLabel !== getCurrentWindow().label + ) return; // The initiating operation captured the pre-start history boundary and // activates speech after native startup succeeds. if (operationInFlightBySession.has(sessionId)) return; startAssistantSpeech(); - }, [sessionId, startAssistantSpeech, status.lifecycle, status.sessionId]); + }, [ + sessionId, + startAssistantSpeech, + status.lifecycle, + status.ownerWindowLabel, + status.sessionId, + ]); useEffect(() => { if ( From 1bfa5ca606946288df7067a14bec4c488e37ca20 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 23:43:36 -0400 Subject: [PATCH 26/38] fix(voice): confirm recovered microphone --- .../useVoiceConversationController.test.ts | 77 ++++++++++++++++++- .../hooks/useVoiceConversationController.ts | 21 ++++- 2 files changed, 95 insertions(+), 3 deletions(-) diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 32a136e63..3657a899f 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -871,12 +871,23 @@ describe("voice transcript delivery coordination", () => { }; const startRequest = deferred(); const start = vi.fn().mockReturnValue(startRequest.promise); + const refreshStatus = vi + .fn() + .mockResolvedValueOnce(stopped) + .mockImplementationOnce(async () => { + useVoiceConversationStore.setState({ + status: running, + uiState: "listening", + error: null, + }); + return running; + }); useVoiceConversationStore.setState({ status: stopped, uiState: "off", hydrated: true, init: vi.fn().mockResolvedValue(undefined), - refreshStatus: vi.fn().mockResolvedValue(stopped), + refreshStatus, drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), start, }); @@ -924,6 +935,70 @@ describe("voice transcript delivery coordination", () => { expect( useChatStore.getState().messagesBySession["session-a"], ).toBeUndefined(); + expect(refreshStatus).toHaveBeenCalledTimes(2); + }); + + it("surfaces a rejected start when owner microphone reconciliation still fails", async () => { + const stopped = { + available: true, + unavailableReason: null, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + microphoneMuted: false, + revision: 1, + }; + const running = { + ...stopped, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "main", + revision: 2, + }; + const startRequest = deferred(); + const start = vi.fn().mockReturnValue(startRequest.promise); + useVoiceConversationStore.setState({ + status: stopped, + uiState: "off", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi + .fn() + .mockResolvedValueOnce(stopped) + .mockRejectedValueOnce(new Error("microphone unavailable")), + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-a", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + let toggling!: Promise; + act(() => { + toggling = Promise.resolve(result.current.onToggle()); + }); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + act(() => { + useVoiceConversationStore.setState({ + status: running, + uiState: "error", + error: "renderer reconciliation failed", + }); + }); + startRequest.reject(new Error("renderer reconciliation failed")); + await toggling; + + expect(nativeAssistantSpeechMocks.start).not.toHaveBeenCalled(); + expect(useChatStore.getState().messagesBySession["session-a"]).toHaveLength( + 1, + ); }); it("does not activate speech when a non-owner mounts an already-running session", () => { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 81e9d2712..dc11f0f3b 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -748,10 +748,27 @@ export function useVoiceConversationController({ startAssistantSpeech(assistantSpeechHistory); } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; - const conversationStarted = + const currentWindowLabel = getCurrentWindow().label; + const exactOwnerLifecycleSurvived = backendStatus.lifecycle === "running" && backendStatus.sessionId === sessionId && - backendStatus.ownerWindowLabel === getCurrentWindow().label; + backendStatus.ownerWindowLabel === currentWindowLabel; + let conversationStarted = false; + if (exactOwnerLifecycleSurvived) { + try { + const reconciledStatus = await useVoiceConversationStore + .getState() + .refreshStatus(); + conversationStarted = + reconciledStatus.lifecycle === "running" && + reconciledStatus.sessionId === sessionId && + reconciledStatus.ownerWindowLabel === currentWindowLabel && + reconciledStatus.revision === backendStatus.revision; + } catch { + // Preserve the original startup failure below. A surviving native + // lifecycle is usable only after microphone reconciliation succeeds. + } + } if (conversationStarted) { useVoiceConversationStore.setState((state) => state.status.sessionId === sessionId && From 3e0c1311841535f4b1d68138f7b143b1e70a3b7c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 23:52:29 -0400 Subject: [PATCH 27/38] fix(voice): revalidate handoff target --- src-tauri/src/commands/native_voice.rs | 75 ++++++++++++++++++- .../api/voiceConversation.ts | 6 ++ .../useVoiceConversationController.test.ts | 50 +++++++++++++ .../hooks/useVoiceConversationController.ts | 9 ++- 4 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index 7cfeae05f..6e68d084b 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1355,8 +1355,32 @@ pub async fn stop_native_voice_conversation_for_replacement( "Only the focused target session can replace a voice conversation.".to_string(), ); } + let _stop_guard = state + .replacement_stop_guard(|| { + let target_owner = window_sessions.label_for(target_session_id); + let owns_foreground_session = capture.foreground_session_matches( + webview_window.label(), + &renderer_id, + renderer_epoch, + target_session_id, + )?; + if !replacement_caller_matches_target( + webview_window.label(), + target_owner.as_deref(), + owns_foreground_session, + ) { + return Err("The target session is no longer in the foreground.".to_string()); + } + if !webview_window.is_focused().map_err(|error| { + format!("Could not confirm the target session window focus: {error}") + })? { + return Err("The target session window is no longer focused.".to_string()); + } + Ok(()) + }) + .await?; state - .stop_active_for_lifecycle(&app, &capture, &session_id, expected_revision) + .stop_active_inner_locked(&app, &capture, Some((&session_id, expected_revision, None))) .await?; Ok(status(&app, &state)) } @@ -1398,6 +1422,18 @@ fn refresh_microphone_claim( } impl NativeVoiceState { + async fn replacement_stop_guard( + &self, + validate_target: F, + ) -> Result, String> + where + F: FnOnce() -> Result<(), String>, + { + let guard = self.stop_serial.lock().await; + validate_target()?; + Ok(guard) + } + pub async fn stop_active( &self, app: &AppHandle, @@ -1448,6 +1484,16 @@ impl NativeVoiceState { expected_lifecycle: Option<(&str, u64, Option<&str>)>, ) -> Result { let _stop_guard = self.stop_serial.lock().await; + self.stop_active_inner_locked(app, capture, expected_lifecycle) + .await + } + + async fn stop_active_inner_locked( + &self, + app: &AppHandle, + capture: &VoiceCaptureState, + expected_lifecycle: Option<(&str, u64, Option<&str>)>, + ) -> Result { let failure_message = expected_lifecycle.and_then(|(_, _, message)| message); let completion = self .stop_lifecycle_locked( @@ -2108,6 +2154,33 @@ fn deliver_recognition_result( mod tests { use super::*; + #[tokio::test] + async fn replacement_revalidates_target_after_waiting_for_stop_serialization() { + let state = NativeVoiceState::default(); + let target_is_foreground = AtomicBool::new(true); + let active_operation = state.stop_serial.lock().await; + let validation = state.replacement_stop_guard(|| { + target_is_foreground + .load(Ordering::SeqCst) + .then_some(()) + .ok_or_else(|| "The target session is no longer in the foreground.".to_string()) + }); + tokio::pin!(validation); + + assert!( + tokio::time::timeout(Duration::from_millis(10), validation.as_mut()) + .await + .is_err() + ); + target_is_foreground.store(false, Ordering::SeqCst); + drop(active_operation); + + assert_eq!( + validation.await.expect_err("stale target must be rejected"), + "The target session is no longer in the foreground." + ); + } + #[test] fn native_mute_control_does_not_latch_the_software_fallback() { assert!(!software_microphone_mute(true, true)); diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index ba8d00f64..1cbba3202 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -365,6 +365,12 @@ async function awaitForegroundSessionClaim( } } +export function confirmVoiceConversationForegroundSession( + sessionId: string, +): Promise { + return awaitForegroundSessionClaim(sessionId); +} + export async function blockNativeVoiceConversationStarts( sessionId: string, ): Promise { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index 3657a899f..ebb7db549 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -10,6 +10,9 @@ const nativeAssistantSpeechMocks = vi.hoisted(() => ({ takeNotices: vi.fn<() => string | null>(() => null), })); const tauriWindowMocks = vi.hoisted(() => ({ label: "main" })); +const voiceApiMocks = vi.hoisted(() => ({ + confirmForegroundSession: vi.fn<() => Promise>(), +})); vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: tauriWindowMocks.label }), @@ -22,6 +25,12 @@ vi.mock("../lib/nativeAssistantSpeech", () => ({ takeVoicePlaybackNotices: nativeAssistantSpeechMocks.takeNotices, })); +vi.mock("../api/voiceConversation", async (importOriginal) => ({ + ...(await importOriginal()), + confirmVoiceConversationForegroundSession: + voiceApiMocks.confirmForegroundSession, +})); + import { canBindVoiceSendRoute, canReplaceActiveVoiceConversation, @@ -194,6 +203,8 @@ describe("voice transcript delivery coordination", () => { nativeAssistantSpeechMocks.start.mockClear(); nativeAssistantSpeechMocks.stop.mockClear(); nativeAssistantSpeechMocks.takeNotices.mockClear(); + voiceApiMocks.confirmForegroundSession.mockReset(); + voiceApiMocks.confirmForegroundSession.mockResolvedValue(undefined); useChatStore.setState({ messagesBySession: {}, sessionStateById: {} }); }); it("serializes deliveries for the same session and re-evaluates in order", async () => { @@ -1212,6 +1223,45 @@ describe("voice transcript delivery coordination", () => { expect(start).toHaveBeenCalledOnce(); }); + it("reconfirms the target after stopping and before starting", async () => { + const order: string[] = []; + const stop = vi.fn(async () => { + order.push("stop"); + return { lifecycle: "stopped", sessionId: null }; + }); + const confirmTarget = vi.fn(async () => { + order.push("confirm"); + }); + const start = vi.fn(async () => { + order.push("start"); + }); + + await expect( + replaceActiveVoiceConversation({ stop, confirmTarget, start }), + ).resolves.toBe(true); + expect(order).toEqual(["stop", "confirm", "start"]); + }); + + it("does not start when the target changes after stopping", async () => { + const start = vi.fn().mockResolvedValue(undefined); + + await expect( + replaceActiveVoiceConversation({ + stop: vi.fn().mockResolvedValue({ + lifecycle: "stopped", + sessionId: null, + }), + confirmTarget: vi + .fn() + .mockRejectedValue( + new Error("The target session is no longer in the foreground."), + ), + start, + }), + ).rejects.toThrow("no longer in the foreground"); + expect(start).not.toHaveBeenCalled(); + }); + it("does not start a replacement when the active call remains running", async () => { const start = vi.fn().mockResolvedValue(undefined); diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index dc11f0f3b..d595b444a 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -18,7 +18,10 @@ import { startNativeAssistantSpeech, takeVoicePlaybackNotices, } from "../lib/nativeAssistantSpeech"; -import { setVoiceConversationControlsSuppressed } from "../api/voiceConversation"; +import { + confirmVoiceConversationForegroundSession, + setVoiceConversationControlsSuppressed, +} from "../api/voiceConversation"; interface VoiceSendRoute { sessionId: string; @@ -93,6 +96,7 @@ export function shouldShowVoiceConversationControl(options: { export async function replaceActiveVoiceConversation(options: { stop: () => Promise<{ lifecycle: string; sessionId: string | null }>; + confirmTarget?: () => Promise; start: () => Promise; }): Promise { const stopped = await options.stop(); @@ -102,6 +106,7 @@ export async function replaceActiveVoiceConversation(options: { ) { return false; } + await options.confirmTarget?.(); await options.start(); return true; } @@ -943,6 +948,8 @@ export function useVoiceConversationController({ try { const replaced = await replaceActiveVoiceConversation({ stop: () => stopForReplacement(currentStatus, sessionId), + confirmTarget: () => + confirmVoiceConversationForegroundSession(sessionId), start: startCurrentConversation, }); if (!replaced) { From 4d8f039d84646a577acdda92ba1c75dfc0d89369 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 00:06:21 -0400 Subject: [PATCH 28/38] fix(voice): bind handoff start claim --- src-tauri/src/commands/native_voice.rs | 111 ++++++++++++------ src-tauri/src/commands/voice_capture.rs | 39 +++++- .../api/voiceConversation.test.ts | 1 + .../api/voiceConversation.ts | 11 +- .../useVoiceConversationController.test.ts | 61 +++++++++- .../hooks/useVoiceConversationController.ts | 19 ++- .../stores/voiceConversationStore.ts | 12 +- 7 files changed, 199 insertions(+), 55 deletions(-) diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index 6e68d084b..6a03dbb50 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -942,14 +942,17 @@ fn reject_pending_transcript( } #[tauri::command] +#[allow(clippy::too_many_arguments)] // Tauri injects four guards beside the lifecycle claim. pub async fn start_native_voice_conversation( app: AppHandle, state: State<'_, NativeVoiceState>, capture: State<'_, VoiceCaptureState>, + window_sessions: State<'_, super::window_session::WindowSessionRegistry>, webview_window: WebviewWindow, session_id: String, renderer_id: String, renderer_epoch: u64, + foreground_generation: u64, ) -> Result { let session_id = session_id.trim().to_string(); if session_id.is_empty() || session_id.len() > 256 { @@ -985,7 +988,28 @@ pub async fn start_native_voice_conversation( return Err(error); } }; - let lifecycle_guard = state.stop_serial.lock().await; + let lifecycle_guard = state + .target_lifecycle_guard(|| { + validate_voice_target_session( + capture.inner(), + &window_sessions, + &webview_window, + &renderer_id, + renderer_epoch, + &session_id, + Some(foreground_generation), + ) + }) + .await; + let lifecycle_guard = match lifecycle_guard { + Ok(guard) => guard, + Err(error) => { + if microphone_claimed { + capture.release_microphone(&window_label, &renderer_id, renderer_epoch, &owner_id); + } + return Err(error); + } + }; match refresh_microphone_claim( capture.inner(), &window_label, @@ -1333,50 +1357,26 @@ pub async fn stop_native_voice_conversation_for_replacement( if target_session_id.is_empty() || target_session_id.len() > 256 { return Err("target session id must be between 1 and 256 bytes".to_string()); } - let target_owner = window_sessions.label_for(target_session_id); - let owns_foreground_session = capture.foreground_session_matches( - webview_window.label(), + validate_voice_target_session( + capture.inner(), + &window_sessions, + &webview_window, &renderer_id, renderer_epoch, target_session_id, + None, )?; - if !replacement_caller_matches_target( - webview_window.label(), - target_owner.as_deref(), - owns_foreground_session, - ) { - return Err("Only the target session window can replace a voice conversation.".to_string()); - } - if !webview_window - .is_focused() - .map_err(|error| format!("Could not confirm the target session window focus: {error}"))? - { - return Err( - "Only the focused target session can replace a voice conversation.".to_string(), - ); - } let _stop_guard = state - .replacement_stop_guard(|| { - let target_owner = window_sessions.label_for(target_session_id); - let owns_foreground_session = capture.foreground_session_matches( - webview_window.label(), + .target_lifecycle_guard(|| { + validate_voice_target_session( + capture.inner(), + &window_sessions, + &webview_window, &renderer_id, renderer_epoch, target_session_id, - )?; - if !replacement_caller_matches_target( - webview_window.label(), - target_owner.as_deref(), - owns_foreground_session, - ) { - return Err("The target session is no longer in the foreground.".to_string()); - } - if !webview_window.is_focused().map_err(|error| { - format!("Could not confirm the target session window focus: {error}") - })? { - return Err("The target session window is no longer focused.".to_string()); - } - Ok(()) + None, + ) }) .await?; state @@ -1399,6 +1399,39 @@ fn replacement_caller_matches_target( } } +fn validate_voice_target_session( + capture: &VoiceCaptureState, + window_sessions: &super::window_session::WindowSessionRegistry, + webview_window: &WebviewWindow, + renderer_id: &str, + renderer_epoch: u64, + target_session_id: &str, + foreground_generation: Option, +) -> Result<(), String> { + let target_owner = window_sessions.label_for(target_session_id); + let owns_foreground_session = capture.foreground_session_matches_generation( + webview_window.label(), + renderer_id, + renderer_epoch, + target_session_id, + foreground_generation, + )?; + if !replacement_caller_matches_target( + webview_window.label(), + target_owner.as_deref(), + owns_foreground_session, + ) { + return Err("The target session is no longer in the foreground.".to_string()); + } + if !webview_window + .is_focused() + .map_err(|error| format!("Could not confirm the target session window focus: {error}"))? + { + return Err("The target session window is no longer focused.".to_string()); + } + Ok(()) +} + fn native_owner_id(session_id: &str) -> String { format!("native-voice:{session_id}") } @@ -1422,7 +1455,7 @@ fn refresh_microphone_claim( } impl NativeVoiceState { - async fn replacement_stop_guard( + async fn target_lifecycle_guard( &self, validate_target: F, ) -> Result, String> @@ -2159,7 +2192,7 @@ mod tests { let state = NativeVoiceState::default(); let target_is_foreground = AtomicBool::new(true); let active_operation = state.stop_serial.lock().await; - let validation = state.replacement_stop_guard(|| { + let validation = state.target_lifecycle_guard(|| { target_is_foreground .load(Ordering::SeqCst) .then_some(()) diff --git a/src-tauri/src/commands/voice_capture.rs b/src-tauri/src/commands/voice_capture.rs index cb071f363..7745252b5 100644 --- a/src-tauri/src/commands/voice_capture.rs +++ b/src-tauri/src/commands/voice_capture.rs @@ -198,12 +198,30 @@ impl VoiceCaptureState { Ok(()) } - pub fn foreground_session_matches( + #[cfg(test)] + fn foreground_session_matches( + &self, + window_label: &str, + renderer_id: &str, + renderer_epoch: u64, + session_id: &str, + ) -> Result { + self.foreground_session_matches_generation( + window_label, + renderer_id, + renderer_epoch, + session_id, + None, + ) + } + + pub fn foreground_session_matches_generation( &self, window_label: &str, renderer_id: &str, renderer_epoch: u64, session_id: &str, + expected_generation: Option, ) -> Result { validate_id("renderer", renderer_id)?; validate_id("session", session_id)?; @@ -219,6 +237,7 @@ impl VoiceCaptureState { claim.renderer_id == renderer_id && claim.renderer_epoch == renderer_epoch && claim.session_id.as_deref() == Some(session_id) + && expected_generation.is_none_or(|generation| claim.generation == generation) })) } @@ -525,6 +544,24 @@ mod tests { assert!(capture .foreground_session_matches("main", "renderer-1", epoch, "session-c") .expect("authorize session C")); + assert!(!capture + .foreground_session_matches_generation( + "main", + "renderer-1", + epoch, + "session-c", + Some(1), + ) + .expect("reject superseded generation")); + assert!(capture + .foreground_session_matches_generation( + "main", + "renderer-1", + epoch, + "session-c", + Some(2), + ) + .expect("authorize current generation")); } #[test] diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index 3243a0686..9f77d145d 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -128,6 +128,7 @@ describe("voice conversation API", () => { sessionId: "session-1", rendererId: "renderer-test", rendererEpoch: 7, + foregroundGeneration: 0, }, ); expect(mocks.stopMicrophone).toHaveBeenCalledOnce(); diff --git a/src/features/voice-conversation/api/voiceConversation.ts b/src/features/voice-conversation/api/voiceConversation.ts index 1cbba3202..aa87abb90 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -309,7 +309,7 @@ function renewForegroundSessionClaim( async function awaitForegroundSessionClaim( targetSessionId: string, -): Promise { +): Promise { let targetClaim = foregroundSessionClaim; if ( foregroundSessionId !== targetSessionId || @@ -360,14 +360,17 @@ async function awaitForegroundSessionClaim( ) { throw new Error("The target session is no longer in the foreground."); } - if (outcome.type === "acknowledged" && latestClaim === targetClaim) return; + if (outcome.type === "acknowledged" && latestClaim === targetClaim) { + return targetClaim.generation; + } targetClaim = latestClaim; } + throw new Error("The target session is no longer in the foreground."); } export function confirmVoiceConversationForegroundSession( sessionId: string, -): Promise { +): Promise { return awaitForegroundSessionClaim(sessionId); } @@ -506,6 +509,7 @@ export function rejectVoiceConversationTranscript( export async function startVoiceConversation( sessionId: string, + foregroundGeneration = 0, ): Promise { resetMicrophoneMuteState(); const { rendererId, rendererEpoch } = await getRendererInstance(); @@ -515,6 +519,7 @@ export async function startVoiceConversation( sessionId, rendererId, rendererEpoch, + foregroundGeneration, }, ); try { diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts index ebb7db549..abc3c9cc3 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -11,7 +11,7 @@ const nativeAssistantSpeechMocks = vi.hoisted(() => ({ })); const tauriWindowMocks = vi.hoisted(() => ({ label: "main" })); const voiceApiMocks = vi.hoisted(() => ({ - confirmForegroundSession: vi.fn<() => Promise>(), + confirmForegroundSession: vi.fn<() => Promise>(), })); vi.mock("@tauri-apps/api/window", () => ({ @@ -204,7 +204,7 @@ describe("voice transcript delivery coordination", () => { nativeAssistantSpeechMocks.stop.mockClear(); nativeAssistantSpeechMocks.takeNotices.mockClear(); voiceApiMocks.confirmForegroundSession.mockReset(); - voiceApiMocks.confirmForegroundSession.mockResolvedValue(undefined); + voiceApiMocks.confirmForegroundSession.mockResolvedValue(1); useChatStore.setState({ messagesBySession: {}, sessionStateById: {} }); }); it("serializes deliveries for the same session and re-evaluates in order", async () => { @@ -463,7 +463,7 @@ describe("voice transcript delivery coordination", () => { })); }); - await waitFor(() => expect(start).toHaveBeenCalledWith("session-1")); + await waitFor(() => expect(start).toHaveBeenCalledWith("session-1", 1)); expect( useVoiceConversationStore.getState().requestedStartSessionId, ).toBeNull(); @@ -552,7 +552,58 @@ describe("voice transcript delivery coordination", () => { stopRequest.resolve(stopped); await handoff; }); - expect(start).toHaveBeenCalledWith("session-b"); + expect(start).toHaveBeenCalledWith("session-b", 1); + }); + + it("does not start a replacement superseded after the active call stops", async () => { + const active = { + available: true, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-a", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 2, + }; + const stopped = { + ...active, + lifecycle: "stopped" as const, + sessionId: null, + ownerWindowLabel: null, + revision: 3, + }; + const start = vi.fn(); + voiceApiMocks.confirmForegroundSession + .mockResolvedValueOnce(1) + .mockRejectedValueOnce( + new Error("The target session is no longer in the foreground."), + ); + useVoiceConversationStore.setState({ + status: active, + uiState: "listening", + hydrated: true, + init: vi.fn().mockResolvedValue(undefined), + refreshStatus: vi.fn().mockResolvedValue(active), + stopForReplacement: vi.fn().mockResolvedValue(stopped), + start, + }); + const { result } = renderHook(() => + useVoiceConversationController({ + sessionId: "session-b", + onSend: vi.fn().mockResolvedValue(true), + enabled: true, + isGooseSession: true, + pocketReady: true, + onPocketSetupRequired: vi.fn(), + }), + ); + + await act(async () => { + await result.current.onToggle(); + }); + + expect(voiceApiMocks.confirmForegroundSession).toHaveBeenCalledTimes(2); + expect(start).not.toHaveBeenCalled(); }); it("accepts a later toggle after a replacement attempt times out", async () => { @@ -794,7 +845,7 @@ describe("voice transcript delivery coordination", () => { startRequest.resolve(runningWinner); await winnerToggle; - expect(start).toHaveBeenCalledWith(winnerSessionId); + expect(start).toHaveBeenCalledWith(winnerSessionId, 1); expect(nativeAssistantSpeechMocks.start).toHaveBeenLastCalledWith( winnerSessionId, expect.any(Function), diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index d595b444a..b3f66fcca 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.ts @@ -96,7 +96,7 @@ export function shouldShowVoiceConversationControl(options: { export async function replaceActiveVoiceConversation(options: { stop: () => Promise<{ lifecycle: string; sessionId: string | null }>; - confirmTarget?: () => Promise; + confirmTarget?: () => Promise; start: () => Promise; }): Promise { const stopped = await options.stop(); @@ -749,7 +749,9 @@ export function useVoiceConversationController({ const route = { sessionId, send: onSend }; activeSendRoute = route; try { - await start(sessionId); + const foregroundGeneration = + await confirmVoiceConversationForegroundSession(sessionId); + await start(sessionId, foregroundGeneration); startAssistantSpeech(assistantSpeechHistory); } catch (startError) { const backendStatus = useVoiceConversationStore.getState().status; @@ -786,8 +788,17 @@ export function useVoiceConversationController({ } : state, ); - startAssistantSpeech(assistantSpeechHistory); - } else { + const currentStatus = useVoiceConversationStore.getState().status; + conversationStarted = + currentStatus.lifecycle === "running" && + currentStatus.sessionId === sessionId && + currentStatus.ownerWindowLabel === currentWindowLabel && + currentStatus.revision === backendStatus.revision; + if (conversationStarted) { + startAssistantSpeech(assistantSpeechHistory); + } + } + if (!conversationStarted) { if (activeSendRoute?.sessionId === route.sessionId) { activeSendRoute = null; } diff --git a/src/features/voice-conversation/stores/voiceConversationStore.ts b/src/features/voice-conversation/stores/voiceConversationStore.ts index bc0b173ac..80684ea59 100644 --- a/src/features/voice-conversation/stores/voiceConversationStore.ts +++ b/src/features/voice-conversation/stores/voiceConversationStore.ts @@ -52,7 +52,10 @@ interface VoiceConversationStore { refreshStatus: () => Promise; requestStart: (sessionId: string) => void; clearRequestedStart: (sessionId: string) => void; - start: (sessionId: string) => Promise; + start: ( + sessionId: string, + foregroundGeneration?: number, + ) => Promise; stop: () => Promise; stopForReplacement: ( status: VoiceConversationStatus, @@ -565,7 +568,7 @@ export const useVoiceConversationStore = create( return status; }, - start: (sessionId) => { + start: (sessionId, foregroundGeneration) => { if (voiceStartBlocks.has(sessionId)) { return Promise.reject( new Error("Voice cannot start while this chat is being archived."), @@ -576,7 +579,10 @@ export const useVoiceConversationStore = create( set({ uiState: "starting", microphoneMuted: false, error: null }); const request = (async () => { try { - const status = await startVoiceConversation(sessionId); + const status = await startVoiceConversation( + sessionId, + foregroundGeneration, + ); set((state) => shouldApplyResponseRevision(state.status, status.revision) || (status.revision === state.status.revision && From 7ec51196e45cda904b80e6654c5fce7b1ded8781 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 00:10:29 -0400 Subject: [PATCH 29/38] fix(voice): serialize microphone reservation --- src-tauri/src/commands/native_voice.rs | 48 +++++++++++++++----------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index 6a03dbb50..8d3d9cebf 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -960,6 +960,19 @@ pub async fn start_native_voice_conversation( } let window_label = webview_window.label().to_string(); let owner_id = native_owner_id(&session_id); + let lifecycle_guard = state + .target_lifecycle_guard(|| { + validate_voice_target_session( + capture.inner(), + &window_sessions, + &webview_window, + &renderer_id, + renderer_epoch, + &session_id, + Some(foreground_generation), + ) + }) + .await?; let mut microphone_claimed = capture.claim_microphone( window_label.clone(), renderer_id.clone(), @@ -988,28 +1001,21 @@ pub async fn start_native_voice_conversation( return Err(error); } }; - let lifecycle_guard = state - .target_lifecycle_guard(|| { - validate_voice_target_session( - capture.inner(), - &window_sessions, - &webview_window, - &renderer_id, - renderer_epoch, - &session_id, - Some(foreground_generation), - ) - }) - .await; - let lifecycle_guard = match lifecycle_guard { - Ok(guard) => guard, - Err(error) => { - if microphone_claimed { - capture.release_microphone(&window_label, &renderer_id, renderer_epoch, &owner_id); - } - return Err(error); + if let Err(error) = validate_voice_target_session( + capture.inner(), + &window_sessions, + &webview_window, + &renderer_id, + renderer_epoch, + &session_id, + Some(foreground_generation), + ) { + drop(lifecycle_guard); + if microphone_claimed { + capture.release_microphone(&window_label, &renderer_id, renderer_epoch, &owner_id); } - }; + return Err(error); + } match refresh_microphone_claim( capture.inner(), &window_label, From 11aea009089b9f74ad7d2068194969fe1536fcee Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 07:31:49 -0400 Subject: [PATCH 30/38] fix(voice): preserve speech delivery across handoff --- .../lib/nativeAssistantSpeech.test.ts | 121 ++++++++++++++++++ .../lib/nativeAssistantSpeech.ts | 28 ++++ 2 files changed, 149 insertions(+) diff --git a/src/features/voice-conversation/lib/nativeAssistantSpeech.test.ts b/src/features/voice-conversation/lib/nativeAssistantSpeech.test.ts index 390b206f3..e61022f83 100644 --- a/src/features/voice-conversation/lib/nativeAssistantSpeech.test.ts +++ b/src/features/voice-conversation/lib/nativeAssistantSpeech.test.ts @@ -748,6 +748,127 @@ describe("native assistant speech stream", () => { ).toMatchObject({ speech: { status: "interrupted" } }); }); + it("preserves terminal delivery before activating a replacement session", async () => { + startNativeAssistantSpeech("session-1", vi.fn()); + useChatStore + .getState() + .setMessages("session-1", [ + assistant([{ type: "text", text: "One. Two. Three." }]), + ]); + await vi.waitFor(() => expect(mocks.append).toHaveBeenCalled()); + const firstStreamId = mocks.start.mock.calls[0]?.[0] as string; + + useVoiceConversationStore.setState((voice) => ({ + status: { + ...voice.status, + lifecycle: "running", + sessionId: "session-2", + revision: voice.status.revision + 1, + }, + })); + await vi.waitFor(() => expect(mocks.stop).toHaveBeenCalled()); + + startNativeAssistantSpeech("session-2", vi.fn()); + expect(mocks.start).toHaveBeenCalledTimes(1); + + mocks.streamHandler?.({ + streamId: firstStreamId, + state: "interrupted", + error: null, + delivery: { + segments: [ + { + text: "One. Two. Three.", + playedFrames: 650, + totalFrames: 1_000, + synthesisComplete: true, + }, + ], + }, + }); + + expect( + useChatStore.getState().messagesBySession["session-1"]?.[0]?.content[0], + ).toMatchObject({ + speech: { status: "interrupted", spokenThrough: 8 }, + }); + expect(useVoiceConversationStore.getState().status).toMatchObject({ + lifecycle: "running", + sessionId: "session-2", + }); + + useChatStore + .getState() + .setMessages("session-2", [ + assistant( + [{ type: "text", text: "Replacement reply." }], + "inProgress", + "assistant-2", + ), + ]); + await vi.waitFor(() => expect(mocks.start).toHaveBeenCalledTimes(2)); + }); + + it("cancels a deferred replacement when its voice lifecycle stops", async () => { + startNativeAssistantSpeech("session-1", vi.fn()); + useChatStore + .getState() + .setMessages("session-1", [ + assistant([{ type: "text", text: "First reply." }]), + ]); + await vi.waitFor(() => expect(mocks.append).toHaveBeenCalled()); + const firstStreamId = mocks.start.mock.calls[0]?.[0] as string; + + useVoiceConversationStore.setState((voice) => ({ + status: { + ...voice.status, + lifecycle: "running", + sessionId: "session-2", + revision: voice.status.revision + 1, + }, + })); + await vi.waitFor(() => expect(mocks.stop).toHaveBeenCalled()); + startNativeAssistantSpeech("session-2", vi.fn()); + + useVoiceConversationStore.setState((voice) => ({ + status: { + ...voice.status, + lifecycle: "stopped", + sessionId: null, + revision: voice.status.revision + 1, + }, + })); + mocks.streamHandler?.({ + streamId: firstStreamId, + state: "interrupted", + error: null, + delivery: { segments: [] }, + }); + await Promise.resolve(); + + useChatStore + .getState() + .setMessages("session-2", [ + assistant( + [{ type: "text", text: "Created while voice was off." }], + "completed", + "assistant-2", + ), + ]); + useVoiceConversationStore.setState((voice) => ({ + status: { + ...voice.status, + lifecycle: "running", + sessionId: "session-2", + revision: voice.status.revision + 1, + }, + })); + startNativeAssistantSpeech("session-2", vi.fn()); + await Promise.resolve(); + + expect(mocks.start).toHaveBeenCalledTimes(1); + }); + it.each([ ["returns false", () => mocks.stop.mockResolvedValue(false)], ["rejects", () => mocks.stop.mockRejectedValue(new Error("stop failed"))], diff --git a/src/features/voice-conversation/lib/nativeAssistantSpeech.ts b/src/features/voice-conversation/lib/nativeAssistantSpeech.ts index 4028ff689..04b5fbf85 100644 --- a/src/features/voice-conversation/lib/nativeAssistantSpeech.ts +++ b/src/features/voice-conversation/lib/nativeAssistantSpeech.ts @@ -74,6 +74,7 @@ let activeSpeechRevision: number | null = null; let activeUtterance: ActiveUtterance | null = null; let stopActiveVoice: () => Promise = stopPocketVoice; let activityReportQueue = Promise.resolve(); +let startRequestGeneration = 0; const pendingNotices = new Map>(); const DELIVERY_NOTICE_TEXT_LIMIT = 250; const INTERRUPTION_TERMINAL_TIMEOUT_MS = 1_000; @@ -626,6 +627,7 @@ function interruptActiveUtterance( } export function stopNativeAssistantSpeech(awaitTerminalDelivery = false): void { + startRequestGeneration += 1; generation += 1; const utterance = activeUtterance; const terminalStreamSubscription = stopStreamSubscription; @@ -669,6 +671,32 @@ export function startNativeAssistantSpeech( initialMessages: Message[] = captureNativeAssistantSpeechHistory(sessionId), ): void { if (activeSpeechSessionId === sessionId) return; + const startRequest = ++startRequestGeneration; + const interruptedUtterance = activeUtterance; + if ( + interruptedUtterance?.interruptionRequested && + interruptedUtterance.interruptionFallback !== null + ) { + const requestedVoice = useVoiceConversationStore.getState().status; + const onTerminal = interruptedUtterance.onTerminal; + interruptedUtterance.onTerminal = () => { + onTerminal(); + queueMicrotask(() => { + if (startRequest !== startRequestGeneration) return; + const currentVoice = useVoiceConversationStore.getState().status; + if ( + currentVoice.lifecycle !== "running" || + currentVoice.sessionId !== sessionId || + currentVoice.revision !== requestedVoice.revision || + currentVoice.ownerWindowLabel !== requestedVoice.ownerWindowLabel + ) { + return; + } + startNativeAssistantSpeech(sessionId, onFailure, initialMessages); + }); + }; + return; + } stopNativeAssistantSpeech(); activeSpeechSessionId = sessionId; activeSpeechRevision = useVoiceConversationStore.getState().status.revision; From 60d958840b13b74edb77d1bcf089b861097064b5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 08:59:34 -0400 Subject: [PATCH 31/38] fix(voice): authorize macOS call handoff focus --- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/native_voice.rs | 85 ++++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c57c6e2d..47256d672 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -111,7 +111,7 @@ windows-sys = { version = "0.59", features = [ [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.6" objc2 = "0.6" -objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSImage", "NSMenu", "NSMenuItem", "NSAlert", "NSButton", "NSControl", "NSCell", "NSResponder", "NSView"] } +objc2-app-kit = { version = "0.3.2", features = ["NSApplication", "NSRunningApplication", "NSImage", "NSMenu", "NSMenuItem", "NSAlert", "NSButton", "NSControl", "NSCell", "NSResponder", "NSView"] } objc2-avf-audio = { version = "0.3.2", features = ["AVAudioApplication", "block2"] } objc2-foundation = { version = "0.3.2", features = ["NSDictionary", "NSError", "NSFileManager", "NSObject", "NSProcessInfo", "NSString", "NSURL"] } objc2-user-notifications = "0.3.2" diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index 8d3d9cebf..735a942b7 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -17,7 +17,8 @@ use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; use tokio::sync::mpsc as tokio_mpsc; use super::{ - native_input_mute, pocket_voice::parakeet_model_dir, voice_capture::VoiceCaptureState, + native_input_mute, pocket_voice::parakeet_model_dir, voice_buddy, + voice_capture::VoiceCaptureState, }; pub(crate) const EVENT_NAME: &str = "voice-conversation:event"; @@ -1405,6 +1406,44 @@ fn replacement_caller_matches_target( } } +fn voice_target_window_focus_is_valid( + window_label: &str, + focused: bool, + app_is_active: bool, + another_window_is_focused: bool, +) -> bool { + focused || (window_label == "main" && app_is_active && !another_window_is_focused) +} + +#[cfg(target_os = "macos")] +fn app_is_active_for_main_window_focus_fallback() -> bool { + use objc2_app_kit::NSRunningApplication; + + // The non-activating floating controls can leave Berd frontmost while + // AppKit reports that none of its ordinary windows are focused. + NSRunningApplication::currentApplication().isActive() +} + +#[cfg(not(target_os = "macos"))] +fn app_is_active_for_main_window_focus_fallback() -> bool { + false +} + +fn another_user_window_is_focused(webview_window: &WebviewWindow) -> Result { + for (label, window) in webview_window.app_handle().webview_windows() { + if label == webview_window.label() || label == voice_buddy::WINDOW_LABEL { + continue; + } + if window + .is_focused() + .map_err(|error| format!("Could not confirm Berd window focus: {error}"))? + { + return Ok(true); + } + } + Ok(false) +} + fn validate_voice_target_session( capture: &VoiceCaptureState, window_sessions: &super::window_session::WindowSessionRegistry, @@ -1429,10 +1468,20 @@ fn validate_voice_target_session( ) { return Err("The target session is no longer in the foreground.".to_string()); } - if !webview_window + let focused = webview_window .is_focused() - .map_err(|error| format!("Could not confirm the target session window focus: {error}"))? - { + .map_err(|error| format!("Could not confirm the target session window focus: {error}"))?; + let app_is_active = !focused + && webview_window.label() == "main" + && app_is_active_for_main_window_focus_fallback(); + let another_window_is_focused = + app_is_active && another_user_window_is_focused(webview_window)?; + if !voice_target_window_focus_is_valid( + webview_window.label(), + focused, + app_is_active, + another_window_is_focused, + ) { return Err("The target session window is no longer focused.".to_string()); } Ok(()) @@ -2253,6 +2302,34 @@ mod tests { )); } + #[test] + fn replacement_focus_accepts_an_active_app_only_for_the_main_window() { + assert!(voice_target_window_focus_is_valid( + "main", true, false, false, + )); + assert!(voice_target_window_focus_is_valid( + "main", false, true, false, + )); + assert!(!voice_target_window_focus_is_valid( + "main", false, true, true, + )); + assert!(!voice_target_window_focus_is_valid( + "main", false, false, false, + )); + assert!(voice_target_window_focus_is_valid( + "session:target", + true, + false, + false, + )); + assert!(!voice_target_window_focus_is_valid( + "session:target", + false, + true, + false, + )); + } + #[test] fn speaker_playback_blocks_vad_ingestion_until_all_guards_finish() { let state = NativeVoiceState::default(); From 339f3490f34c4d63f5d477a61649cccaca3cd493 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 09:02:37 -0400 Subject: [PATCH 32/38] fix(voice): constrain macOS handoff fallback --- src-tauri/src/commands/native_voice.rs | 41 ++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index 735a942b7..18af83c21 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1410,9 +1410,18 @@ fn voice_target_window_focus_is_valid( window_label: &str, focused: bool, app_is_active: bool, + main_surface_is_available: bool, another_window_is_focused: bool, ) -> bool { - focused || (window_label == "main" && app_is_active && !another_window_is_focused) + focused + || (window_label == "main" + && app_is_active + && main_surface_is_available + && !another_window_is_focused) +} + +fn voice_main_surface_is_available(visible: bool, minimized: bool) -> bool { + visible && !minimized } #[cfg(target_os = "macos")] @@ -1474,12 +1483,24 @@ fn validate_voice_target_session( let app_is_active = !focused && webview_window.label() == "main" && app_is_active_for_main_window_focus_fallback(); + let main_surface_is_available = if app_is_active { + let visible = webview_window + .is_visible() + .map_err(|error| format!("Could not confirm the main window visibility: {error}"))?; + let minimized = webview_window + .is_minimized() + .map_err(|error| format!("Could not confirm the main window state: {error}"))?; + voice_main_surface_is_available(visible, minimized) + } else { + false + }; let another_window_is_focused = - app_is_active && another_user_window_is_focused(webview_window)?; + main_surface_is_available && another_user_window_is_focused(webview_window)?; if !voice_target_window_focus_is_valid( webview_window.label(), focused, app_is_active, + main_surface_is_available, another_window_is_focused, ) { return Err("The target session window is no longer focused.".to_string()); @@ -2304,28 +2325,36 @@ mod tests { #[test] fn replacement_focus_accepts_an_active_app_only_for_the_main_window() { + assert!(voice_main_surface_is_available(true, false)); + assert!(!voice_main_surface_is_available(false, false)); + assert!(!voice_main_surface_is_available(true, true)); assert!(voice_target_window_focus_is_valid( - "main", true, false, false, + "main", true, false, false, false, )); assert!(voice_target_window_focus_is_valid( - "main", false, true, false, + "main", false, true, true, false, )); assert!(!voice_target_window_focus_is_valid( - "main", false, true, true, + "main", false, true, true, true, )); assert!(!voice_target_window_focus_is_valid( - "main", false, false, false, + "main", false, true, false, false, + )); + assert!(!voice_target_window_focus_is_valid( + "main", false, false, true, false, )); assert!(voice_target_window_focus_is_valid( "session:target", true, false, false, + false, )); assert!(!voice_target_window_focus_is_valid( "session:target", false, true, + true, false, )); } From fe1a8e99711360b46805bdc93f112d66c8e86c6b Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 10:07:31 -0400 Subject: [PATCH 33/38] fix(voice): keep macOS controls from stealing focus --- src-tauri/src/commands/voice_buddy.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/commands/voice_buddy.rs b/src-tauri/src/commands/voice_buddy.rs index 411b4541f..604a3dac3 100644 --- a/src-tauri/src/commands/voice_buddy.rs +++ b/src-tauri/src/commands/voice_buddy.rs @@ -183,6 +183,8 @@ pub fn install(app: &AppHandle) -> Result<(), String> { .skip_taskbar(true) .focused(false) .visible(false); + #[cfg(target_os = "macos")] + let builder = builder.focusable(false).accept_first_mouse(true); #[cfg(not(target_os = "macos"))] let builder = builder.transparent(true); let window = builder.build().map_err(|error| error.to_string())?; From 374ea9713fd6de3e6298614086b937d2a0ba17f5 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 10:41:18 -0400 Subject: [PATCH 34/38] fix(voice): show controls without activation --- src-tauri/src/commands/voice_buddy.rs | 28 ++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/voice_buddy.rs b/src-tauri/src/commands/voice_buddy.rs index 604a3dac3..1b1957558 100644 --- a/src-tauri/src/commands/voice_buddy.rs +++ b/src-tauri/src/commands/voice_buddy.rs @@ -153,6 +153,28 @@ fn make_macos_transparent(window: &WebviewWindow) -> Result<(), String> { Ok(()) } +fn show_controls_without_activation(window: &WebviewWindow) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + use objc2::msg_send; + use objc2::runtime::AnyObject; + + return window.with_webview(|platform_webview| unsafe { + let webview = platform_webview.inner() as *mut AnyObject; + if webview.is_null() { + return; + } + let ns_window: *mut AnyObject = msg_send![&*webview, window]; + if !ns_window.is_null() { + let _: () = msg_send![&*ns_window, orderFrontRegardless]; + } + }); + } + + #[cfg(not(target_os = "macos"))] + window.show() +} + pub fn install(app: &AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window(WINDOW_LABEL) { window @@ -184,7 +206,7 @@ pub fn install(app: &AppHandle) -> Result<(), String> { .focused(false) .visible(false); #[cfg(target_os = "macos")] - let builder = builder.focusable(false).accept_first_mouse(true); + let builder = builder.accept_first_mouse(true); #[cfg(not(target_os = "macos"))] let builder = builder.transparent(true); let window = builder.build().map_err(|error| error.to_string())?; @@ -402,7 +424,7 @@ pub async fn show_voice_conversation_controls( let apply_result = if target.suppressed { window.hide() } else { - window.show() + show_controls_without_activation(&window) }; if let Err(error) = apply_result { if state.active_session_lifecycle_target() @@ -481,7 +503,7 @@ pub fn set_voice_conversation_controls_suppressed( return Err("The floating voice controls are no longer available.".to_string()); }; let result = if should_show { - controls.show() + show_controls_without_activation(&controls) } else { controls.hide() }; From 774afc3a844e93490e974d945df29f7f9e28070e Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 10:44:15 -0400 Subject: [PATCH 35/38] fix(voice): verify floating controls visibility --- src-tauri/src/commands/voice_buddy.rs | 30 ++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/voice_buddy.rs b/src-tauri/src/commands/voice_buddy.rs index 1b1957558..26dc0021c 100644 --- a/src-tauri/src/commands/voice_buddy.rs +++ b/src-tauri/src/commands/voice_buddy.rs @@ -153,26 +153,32 @@ fn make_macos_transparent(window: &WebviewWindow) -> Result<(), String> { Ok(()) } -fn show_controls_without_activation(window: &WebviewWindow) -> tauri::Result<()> { +fn show_controls_without_activation(window: &WebviewWindow) -> Result<(), String> { #[cfg(target_os = "macos")] { use objc2::msg_send; use objc2::runtime::AnyObject; - return window.with_webview(|platform_webview| unsafe { - let webview = platform_webview.inner() as *mut AnyObject; - if webview.is_null() { - return; - } - let ns_window: *mut AnyObject = msg_send![&*webview, window]; - if !ns_window.is_null() { - let _: () = msg_send![&*ns_window, orderFrontRegardless]; - } - }); + window + .with_webview(|platform_webview| unsafe { + let webview = platform_webview.inner() as *mut AnyObject; + if webview.is_null() { + return; + } + let ns_window: *mut AnyObject = msg_send![&*webview, window]; + if !ns_window.is_null() { + let _: () = msg_send![&*ns_window, orderFrontRegardless]; + } + }) + .map_err(|error| error.to_string())?; + if !window.is_visible().map_err(|error| error.to_string())? { + return Err("The floating voice controls could not be shown.".to_string()); + } + return Ok(()); } #[cfg(not(target_os = "macos"))] - window.show() + window.show().map_err(|error| error.to_string()) } pub fn install(app: &AppHandle) -> Result<(), String> { From 2bd9470721f7a06d7d20f832c7065d68dc5e84b8 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 10:46:01 -0400 Subject: [PATCH 36/38] fix(voice): preserve visibility error contract --- src-tauri/src/commands/voice_buddy.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/voice_buddy.rs b/src-tauri/src/commands/voice_buddy.rs index 26dc0021c..c89cd9833 100644 --- a/src-tauri/src/commands/voice_buddy.rs +++ b/src-tauri/src/commands/voice_buddy.rs @@ -428,7 +428,7 @@ pub async fn show_voice_conversation_controls( }; loop { let apply_result = if target.suppressed { - window.hide() + window.hide().map_err(|error| error.to_string()) } else { show_controls_without_activation(&window) }; @@ -511,7 +511,7 @@ pub fn set_voice_conversation_controls_suppressed( let result = if should_show { show_controls_without_activation(&controls) } else { - controls.hide() + controls.hide().map_err(|error| error.to_string()) }; if let Err(error) = result { state.rollback_controls_suppression( From cee5091c74b49aaddff7fc7435bfd05b7a7c0d4b Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 10:51:33 -0400 Subject: [PATCH 37/38] style(voice): satisfy clippy --- src-tauri/src/commands/voice_buddy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/commands/voice_buddy.rs b/src-tauri/src/commands/voice_buddy.rs index c89cd9833..1b66dd789 100644 --- a/src-tauri/src/commands/voice_buddy.rs +++ b/src-tauri/src/commands/voice_buddy.rs @@ -79,7 +79,7 @@ pub fn restore_hidden_owner(app: &AppHandle, owner_window_label: &str) { pub fn open_active_session(app: &AppHandle) -> Result<(), String> { let state = app.state::(); let Some((session_id, owner_window_label)) = state.active_session_target() else { - return Ok(()); + Ok(()) }; let window = app .get_webview_window(&owner_window_label) From e76b1379dca530bbc14d42b08ce5e56a31e38197 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Mon, 24 Aug 2026 10:53:21 -0400 Subject: [PATCH 38/38] fix(voice): restore empty session return --- src-tauri/src/commands/voice_buddy.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/voice_buddy.rs b/src-tauri/src/commands/voice_buddy.rs index 1b66dd789..de5966612 100644 --- a/src-tauri/src/commands/voice_buddy.rs +++ b/src-tauri/src/commands/voice_buddy.rs @@ -79,7 +79,7 @@ pub fn restore_hidden_owner(app: &AppHandle, owner_window_label: &str) { pub fn open_active_session(app: &AppHandle) -> Result<(), String> { let state = app.state::(); let Some((session_id, owner_window_label)) = state.active_session_target() else { - Ok(()) + return Ok(()); }; let window = app .get_webview_window(&owner_window_label) @@ -174,7 +174,7 @@ fn show_controls_without_activation(window: &WebviewWindow) -> Result<(), String if !window.is_visible().map_err(|error| error.to_string())? { return Err("The floating voice controls could not be shown.".to_string()); } - return Ok(()); + Ok(()) } #[cfg(not(target_os = "macos"))]