diff --git a/src-tauri/src/commands/native_voice.rs b/src-tauri/src/commands/native_voice.rs index d49660cb5..7cfeae05f 100644 --- a/src-tauri/src/commands/native_voice.rs +++ b/src-tauri/src/commands/native_voice.rs @@ -1315,6 +1315,66 @@ 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>, + 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 { + 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); + 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 + .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>, + 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", + } +} + fn native_owner_id(session_id: &str) -> String { format!("native-voice:{session_id}") } @@ -2055,6 +2115,32 @@ mod tests { assert!(!software_microphone_mute(false, false)); } + #[test] + fn replacement_stop_requires_the_target_session_window() { + 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, + )); + } + #[test] fn speaker_playback_blocks_vad_ingestion_until_all_guards_finish() { let state = NativeVoiceState::default(); diff --git a/src-tauri/src/commands/voice_capture.rs b/src-tauri/src/commands/voice_capture.rs index 56d15c66c..cb071f363 100644 --- a/src-tauri/src/commands/voice_capture.rs +++ b/src-tauri/src/commands/voice_capture.rs @@ -2,6 +2,7 @@ use std::{collections::HashMap, sync::Mutex}; +use serde::Deserialize; use tauri::{State, WebviewWindow}; const MAX_ID_LEN: usize = 256; @@ -14,11 +15,29 @@ struct MicrophoneOwner { owner_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ForegroundSessionClaim { + renderer_id: String, + renderer_epoch: u64, + generation: u64, + session_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ForegroundSessionRequest { + renderer_id: String, + renderer_epoch: u64, + generation: u64, + session_id: Option, +} + #[derive(Default)] struct CaptureState { renderer_epoch: u64, pending_renderers: HashMap, current_renderers: HashMap, + foreground_sessions: HashMap, microphone_owner: Option, } @@ -67,10 +86,18 @@ impl CaptureState { _ => return Err("Voice renderer instance is not registered".to_string()), } - self.current_renderers.insert( - window_label.to_string(), - (renderer_id.to_string(), renderer_epoch), - ); + let replaced_renderer = self + .current_renderers + .insert( + window_label.to_string(), + (renderer_id.to_string(), renderer_epoch), + ) + .is_some_and(|(active_renderer, active_epoch)| { + active_renderer != renderer_id || active_epoch != renderer_epoch + }); + if replaced_renderer { + self.foreground_sessions.remove(window_label); + } if self .microphone_owner .as_ref() @@ -131,6 +158,70 @@ impl VoiceCaptureState { .activate_renderer(window_label, renderer_id, renderer_epoch) } + pub fn set_foreground_session( + &self, + window_label: &str, + renderer_id: &str, + renderer_epoch: u64, + generation: u64, + session_id: Option<&str>, + ) -> Result<(), String> { + validate_id("renderer", renderer_id)?; + if let Some(session_id) = session_id { + validate_id("session", session_id)?; + } + let mut state = self + .state + .lock() + .map_err(|_| "Voice capture state lock was poisoned".to_string())?; + state.activate_renderer(window_label, renderer_id, renderer_epoch)?; + if state + .foreground_sessions + .get(window_label) + .is_some_and(|claim| { + claim.renderer_id == renderer_id + && claim.renderer_epoch == renderer_epoch + && claim.generation >= generation + }) + { + return Ok(()); + } + state.foreground_sessions.insert( + window_label.to_string(), + ForegroundSessionClaim { + renderer_id: renderer_id.to_string(), + renderer_epoch, + generation, + session_id: session_id.map(ToString::to_string), + }, + ); + Ok(()) + } + + pub fn foreground_session_matches( + &self, + window_label: &str, + renderer_id: &str, + renderer_epoch: u64, + session_id: &str, + ) -> Result { + validate_id("renderer", renderer_id)?; + validate_id("session", session_id)?; + let mut state = self + .state + .lock() + .map_err(|_| "Voice capture state lock was poisoned".to_string())?; + state.activate_renderer(window_label, renderer_id, renderer_epoch)?; + Ok(state + .foreground_sessions + .get(window_label) + .is_some_and(|claim| { + claim.renderer_id == renderer_id + && claim.renderer_epoch == renderer_epoch + && claim.session_id.as_deref() == Some(session_id) + })) + } + pub fn claim_microphone( &self, window_label: String, @@ -209,9 +300,25 @@ impl VoiceCaptureState { } state.current_renderers.remove(window_label); state.pending_renderers.remove(window_label); + state.foreground_sessions.remove(window_label); } } +#[tauri::command] +pub fn set_voice_renderer_foreground_session( + state: State<'_, VoiceCaptureState>, + webview_window: WebviewWindow, + request: ForegroundSessionRequest, +) -> Result<(), String> { + state.set_foreground_session( + webview_window.label(), + &request.renderer_id, + request.renderer_epoch, + request.generation, + request.session_id.as_deref(), + ) +} + #[tauri::command] pub fn register_voice_renderer_instance( state: State<'_, VoiceCaptureState>, @@ -397,4 +504,42 @@ mod tests { .is_err()); assert!(!operation_ran.get()); } + + #[test] + fn foreground_session_claim_rejects_a_stale_navigation_target() { + let capture = VoiceCaptureState::default(); + let epoch = capture.register_renderer_for_test("main", "renderer-1"); + capture + .set_foreground_session("main", "renderer-1", epoch, 1, Some("session-b")) + .expect("claim session B"); + assert!(capture + .foreground_session_matches("main", "renderer-1", epoch, "session-b") + .expect("authorize session B")); + + capture + .set_foreground_session("main", "renderer-1", epoch, 2, Some("session-c")) + .expect("navigate to session C"); + assert!(!capture + .foreground_session_matches("main", "renderer-1", epoch, "session-b") + .expect("reject stale session B")); + assert!(capture + .foreground_session_matches("main", "renderer-1", epoch, "session-c") + .expect("authorize session C")); + } + + #[test] + fn foreground_session_claim_ignores_out_of_order_updates() { + let capture = VoiceCaptureState::default(); + let epoch = capture.register_renderer_for_test("main", "renderer-1"); + capture + .set_foreground_session("main", "renderer-1", epoch, 2, Some("session-c")) + .expect("claim newest session"); + capture + .set_foreground_session("main", "renderer-1", epoch, 1, Some("session-b")) + .expect("ignore stale claim"); + + assert!(capture + .foreground_session_matches("main", "renderer-1", epoch, "session-c") + .expect("retain newest session")); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b01ce27d8..88f073e43 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, @@ -657,6 +658,7 @@ pub fn run() { commands::voice_buddy::stop_voice_conversation_from_buddy, commands::notifications::should_suppress_completion_notification, commands::voice_capture::register_voice_renderer_instance, + commands::voice_capture::set_voice_renderer_foreground_session, commands::window_session::get_session_window_support, commands::window_session::open_session_window, commands::window_session::release_session, diff --git a/src/app/AppShell.berdctl.test.tsx b/src/app/AppShell.berdctl.test.tsx index 09ef3ccd6..24e9c7afd 100644 --- a/src/app/AppShell.berdctl.test.tsx +++ b/src/app/AppShell.berdctl.test.tsx @@ -47,6 +47,7 @@ vi.mock( releaseNativeVoiceConversationStartBlock: vi .fn() .mockResolvedValue(undefined), + setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined), }), ); diff --git a/src/app/AppShell.navigation.test.tsx b/src/app/AppShell.navigation.test.tsx index 6926f031e..208707970 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"; @@ -37,6 +36,7 @@ import { import { blockNativeVoiceConversationStarts, releaseNativeVoiceConversationStartBlock, + setVoiceConversationForegroundSession, } from "@/features/voice-conversation/api/voiceConversation"; import { dispatchOnboarding } from "@/features/onboarding/model"; import { @@ -74,6 +74,7 @@ vi.mock( releaseNativeVoiceConversationStartBlock: vi .fn() .mockResolvedValue(undefined), + setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined), }), ); @@ -963,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(); @@ -1987,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 31fd1ad23..6db9a7654 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -229,7 +229,10 @@ import { blockVoiceConversationStarts, useVoiceConversationStore, } from "@/features/voice-conversation/stores/voiceConversationStore"; -import { listenToVoiceConversationOpenSession } from "@/features/voice-conversation/api/voiceConversation"; +import { + listenToVoiceConversationOpenSession, + setVoiceConversationForegroundSession, +} from "@/features/voice-conversation/api/voiceConversation"; import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup"; import { useSiriVoiceSetup } from "@/features/voice-conversation/hooks/useSiriVoiceSetup"; import { useVoiceOutputPreference } from "@/features/voice-conversation/lib/voiceOutputPreference"; @@ -772,6 +775,14 @@ export function AppShell({ }, [capabilities.voiceConversation, stopVoiceConversation]); const sessions = useChatSessionStore(selectSessions); const activeSessionId = useChatSessionStore(selectActiveSessionId); + useLayoutEffect(() => { + const foregroundSessionId = activeView === "chat" ? activeSessionId : null; + void setVoiceConversationForegroundSession(foregroundSessionId).catch( + (error) => { + console.warn("Failed to publish the foreground voice session", error); + }, + ); + }, [activeSessionId, activeView]); const messagesBySession = useChatStore((state) => state.messagesBySession); const previousActiveSessionIdRef = useRef(activeSessionId); useEffect(() => { diff --git a/src/app/SessionWindowApp.tsx b/src/app/SessionWindowApp.tsx index 07969d42a..69d52c7b8 100644 --- a/src/app/SessionWindowApp.tsx +++ b/src/app/SessionWindowApp.tsx @@ -42,6 +42,7 @@ import { useWorkspaceNameRequestQueue } from "@/features/chat/hooks/useWorkspace import { ProjectWorkspaceStartupNameDialog } from "@/features/projects/ui/ProjectWorkspaceStartupNameDialog"; import { Button } from "@/shared/ui/button"; import { SecurityConfirmationFallback } from "@/features/security/ui/SecurityConfirmationPanel"; +import { setVoiceConversationForegroundSession } from "@/features/voice-conversation/api/voiceConversation"; import { useSecurityConfirmationStore } from "@/features/security/stores/securityConfirmationStore"; type Phase = "loading" | "mirror" | "recoverable" | "ready" | "missing"; @@ -278,6 +279,16 @@ export function SessionWindowApp({ }; }, [currentWindowLabelOverride, loadOwnedSession, sessionId]); + useEffect(() => { + const foregroundSessionId = + phase === "ready" || phase === "mirror" ? sessionId : null; + void setVoiceConversationForegroundSession(foregroundSessionId).catch( + (error) => { + console.warn("Failed to publish the foreground voice session", error); + }, + ); + }, [phase, sessionId]); + useEffect(() => { if (phase !== "mirror" || !currentWindowLabel) { return; diff --git a/src/app/__tests__/SessionWindowApp.test.tsx b/src/app/__tests__/SessionWindowApp.test.tsx index f8809b4f8..1c9f395d8 100644 --- a/src/app/__tests__/SessionWindowApp.test.tsx +++ b/src/app/__tests__/SessionWindowApp.test.tsx @@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => ({ buildFeatures: { securityMl: true, }, + setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/app/lib/chatRuntimeStartup", () => ({ @@ -94,6 +95,11 @@ vi.mock("@/features/chat/ui/ChatView", () => ({ ), })); +vi.mock("@/features/voice-conversation/api/voiceConversation", () => ({ + setVoiceConversationForegroundSession: + mocks.setVoiceConversationForegroundSession, +})); + import { SessionWindowApp } from "@/app/SessionWindowApp"; const session: ChatSession = { @@ -219,6 +225,7 @@ describe("SessionWindowApp", () => { vi.mocked(readSessionHandoffSnapshot).mockReset(); vi.mocked(readSessionHandoffSnapshot).mockResolvedValue(null); vi.mocked(recoverSessionHandoff).mockClear(); + mocks.setVoiceConversationForegroundSession.mockClear(); }); it("renders an error state for an unknown session after hydration", async () => { @@ -233,6 +240,11 @@ describe("SessionWindowApp", () => { seedSession(); renderSessionWindow(); await screen.findByTestId("chat-view"); + await waitFor(() => + expect(mocks.setVoiceConversationForegroundSession).toHaveBeenCalledWith( + "session-1", + ), + ); await waitFor(() => expect(handoffListeners.searchTarget).toBeDefined()); act(() => { diff --git a/src/features/chat/ui/ChatInputToolbar.tsx b/src/features/chat/ui/ChatInputToolbar.tsx index 2fdc786dc..3050e94f7 100644 --- a/src/features/chat/ui/ChatInputToolbar.tsx +++ b/src/features/chat/ui/ChatInputToolbar.tsx @@ -161,7 +161,7 @@ export function ChatInputToolbar({ const voiceConversationTooltip = ownsActiveVoiceConversation ? t("toolbar.voiceConversation.hangUp") : voiceConversationRunning - ? t("toolbar.voiceConversation.buddy.openSession") + ? t("toolbar.voiceConversation.start") : voiceConversationState !== "off" ? t(`toolbar.voiceConversation.states.${voiceConversationState}`, { sessionId: voiceConversation?.boundSessionId ?? "", diff --git a/src/features/chat/ui/__tests__/ChatInput.test.tsx b/src/features/chat/ui/__tests__/ChatInput.test.tsx index cfe4bf9d7..cfab01afd 100644 --- a/src/features/chat/ui/__tests__/ChatInput.test.tsx +++ b/src/features/chat/ui/__tests__/ChatInput.test.tsx @@ -1173,7 +1173,8 @@ describe("ChatInput", () => { expect(button.querySelector(".lucide-phone-off")).toBeInTheDocument(); }); - it("shows a non-destructive open control outside the owning session", () => { + it("shows a new-call control outside the owning session", async () => { + const onToggle = vi.fn(); render( { active: true, ownsActiveConversation: false, microphoneMuted: false, - onToggle: vi.fn(), + onToggle, onMicrophoneMuteToggle: vi.fn(), }} />, ); - const open = screen.getByRole("button", { name: "Open voice session" }); - expect(open).not.toHaveClass("bg-destructive"); - expect(open.querySelector(".lucide-phone")).toBeInTheDocument(); + const start = screen.getByRole("button", { + name: "Start voice conversation", + }); + expect(start).not.toHaveClass("bg-destructive"); + expect(start.querySelector(".lucide-phone")).toBeInTheDocument(); + await userEvent.click(start); + expect(onToggle).toHaveBeenCalledOnce(); expect( screen.queryByRole("button", { name: "Mute microphone" }), ).not.toBeInTheDocument(); diff --git a/src/features/voice-conversation/api/voiceConversation.test.ts b/src/features/voice-conversation/api/voiceConversation.test.ts index 034be4b82..3243a0686 100644 --- a/src/features/voice-conversation/api/voiceConversation.test.ts +++ b/src/features/voice-conversation/api/voiceConversation.test.ts @@ -21,28 +21,41 @@ 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, drainVoiceConversationTranscripts, + FOREGROUND_SESSION_CLAIM_TIMEOUT_MS, getVoiceConversationStatus, listenToVoiceConversation, openVoiceConversationSession, reconcileVoiceConversationMicrophone, releaseNativeVoiceConversationStartBlock, + resetVoiceConversationForegroundSessionForTest, setVoiceConversationAssistantSpeaking, setVoiceConversationControlsSuppressed, + setVoiceConversationForegroundSession, setVoiceConversationMicrophoneMuted, startVoiceConversation, showVoiceConversationControls, stopActiveMicrophoneForTest, stopVoiceConversationFromBuddy, stopVoiceConversation, + stopVoiceConversationForReplacement, } from "./voiceConversation"; describe("voice conversation API", () => { beforeEach(() => { stopActiveMicrophoneForTest(); + resetVoiceConversationForegroundSessionForTest(); mocks.invoke.mockReset(); mocks.listen.mockReset(); mocks.startMicrophone.mockReset().mockResolvedValue({ @@ -159,6 +172,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 @@ -343,6 +400,252 @@ 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(undefined); + await setVoiceConversationForegroundSession("session-2"); + mocks.invoke.mockReset().mockResolvedValueOnce(stoppedStatus); + + await expect( + stopVoiceConversationForReplacement(activeStatus, "session-2"), + ).resolves.toEqual(stoppedStatus); + expect(mocks.invoke).toHaveBeenCalledWith( + "stop_native_voice_conversation_for_replacement", + { + rendererId: "renderer-test", + rendererEpoch: 7, + sessionId: "session-1", + expectedRevision: 3, + targetSessionId: "session-2", + }, + ); + }); + + 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("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); + + void setVoiceConversationForegroundSession("session-b"); + const replacement = stopVoiceConversationForReplacement( + activeStatus, + "session-b", + ); + const publishSecond = setVoiceConversationForegroundSession("session-b"); + 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, + unavailableReason: null, + lifecycle: "running" as const, + sessionId: "session-1", + ownerWindowLabel: "session-window-a", + microphoneMuted: false, + revision: 3, + }; + const sessionBClaim = deferred(); + mocks.invoke + .mockReturnValueOnce(sessionBClaim.promise) + .mockResolvedValueOnce(undefined); + void setVoiceConversationForegroundSession("session-b"); + const replacement = stopVoiceConversationForReplacement( + activeStatus, + "session-b", + ); + await setVoiceConversationForegroundSession("session-c"); + + 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("renews a timed-out foreground claim for the next replacement", 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(); + 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( + 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).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(); + } + }); + + 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(3); + 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 531b1bab6..ba8d00f64 100644 --- a/src/features/voice-conversation/api/voiceConversation.ts +++ b/src/features/voice-conversation/api/voiceConversation.ts @@ -16,6 +16,15 @@ let microphoneMuted = false; let microphoneMuteIntent = 0; 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; + superseded: Promise; + supersede: () => void; +} | null = null; function resetMicrophoneMuteState(): void { microphoneMuteIntent += 1; @@ -236,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( @@ -243,6 +253,118 @@ export function getVoiceConversationStatus(): Promise { ); } +export function setVoiceConversationForegroundSession( + sessionId: string | null, +): Promise { + const generation = ++foregroundSessionGeneration; + foregroundSessionId = sessionId; + const acknowledgement = getRendererInstance().then( + ({ rendererId, rendererEpoch }) => + invoke("set_voice_renderer_foreground_session", { + request: { + rendererId, + rendererEpoch, + generation, + sessionId, + }, + }), + ); + 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; +} + +function renewForegroundSessionClaim( + failedClaim: NonNullable, + targetSessionId: string, +): void { + if ( + foregroundSessionClaim !== failedClaim || + foregroundSessionId !== targetSessionId + ) { + return; + } + void setVoiceConversationForegroundSession(targetSessionId).catch( + () => undefined, + ); +} + +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."); + } + const acknowledgementDeadline = + Date.now() + FOREGROUND_SESSION_CLAIM_TIMEOUT_MS; + + 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" }), + Math.max(0, acknowledgementDeadline - Date.now()), + ); + }), + ]).finally(() => { + 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") { + const latestClaim = foregroundSessionClaim; + if ( + latestClaim !== targetClaim && + latestClaim?.sessionId === targetSessionId + ) { + targetClaim = latestClaim; + continue; + } + renewForegroundSessionClaim(targetClaim, targetSessionId); + throw outcome.error; + } + const latestClaim = foregroundSessionClaim; + if ( + foregroundSessionId !== targetSessionId || + latestClaim?.sessionId !== targetSessionId + ) { + throw new Error("The target session is no longer in the foreground."); + } + if (outcome.type === "acknowledged" && latestClaim === targetClaim) return; + targetClaim = latestClaim; + } +} + export async function blockNativeVoiceConversationStarts( sessionId: string, ): Promise { @@ -421,6 +543,27 @@ export async function stopVoiceConversation( return nextStatus; } +export async function stopVoiceConversationForReplacement( + status: VoiceConversationStatus, + targetSessionId: string, +): Promise { + await awaitForegroundSessionClaim(targetSessionId); + resetMicrophoneMuteState(); + const { rendererId, rendererEpoch } = await getRendererInstance(); + const nextStatus = await invoke( + "stop_native_voice_conversation_for_replacement", + { + rendererId, + rendererEpoch, + sessionId: status.sessionId, + expectedRevision: status.revision, + targetSessionId, + }, + ); + 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 e4518f879..b8f152807 100644 --- a/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts +++ b/src/features/voice-conversation/hooks/useVoiceConversationController.test.ts @@ -17,22 +17,35 @@ vi.mock("../lib/nativeAssistantSpeech", () => ({ import { canBindVoiceSendRoute, + canReplaceActiveVoiceConversation, canClaimVoiceSendRoute, beginVoiceControlsVisibilityLease, createVoiceTranscriptDeliveryQueue, hasDeliveredVoiceTranscript, observeVoiceConversationControlVisibility, + replaceActiveVoiceConversation, resetVoiceUiWhenRunSettles, resolveActiveVoiceButtonAction, resolveVoiceRouteMount, resolveVoiceToggleAction, shouldSuppressVoiceConversationControls, + shouldShowVoiceConversationControl, shouldStartRequestedVoiceConversation, startPendingTranscriptRecovery, useVoiceConversationController, waitForVoiceDeliveryOpportunity, } from "./useVoiceConversationController"; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + describe("voice transcript delivery coordination", () => { it("suppresses floating controls only for the focused owner session", () => { const base = { @@ -371,6 +384,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, @@ -392,6 +410,7 @@ describe("voice transcript delivery coordination", () => { }, hydrated: true, init, + refreshStatus, start, requestedStartSessionId: "session-1", }); @@ -441,15 +460,432 @@ 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("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("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("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, + drainPendingTranscripts: vi.fn().mockResolvedValue(undefined), + 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(); + }); + + 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("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, + 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({ + 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) + | 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 expect(replacement).resolves.toBe(true); + 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, + }), + ).resolves.toBe(false); + expect(start).not.toHaveBeenCalled(); + }); + + it("does not start a replacement when stopping the active call fails", async () => { + const start = vi.fn().mockResolvedValue(undefined); + + await expect( + replaceActiveVoiceConversation({ + stop: vi.fn().mockRejectedValue(new Error("stop failed")), + start, + }), + ).rejects.toThrow("stop failed"); + expect(start).not.toHaveBeenCalled(); + }); + it("drains retained transcripts without stealing a stopped session route", () => { expect( resolveVoiceRouteMount({ diff --git a/src/features/voice-conversation/hooks/useVoiceConversationController.ts b/src/features/voice-conversation/hooks/useVoiceConversationController.ts index 456927956..6f3577f72 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,10 +17,7 @@ import { stopNativeAssistantSpeech, takeVoicePlaybackNotices, } from "../lib/nativeAssistantSpeech"; -import { - openVoiceConversationSession, - setVoiceConversationControlsSuppressed, -} from "../api/voiceConversation"; +import { setVoiceConversationControlsSuppressed } from "../api/voiceConversation"; interface VoiceSendRoute { sessionId: string; @@ -31,7 +29,7 @@ interface VoiceSendRoute { // view for its bound session is mounted. let activeSendRoute: VoiceSendRoute | null = null; let deliveryInitialized = false; -let operationInFlight = false; +const operationInFlightBySession = new Set(); export function createVoiceTranscriptDeliveryQueue() { const queues = new Map>(); @@ -68,8 +66,42 @@ 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 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 { + const stopped = await options.stop(); + if ( + stopped.sessionId !== null || + (stopped.lifecycle !== "stopped" && stopped.lifecycle !== "unavailable") + ) { + return false; + } + await options.start(); + return true; } export function shouldSuppressVoiceConversationControls(options: { @@ -551,13 +583,20 @@ 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); 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( + (state) => state.stopForReplacement, + ); const microphoneMuted = useVoiceConversationStore( (state) => state.microphoneMuted, ); @@ -684,6 +723,34 @@ 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(); + const route = { sessionId, send: onSend }; + activeSendRoute = route; + // Capture the history boundary before native startup can admit a + // transcript and produce the first assistant response. + startAssistantSpeech(); + try { + await start(sessionId); + } catch (startError) { + const backendStatus = useVoiceConversationStore.getState().status; + const conversationStarted = + backendStatus.lifecycle === "running" && + backendStatus.sessionId === sessionId; + if ( + !conversationStarted && + activeSendRoute?.sessionId === route.sessionId + ) { + activeSendRoute = null; + stopNativeAssistantSpeech(); + } + addErrorNotification(sessionId, errorText(startError)); + } + }, [onSend, sessionId, start, startAssistantSpeech]); + useEffect(() => { if (status.lifecycle !== "running" || status.sessionId !== sessionId) return; @@ -776,14 +843,21 @@ 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; - operationInFlight = true; + if (operationInFlightBySession.has(sessionId)) return; + operationInFlightBySession.add(sessionId); 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" && @@ -795,14 +869,36 @@ export function useVoiceConversationController({ }); if (action === "stop") { const boundSessionId = currentStatus.sessionId; - if ( - resolveActiveVoiceButtonAction(boundSessionId, sessionId) === - "open-owner" - ) { + const activeButtonAction = resolveActiveVoiceButtonAction( + boundSessionId, + sessionId, + ); + if (activeButtonAction === "replace") { + if ( + !canReplaceActiveVoiceConversation({ + canToggle, + hydrated, + pocketReady, + }) + ) { + return; + } try { - await openVoiceConversationSession(); - } catch (openError) { - addErrorNotification(boundSessionId, errorText(openError)); + const replaced = await replaceActiveVoiceConversation({ + stop: () => stopForReplacement(currentStatus, sessionId), + start: startCurrentConversation, + }); + if (!replaced) { + addErrorNotification( + sessionId, + t("toolbar.voiceConversation.buddy.errors.stop"), + ); + } + } catch { + addErrorNotification( + sessionId, + t("toolbar.voiceConversation.buddy.errors.stop"), + ); } return; } @@ -821,36 +917,21 @@ 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; + operationInFlightBySession.delete(sessionId); } }, [ canToggle, + hydrated, onPocketSetupRequired, - onSend, pocketReady, + refreshStatus, sessionId, - start, - startAssistantSpeech, + startCurrentConversation, stop, + stopForReplacement, + t, ]); useEffect(() => { @@ -892,31 +973,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, diff --git a/src/features/voice-conversation/stores/voiceConversationStore.test.ts b/src/features/voice-conversation/stores/voiceConversationStore.test.ts index 917d2365f..a52bc0dfe 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( @@ -51,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", () => { @@ -68,6 +72,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 +423,115 @@ 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, "session-c"), + ).resolves.toEqual(winner); + + expect(store.getState()).toMatchObject({ + status: winner, + uiState: "listening", + error: null, + }); + 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"); + 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(observedWinner); + } else { + mocks.getStatus.mockRejectedValue(new Error("status unavailable")); + } + + const replacingWithB = store + .getState() + .stopForReplacement(active, "session-b"); + store.setState({ + status: winner, + uiState: "agent-speaking", + assistantSpeaking: true, + 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: 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", + }); + }); + + 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 () => { const store = await loadStore(); store.setState({ @@ -491,6 +605,122 @@ describe("voice conversation store lifecycle ordering", () => { }); }); + 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.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: "user-speaking", + userSpeaking: true, + error: "session B playback warning", + }); + const mutedRunningB = { ...runningB, microphoneMuted: true }; + statusRefresh.resolve(mutedRunningB); + + await expect(startingA).rejects.toThrow("session A start tail failed"); + expect(store.getState()).toMatchObject({ + status: mutedRunningB, + uiState: "listening", + error: "session B playback warning", + microphoneMuted: true, + userSpeaking: false, + }); + 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 c561694fb..bc0b173ac 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, @@ -48,10 +49,15 @@ 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; drainPendingTranscripts: (sessionId: string) => Promise; @@ -521,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( @@ -552,15 +596,55 @@ 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) => - status.revision >= state.status.revision - ? { status, uiState: "error", error: message } - : state, - ); + set((state) => { + if (status.revision < state.status.revision) return state; + if (status.sessionId !== null && status.sessionId !== sessionId) { + if ( + state.status.sessionId === status.sessionId && + state.status.revision === status.revision && + state.status.lifecycle === status.lifecycle + ) { + const preserveCurrentMute = + isSameRunningLifecycle(state.status, status) && + (muteRequestWasPending || + pendingMicrophoneMuteRequests > 0 || + muteStateVersion !== microphoneMuteStateVersion); + const microphoneMuted = preserveCurrentMute + ? state.microphoneMuted + : status.microphoneMuted; + const userSpeaking = microphoneMuted + ? false + : state.userSpeaking; + return { + status: { ...state.status, microphoneMuted }, + microphoneMuted, + userSpeaking, + uiState: + microphoneMuted && state.uiState === "user-speaking" + ? activityUiState({ ...state, userSpeaking }) + : state.uiState, + }; + } + 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.sessionId !== null && + state.status.sessionId !== sessionId + ? state + : { uiState: "error", error: message }, + ); } throw error; } @@ -632,6 +716,99 @@ export const useVoiceConversationStore = create( return request; }, + stopForReplacement: async (activeStatus, targetSessionId) => { + microphoneMuteIntent += 1; + microphoneMuteStateVersion += 1; + set({ + uiState: "stopping", + microphoneMuted: false, + error: null, + requestedStartSessionId: null, + }); + try { + const status = await stopVoiceConversationForReplacement( + activeStatus, + targetSessionId, + ); + 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 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 && + status.sessionId !== targetSessionId; + if (foreignWinner) { + if ( + state.status.sessionId === status.sessionId && + state.status.revision === status.revision && + state.status.lifecycle === status.lifecycle + ) { + const preserveCurrentMute = + isSameRunningLifecycle(state.status, status) && + (muteRequestWasPending || + pendingMicrophoneMuteRequests > 0 || + muteStateVersion !== microphoneMuteStateVersion); + const microphoneMuted = preserveCurrentMute + ? state.microphoneMuted + : status.microphoneMuted; + const userSpeaking = microphoneMuted + ? false + : state.userSpeaking; + return { + status: { ...state.status, microphoneMuted }, + microphoneMuted, + userSpeaking, + uiState: + microphoneMuted && state.uiState === "user-speaking" + ? activityUiState({ ...state, userSpeaking }) + : state.uiState, + }; + } + return { + status, + uiState: uiStateForStatus(status), + microphoneMuted: status.microphoneMuted, + error: null, + }; + } + return status.revision >= state.status.revision + ? { status, uiState: "error", error: message } + : state; + }); + await reconcileVoiceConversationMicrophone(get().status); + } catch { + set((state) => + state.status.sessionId !== null && + state.status.sessionId !== activeStatus.sessionId && + state.status.sessionId !== targetSessionId + ? state + : { uiState: "error", error: message }, + ); + } + throw error; + } + }, + setUiState: (uiState, error) => set((state) => { const activityFallbackState = [