From 282fa11994eca4ee9771f52e8abb4032978a1c59 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 19:37:27 -0400 Subject: [PATCH 01/21] 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 2fdc786d..3050e94f 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 cfe4bf9d..cfab01af 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 e4518f87..0b903229 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 45692795..b6d8f702 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 3feba1f19f424d1324a1cff1e2e3bf2cefe77863 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 19:41:28 -0400 Subject: [PATCH 02/21] fix(test): remove duplicate voice store import --- src/app/AppShell.navigation.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 6926f031..161e125a 100644 --- a/src/app/AppShell.navigation.test.tsx +++ b/src/app/AppShell.navigation.test.tsx @@ -26,7 +26,6 @@ import type { Message } from "@/shared/types/messages"; import type { GitState } from "@/shared/types/git"; import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference"; import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents"; -import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore"; import { SHORTCUT_PREFERENCES_STORAGE_KEY } from "@/features/shortcuts/lib/shortcutRegistry"; import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; From cad3a13007fbe1fcb16bcc743cfb02f4ec87423d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:32:37 -0400 Subject: [PATCH 03/21] 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 d49660cb..634d495b 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 b01ce27d..db72ce75 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 034be4b8..f58f240a 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 531b1bab..5a5250c1 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 0b903229..ca884458 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 b6d8f702..37978cb3 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 bb9f49277381c43222cdca998575f1799b8c0942 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:39:50 -0400 Subject: [PATCH 04/21] 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 37978cb3..44c5e80f 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 917d2365..12de1ffe 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 c561694f..c6d0fff8 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 4764cf18004b4162b38de66fb5fe266fe42fb33a Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:48:40 -0400 Subject: [PATCH 05/21] 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 634d495b..a137fbd0 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 f58f240a..972f8b63 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 5a5250c1..dbea6714 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 ca884458..96d6f44e 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 44c5e80f..be3021f5 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 12de1ffe..96137e47 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 c6d0fff8..9889422b 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 dee9578333a1d2ae8cf8857c5e2d8214fd2dbc3c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 20:56:29 -0400 Subject: [PATCH 06/21] 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 96d6f44e..a8361e23 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 24375238ee269677059d88c12706f61052796630 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:08:16 -0400 Subject: [PATCH 07/21] 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 a137fbd0..7cfeae05 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 56d15c66..cb071f36 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 db72ce75..88f073e4 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 09ef3ccd..24e9c7af 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 161e125a..20870797 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 31fd1ad2..6db9a765 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 07969d42..69d52c7b 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 f8809b4f..1c9f395d 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 972f8b63..cc8778d7 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 dbea6714..d657010e 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 01f351d0ab8b201e4bb6af1b13030b4c03d3626a Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:16:44 -0400 Subject: [PATCH 08/21] 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 cc8778d7..da50ec80 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 d657010e..3337d016 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 6319ca7c4565c2e95e718914b39bf520265e0d09 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:21:33 -0400 Subject: [PATCH 09/21] 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 da50ec80..a8c2f3f5 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 3337d016..c9479d67 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 8fbcf8f489bb091b9ec474aeaf15cf636527a369 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 22:26:33 -0400 Subject: [PATCH 10/21] 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 a8c2f3f5..eba1ca42 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 c9479d67..74cc6311 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 432794afe2e047178390daf04d7ec968d8c0fdc3 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 23:04:08 -0400 Subject: [PATCH 11/21] 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 eba1ca42..2d3568a5 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 74cc6311..ba38d1bc 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 a8361e23..fb460970 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 2ad29777ceb8fd9cbdc94f0f1e197e50d3debe73 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 23:06:27 -0400 Subject: [PATCH 12/21] 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 2d3568a5..fc43b264 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 ba38d1bc..423029f4 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 6011cadb08f199fc118c160343da47430b22d898 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 22 Aug 2026 23:13:00 -0400 Subject: [PATCH 13/21] 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 fc43b264..3243a068 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 423029f4..ba8d00f6 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 ce5f72fc944802eedfec2882445f6bb6d3049c38 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 20:58:51 -0400 Subject: [PATCH 14/21] 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 fb460970..70fd03ff 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 be3021f5..ab0abc86 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 0307bf4d875f68487e6e7dc3d6201364adaafe90 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:02:13 -0400 Subject: [PATCH 15/21] 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 70fd03ff..77cacd4f 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 ab0abc86..e4be054c 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 37f7b27ca43780fc2f7d9018141f46f9adb9ad14 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:19:39 -0400 Subject: [PATCH 16/21] 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 77cacd4f..a0134af5 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 e4be054c..adc899b3 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 96137e47..24eac8af 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 9889422b..d7733d2a 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 d910612ac4f721f26bbb59e5d2d013a3a8a1b173 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:33:41 -0400 Subject: [PATCH 17/21] 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 24eac8af..6a8b783f 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 d7733d2a..afbe0c6f 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 e9ae03a857dded51971b776892407b66c6aedc02 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:38:25 -0400 Subject: [PATCH 18/21] 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 6a8b783f..283eba62 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 afbe0c6f..90c95c52 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 7837bb47820c9e7fca85caecf17e6877f939f899 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:51:49 -0400 Subject: [PATCH 19/21] 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 a0134af5..b8f15280 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 adc899b3..6f3577f7 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 283eba62..e509841b 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 90c95c52..31ca5618 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 244c2fa984c20f3d010a9837c91a69d3e539689b Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 21:59:40 -0400 Subject: [PATCH 20/21] 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 e509841b..0e72462e 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 31ca5618..72d1ab7f 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 641cbde7c0a26d186a5840d0d9100481a05e170d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sun, 23 Aug 2026 22:21:05 -0400 Subject: [PATCH 21/21] 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 0e72462e..a52bc0df 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 72d1ab7f..bc0b173a 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 {