Compare commits

...
Author SHA1 Message Date
Mohamed Boudra b1bbb7a102 fix(voice): initialize manual speech on demand 2026-09-05 13:41:14 +02:00
Mohamed Boudra 20353ca208 fix(server): align script compiler library 2026-09-05 13:41:14 +02:00
Mohamed Boudra 16e8de01e3 feat(voice): move voice chat to an app-level capability
Keep call lifecycle, provider configuration, and media transports independent from ordinary agent sessions so alternate voice providers can be added without changing the shared runtime.
2026-09-05 13:41:14 +02:00
86 changed files with 4926 additions and 3311 deletions
+24 -2
View File
@@ -75,6 +75,8 @@ not retain non-Git directories.
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
| `server/voice-chat/` | App-level voice calls, provider orchestration, and semantic call events |
| `server/session/dictation/` | Composer dictation stream lifetime and speech-readiness gating |
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
@@ -111,7 +113,26 @@ Cross-platform React Native app that connects to one or more daemons.
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
- Voice features: dictation (STT) and voice agent (realtime)
- App `VoiceRuntime` owns the app-level call UX, current route context, and semantic transcript. It
prepares installed client transports and accepts the server's selected answer without inspecting
transport kind. Each transport owns capture, playback, its data plane, mute, volume, and media
state; `transports/daemon-audio.ts` is the production transport in this slice. A WebRTC transport
adds another factory at app composition and does not change `VoiceRuntime`.
- Server `VoiceChat` serializes start/stop, cancels startup, routes provider-neutral state/events,
forwards opaque transport data, and reacts to the provider's terminal signal. It knows only the
`VoiceCallProvider` contract; concrete data-plane validation and serialization stay in the
provider and matching client transport. Providers own prompts, credentials, hidden resources,
tools, media integration, and cleanup. The manual provider creates a hidden orchestrator through
the atomic required-Paseo-tools capability with an authoritative provider prompt, and owns a
separate speech runtime configured from the top-level `manualVoice` block. That runtime is created
on the first valid manual call and reused; ordinary daemon startup does not load or download voice
models. Dictation keeps its own speech runtime. A complete manual orchestrator provider and mode
selection is required; partial configuration does not fall back to another provider or mode.
Cleanup detaches work delegated by the hidden orchestrator before archiving it.
- Voice calls end as soon as the final client socket disconnects. The containing client session may
remain reconnectable, but calls are not replayed or rehydrated in this protocol slice. The sidebar
footer starts the call and hosts its controls; routes and visible agents do not own its lifetime.
Dictation remains a separate composer feature.
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
@@ -264,7 +285,8 @@ optionally carry the same `turnId`. New clients use these fields when present an
status once at the directory boundary rather than maintaining a second activity model.
- Terminal subscribe/input/capture commands
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
- App-level voice call commands and events (`voice.call.*`) and independent dictation streams
(`dictation_stream_*`)
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
+32 -2
View File
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
import {
Stack,
useGlobalSearchParams,
useNavigationContainerRef,
usePathname,
useRouter,
} from "expo-router";
import {
createContext,
type ReactNode,
@@ -63,7 +69,7 @@ import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context";
import { useVoiceOptional, VoiceProvider } from "@/contexts/voice-context";
import {
resolveStartupBlocker,
resolveStartupNavigationReady,
@@ -667,11 +673,35 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
<HostSessionManager />
<FaviconStatusSync />
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
<VoiceCallContextSync />
</VoiceProvider>
</AppearanceProvider>
);
}
function VoiceCallContextSync() {
const voice = useVoiceOptional();
const params = useGlobalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
agentId?: string | string[];
}>();
const serverId = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId;
const workspaceId = Array.isArray(params.workspaceId)
? params.workspaceId[0]
: params.workspaceId;
const agentId = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
useEffect(() => {
if (!voice?.isVoiceMode || voice.activeServerId !== serverId) return;
void voice
.updateCallContext({ workspaceId: workspaceId ?? null, agentId: agentId ?? null })
.catch((error) => console.warn("[VoiceCall] Failed to update app context", error));
}, [agentId, serverId, voice, workspaceId]);
return null;
}
function DesktopWindowControlsSync() {
const { isLoading } = useAppSettings();
const { theme } = useUnistyles();
+96 -5
View File
@@ -1,5 +1,13 @@
import { router } from "expo-router";
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { router, useGlobalSearchParams } from "expo-router";
import {
AudioLines,
FolderPlus,
GitBranch,
Import,
Server,
Settings,
X,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -42,7 +50,9 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
@@ -52,6 +62,11 @@ import { openHostOverview } from "@/navigation/settings-navigation";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { VoiceCallSurface } from "./voice-call-surface";
import {
useActiveWorkspaceSelection,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -268,6 +283,7 @@ function sidebarHostOptionTestID(serverId: string): string {
function FooterIconButton({
buttonRef,
onPress,
disabled = false,
testID,
label,
icon: Icon,
@@ -276,6 +292,7 @@ function FooterIconButton({
theme,
}: {
onPress: () => void;
disabled?: boolean;
testID: string;
label: string;
icon: typeof FolderPlus;
@@ -289,19 +306,20 @@ function FooterIconButton({
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
accessible
accessibilityLabel={label}
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={[styles.footerIconButton, disabled && styles.footerIconButtonDisabled]}
>
{({ hovered }) => (
<Icon
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
color={hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
@@ -313,6 +331,68 @@ function FooterIconButton({
);
}
function firstSearchParam(value: string | string[] | undefined): string | null {
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
}
function SidebarVoiceChatButton({ theme }: { theme: SidebarTheme }) {
const { t } = useTranslation();
const toast = useToast();
const voice = useVoiceOptional();
const activeWorkspace = useActiveWorkspaceSelection();
const lastWorkspace = useLastWorkspaceSelection();
const workspace = activeWorkspace ?? lastWorkspace;
const params = useGlobalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
agentId?: string | string[];
}>();
const isConnected = useHostRuntimeIsConnected(workspace?.serverId ?? "");
const isActiveRoute =
workspace?.serverId === firstSearchParam(params.serverId) &&
workspace?.workspaceId === firstSearchParam(params.workspaceId);
const agentId = isActiveRoute ? firstSearchParam(params.agentId) : null;
const disabled = !voice || voice.isVoiceSwitching;
const handlePress = useCallback(() => {
if (!voice || disabled) return;
if (!workspace) {
toast.error("Open a workspace before starting voice chat.");
return;
}
if (!isConnected) {
toast.error("Connect to the host before starting voice chat.");
return;
}
void voice
.startCall(workspace.serverId, { workspaceId: workspace.workspaceId, agentId })
.catch((error) => {
console.error("[Sidebar] Failed to start voice chat", error);
toast.error(error instanceof Error ? error.message : "Unable to start voice chat");
});
}, [agentId, disabled, isConnected, toast, voice, workspace]);
if (voice?.isVoiceMode) return null;
return (
<FooterIconButton
onPress={handlePress}
disabled={disabled}
testID="sidebar-voice-chat"
label={t("composer.voice.voiceMode")}
icon={AudioLines}
theme={theme}
/>
);
}
function VoiceWorkspaceListClearance() {
const voice = useVoiceOptional();
return voice?.isVoiceMode ? <View style={styles.voiceWorkspaceListClearance} /> : null;
}
const voiceWorkspaceListClearance = <VoiceWorkspaceListClearance />;
function footerAddProjectButtonStyle({
hovered,
}: PressableStateCallbackType & { hovered?: boolean }) {
@@ -489,6 +569,7 @@ function SidebarFooter({
icon={Import}
theme={theme}
/>
<SidebarVoiceChatButton theme={theme} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
@@ -596,12 +677,14 @@ function MobileSidebar({
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
listFooterComponent={voiceWorkspaceListClearance}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<VoiceCallSurface />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
@@ -773,12 +856,14 @@ function DesktopSidebar({
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
listFooterComponent={voiceWorkspaceListClearance}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarCalloutSlot />
<VoiceCallSurface />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
@@ -976,6 +1061,12 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
footerIconButtonDisabled: {
opacity: theme.opacity[50],
},
voiceWorkspaceListClearance: {
height: theme.spacing[12],
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -3,24 +3,26 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
import { FOOTER_HEIGHT } from "@/constants/layout";
import { MessageCircle, Mic, MicOff, Square } from "lucide-react-native";
import { useVoiceTelemetry } from "@/contexts/voice-context";
import { VolumeMeter } from "./volume-meter";
interface RealtimeVoiceOverlayProps {
isMuted: boolean;
isSwitching: boolean;
isTranscriptOpen: boolean;
onToggleTranscript: () => void;
onToggleMute: () => void;
onStop: () => void;
}
const OVERLAY_BUTTON_SIZE = 44;
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
const OVERLAY_BUTTON_SIZE = 28;
export function RealtimeVoiceOverlay({
isMuted,
isSwitching,
isTranscriptOpen,
onToggleTranscript,
onToggleMute,
onStop,
}: RealtimeVoiceOverlayProps) {
@@ -42,12 +44,26 @@ export function RealtimeVoiceOverlay({
);
return (
<View style={styles.container}>
<Pressable
onPress={onToggleTranscript}
accessibilityRole="button"
accessibilityLabel={isTranscriptOpen ? "Hide voice transcript" : "Show voice transcript"}
style={[
styles.actionButton,
styles.transcriptButton,
isTranscriptOpen && styles.transcriptButtonOpen,
]}
>
<MessageCircle size={theme.iconSize.md} color={theme.colors.foreground} strokeWidth={2.5} />
</Pressable>
<View style={styles.meterContainer}>
<VolumeMeter
volume={volume}
isMuted={isMuted}
isSpeaking={isSpeaking}
orientation="horizontal"
variant="compact"
/>
</View>
@@ -62,9 +78,9 @@ export function RealtimeVoiceOverlay({
style={muteButtonStyle}
>
{isMuted ? (
<MicOff size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
<MicOff size={theme.iconSize.md} color={theme.colors.palette.white} strokeWidth={2.5} />
) : (
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} strokeWidth={2.5} />
<Mic size={theme.iconSize.md} color={theme.colors.foreground} strokeWidth={2.5} />
)}
</Pressable>
@@ -79,7 +95,7 @@ export function RealtimeVoiceOverlay({
<LoadingSpinner size="small" color={theme.colors.palette.white} />
) : (
<Square
size={theme.iconSize.lg}
size={theme.iconSize.md}
color={theme.colors.palette.white}
fill={theme.colors.palette.white}
strokeWidth={2.5}
@@ -96,24 +112,25 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
width: "100%",
height: FOOTER_HEIGHT,
borderRadius: theme.borderRadius["2xl"],
minHeight: 40,
borderRadius: theme.borderRadius.xl,
justifyContent: "space-between",
paddingHorizontal: theme.spacing[4],
paddingVertical: OVERLAY_VERTICAL_PADDING,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
meterContainer: {
flex: 1,
minWidth: 0,
alignItems: "center",
justifyContent: "center",
},
actionsContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
gap: theme.spacing[1],
},
actionButton: {
width: OVERLAY_BUTTON_SIZE,
@@ -127,6 +144,14 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
transcriptButton: {
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
transcriptButtonOpen: {
backgroundColor: theme.colors.interactionHighlight,
},
muteButtonMuted: {
backgroundColor: theme.colors.palette.red[600],
borderColor: theme.colors.palette.red[800],
@@ -0,0 +1,149 @@
import { useCallback, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import { useVoiceOptional } from "@/contexts/voice-context";
import type { VoiceCallEvent } from "@getpaseo/protocol/messages";
function VoiceCallEventRow({ event }: { event: VoiceCallEvent }) {
switch (event.type) {
case "transcript":
return (
<Text style={styles.eventText}>
<Text style={styles.eventLabel}>{event.speaker === "user" ? "You:" : "Paseo:"} </Text>
{event.text}
</Text>
);
case "activity":
return (
<View style={styles.activityRow}>
<Text style={styles.activityLabel}>{event.label}</Text>
<Text style={styles.activityState}>{event.state}</Text>
</View>
);
case "notice":
return <Text style={styles.notice}>{event.text}</Text>;
case "error":
return <Text style={styles.error}>{event.message}</Text>;
}
}
export function VoiceCallSurface() {
const voice = useVoiceOptional();
const [transcriptOpen, setTranscriptOpen] = useState(false);
const toggleTranscript = useCallback(() => setTranscriptOpen((open) => !open), []);
if (!voice || (!voice.isVoiceMode && voice.events.length === 0)) return null;
const showTranscript = transcriptOpen || !voice.isVoiceMode;
return (
<View pointerEvents="box-none" style={styles.host}>
<View style={styles.surface}>
{showTranscript ? (
<ScrollView style={styles.transcript} contentContainerStyle={styles.transcriptContent}>
{voice.events.length === 0 ? (
<Text style={styles.empty}>Conversation will appear here.</Text>
) : (
voice.events.map((event) => <VoiceCallEventRow key={event.id} event={event} />)
)}
</ScrollView>
) : null}
{voice.isVoiceMode ? (
<RealtimeVoiceOverlay
isMuted={voice.isMuted}
isSwitching={voice.isVoiceSwitching}
isTranscriptOpen={transcriptOpen}
onToggleTranscript={toggleTranscript}
onToggleMute={voice.toggleMute}
onStop={voice.stopVoice}
/>
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel="Dismiss voice transcript"
onPress={voice.dismissTranscript}
style={styles.transcriptButton}
>
<Text style={styles.transcriptButtonText}>Dismiss</Text>
</Pressable>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
host: {
width: "100%",
paddingHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[1.5],
},
surface: {
width: "100%",
gap: theme.spacing[2],
},
transcript: {
maxHeight: 200,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
transcriptContent: {
gap: theme.spacing[1.5],
},
empty: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
eventText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
eventLabel: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.medium,
},
activityRow: {
minWidth: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
activityLabel: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
activityState: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
},
notice: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
error: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm,
},
transcriptButton: {
alignSelf: "flex-end",
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
transcriptButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "600",
},
}));
+2 -216
View File
@@ -1,11 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Pressable,
Text,
StyleSheet as RNStyleSheet,
type PressableStateCallbackType,
} from "react-native";
import { View, Pressable, Text, StyleSheet as RNStyleSheet } from "react-native";
import type { TFunction } from "i18next";
import {
useState,
@@ -25,7 +19,6 @@ import {
ArrowUp,
Square,
Pencil,
AudioLines,
CircleDot,
FileText,
GitPullRequest,
@@ -75,7 +68,6 @@ import {
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -182,14 +174,6 @@ function resolveIsComposerLocked(
return submitBehavior === "preserve-and-lock" && isSubmitLoading;
}
function resolveIsVoiceModeForAgent(
voice: ReturnType<typeof useVoiceOptional>,
serverId: string,
agentId: string,
): boolean {
return voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
}
function resolveKeyboardPriority(isMessageInputFocused: boolean): number {
return isMessageInputFocused ? 200 : 100;
}
@@ -241,19 +225,6 @@ function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
}
function buildRealtimeVoiceButtonStyle(
hovered: boolean | undefined,
voiceButtonDisabled: boolean,
reserveLeadingSpace: boolean,
): object[] {
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
(value): value is object => Boolean(value),
);
}
function buildAgentStateSelector(serverId: string, agentId: string) {
return (state: ReturnType<typeof useSessionStore.getState>) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -487,29 +458,6 @@ function resolveErrorMessage(error: unknown): string | null {
return null;
}
interface AttemptStartRealtimeVoiceArgs {
voice: ReturnType<typeof useVoiceOptional>;
isConnected: boolean;
hasAgent: boolean;
serverId: string;
agentId: string;
toastErrorRef: { current: (message: string) => void };
}
function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
const { voice, isConnected, hasAgent, serverId, agentId, toastErrorRef } = args;
if (!voice || !isConnected || !hasAgent) return;
if (voice.isVoiceSwitching) return;
if (voice.isVoiceModeForAgent(serverId, agentId)) return;
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[Composer] Failed to start voice mode", error);
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
});
}
function focusMessageInputWithPlatformStrategy(messageInputRef: {
current: MessageInputRef | null;
}): void {
@@ -1043,88 +991,6 @@ function ComposerCancelButton({
);
}
interface ComposerVoiceModeButtonProps {
buttonIconSize: number;
handleToggleRealtimeVoice: () => void;
isConnected: boolean;
isVoiceSwitching: boolean;
realtimeVoiceButtonStyle: (
state: PressableStateCallbackType & { hovered?: boolean },
) => (object | undefined)[];
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
t: TFunction;
}
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
isVoiceModeForAgent: boolean;
hasAgent: boolean;
isAgentRunning: boolean;
hasSendableContent: boolean;
isCompact: boolean;
showVoice: boolean;
}
function ComposerRightControlsSlot({
isVoiceModeForAgent,
hasAgent,
isAgentRunning,
hasSendableContent,
isCompact,
showVoice,
...voiceProps
}: ComposerRightControlsSlotProps) {
const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton =
showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
if (!showVoiceModeButton) return null;
return (
<View style={styles.rightControls}>
<ComposerVoiceModeButton {...voiceProps} />
</View>
);
}
function ComposerVoiceModeButton({
buttonIconSize,
handleToggleRealtimeVoice,
isConnected,
isVoiceSwitching,
realtimeVoiceButtonStyle,
voiceToggleKeys,
t,
}: ComposerVoiceModeButtonProps) {
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
const renderTriggerContent = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
if (isVoiceSwitching) {
return <LoadingSpinner size="small" color="white" />;
}
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
},
[buttonIconSize, isVoiceSwitching],
);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || isVoiceSwitching}
accessibilityLabel={t("composer.voice.enableVoiceMode")}
accessibilityRole="button"
style={realtimeVoiceButtonStyle}
>
{renderTriggerContent}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
{shortcutNode}
</View>
</TooltipContent>
</Tooltip>
);
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
@@ -1192,8 +1058,6 @@ function ComposerContentImpl({
const toast = useToast();
const toastErrorRef = useRef(toast.error);
toastErrorRef.current = toast.error;
const voice = useVoiceOptional();
const voiceToggleKeys = useShortcutKeys("voice-toggle");
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
const isDictationReady = useIsDictationReady({
serverId,
@@ -1838,19 +1702,6 @@ function ComposerContentImpl({
focusMessageInputWithPlatformStrategy(messageInputRef);
}, []);
const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId);
const handleToggleRealtimeVoice = useCallback(() => {
attemptStartRealtimeVoice({
voice,
isConnected,
hasAgent,
serverId,
agentId,
toastErrorRef,
});
}, [agentId, hasAgent, isConnected, serverId, voice]);
const handleEditQueuedMessage = useCallback(
(id: string) => {
const result = editQueuedComposerMessage({
@@ -1912,8 +1763,6 @@ function ComposerContentImpl({
],
);
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback(
(event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event),
@@ -1925,14 +1774,6 @@ function ComposerContentImpl({
[isConnected, isCancellingAgent],
);
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
const realtimeVoiceButtonStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) =>
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
[isCompactLayout, voiceButtonDisabled],
);
const activeActionContent = useMemo(
() => (
<ComposerCancelButton
@@ -1956,41 +1797,6 @@ function ComposerContentImpl({
],
);
const rightContent = useMemo(
() => (
<ComposerRightControlsSlot
isVoiceModeForAgent={isVoiceModeForAgent}
hasAgent={hasAgent}
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isCompact={isCompactLayout}
showVoice={mode.showVoice}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
isVoiceSwitching={isVoiceSwitching}
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
voiceToggleKeys={voiceToggleKeys}
t={t}
/>
),
[
buttonIconSize,
handleToggleRealtimeVoice,
hasAgent,
hasSendableContent,
isAgentRunning,
isConnected,
isCompactLayout,
isVoiceModeForAgent,
isVoiceSwitching,
mode.showVoice,
realtimeVoiceButtonStyle,
t,
voiceToggleKeys,
],
);
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
@@ -2362,9 +2168,9 @@ function ComposerContentImpl({
disabled={isSubmitLoading}
leftContent={leftContent}
beforeVoiceContent={beforeVoiceContent}
rightContent={rightContent}
activeActionContent={activeActionContent}
voiceServerId={serverId}
voiceWorkspaceId={workspaceId ?? undefined}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
@@ -2461,11 +2267,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center",
marginLeft: theme.spacing[1],
},
rightControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
@@ -2473,20 +2274,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButtonCompactReserve: {
marginLeft: theme.spacing[1],
},
realtimeVoiceButtonActive: {
backgroundColor: theme.colors.palette.green[600],
borderColor: theme.colors.palette.green[800],
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
@@ -2554,7 +2341,6 @@ const ThemedPencil = withUnistyles(Pencil);
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
+40 -45
View File
@@ -24,7 +24,6 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
@@ -144,6 +143,7 @@ export interface MessageInputProps {
/** Primary action to render when the agent is active and the composer has no sendable content. */
activeActionContent?: React.ReactNode;
voiceServerId?: string;
voiceWorkspaceId?: string;
voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean;
@@ -594,16 +594,9 @@ function MessageInputOverlay({
/>
);
}
if (showRealtimeOverlay && voice) {
return (
<RealtimeVoiceOverlay
isMuted={voice.isMuted}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={onRealtimeVoiceStop}
/>
);
}
void showRealtimeOverlay;
void voice;
void onRealtimeVoiceStop;
return null;
}
@@ -830,41 +823,50 @@ interface ToggleRealtimeVoiceContext {
voice:
| {
isVoiceSwitching: boolean;
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
startVoice: (serverId: string, agentId: string) => Promise<unknown>;
isCallActiveOnServer: (serverId: string) => boolean;
startCall: (
serverId: string,
context: { workspaceId: string | null; agentId: string | null },
) => Promise<unknown>;
}
| null
| undefined;
voiceServerId: string | undefined;
voiceWorkspaceId: string | undefined;
voiceAgentId: string | undefined;
isConnected: boolean;
disabled: boolean;
isAgentRunning: boolean;
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
if (!ctx.voice || !ctx.voiceServerId || !ctx.voiceAgentId || !ctx.isConnected || ctx.disabled) {
if (
!ctx.voice ||
!ctx.voiceServerId ||
!ctx.voiceWorkspaceId ||
!ctx.isConnected ||
ctx.disabled
) {
return;
}
if (ctx.voice.isVoiceSwitching) return;
if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) {
if (ctx.voice.isCallActiveOnServer(ctx.voiceServerId)) {
void ctx.handleStopRealtimeVoice();
return;
}
if (ctx.isAgentRunning) {
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
return;
}
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = extractErrorMessage(error);
if (message && message.trim().length > 0) {
ctx.toast.error(message);
}
});
void ctx.voice
.startCall(ctx.voiceServerId, {
workspaceId: ctx.voiceWorkspaceId,
agentId: ctx.voiceAgentId ?? null,
})
.catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = extractErrorMessage(error);
if (message && message.trim().length > 0) {
ctx.toast.error(message);
}
});
}
interface StartDictationContext {
@@ -959,12 +961,11 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
}
function computeIsRealtimeVoiceForAgent(
voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined,
voice: { isCallActiveOnServer: (serverId: string) => boolean } | null | undefined,
voiceServerId: string | undefined,
voiceAgentId: string | undefined,
): boolean {
if (!voice || !voiceServerId || !voiceAgentId) return false;
return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
if (!voice || !voiceServerId) return false;
return voice.isCallActiveOnServer(voiceServerId);
}
function computeShouldShowDictationOverlay(
@@ -1068,6 +1069,7 @@ interface ResolvedMessageInputProps {
rightContent: React.ReactNode;
activeActionContent: React.ReactNode;
voiceServerId: string | undefined;
voiceWorkspaceId: string | undefined;
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "steer" | "queue";
@@ -1115,6 +1117,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
rightContent: props.rightContent,
activeActionContent: props.activeActionContent,
voiceServerId: props.voiceServerId,
voiceWorkspaceId: props.voiceWorkspaceId,
voiceAgentId: props.voiceAgentId,
isAgentRunning: props.isAgentRunning ?? false,
defaultSendBehavior: props.defaultSendBehavior,
@@ -1170,6 +1173,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
rightContent,
activeActionContent,
voiceServerId,
voiceWorkspaceId,
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
@@ -1375,11 +1379,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
enableDuration: true,
});
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
voice,
voiceServerId,
voiceAgentId,
);
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(voice, voiceServerId);
const showDictationOverlay = computeShouldShowDictationOverlay(
isDictating,
isDictationProcessing,
@@ -1452,9 +1452,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
await stopRealtimeVoice({
voice,
isRealtimeVoiceForCurrentAgent,
isAgentRunning,
client,
voiceAgentId,
});
} catch (error) {
console.error("[MessageInput] Failed to stop realtime voice", error);
@@ -1463,30 +1460,28 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
toast.error(message);
}
}
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]);
}, [isRealtimeVoiceForCurrentAgent, toast, voice]);
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
toggleRealtimeVoiceImpl({
voice,
voiceServerId,
voiceWorkspaceId,
voiceAgentId,
isConnected,
disabled,
isAgentRunning,
handleStopRealtimeVoice,
toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
});
}, [
disabled,
handleStopRealtimeVoice,
isAgentRunning,
isConnected,
t,
toast,
voice,
voiceAgentId,
voiceServerId,
voiceWorkspaceId,
]);
const minimizeInputHeight = useCallback(() => {
+3 -32
View File
@@ -276,43 +276,14 @@ describe("composer send behavior", () => {
});
describe("stopRealtimeVoice", () => {
it("keeps voice mode active when the running agent refuses cancellation", async () => {
const cancellationError = new Error("active run cancellation was not acknowledged");
const cancelAgent = vi.fn().mockRejectedValue(cancellationError);
it("stops the app-level call without touching the visible agent", async () => {
const stopVoice = vi.fn().mockResolvedValue(undefined);
await expect(
stopRealtimeVoice({
voice: { stopVoice },
isRealtimeVoiceForCurrentAgent: true,
isAgentRunning: true,
client: { cancelAgent },
voiceAgentId: "agent-1",
}),
).rejects.toBe(cancellationError);
expect(stopVoice).not.toHaveBeenCalled();
});
it("stops voice mode after the running agent acknowledges cancellation", async () => {
const calls: string[] = [];
await stopRealtimeVoice({
voice: {
stopVoice: async () => {
calls.push("stop voice");
},
},
voice: { stopVoice },
isRealtimeVoiceForCurrentAgent: true,
isAgentRunning: true,
client: {
cancelAgent: async () => {
calls.push("cancel agent");
},
},
voiceAgentId: "agent-1",
});
expect(calls).toEqual(["cancel agent", "stop voice"]);
expect(stopVoice).toHaveBeenCalledOnce();
});
});
-10
View File
@@ -41,9 +41,6 @@ export function resolveComposerSurfacePresentation(
interface StopRealtimeVoiceContext {
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
isRealtimeVoiceForCurrentAgent: boolean;
isAgentRunning: boolean;
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
voiceAgentId: string | undefined;
}
interface SendActionContext {
@@ -183,12 +180,5 @@ export function runMessageInputKeyboardAction(
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
if (ctx.isAgentRunning) {
if (!ctx.client || !ctx.voiceAgentId) {
throw new Error("Cannot stop the running voice agent while the host is unavailable");
}
await ctx.client.cancelAgent(ctx.voiceAgentId);
}
await ctx.voice.stopVoice();
}
+21 -159
View File
@@ -1,5 +1,4 @@
import { useRef, ReactNode, useCallback, useEffect } from "react";
import { Buffer } from "buffer";
import { AppState } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
@@ -34,8 +33,7 @@ import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import { useVoiceRuntimeOptional } from "@/contexts/voice-context";
import {
selectAgentTimelineState,
useSessionStore,
@@ -72,50 +70,11 @@ export type {
AgentFileExplorerState,
} from "@/stores/session-store";
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
interface BufferedAudioChunk {
chunkIndex: number;
audio: string;
format: string;
id: string;
}
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode {
return selectiveAgentTimeline ? "selective" : "legacy";
}
function decodeBase64Chunk(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}
function buildAudioPlaybackSource(chunks: BufferedAudioChunk[]): AudioPlaybackSource {
const decodedChunks = chunks.map((chunk) => decodeBase64Chunk(chunk.audio));
const totalSize = decodedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const output = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of decodedChunks) {
output.set(chunk, offset);
offset += chunk.length;
}
const format = chunks[0]?.format ?? "pcm";
let mimeType: string;
if (format === "pcm") mimeType = "audio/pcm;rate=24000;bits=16";
else if (format === "mp3") mimeType = "audio/mpeg";
else mimeType = `audio/${format}`;
const bytes = output.slice();
return {
size: bytes.byteLength,
type: mimeType,
async arrayBuffer() {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
},
};
}
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i];
@@ -186,15 +145,6 @@ type WorkspaceSetupProgressPayload = Extract<
{ type: "workspace_setup_progress" }
>["payload"];
function notifyVoiceAbortFailure(
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
notifyError: (message: string) => void,
): void {
if (data.type === "error" && data.metadata?.voiceAbortFailed === true) {
notifyError(data.content);
}
}
interface SessionProviderSharedProps {
children: ReactNode;
serverId: string;
@@ -222,7 +172,6 @@ export function SessionProvider(props: SessionProviderProps) {
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
const { t } = useTranslation();
const voiceRuntime = useVoiceRuntimeOptional();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
@@ -255,8 +204,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const appStateRef = useRef(AppState.currentState);
const forcedTimelineTailReplacements = useRef(new Set<string>());
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const isAppVisible = useAppVisible();
useEffect(() => {
@@ -369,29 +316,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
serverId,
setVoiceMode: async (enabled, agentId) => {
startCall: async (context, transports) => {
if (!client) {
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.setVoiceMode(enabled, agentId);
return client.startVoiceCall(context, transports);
},
sendVoiceAudioChunk: async (audioData, mimeType) => {
stopCall: async (callId) => {
if (!client) {
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.sendVoiceAudioChunk(audioData, mimeType);
await client.stopVoiceCall(callId);
},
audioPlayed: async (chunkId) => {
updateCallContext: async (callId, context) => {
if (!client) {
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.audioPlayed(chunkId);
await client.updateVoiceCallContext(callId, context);
},
abortRequest: async () => {
sendTransportMessage: (callId, data) => {
if (!client) {
throw new Error(t("common.errors.daemonUnavailable"));
}
await client.abortRequest();
client.sendVoiceCallTransportMessage(callId, data);
},
setAssistantAudioPlaying: (isPlaying) => {
setIsPlayingAudio(serverId, isPlaying);
@@ -532,14 +479,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const { agentId, event, timestamp, seq, epoch } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event;
if (
event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
const turnLiveness = deriveAgentStreamTurnLiveness([
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
]);
@@ -661,94 +600,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
});
const unsubAudioOutput = client.on("audio_output", async (message) => {
if (message.type !== "audio_output") return;
if (!voiceAudioEngine) {
return;
}
const payload: AudioOutputPayload = message.payload;
if (payload.isVoiceMode && voiceRuntime) {
voiceRuntime.handleAudioOutput(serverId, payload);
return;
}
const playbackGroupId = payload.groupId ?? payload.id;
const chunkIndex = payload.chunkIndex ?? 0;
const isFinalChunk = payload.isLastChunk ?? true;
if (!audioOutputBuffersRef.current.has(playbackGroupId)) {
audioOutputBuffersRef.current.set(playbackGroupId, []);
}
const bufferedChunks = audioOutputBuffersRef.current.get(playbackGroupId)!;
bufferedChunks.push({
chunkIndex,
audio: payload.audio,
format: payload.format,
id: payload.id,
});
activeAudioGroupsRef.current.add(playbackGroupId);
setIsPlayingAudio(serverId, true);
if (!isFinalChunk) {
return;
}
bufferedChunks.sort((left, right) => left.chunkIndex - right.chunkIndex);
const chunkIds = bufferedChunks.map((chunk) => chunk.id);
const shouldPlay =
!payload.isVoiceMode || (voiceRuntime?.shouldPlayVoiceAudio(serverId) ?? false);
const audioBlob = buildAudioPlaybackSource(bufferedChunks);
function logAudioPlayedError(error: unknown): void {
console.warn("[Session] Failed to confirm audio playback:", error);
}
const confirmAudioPlayed = async () => {
await Promise.all(
chunkIds.map((chunkId) => client.audioPlayed(chunkId).catch(logAudioPlayedError)),
);
};
let startedVoicePlayback = false;
try {
if (shouldPlay) {
if (payload.isVoiceMode) {
startedVoicePlayback = true;
voiceRuntime?.onAssistantAudioStarted(serverId);
}
await voiceAudioEngine.play(audioBlob);
}
await confirmAudioPlayed();
} catch (error) {
console.error("[Session] Audio playback error:", error);
await confirmAudioPlayed();
} finally {
audioOutputBuffersRef.current.delete(playbackGroupId);
activeAudioGroupsRef.current.delete(playbackGroupId);
setIsPlayingAudio(serverId, activeAudioGroupsRef.current.size > 0);
if (startedVoicePlayback) {
voiceRuntime?.onAssistantAudioFinished(serverId);
}
}
const unsubVoiceCallTransport = client.on("voice.call.transport.server", (message) => {
if (message.type !== "voice.call.transport.server" || !voiceRuntime) return;
voiceRuntime.handleTransportMessage(serverId, message.payload.callId, message.payload.data);
});
const unsubActivity = client.on("activity_log", (message) => {
if (message.type !== "activity_log") return;
notifyVoiceAbortFailure(message.payload, toast.error);
const unsubVoiceCallState = client.on("voice.call.state", (message) => {
if (message.type !== "voice.call.state") return;
voiceRuntime?.handleCallState(serverId, message.payload);
});
const unsubTranscription = client.on("transcription_result", (message) => {
if (message.type !== "transcription_result") return;
const transcriptText = message.payload.text.trim();
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
});
const unsubVoiceInputState = client.on("voice_input_state", (message) => {
if (message.type !== "voice_input_state") return;
voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking);
const unsubVoiceCallEvent = client.on("voice.call.event", (message) => {
if (message.type !== "voice.call.event") return;
voiceRuntime?.handleCallEvent(serverId, message.payload.callId, message.payload.event);
});
const unsubTerminalAttention = client.on("terminal_attention_required", (message) => {
@@ -784,10 +648,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubStatus();
unsubPermissionRequest();
unsubPermissionResolved();
unsubAudioOutput();
unsubActivity();
unsubTranscription();
unsubVoiceInputState();
unsubVoiceCallTransport();
unsubVoiceCallState();
unsubVoiceCallEvent();
unsubTerminalAttention();
};
}, [
@@ -808,7 +671,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
updateSessionServerInfo,
toast,
voiceRuntime,
voiceAudioEngine,
]);
const _cancelAgentRun = useCallback(
+36 -14
View File
@@ -8,7 +8,9 @@ import {
type ReactNode,
} from "react";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import type { VoiceAppContext } from "@getpaseo/protocol/messages";
import { useSessionStore } from "@/stores/session-store";
import { useToast } from "@/contexts/toast-context";
import { createAudioEngine } from "@/voice/audio-engine";
import type { AudioEngine } from "@/voice/audio-engine-types";
import {
@@ -16,12 +18,18 @@ import {
type VoiceRuntime,
type VoiceRuntimeSnapshot,
type VoiceRuntimeTelemetrySnapshot,
} from "@/voice/voice-runtime";
} from "@/voice-chat/runtime";
import {
createDaemonAudioTransportFactory,
type DaemonAudioTransportFactory,
} from "@/voice-chat/transports/daemon-audio";
interface VoiceContextValue extends VoiceRuntimeSnapshot {
startVoice: (serverId: string, agentId: string) => Promise<void>;
startCall: (serverId: string, context: VoiceAppContext) => Promise<void>;
stopVoice: () => Promise<void>;
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
updateCallContext: (context: VoiceAppContext) => Promise<void>;
dismissTranscript: () => void;
isCallActiveOnServer: (serverId: string) => boolean;
toggleMute: () => void;
}
@@ -31,7 +39,8 @@ const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = {
isVoiceSwitching: false,
isMuted: false,
activeServerId: null,
activeAgentId: null,
activeCallId: null,
events: [],
};
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
@@ -73,9 +82,11 @@ export function useVoiceOptional(): VoiceContextValue | null {
}
return {
...snapshot,
startVoice: runtime.startVoice,
startCall: runtime.startCall,
stopVoice: runtime.stopVoice,
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
updateCallContext: runtime.updateCallContext,
dismissTranscript: runtime.dismissTranscript,
isCallActiveOnServer: runtime.isCallActiveOnServer,
toggleMute: runtime.toggleMute,
};
}, [snapshot, runtime]);
@@ -113,17 +124,24 @@ interface VoiceProviderProps {
}
export function VoiceProvider({ children }: VoiceProviderProps) {
const toast = useToast();
const engineRef = useRef<AudioEngine | null>(null);
const runtimeRef = useRef<VoiceRuntime | null>(null);
if (!engineRef.current) {
let runtime: VoiceRuntime | null = null;
let daemonAudio: DaemonAudioTransportFactory | null = null;
const reportMediaError = (error: unknown) => {
const message = error instanceof Error ? error.message : "Voice media failed";
toast.error(message);
void runtime?.reportError(message);
};
const engine = createAudioEngine({
onCaptureData: (pcm) => {
runtime?.handleCapturePcm(pcm);
daemonAudio?.handleCapturePcm(pcm);
},
onVolumeLevel: (level) => {
runtime?.handleCaptureVolume(level);
daemonAudio?.handleCaptureVolume(level);
},
onInterruption: () => {
void runtime?.stopVoice().catch((error) => {
@@ -131,12 +149,13 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
});
},
onError: (error) => {
console.error("[VoiceEngine] Capture error:", error);
reportMediaError(error);
},
});
daemonAudio = createDaemonAudioTransportFactory({ engine, onError: reportMediaError });
runtime = createVoiceRuntime({
engine,
transports: [daemonAudio],
getServerInfo: (serverId) =>
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
activateKeepAwake: async (tag) => {
@@ -156,11 +175,14 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
useEffect(() => {
return () => {
void runtime.destroy().catch((error) => {
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
});
void runtime
.destroy()
.then(() => engine.destroy())
.catch((error) => {
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
});
};
}, [runtime]);
}, [engine, runtime]);
return (
<VoiceAudioEngineContext.Provider value={engine}>
+1
View File
@@ -1944,6 +1944,7 @@ export const ar: TranslationResources = {
projects: "المشاريع",
connections: "اتصالات",
agents: "Agents",
voice: "الصوت",
metadata: "البيانات الوصفية",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
+1
View File
@@ -1989,6 +1989,7 @@ export const en = {
projects: "Projects",
connections: "Connections",
agents: "Agents",
voice: "Voice",
metadata: "Metadata",
workspaces: "Workspaces",
providers: "Providers",
+1
View File
@@ -1990,6 +1990,7 @@ export const es: TranslationResources = {
projects: "Proyectos",
connections: "Conexiones",
agents: "Agents",
voice: "Voz",
metadata: "Metadatos",
workspaces: "Workspaces",
providers: "Proveedores",
+1
View File
@@ -1995,6 +1995,7 @@ export const fr: TranslationResources = {
projects: "Projets",
connections: "Relations",
agents: "Agents",
voice: "Voix",
metadata: "Métadonnées",
workspaces: "Workspaces",
providers: "Fournisseurs",
+1
View File
@@ -1962,6 +1962,7 @@ export const ja: TranslationResources = {
projects: "プロジェクト",
connections: "接続",
agents: "エージェント",
voice: "音声",
metadata: "メタデータ",
workspaces: "ワークスペース",
providers: "プロバイダー",
+1
View File
@@ -1954,6 +1954,7 @@ export const ko: TranslationResources = {
projects: "프로젝트",
connections: "연결",
agents: "에이전트",
voice: "음성",
metadata: "메타데이터",
workspaces: "워크스페이스",
providers: "프로바이더",
+1
View File
@@ -1976,6 +1976,7 @@ export const ptBR: TranslationResources = {
projects: "Projetos",
connections: "Conexões",
agents: "Agentes",
voice: "Voz",
metadata: "Metadados",
workspaces: "Workspaces",
providers: "Provedores",
+1
View File
@@ -1975,6 +1975,7 @@ export const ru: TranslationResources = {
projects: "Проекты",
connections: "Подключения",
agents: "Агенты",
voice: "Голос",
metadata: "Метаданные",
workspaces: "Рабочие пространства",
providers: "Провайдеры",
+1
View File
@@ -1923,6 +1923,7 @@ export const zhCN: TranslationResources = {
projects: "项目",
connections: "连接",
agents: "Agents",
voice: "语音",
metadata: "元数据",
workspaces: "Workspaces",
providers: "Providers",
@@ -39,6 +39,7 @@ import {
Sparkles,
Blocks,
PanelsTopLeft,
AudioLines,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -114,6 +115,7 @@ import {
} from "@/screens/settings/host-page";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import { VoiceSettingsPage } from "@/screens/settings/voice-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
@@ -189,6 +191,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "voice", labelKey: "settings.hostSections.voice", icon: AudioLines },
{ id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
@@ -210,6 +213,8 @@ function renderHostSettingsContent(
return <HostPairDevicePage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "voice":
return <VoiceSettingsPage serverId={view.serverId} />;
case "metadata":
return <MetadataGenerationPage serverId={view.serverId} />;
case "workspaces":
@@ -0,0 +1,259 @@
import { useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { MutableManualVoiceOrchestratorConfig } from "@getpaseo/protocol/messages";
import type { TFunction } from "i18next";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useToast } from "@/contexts/toast-context";
import {
SelectField,
type SelectFieldDisplay,
type SelectFieldOption,
} from "@/components/ui/select-field";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { resolveVoiceOrchestratorSelection } from "./voice-settings-model";
function findEntry(entries: ProviderSnapshotEntry[], provider: string | null) {
return entries.find((entry) => entry.provider === provider) ?? null;
}
function optionDisplay(
options: SelectFieldOption<string>[],
value: string | null,
): SelectFieldDisplay | null {
const option = options.find((candidate) => candidate.value === value);
return option ? { label: option.label, description: option.description } : null;
}
export function VoiceSettingsPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const toast = useToast();
const { config, isLoading: isConfigLoading, patchConfig } = useDaemonConfig(serverId);
const snapshot = useProvidersSnapshot(serverId);
const entries = useMemo(() => snapshot.entries ?? [], [snapshot.entries]);
const [isSaving, setIsSaving] = useState(false);
const orchestrator = config?.manualVoice?.orchestrator;
const providers = useMemo(() => buildSelectableProviderSelectorProviders(entries), [entries]);
const selectedEntry = findEntry(entries, orchestrator?.provider ?? null);
const modeOptions = useMemo<SelectFieldOption<string>[]>(
() =>
(selectedEntry?.modes ?? []).map((mode) => ({
id: mode.id,
value: mode.id,
label: mode.label,
description: mode.description,
})),
[selectedEntry],
);
const selectedModel =
selectedEntry?.models?.find((model) => model.id === orchestrator?.model) ?? null;
const thinkingOptions = useMemo<SelectFieldOption<string>[]>(
() =>
(selectedModel?.thinkingOptions ?? []).map((option) => ({
id: option.id,
value: option.id,
label: option.label,
description: option.description,
})),
[selectedModel],
);
const save = useCallback(
async (next: {
provider?: string | null;
model?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
}) => {
setIsSaving(true);
try {
await patchConfig({ manualVoice: { orchestrator: next } });
} catch (error) {
toast.error(error instanceof Error ? error.message : t("common.errors.unableToSave"));
} finally {
setIsSaving(false);
}
},
[patchConfig, t, toast],
);
const handleModelSelect = useCallback(
(provider: AgentProvider, model: string) => {
const selection = resolveVoiceOrchestratorSelection({
entries,
current: orchestrator,
provider,
model,
});
if (!selection) {
toast.error("This provider does not offer an agent mode.");
return;
}
void save(selection);
},
[entries, orchestrator, save, toast],
);
const handleModeSelect = useCallback((modeId: string) => void save({ modeId }), [save]);
const handleThinkingSelect = useCallback(
(thinkingOptionId: string) => void save({ thinkingOptionId }),
[save],
);
const handleSelectorOpen = useCallback(() => {
snapshot.refetchIfStale(orchestrator?.provider ?? undefined);
}, [orchestrator?.provider, snapshot]);
const handleRetryProvider = useCallback(
(provider: AgentProvider) => snapshot.refresh([provider]),
[snapshot],
);
if (isConfigLoading || !config) {
return (
<View style={styles.loading}>
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
</View>
);
}
return (
<VoiceSettingsFields
t={t}
serverId={serverId}
orchestrator={orchestrator}
providers={providers}
modeOptions={modeOptions}
thinkingOptions={thinkingOptions}
isLoadingProviders={snapshot.isLoading || snapshot.isFetching}
isRefreshingProvider={snapshot.isRefreshing}
isSaving={isSaving}
onModelSelect={handleModelSelect}
onModeSelect={handleModeSelect}
onThinkingSelect={handleThinkingSelect}
onSelectorOpen={handleSelectorOpen}
onRetryProvider={handleRetryProvider}
/>
);
}
interface VoiceSettingsFieldsProps {
t: TFunction;
serverId: string;
orchestrator: MutableManualVoiceOrchestratorConfig | undefined;
providers: ProviderSelectorProvider[];
modeOptions: SelectFieldOption<string>[];
thinkingOptions: SelectFieldOption<string>[];
isLoadingProviders: boolean;
isRefreshingProvider: boolean;
isSaving: boolean;
onModelSelect(provider: AgentProvider, model: string): void;
onModeSelect(modeId: string): void;
onThinkingSelect(thinkingOptionId: string): void;
onSelectorOpen(): void;
onRetryProvider(provider: AgentProvider): void;
}
function VoiceSettingsFields(props: VoiceSettingsFieldsProps) {
const { orchestrator, modeOptions, thinkingOptions } = props;
return (
<SettingsSection title={props.t("composer.voice.voiceMode")} testID="voice-settings">
<Text style={styles.description}>
Choose the hidden orchestrator Paseo creates for each manual voice call. It receives Paseo
tools independently of ordinary agent MCP injection.
</Text>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{props.t("modelSelector.model")}</Text>
<Text style={settingsStyles.rowHint}>
Provider and model for the voice orchestrator
</Text>
</View>
<CombinedModelSelector
providers={props.providers}
selectedProvider={orchestrator?.provider ?? ""}
selectedModel={orchestrator?.model ?? ""}
onSelect={props.onModelSelect}
isLoading={props.isLoadingProviders}
onOpen={props.onSelectorOpen}
onRetryProvider={props.onRetryProvider}
isRetryingProvider={props.isRefreshingProvider}
disabled={props.isSaving}
serverId={props.serverId}
desktopPlacement="bottom-start"
desktopMinWidth={360}
/>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{props.t("agentControls.mode.title")}</Text>
<Text style={settingsStyles.rowHint}>Access and approval policy for voice work</Text>
</View>
<SelectField
label={props.t("agentControls.mode.title")}
value={orchestrator?.modeId ?? null}
selectedDisplay={optionDisplay(modeOptions, orchestrator?.modeId ?? null)}
options={modeOptions}
onChange={props.onModeSelect}
placeholder="Select mode"
emptyText="No modes available"
disabled={props.isSaving || !orchestrator?.provider || modeOptions.length === 0}
searchable={modeOptions.length > 6}
size="sm"
field={false}
triggerTestID="voice-mode-trigger"
/>
</View>
{thinkingOptions.length > 0 ? (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{props.t("agentControls.thinking.title")}</Text>
<Text style={settingsStyles.rowHint}>Reasoning effort for the orchestrator</Text>
</View>
<SelectField
label={props.t("agentControls.thinking.title")}
value={orchestrator?.thinkingOptionId ?? null}
selectedDisplay={optionDisplay(
thinkingOptions,
orchestrator?.thinkingOptionId ?? null,
)}
options={thinkingOptions}
onChange={props.onThinkingSelect}
placeholder="Select thinking"
emptyText="No thinking options available"
disabled={props.isSaving}
searchable={thinkingOptions.length > 6}
size="sm"
field={false}
triggerTestID="voice-thinking-trigger"
/>
</View>
) : null}
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
marginHorizontal: theme.spacing[1],
},
loading: {
alignItems: "center",
justifyContent: "center",
minHeight: 180,
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
}));
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { resolveVoiceOrchestratorSelection } from "./voice-settings-model";
const entries: ProviderSnapshotEntry[] = [
{
provider: "codex",
enabled: true,
status: "ready",
defaultModeId: "auto-review",
modes: [
{ id: "auto-review", label: "Agent" },
{ id: "full-access", label: "Full access" },
],
models: [
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
],
},
],
},
];
describe("voice orchestrator settings", () => {
it("always resolves an explicit provider mode with a model selection", () => {
expect(
resolveVoiceOrchestratorSelection({
entries,
current: undefined,
provider: "codex",
model: "gpt-5.4",
}),
).toEqual({
provider: "codex",
model: "gpt-5.4",
modeId: "auto-review",
thinkingOptionId: "high",
});
});
it("preserves an explicitly selected valid mode", () => {
expect(
resolveVoiceOrchestratorSelection({
entries,
current: {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "medium",
},
provider: "codex",
model: "gpt-5.4",
}),
).toMatchObject({ modeId: "full-access", thinkingOptionId: "medium" });
});
it("rejects providers that cannot supply a mode", () => {
expect(
resolveVoiceOrchestratorSelection({
entries: [{ provider: "pi", enabled: true, status: "ready", modes: [] }],
current: undefined,
provider: "pi",
model: "",
}),
).toBeNull();
});
});
@@ -0,0 +1,58 @@
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { MutableManualVoiceOrchestratorConfig } from "@getpaseo/protocol/messages";
function resolveModeId(input: {
entry: ProviderSnapshotEntry | undefined;
current: MutableManualVoiceOrchestratorConfig | undefined;
provider: AgentProvider;
}): string | null {
const modes = input.entry?.modes ?? [];
const selected = modes.find((mode) => mode.id === input.current?.modeId)?.id;
if (input.current?.provider === input.provider && selected) return selected;
return input.entry?.defaultModeId ?? modes[0]?.id ?? null;
}
function resolveThinkingOptionId(input: {
entry: ProviderSnapshotEntry | undefined;
current: MutableManualVoiceOrchestratorConfig | undefined;
provider: AgentProvider;
model: string | null;
}): string | null {
const definition = input.entry?.models?.find((candidate) => candidate.id === input.model);
const selected = definition?.thinkingOptions?.find(
(option) => option.id === input.current?.thinkingOptionId,
)?.id;
if (
input.current?.provider === input.provider &&
input.current.model === input.model &&
selected
) {
return selected;
}
return (
definition?.defaultThinkingOptionId ??
definition?.thinkingOptions?.find((option) => option.isDefault)?.id ??
null
);
}
export function resolveVoiceOrchestratorSelection(input: {
entries: ProviderSnapshotEntry[];
current: MutableManualVoiceOrchestratorConfig | undefined;
provider: AgentProvider;
model: string;
}): MutableManualVoiceOrchestratorConfig | null {
const entry = input.entries.find((candidate) => candidate.provider === input.provider);
const modeId = resolveModeId({ entry, current: input.current, provider: input.provider });
if (!modeId) return null;
const model = input.model || null;
const thinkingOptionId = resolveThinkingOptionId({
entry,
current: input.current,
provider: input.provider,
model,
});
return { provider: input.provider, model, modeId, thinkingOptionId };
}
+1
View File
@@ -513,6 +513,7 @@ export const HOST_SECTION_SLUGS = [
"connections",
"pair-device",
"agents",
"voice",
"metadata",
"workspaces",
"providers",
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, it, vi } from "vitest";
import type { DaemonServerInfo } from "@/stores/session-store";
import { createVoiceRuntime, type VoiceSessionAdapter } from "@/voice-chat/runtime";
import type {
VoiceClientTransport,
VoiceClientTransportFactory,
VoiceTransportMediaState,
} from "@/voice-chat/transport";
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createServerInfo(voiceChat = true): DaemonServerInfo {
return {
serverId: "server-1",
hostname: "host",
version: "1.0.0",
features: { voiceChat },
};
}
function createAdapter(): VoiceSessionAdapter {
return {
serverId: "server-1",
startCall: vi.fn().mockResolvedValue({
callId: "call-1",
transport: { kind: "webrtc", answerSdp: "answer-sdp" },
}),
stopCall: vi.fn().mockResolvedValue(undefined),
updateCallContext: vi.fn().mockResolvedValue(undefined),
sendTransportMessage: vi.fn(),
setAssistantAudioPlaying: vi.fn(),
};
}
function createWebRtcTransportProbe() {
let publish: ((state: VoiceTransportMediaState) => void) | null = null;
const transport: VoiceClientTransport = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
handleServerMessage: vi.fn(),
updateCallState: vi.fn(),
toggleMute: vi.fn(),
};
const factory: VoiceClientTransportFactory = {
async prepare() {
return {
offer: { kind: "webrtc", offerSdp: "offer-sdp" },
accepts: (answer) => answer.kind === "webrtc",
async accept(input) {
if (input.answer.kind !== "webrtc") throw new Error("Expected WebRTC answer");
publish = input.publish;
return transport;
},
async discard() {},
};
},
};
return {
factory,
transport,
publishMedia(state: VoiceTransportMediaState) {
if (!publish) throw new Error("Transport was not accepted");
publish(state);
},
};
}
function setup(voiceChat = true) {
const transportProbe = createWebRtcTransportProbe();
const runtime = createVoiceRuntime({
transports: [transportProbe.factory],
getServerInfo: () => createServerInfo(voiceChat),
activateKeepAwake: vi.fn().mockResolvedValue(undefined),
deactivateKeepAwake: vi.fn().mockResolvedValue(undefined),
});
const adapter = createAdapter();
runtime.registerSession(adapter);
return { runtime, adapter, transportProbe };
}
describe("voice call runtime", () => {
it("starts through a WebRTC-shaped client transport without transport logic in runtime", async () => {
const { runtime, adapter, transportProbe } = setup();
const context = { workspaceId: "workspace-1", agentId: "agent-1" };
await runtime.startCall("server-1", context);
expect(adapter.startCall).toHaveBeenCalledWith(context, [
{ kind: "webrtc", offerSdp: "offer-sdp" },
]);
expect(transportProbe.transport.start).toHaveBeenCalledOnce();
expect(runtime.getSnapshot()).toMatchObject({
phase: "listening",
isVoiceMode: true,
activeServerId: "server-1",
activeCallId: "call-1",
});
});
it("derives mute, volume, and playback from transport-owned media state", async () => {
const { runtime, transportProbe } = setup();
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
transportProbe.publishMedia({ isMuted: true, volume: 0.4, output: "speaking" });
expect(runtime.getSnapshot()).toMatchObject({ isMuted: true, phase: "playing" });
expect(runtime.getTelemetrySnapshot().volume).toBe(0.4);
});
it("keeps provider events as the lightweight transcript", async () => {
const { runtime } = setup();
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
runtime.handleCallEvent("server-1", "call-1", {
type: "transcript",
id: "utterance-1",
speaker: "user",
text: "hello",
final: false,
});
runtime.handleCallEvent("server-1", "call-1", {
type: "transcript",
id: "utterance-1",
speaker: "user",
text: "hello there",
final: true,
});
expect(runtime.getSnapshot().events).toEqual([
expect.objectContaining({ id: "utterance-1", text: "hello there", final: true }),
]);
});
it("stops transport and server call exactly once", async () => {
const { runtime, adapter, transportProbe } = setup();
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
await runtime.stopVoice();
expect(adapter.stopCall).toHaveBeenCalledWith("call-1");
expect(transportProbe.transport.stop).toHaveBeenCalledOnce();
expect(runtime.getSnapshot().phase).toBe("disabled");
});
it("terminates locally on disconnect so reconnect starts clean", async () => {
const { runtime, transportProbe } = setup();
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
runtime.updateSessionConnection("server-1", false);
await vi.waitFor(() => expect(runtime.getSnapshot().phase).toBe("disabled"));
expect(transportProbe.transport.stop).toHaveBeenCalledOnce();
});
it("stops a server call that is accepted after local startup cancellation", async () => {
const { runtime, adapter, transportProbe } = setup();
const started = deferred<{
callId: string;
transport: { kind: "webrtc"; answerSdp: string };
}>();
vi.mocked(adapter.startCall).mockReturnValue(started.promise);
const starting = runtime.startCall("server-1", { workspaceId: null, agentId: null });
await vi.waitFor(() => expect(adapter.startCall).toHaveBeenCalledOnce());
await runtime.stopVoice();
started.resolve({
callId: "late-call",
transport: { kind: "webrtc", answerSdp: "answer-sdp" },
});
await starting;
expect(adapter.stopCall).toHaveBeenCalledWith("late-call");
expect(transportProbe.transport.start).not.toHaveBeenCalled();
expect(runtime.getSnapshot().phase).toBe("disabled");
});
it("does not let an older startup replace a newer call", async () => {
const { runtime, adapter, transportProbe } = setup();
const older = deferred<{
callId: string;
transport: { kind: "webrtc"; answerSdp: string };
}>();
vi.mocked(adapter.startCall)
.mockReturnValueOnce(older.promise)
.mockResolvedValueOnce({
callId: "newer-call",
transport: { kind: "webrtc", answerSdp: "newer-answer" },
});
const olderStart = runtime.startCall("server-1", { workspaceId: null, agentId: null });
await vi.waitFor(() => expect(adapter.startCall).toHaveBeenCalledOnce());
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
older.resolve({
callId: "older-call",
transport: { kind: "webrtc", answerSdp: "older-answer" },
});
await olderStart;
expect(runtime.getSnapshot()).toMatchObject({
phase: "listening",
activeCallId: "newer-call",
});
expect(adapter.stopCall).toHaveBeenCalledWith("older-call");
expect(transportProbe.transport.start).toHaveBeenCalledOnce();
});
it("forwards provider state and transport messages to the selected transport", async () => {
const { runtime, transportProbe } = setup();
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
const state = {
callId: "call-1",
lifecycle: "active" as const,
input: "speech_detected" as const,
error: null,
};
const data = { type: "candidate", value: "server-data" };
runtime.handleCallState("server-1", state);
runtime.handleTransportMessage("server-1", "call-1", data);
expect(transportProbe.transport.updateCallState).toHaveBeenCalledWith(state);
expect(transportProbe.transport.handleServerMessage).toHaveBeenCalledWith(data);
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
});
it("keeps provider terminal failures in the lightweight transcript", async () => {
const { runtime } = setup();
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
runtime.handleCallState("server-1", {
callId: "call-1",
lifecycle: "failed",
input: "idle",
error: "Realtime authentication expired",
});
await vi.waitFor(() => expect(runtime.getSnapshot().phase).toBe("disabled"));
expect(runtime.getSnapshot().events).toContainEqual({
type: "error",
id: "call-terminal-call-1",
message: "Realtime authentication expired",
});
});
it("keeps media failure visible while terminating the call", async () => {
const { runtime, adapter } = setup();
await runtime.startCall("server-1", { workspaceId: null, agentId: null });
await runtime.reportError("Microphone disconnected");
expect(adapter.stopCall).toHaveBeenCalledWith("call-1");
expect(runtime.getSnapshot()).toMatchObject({ phase: "disabled" });
expect(runtime.getSnapshot().events).toContainEqual({
type: "error",
id: "media-error-call-1",
message: "Microphone disconnected",
});
});
it("rejects hosts without the new capability", async () => {
const { runtime, adapter } = setup(false);
await expect(
runtime.startCall("server-1", { workspaceId: null, agentId: null }),
).rejects.toThrow("Update the Paseo host");
expect(adapter.startCall).not.toHaveBeenCalled();
});
});
+404
View File
@@ -0,0 +1,404 @@
import type {
VoiceAppContext,
VoiceCallEvent,
VoiceCallState,
VoiceCallTransport,
VoiceCallTransportOffer,
} from "@getpaseo/protocol/messages";
import type { DaemonServerInfo } from "@/stores/session-store";
import {
acceptVoiceClientTransport,
type PreparedVoiceClientTransport,
type VoiceClientTransport,
type VoiceClientTransportFactory,
type VoiceTransportMediaState,
type VoiceTransportSession,
} from "@/voice-chat/transport";
const KEEP_AWAKE_TAG = "paseo:voice";
export type VoiceRuntimePhase = "disabled" | "starting" | "listening" | "playing" | "stopping";
export interface VoiceRuntimeSnapshot {
phase: VoiceRuntimePhase;
isVoiceMode: boolean;
isVoiceSwitching: boolean;
isMuted: boolean;
activeServerId: string | null;
activeCallId: string | null;
events: VoiceCallEvent[];
}
export interface VoiceRuntimeTelemetrySnapshot {
volume: number;
isSpeaking: boolean;
segmentDuration: number;
}
export interface VoiceSessionAdapter extends VoiceTransportSession {
serverId: string;
startCall(
context: VoiceAppContext,
transports: VoiceCallTransportOffer[],
): Promise<{ callId: string; transport: VoiceCallTransport }>;
stopCall(callId: string): Promise<void>;
updateCallContext(callId: string, context: VoiceAppContext): Promise<void>;
}
export interface VoiceRuntimeDeps {
transports: readonly VoiceClientTransportFactory[];
getServerInfo(serverId: string): DaemonServerInfo | null;
activateKeepAwake(tag: string): Promise<void>;
deactivateKeepAwake(tag: string): Promise<void>;
}
interface RuntimeSession {
adapter: VoiceSessionAdapter;
connected: boolean;
}
interface ActiveRuntimeCall {
serverId: string;
callId: string;
transport: VoiceClientTransport;
media: VoiceTransportMediaState;
}
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
phase: "disabled",
isVoiceMode: false,
isVoiceSwitching: false,
isMuted: false,
activeServerId: null,
activeCallId: null,
events: [],
};
const INITIAL_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
volume: 0,
isSpeaking: false,
segmentDuration: 0,
};
function snapshotsEqual(left: VoiceRuntimeSnapshot, right: VoiceRuntimeSnapshot): boolean {
return (
left.phase === right.phase &&
left.isVoiceMode === right.isVoiceMode &&
left.isVoiceSwitching === right.isVoiceSwitching &&
left.isMuted === right.isMuted &&
left.activeServerId === right.activeServerId &&
left.activeCallId === right.activeCallId &&
left.events === right.events
);
}
function telemetryEqual(
left: VoiceRuntimeTelemetrySnapshot,
right: VoiceRuntimeTelemetrySnapshot,
): boolean {
return (
left.volume === right.volume &&
left.isSpeaking === right.isSpeaking &&
left.segmentDuration === right.segmentDuration
);
}
export interface VoiceRuntime {
subscribe(listener: () => void): () => void;
getSnapshot(): VoiceRuntimeSnapshot;
subscribeTelemetry(listener: () => void): () => void;
getTelemetrySnapshot(): VoiceRuntimeTelemetrySnapshot;
registerSession(adapter: VoiceSessionAdapter): () => void;
updateSessionConnection(serverId: string, connected: boolean): void;
handleTransportMessage(serverId: string, callId: string, data: unknown): void;
handleCallState(serverId: string, callState: VoiceCallState): void;
handleCallEvent(serverId: string, callId: string, event: VoiceCallEvent): void;
startCall(serverId: string, context: VoiceAppContext): Promise<void>;
stopVoice(): Promise<void>;
updateCallContext(context: VoiceAppContext): Promise<void>;
reportError(message: string): Promise<void>;
dismissTranscript(): void;
destroy(): Promise<void>;
toggleMute(): void;
isCallActiveOnServer(serverId: string): boolean;
}
export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
const listeners = new Set<() => void>();
const telemetryListeners = new Set<() => void>();
const sessions = new Map<string, RuntimeSession>();
let snapshot = INITIAL_SNAPSHOT;
let telemetry = INITIAL_TELEMETRY;
let activeCall: ActiveRuntimeCall | null = null;
let generation = 0;
let speechStartedAt: number | null = null;
let segmentTimer: ReturnType<typeof setInterval> | null = null;
function emit(): void {
for (const listener of listeners) listener();
}
function emitTelemetry(): void {
for (const listener of telemetryListeners) listener();
}
function patchSnapshot(
patch:
| Partial<VoiceRuntimeSnapshot>
| ((previous: VoiceRuntimeSnapshot) => VoiceRuntimeSnapshot),
): void {
const next = typeof patch === "function" ? patch(snapshot) : { ...snapshot, ...patch };
if (snapshotsEqual(next, snapshot)) return;
snapshot = next;
emit();
}
function patchTelemetry(patch: Partial<VoiceRuntimeTelemetrySnapshot>): void {
const next = { ...telemetry, ...patch };
if (telemetryEqual(next, telemetry)) return;
telemetry = next;
emitTelemetry();
}
function clearSegmentTimer(): void {
if (!segmentTimer) return;
clearInterval(segmentTimer);
segmentTimer = null;
}
function publishSpeechState(isSpeaking: boolean): void {
speechStartedAt = isSpeaking ? (speechStartedAt ?? Date.now()) : null;
patchTelemetry({ isSpeaking, ...(isSpeaking ? {} : { segmentDuration: 0 }) });
if (!isSpeaking) {
clearSegmentTimer();
return;
}
if (segmentTimer) return;
segmentTimer = setInterval(() => {
patchTelemetry({ segmentDuration: speechStartedAt ? Date.now() - speechStartedAt : 0 });
}, 100);
}
function publishMedia(callGeneration: number, media: VoiceTransportMediaState): void {
if (callGeneration !== generation || !activeCall) return;
activeCall.media = media;
patchTelemetry({ volume: media.volume });
patchSnapshot({
isMuted: media.isMuted,
phase: media.output === "speaking" ? "playing" : "listening",
});
}
async function stopLocal(call: ActiveRuntimeCall | null): Promise<void> {
activeCall = null;
clearSegmentTimer();
speechStartedAt = null;
await call?.transport.stop().catch(() => undefined);
await deps.deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
patchTelemetry({ ...INITIAL_TELEMETRY });
patchSnapshot({ ...INITIAL_SNAPSHOT, events: snapshot.events });
}
async function prepareTransports(
session: RuntimeSession,
): Promise<PreparedVoiceClientTransport[]> {
if (deps.transports.length === 0) throw new Error("No voice transports are installed");
return Promise.all(deps.transports.map((factory) => factory.prepare(session.adapter)));
}
const api: VoiceRuntime = {
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getSnapshot() {
return snapshot;
},
subscribeTelemetry(listener) {
telemetryListeners.add(listener);
return () => telemetryListeners.delete(listener);
},
getTelemetrySnapshot() {
return telemetry;
},
registerSession(adapter) {
sessions.set(adapter.serverId, { adapter, connected: true });
return () => {
sessions.delete(adapter.serverId);
if (snapshot.activeServerId === adapter.serverId && snapshot.phase !== "disabled") {
generation += 1;
void stopLocal(activeCall);
}
};
},
updateSessionConnection(serverId, connected) {
const session = sessions.get(serverId);
if (!session) return;
session.connected = connected;
if (!connected && snapshot.activeServerId === serverId && snapshot.phase !== "disabled") {
generation += 1;
void stopLocal(activeCall);
}
},
handleTransportMessage(serverId, callId, data) {
if (activeCall?.serverId !== serverId || activeCall.callId !== callId) return;
activeCall.transport.handleServerMessage(data);
},
handleCallState(serverId, callState) {
const call = activeCall;
if (!call || call.serverId !== serverId || call.callId !== callState.callId) return;
call.transport.updateCallState(callState);
publishSpeechState(callState.input === "speech_detected");
if (callState.lifecycle === "ended" || callState.lifecycle === "failed") {
if (callState.lifecycle === "failed" && callState.error) {
const eventId = `call-terminal-${call.callId}`;
const errorMessage = callState.error;
patchSnapshot((previous) => {
if (previous.events.some((event) => event.id === eventId)) return previous;
return {
...previous,
events: [...previous.events, { type: "error", id: eventId, message: errorMessage }],
};
});
}
generation += 1;
void stopLocal(call);
}
},
handleCallEvent(serverId, callId, event) {
if (activeCall?.serverId !== serverId || activeCall.callId !== callId) return;
patchSnapshot((previous) => {
const index = previous.events.findIndex((candidate) => candidate.id === event.id);
const events =
index === -1 ? [...previous.events, event] : previous.events.with(index, event);
return { ...previous, events };
});
},
async startCall(serverId, context) {
const session = sessions.get(serverId);
if (!session) throw new Error(`Voice runtime is not ready for host ${serverId}`);
if (!session.connected) throw new Error(`Host ${serverId} is not connected`);
if (deps.getServerInfo(serverId)?.features?.voiceChat !== true) {
throw new Error("Update the Paseo host to use voice chat.");
}
const callGeneration = ++generation;
const previousCall = activeCall;
const previousSession = previousCall ? sessions.get(previousCall.serverId) : null;
activeCall = null;
patchSnapshot({
...snapshot,
isVoiceSwitching: true,
phase: "starting",
activeServerId: serverId,
activeCallId: null,
events: [],
});
if (previousCall) {
await previousSession?.adapter.stopCall(previousCall.callId).catch(() => undefined);
await previousCall.transport.stop().catch(() => undefined);
}
let prepared: PreparedVoiceClientTransport[] = [];
let acceptedCallId: string | null = null;
let transport: VoiceClientTransport | null = null;
try {
await deps.activateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
prepared = await prepareTransports(session);
const started = await session.adapter.startCall(
context,
prepared.map((candidate) => candidate.offer),
);
acceptedCallId = started.callId;
if (generation !== callGeneration) {
await Promise.all(prepared.map((candidate) => candidate.discard()));
await session.adapter.stopCall(started.callId).catch(() => undefined);
return;
}
patchSnapshot({ activeCallId: started.callId });
transport = await acceptVoiceClientTransport({
prepared,
callId: started.callId,
answer: started.transport,
publish: (media) => publishMedia(callGeneration, media),
});
if (generation !== callGeneration) {
await transport.stop();
await session.adapter.stopCall(started.callId).catch(() => undefined);
return;
}
const call: ActiveRuntimeCall = {
serverId,
callId: started.callId,
transport,
media: { isMuted: false, volume: 0, output: "idle" },
};
activeCall = call;
await transport.start();
if (generation !== callGeneration) {
if (activeCall === call) activeCall = null;
await transport.stop();
await session.adapter.stopCall(started.callId).catch(() => undefined);
return;
}
patchSnapshot({
isVoiceMode: true,
isVoiceSwitching: false,
phase: "listening",
isMuted: call.media.isMuted,
});
} catch (error) {
await Promise.all(prepared.map((candidate) => candidate.discard().catch(() => undefined)));
await transport?.stop().catch(() => undefined);
if (acceptedCallId) await session.adapter.stopCall(acceptedCallId).catch(() => undefined);
if (generation === callGeneration) await stopLocal(activeCall);
throw error;
}
},
async stopVoice() {
const call = activeCall;
if (!call && snapshot.phase === "disabled") return;
const stopGeneration = ++generation;
patchSnapshot({ isVoiceSwitching: true, phase: "stopping" });
try {
const session = call ? sessions.get(call.serverId) : null;
if (call && session?.connected) await session.adapter.stopCall(call.callId);
} finally {
if (stopGeneration === generation) await stopLocal(call);
}
},
async updateCallContext(context) {
const call = activeCall;
if (!call) return;
const session = sessions.get(call.serverId);
if (!session?.connected) return;
await session.adapter.updateCallContext(call.callId, context);
},
async reportError(message) {
const call = activeCall;
if (!call) return;
patchSnapshot((previous) => ({
...previous,
events: [...previous.events, { type: "error", id: `media-error-${call.callId}`, message }],
}));
await api.stopVoice();
},
dismissTranscript() {
patchSnapshot((previous) => ({ ...previous, events: [] }));
},
async destroy() {
await api.stopVoice().catch(() => undefined);
listeners.clear();
telemetryListeners.clear();
sessions.clear();
},
toggleMute() {
activeCall?.transport.toggleMute();
},
isCallActiveOnServer(serverId) {
return activeCall?.serverId === serverId && snapshot.isVoiceMode;
},
};
return api;
}
+60
View File
@@ -0,0 +1,60 @@
import type {
VoiceCallState,
VoiceCallTransport,
VoiceCallTransportOffer,
} from "@getpaseo/protocol/messages";
export interface VoiceTransportMediaState {
isMuted: boolean;
volume: number;
output: "idle" | "speaking";
}
export interface VoiceTransportSession {
sendTransportMessage(callId: string, data: unknown): void;
setAssistantAudioPlaying(isPlaying: boolean): void;
}
export interface VoiceClientTransport {
start(): Promise<void>;
stop(): Promise<void>;
handleServerMessage(data: unknown): void;
updateCallState(state: VoiceCallState): void;
toggleMute(): void;
}
export interface PreparedVoiceClientTransport {
readonly offer: VoiceCallTransportOffer;
accepts(answer: VoiceCallTransport): boolean;
accept(input: {
callId: string;
answer: VoiceCallTransport;
publish(state: VoiceTransportMediaState): void;
}): Promise<VoiceClientTransport>;
discard(): Promise<void>;
}
export interface VoiceClientTransportFactory {
prepare(session: VoiceTransportSession): Promise<PreparedVoiceClientTransport>;
}
export async function acceptVoiceClientTransport(input: {
prepared: PreparedVoiceClientTransport[];
callId: string;
answer: VoiceCallTransport;
publish(state: VoiceTransportMediaState): void;
}): Promise<VoiceClientTransport> {
const selected = input.prepared.find((candidate) => candidate.accepts(input.answer));
if (!selected) {
await Promise.all(input.prepared.map((candidate) => candidate.discard()));
throw new Error(`No installed client transport accepted '${input.answer.kind}'`);
}
const unselected = input.prepared.filter((candidate) => candidate !== selected);
const transport = await selected.accept({
callId: input.callId,
answer: input.answer,
publish: input.publish,
});
await Promise.all(unselected.map((candidate) => candidate.discard()));
return transport;
}
@@ -0,0 +1,95 @@
import { expect, test, vi } from "vitest";
import type { AudioEngine } from "@/voice/audio-engine-types";
import { createDaemonAudioTransportFactory } from "@/voice-chat/transports/daemon-audio";
import type { VoiceTransportMediaState } from "@/voice-chat/transport";
function createEngine(): AudioEngine {
return {
initialize: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
startCapture: vi.fn().mockResolvedValue(undefined),
stopCapture: vi.fn().mockResolvedValue(undefined),
toggleMute: vi.fn().mockReturnValue(true),
isMuted: vi.fn().mockReturnValue(false),
play: vi.fn().mockResolvedValue(0.1),
stop: vi.fn(),
clearQueue: vi.fn(),
isPlaying: vi.fn().mockReturnValue(false),
};
}
test("daemon audio owns capture, playback, acknowledgement, and mute state", async () => {
const engine = createEngine();
const sent = vi.fn();
const playback = vi.fn();
const onError = vi.fn();
const media: VoiceTransportMediaState[] = [];
const factory = createDaemonAudioTransportFactory({ engine, onError });
const prepared = await factory.prepare({
sendTransportMessage: sent,
setAssistantAudioPlaying: playback,
});
const transport = await prepared.accept({
callId: "call-1",
answer: { kind: "daemon-audio" },
publish: (state) => media.push(state),
});
await transport.start();
factory.handleCapturePcm(new Uint8Array([1, 2, 3]));
transport.handleServerMessage({
type: "audio",
outputId: "audio-1",
audio: "AQID",
format: "pcm",
isLastChunk: true,
});
transport.toggleMute();
await vi.waitFor(() => {
expect(sent).toHaveBeenCalledWith("call-1", { type: "played", outputId: "audio-1" });
});
expect(sent).toHaveBeenCalledWith("call-1", {
type: "append",
audio: "AQID",
format: "audio/pcm;rate=16000;bits=16",
});
expect(playback).toHaveBeenNthCalledWith(1, true);
expect(playback).toHaveBeenLastCalledWith(false);
expect(media).toContainEqual(expect.objectContaining({ isMuted: true }));
await transport.stop();
expect(engine.stopCapture).toHaveBeenCalledOnce();
});
test("failed playback reports the error without acknowledging success", async () => {
const engine = createEngine();
vi.mocked(engine.play).mockRejectedValueOnce(new Error("speaker unavailable"));
const sent = vi.fn();
const onError = vi.fn();
const prepared = await createDaemonAudioTransportFactory({ engine, onError }).prepare({
sendTransportMessage: sent,
setAssistantAudioPlaying: vi.fn(),
});
const transport = await prepared.accept({
callId: "call-1",
answer: { kind: "daemon-audio" },
publish: vi.fn(),
});
await transport.start();
transport.handleServerMessage({
type: "audio",
outputId: "audio-1",
audio: "AQID",
format: "pcm",
isLastChunk: true,
});
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(expect.any(Error)));
expect(sent).not.toHaveBeenCalledWith("call-1", {
type: "played",
outputId: "audio-1",
});
await transport.stop();
});
@@ -0,0 +1,348 @@
import { Buffer } from "buffer";
import { z } from "zod";
import type { VoiceCallState } from "@getpaseo/protocol/messages";
import type { AudioEngine } from "@/voice/audio-engine-types";
import type {
PreparedVoiceClientTransport,
VoiceClientTransport,
VoiceClientTransportFactory,
VoiceTransportMediaState,
VoiceTransportSession,
} from "@/voice-chat/transport";
const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
const DISPLAY_VOLUME_PUBLISH_INTERVAL_MS = 120;
const DISPLAY_VOLUME_CHANGE_EPSILON = 0.02;
const DISPLAY_VOLUME_ATTACK = 0.35;
const DISPLAY_VOLUME_RELEASE = 0.18;
const DaemonAudioServerMessageSchema = z.object({
type: z.literal("audio"),
outputId: z.string(),
audio: z.string(),
format: z.string(),
groupId: z.string().optional(),
chunkIndex: z.number().int().nonnegative().optional(),
isLastChunk: z.boolean().optional(),
});
interface PlaybackSource {
arrayBuffer(): Promise<ArrayBuffer>;
size: number;
type: string;
}
interface PlaybackChunk {
id: string;
source: PlaybackSource;
}
interface PlaybackGroup {
groupId: string;
chunks: Map<number, PlaybackChunk>;
nextChunkToPlay: number;
finalChunkIndex: number | null;
started: boolean;
acknowledged: Set<string>;
}
interface PlaybackState {
groups: Map<string, PlaybackGroup>;
orderedGroupIds: string[];
activeGroupId: string | null;
processing: boolean;
generation: number;
}
export interface DaemonAudioTransportFactory extends VoiceClientTransportFactory {
handleCapturePcm(chunk: Uint8Array): void;
handleCaptureVolume(level: number): void;
}
export function createDaemonAudioTransportFactory(input: {
engine: AudioEngine;
onError(error: unknown): void;
}): DaemonAudioTransportFactory {
let active: DaemonAudioTransport | null = null;
return {
async prepare(session) {
return createPreparedDaemonAudioTransport({
engine: input.engine,
onError: input.onError,
session,
activate(transport) {
active = transport;
},
deactivate(transport) {
if (active === transport) active = null;
},
});
},
handleCapturePcm(chunk) {
active?.handleCapturePcm(chunk);
},
handleCaptureVolume(level) {
active?.handleCaptureVolume(level);
},
};
}
function createPreparedDaemonAudioTransport(input: {
engine: AudioEngine;
onError(error: unknown): void;
session: VoiceTransportSession;
activate(transport: DaemonAudioTransport): void;
deactivate(transport: DaemonAudioTransport): void;
}): PreparedVoiceClientTransport {
return {
offer: { kind: "daemon-audio" },
accepts(answer) {
return answer.kind === "daemon-audio";
},
async accept({ callId, answer, publish }) {
if (answer.kind !== "daemon-audio") {
throw new Error(`Daemon audio cannot accept '${answer.kind}'`);
}
const transport = new DaemonAudioTransport({
callId,
engine: input.engine,
onError: input.onError,
session: input.session,
publish,
deactivate: input.deactivate,
});
input.activate(transport);
return transport;
},
async discard() {},
};
}
class DaemonAudioTransport implements VoiceClientTransport {
private readonly callId: string;
private readonly engine: AudioEngine;
private readonly onError: (error: unknown) => void;
private readonly session: VoiceTransportSession;
private readonly publish: (state: VoiceTransportMediaState) => void;
private readonly deactivate: (transport: DaemonAudioTransport) => void;
private media: VoiceTransportMediaState = { isMuted: false, volume: 0, output: "idle" };
private playback: PlaybackState = {
groups: new Map(),
orderedGroupIds: [],
activeGroupId: null,
processing: false,
generation: 0,
};
private started = false;
private stopped = false;
private lastVolumePublishMs = 0;
constructor(input: {
callId: string;
engine: AudioEngine;
onError(error: unknown): void;
session: VoiceTransportSession;
publish(state: VoiceTransportMediaState): void;
deactivate(transport: DaemonAudioTransport): void;
}) {
this.callId = input.callId;
this.engine = input.engine;
this.onError = input.onError;
this.session = input.session;
this.publish = input.publish;
this.deactivate = input.deactivate;
}
async start(): Promise<void> {
if (this.started) return;
await this.engine.initialize();
await this.engine.startCapture();
this.started = true;
this.patchMedia({ isMuted: this.engine.isMuted() });
}
async stop(): Promise<void> {
if (this.stopped) return;
this.stopped = true;
this.resetPlayback();
this.engine.stop();
this.engine.clearQueue();
this.session.setAssistantAudioPlaying(false);
await this.engine.stopCapture().catch(() => undefined);
this.deactivate(this);
this.patchMedia({ volume: 0, output: "idle" });
}
handleCapturePcm(chunk: Uint8Array): void {
if (!this.started || this.stopped || this.media.isMuted || chunk.byteLength === 0) return;
this.session.sendTransportMessage(this.callId, {
type: "append",
audio: Buffer.from(chunk).toString("base64"),
format: PCM_MIME_TYPE,
});
}
handleCaptureVolume(level: number): void {
const now = Date.now();
const displayed = this.media.isMuted ? 0 : level;
const smoothing =
displayed >= this.media.volume ? DISPLAY_VOLUME_ATTACK : DISPLAY_VOLUME_RELEASE;
const next = Math.max(
0,
Math.min(1, this.media.volume + (displayed - this.media.volume) * smoothing),
);
const enoughTime = now - this.lastVolumePublishMs >= DISPLAY_VOLUME_PUBLISH_INTERVAL_MS;
const enoughChange = Math.abs(next - this.media.volume) >= DISPLAY_VOLUME_CHANGE_EPSILON;
if (!enoughTime && !enoughChange) return;
this.lastVolumePublishMs = now;
this.patchMedia({ volume: Number(next.toFixed(3)) });
}
handleServerMessage(data: unknown): void {
if (this.stopped) return;
const parsed = DaemonAudioServerMessageSchema.safeParse(data);
if (!parsed.success) return;
const payload = parsed.data;
const groupId = payload.groupId ?? payload.outputId;
const chunkIndex = payload.chunkIndex ?? 0;
let group = this.playback.groups.get(groupId);
if (!group) {
group = {
groupId,
chunks: new Map(),
nextChunkToPlay: 0,
finalChunkIndex: null,
started: false,
acknowledged: new Set(),
};
this.playback.groups.set(groupId, group);
this.playback.orderedGroupIds.push(groupId);
this.playback.activeGroupId ??= groupId;
}
group.chunks.set(chunkIndex, {
id: payload.outputId,
source: toPlaybackSource(Buffer.from(payload.audio, "base64"), payload.format),
});
if (payload.isLastChunk) group.finalChunkIndex = chunkIndex;
void this.processPlayback();
}
updateCallState(state: VoiceCallState): void {
if (state.callId !== this.callId || state.input !== "speech_detected") return;
const hadPlayback = this.playback.groups.size > 0;
this.resetPlayback();
if (hadPlayback) {
this.engine.stop();
this.engine.clearQueue();
this.session.setAssistantAudioPlaying(false);
this.patchMedia({ output: "idle" });
}
}
toggleMute(): void {
const isMuted = this.engine.toggleMute();
this.patchMedia({ isMuted, ...(isMuted ? { volume: 0 } : {}) });
}
private patchMedia(patch: Partial<VoiceTransportMediaState>): void {
this.media = { ...this.media, ...patch };
this.publish(this.media);
}
private resetPlayback(): void {
this.playback.generation += 1;
this.playback.groups.clear();
this.playback.orderedGroupIds = [];
this.playback.activeGroupId = null;
this.playback.processing = false;
}
private activateNextGroup(): void {
while (this.playback.orderedGroupIds.length > 0) {
const groupId = this.playback.orderedGroupIds[0];
if (this.playback.groups.has(groupId)) {
this.playback.activeGroupId = groupId;
return;
}
this.playback.orderedGroupIds.shift();
}
this.playback.activeGroupId = null;
}
private retireGroup(group: PlaybackGroup): void {
this.playback.groups.delete(group.groupId);
this.playback.orderedGroupIds = this.playback.orderedGroupIds.filter(
(groupId) => groupId !== group.groupId,
);
if (group.started && this.playback.groups.size === 0) {
this.session.setAssistantAudioPlaying(false);
this.patchMedia({ output: "idle" });
}
}
private async processPlayback(): Promise<void> {
if (this.playback.processing) return;
this.playback.processing = true;
const generation = this.playback.generation;
try {
while (this.playback.activeGroupId && generation === this.playback.generation) {
const group = this.playback.groups.get(this.playback.activeGroupId);
if (!group) {
this.activateNextGroup();
continue;
}
const chunk = group.chunks.get(group.nextChunkToPlay);
if (!chunk) {
const complete =
group.finalChunkIndex !== null && group.nextChunkToPlay > group.finalChunkIndex;
if (!complete) return;
this.retireGroup(group);
this.activateNextGroup();
continue;
}
group.chunks.delete(group.nextChunkToPlay);
if (!group.started) {
group.started = true;
this.session.setAssistantAudioPlaying(true);
this.patchMedia({ output: "speaking" });
}
try {
await this.engine.play(chunk.source);
} catch (error) {
if (generation !== this.playback.generation) return;
this.onError(error);
return;
}
if (generation !== this.playback.generation) return;
if (!group.acknowledged.has(chunk.id)) {
group.acknowledged.add(chunk.id);
this.session.sendTransportMessage(this.callId, {
type: "played",
outputId: chunk.id,
});
}
group.nextChunkToPlay += 1;
}
} finally {
if (generation === this.playback.generation) this.playback.processing = false;
}
}
}
function toPlaybackSource(bytes: Uint8Array, format: string): PlaybackSource {
const type = resolveMimeType(format);
return {
size: bytes.byteLength,
type,
async arrayBuffer() {
return Uint8Array.from(bytes).buffer;
},
};
}
function resolveMimeType(format: string): string {
if (format === "pcm") return "audio/pcm;rate=24000;bits=16";
if (format === "mp3") return "audio/mpeg";
return `audio/${format}`;
}
@@ -1,479 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonServerInfo } from "@/stores/session-store";
import type { AudioEngine } from "@/voice/audio-engine-types";
import { createVoiceRuntime, type VoiceSessionAdapter } from "@/voice/voice-runtime";
import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config";
function createAudioEngineMock(): AudioEngine {
return {
initialize: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
startCapture: vi.fn().mockResolvedValue(undefined),
stopCapture: vi.fn().mockResolvedValue(undefined),
toggleMute: vi.fn().mockReturnValue(true),
isMuted: vi.fn().mockReturnValue(false),
play: vi.fn().mockResolvedValue(0.1),
stop: vi.fn(),
clearQueue: vi.fn(),
isPlaying: vi.fn().mockReturnValue(false),
};
}
function createSessionAdapter(serverId = "server-1"): VoiceSessionAdapter {
return {
serverId,
setVoiceMode: vi.fn().mockResolvedValue(undefined),
sendVoiceAudioChunk: vi.fn().mockResolvedValue(undefined),
audioPlayed: vi.fn().mockResolvedValue(undefined),
abortRequest: vi.fn().mockResolvedValue(undefined),
setAssistantAudioPlaying: vi.fn(),
};
}
function createAudioPayload(args: {
id: string;
groupId: string;
chunkIndex: number;
isLastChunk: boolean;
isVoiceMode?: boolean;
}) {
return {
id: args.id,
groupId: args.groupId,
chunkIndex: args.chunkIndex,
isLastChunk: args.isLastChunk,
isVoiceMode: args.isVoiceMode ?? true,
format: "pcm",
audio: Buffer.from(`chunk-${args.chunkIndex}`).toString("base64"),
} as const;
}
function createServerInfo(): DaemonServerInfo {
return {
serverId: "server-1",
hostname: "host",
version: "1.0.0",
capabilities: {
voice: {
dictation: { enabled: true, reason: "" },
voice: { enabled: true, reason: "" },
},
},
};
}
function createRuntime(options?: {
engine?: AudioEngine;
getServerInfo?: (serverId: string) => DaemonServerInfo | null;
}) {
const engine = options?.engine ?? createAudioEngineMock();
const runtime = createVoiceRuntime({
engine,
getServerInfo: options?.getServerInfo ?? (() => createServerInfo()),
activateKeepAwake: vi.fn().mockResolvedValue(undefined),
deactivateKeepAwake: vi.fn().mockResolvedValue(undefined),
});
return { runtime, engine };
}
describe("voice runtime", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("starts voice when adapter is ready", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
expect(engine.initialize).toHaveBeenCalled();
expect(adapter.setVoiceMode).toHaveBeenCalledWith(true, "agent-1");
expect(engine.startCapture).toHaveBeenCalled();
expect(runtime.getSnapshot()).toMatchObject({
phase: "listening",
isVoiceMode: true,
activeServerId: "server-1",
activeAgentId: "agent-1",
});
});
it("streams continuous PCM chunks and waits after receiving a transcript", async () => {
const adapter = createSessionAdapter();
const { runtime } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.handleCapturePcm(new Uint8Array(32000));
expect(runtime.getSnapshot().phase).toBe("listening");
expect(adapter.sendVoiceAudioChunk).toHaveBeenLastCalledWith(
expect.any(String),
"audio/pcm;rate=16000;bits=16",
);
runtime.onTranscriptionResult("server-1", "hello");
expect(runtime.getSnapshot().phase).toBe("waiting");
});
it("moves from listening to playing on the first assistant audio", async () => {
const adapter = createSessionAdapter();
const { runtime, engine: _engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onAssistantAudioStarted("server-1");
expect(runtime.getSnapshot().phase).toBe("playing");
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(true);
});
it("starts playback on the first chunk before isLastChunk arrives", async () => {
const adapter = createSessionAdapter();
let resolvePlay!: (duration: number) => void;
const engine = createAudioEngineMock();
vi.mocked(engine.play).mockImplementation(
() =>
new Promise<number>((resolve) => {
resolvePlay = resolve;
}),
);
const { runtime } = createRuntime({ engine });
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
vi.mocked(engine.play).mockClear();
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "chunk-0",
groupId: "group-1",
chunkIndex: 0,
isLastChunk: false,
}),
);
await vi.waitFor(() => {
expect(engine.play).toHaveBeenCalledTimes(1);
expect(runtime.getSnapshot().phase).toBe("playing");
expect(adapter.audioPlayed).not.toHaveBeenCalled();
});
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "chunk-1",
groupId: "group-1",
chunkIndex: 1,
isLastChunk: true,
}),
);
expect(engine.play).toHaveBeenCalledTimes(1);
resolvePlay(0.1);
await vi.waitFor(() => {
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0");
expect(engine.play).toHaveBeenCalledTimes(2);
});
});
it("acknowledges chunks only after they are consumed and finishes after the final chunk", async () => {
const adapter = createSessionAdapter();
const playResolvers: Array<(duration: number) => void> = [];
const engine = createAudioEngineMock();
vi.mocked(engine.play).mockImplementation(
() =>
new Promise<number>((resolve) => {
playResolvers.push(resolve);
}),
);
const { runtime } = createRuntime({ engine });
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
vi.mocked(engine.play).mockClear();
playResolvers.length = 0;
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "chunk-0",
groupId: "group-1",
chunkIndex: 0,
isLastChunk: false,
}),
);
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "chunk-1",
groupId: "group-1",
chunkIndex: 1,
isLastChunk: true,
}),
);
await vi.waitFor(() => {
expect(engine.play).toHaveBeenCalledTimes(1);
});
expect(adapter.audioPlayed).not.toHaveBeenCalled();
playResolvers.shift()!(0.1);
await vi.waitFor(() => {
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-0");
expect(engine.play).toHaveBeenCalledTimes(2);
});
playResolvers.shift()!(0.1);
await vi.waitFor(() => {
expect(adapter.audioPlayed).toHaveBeenCalledWith("chunk-1");
expect(runtime.getSnapshot().phase).toBe("waiting");
});
});
it("leaves playback phase unchanged after assistant playback while the turn is still active", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
runtime.onAssistantAudioStarted("server-1");
runtime.onAssistantAudioFinished("server-1");
expect(runtime.getSnapshot().phase).toBe("waiting");
expect(engine.play).toHaveBeenCalled();
});
it("starts the thinking tone when an agent turn begins before playback", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
expect(runtime.getSnapshot().phase).toBe("waiting");
expect(engine.play).toHaveBeenCalled();
});
it("does not restart the thinking tone from local detection jitter while waiting", async () => {
const adapter = createSessionAdapter();
let resolvePlay!: (duration: number) => void;
const engine = createAudioEngineMock();
vi.mocked(engine.play).mockImplementation(
() =>
new Promise<number>((resolve) => {
resolvePlay = resolve;
}),
);
const { runtime } = createRuntime({ engine });
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
expect(runtime.getSnapshot().phase).toBe("waiting");
expect(engine.play).toHaveBeenCalledTimes(1);
vi.mocked(engine.stop).mockClear();
vi.mocked(engine.clearQueue).mockClear();
runtime.handleCaptureVolume(REALTIME_VOICE_VAD_CONFIG.volumeThreshold + 0.05);
runtime.handleCaptureVolume(0);
expect(engine.stop).not.toHaveBeenCalled();
expect(engine.clearQueue).not.toHaveBeenCalled();
expect(engine.play).toHaveBeenCalledTimes(1);
runtime.onServerSpeechStateChanged("server-1", true);
expect(engine.stop).toHaveBeenCalledTimes(1);
expect(engine.clearQueue).toHaveBeenCalledTimes(1);
resolvePlay(0.1);
});
it("returns to listening after assistant playback once the turn is complete", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
runtime.onAssistantAudioStarted("server-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_completed");
runtime.onAssistantAudioFinished("server-1");
expect(runtime.getSnapshot().phase).toBe("listening");
expect(engine.play).toHaveBeenCalled();
});
it("keeps local volume alone non-authoritative for playback interruption", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
runtime.onAssistantAudioStarted("server-1");
vi.mocked(engine.stop).mockClear();
vi.mocked(engine.clearQueue).mockClear();
runtime.handleCaptureVolume(0.5);
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(false);
expect(adapter.abortRequest).not.toHaveBeenCalled();
expect(runtime.getSnapshot().phase).toBe("playing");
});
it("keeps the meter white state driven by server speech detection", async () => {
const adapter = createSessionAdapter();
const { runtime } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.handleCaptureVolume(0.7);
expect(runtime.getTelemetrySnapshot()).toMatchObject({
volume: expect.any(Number),
isSpeaking: false,
});
runtime.onServerSpeechStateChanged("server-1", true);
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
runtime.onServerSpeechStateChanged("server-1", false);
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(false);
});
it("stops assistant playback immediately when server speech starts", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onAssistantAudioStarted("server-1");
runtime.onServerSpeechStateChanged("server-1", true);
expect(engine.stop).toHaveBeenCalled();
expect(engine.clearQueue).toHaveBeenCalled();
expect(adapter.setAssistantAudioPlaying).toHaveBeenCalledWith(false);
expect(runtime.getTelemetrySnapshot().isSpeaking).toBe(true);
});
it("drops queued voice chunks that arrive after server speech interrupts playback", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
runtime.onTurnEvent("server-1", "agent-1", "turn_started");
vi.mocked(engine.play).mockClear();
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "chunk-0",
groupId: "group-1",
chunkIndex: 0,
isLastChunk: false,
}),
);
await vi.waitFor(() => {
expect(engine.play).toHaveBeenCalledTimes(1);
});
runtime.onServerSpeechStateChanged("server-1", true);
runtime.handleAudioOutput(
"server-1",
createAudioPayload({
id: "chunk-1",
groupId: "group-1",
chunkIndex: 1,
isLastChunk: true,
}),
);
expect(engine.stop).toHaveBeenCalled();
expect(engine.clearQueue).toHaveBeenCalled();
expect(vi.mocked(adapter.audioPlayed).mock.calls.flat()).not.toContain("chunk-1");
});
it("authoritatively stops and suppresses later voice audio", async () => {
const adapter = createSessionAdapter();
const { runtime, engine } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
await runtime.stopVoice();
expect(adapter.setVoiceMode).toHaveBeenLastCalledWith(false);
expect(engine.stopCapture).toHaveBeenCalled();
expect(runtime.getSnapshot().phase).toBe("disabled");
expect(runtime.shouldPlayVoiceAudio("server-1")).toBe(false);
});
it("rolls daemon voice mode back when capture fails after enabling it", async () => {
const adapter = createSessionAdapter();
const engine = createAudioEngineMock();
vi.mocked(engine.startCapture).mockRejectedValue(new Error("audio focus unavailable"));
const { runtime } = createRuntime({ engine });
runtime.registerSession(adapter);
await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow(
"audio focus unavailable",
);
expect(adapter.setVoiceMode).toHaveBeenNthCalledWith(1, true, "agent-1");
expect(adapter.setVoiceMode).toHaveBeenNthCalledWith(2, false);
expect(runtime.getSnapshot().phase).toBe("disabled");
});
it("returns an explicit not-ready error when the adapter is missing", async () => {
const { runtime } = createRuntime();
await expect(runtime.startVoice("server-1", "agent-1")).rejects.toThrow(
"Voice runtime is not ready for host server-1",
);
});
it("resyncs voice mode after connection recovers", async () => {
const adapter = createSessionAdapter();
const { runtime } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
vi.mocked(adapter.setVoiceMode).mockClear();
runtime.updateSessionConnection("server-1", false);
runtime.updateSessionConnection("server-1", true);
await Promise.resolve();
expect(adapter.setVoiceMode).toHaveBeenCalledWith(true, "agent-1");
});
it("does not emit when the snapshot is unchanged", async () => {
const adapter = createSessionAdapter();
const { runtime } = createRuntime();
runtime.registerSession(adapter);
await runtime.startVoice("server-1", "agent-1");
const listener = vi.fn();
const unsubscribe = runtime.subscribe(listener);
runtime.handleCaptureVolume(0);
runtime.handleCaptureVolume(0);
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
});
-960
View File
@@ -1,960 +0,0 @@
import { Buffer } from "buffer";
import type { AgentStreamEventPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
import type { DaemonServerInfo } from "@/stores/session-store";
import type { AudioEngine } from "@/voice/audio-engine-types";
import {
THINKING_TONE_NATIVE_PCM_BASE64,
THINKING_TONE_NATIVE_PCM_DURATION_MS,
} from "@/utils/thinking-tone.native-pcm";
const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16";
const KEEP_AWAKE_TAG = "paseo:voice";
const THINKING_TONE_REPEAT_GAP_MS = 350;
const DISPLAY_VOLUME_PUBLISH_INTERVAL_MS = 120;
const DISPLAY_VOLUME_CHANGE_EPSILON = 0.02;
const DISPLAY_VOLUME_ATTACK = 0.35;
const DISPLAY_VOLUME_RELEASE = 0.18;
type TurnEventType = Extract<
AgentStreamEventPayload["type"],
"turn_started" | "turn_completed" | "turn_failed" | "turn_canceled"
>;
export type VoiceRuntimePhase =
| "disabled"
| "starting"
| "listening"
| "submitting"
| "waiting"
| "playing"
| "stopping";
export interface VoiceRuntimeSnapshot {
phase: VoiceRuntimePhase;
isVoiceMode: boolean;
isVoiceSwitching: boolean;
isMuted: boolean;
activeServerId: string | null;
activeAgentId: string | null;
}
export interface VoiceRuntimeTelemetrySnapshot {
volume: number;
isSpeaking: boolean;
segmentDuration: number;
}
export interface VoiceSessionAdapter {
serverId: string;
setVoiceMode(enabled: boolean, agentId?: string): Promise<void>;
sendVoiceAudioChunk(audioData: string, mimeType: string): Promise<void>;
audioPlayed(chunkId: string): Promise<void>;
abortRequest(): Promise<void>;
setAssistantAudioPlaying(isPlaying: boolean): void;
}
export interface VoiceRuntimeDeps {
engine: AudioEngine;
getServerInfo(serverId: string): DaemonServerInfo | null;
activateKeepAwake(tag: string): Promise<void>;
deactivateKeepAwake(tag: string): Promise<void>;
}
interface RuntimeSessionState {
adapter: VoiceSessionAdapter;
connected: boolean;
}
interface ContinuousVoiceUploader {
reset(): void;
pushPcmChunk(chunk: Uint8Array): void;
}
interface RuntimeState {
snapshot: VoiceRuntimeSnapshot;
telemetry: VoiceRuntimeTelemetrySnapshot;
turnInProgress: boolean;
serverSpeechDetected: boolean;
transportReady: boolean;
generation: number;
segmentDurationTimer: ReturnType<typeof setInterval> | null;
lastDisplayVolumePublishMs: number;
serverSpeechStartedAt: number | null;
}
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
interface StreamingPlaybackChunk {
id: string;
chunkIndex: number;
source: { arrayBuffer(): Promise<ArrayBuffer>; size: number; type: string };
}
interface StreamingPlaybackGroup {
groupId: string;
isVoiceMode: boolean;
shouldPlay: boolean;
chunks: Map<number, StreamingPlaybackChunk>;
nextChunkToPlay: number;
finalChunkIndex: number | null;
started: boolean;
ackedChunkIds: Set<string>;
}
interface RuntimePlaybackState {
groups: Map<string, StreamingPlaybackGroup>;
orderedGroupIds: string[];
activeGroupId: string | null;
processing: boolean;
generation: number;
}
interface CueState {
active: boolean;
token: number;
timeout: ReturnType<typeof setTimeout> | null;
playing: boolean;
}
const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = {
phase: "disabled",
isVoiceMode: false,
isVoiceSwitching: false,
isMuted: false,
activeServerId: null,
activeAgentId: null,
};
const INITIAL_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
volume: 0,
isSpeaking: false,
segmentDuration: 0,
};
let nextVoiceRuntimeInstanceId = 1;
function snapshotsEqual(left: VoiceRuntimeSnapshot, right: VoiceRuntimeSnapshot): boolean {
return (
left.phase === right.phase &&
left.isVoiceMode === right.isVoiceMode &&
left.isVoiceSwitching === right.isVoiceSwitching &&
left.isMuted === right.isMuted &&
left.activeServerId === right.activeServerId &&
left.activeAgentId === right.activeAgentId
);
}
function telemetryEqual(
left: VoiceRuntimeTelemetrySnapshot,
right: VoiceRuntimeTelemetrySnapshot,
): boolean {
return (
left.volume === right.volume &&
left.isSpeaking === right.isSpeaking &&
left.segmentDuration === right.segmentDuration
);
}
export interface VoiceRuntime {
subscribe(listener: () => void): () => void;
getSnapshot(): VoiceRuntimeSnapshot;
subscribeTelemetry(listener: () => void): () => void;
getTelemetrySnapshot(): VoiceRuntimeTelemetrySnapshot;
registerSession(adapter: VoiceSessionAdapter): () => void;
updateSessionConnection(serverId: string, connected: boolean): void;
handleCapturePcm(chunk: Uint8Array): void;
handleCaptureVolume(level: number): void;
handleAudioOutput(serverId: string, payload: AudioOutputPayload): void;
startVoice(serverId: string, agentId: string): Promise<void>;
stopVoice(): Promise<void>;
destroy(): Promise<void>;
toggleMute(): void;
isVoiceModeForAgent(serverId: string, agentId: string): boolean;
shouldPlayVoiceAudio(serverId: string): boolean;
onAssistantAudioStarted(serverId: string): void;
onAssistantAudioFinished(serverId: string): void;
onTranscriptionResult(serverId: string, text: string): void;
onServerSpeechStateChanged(serverId: string, isSpeaking: boolean): void;
onTurnEvent(serverId: string, agentId: string, eventType: TurnEventType): void;
}
export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime {
const instanceId = nextVoiceRuntimeInstanceId++;
const listeners = new Set<() => void>();
const telemetryListeners = new Set<() => void>();
const sessions = new Map<string, RuntimeSessionState>();
const state: RuntimeState = {
snapshot: INITIAL_SNAPSHOT,
telemetry: INITIAL_TELEMETRY,
turnInProgress: false,
serverSpeechDetected: false,
transportReady: false,
generation: 0,
segmentDurationTimer: null,
lastDisplayVolumePublishMs: 0,
serverSpeechStartedAt: null,
};
const playback: RuntimePlaybackState = {
groups: new Map(),
orderedGroupIds: [],
activeGroupId: null,
processing: false,
generation: 0,
};
const cue: CueState = {
active: false,
token: 0,
timeout: null,
playing: false,
};
const cuePcm16 = Uint8Array.from(Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64"));
const cueSource = {
size: cuePcm16.byteLength,
type: "audio/pcm;rate=16000;bits=16",
async arrayBuffer() {
return cuePcm16.buffer.slice(cuePcm16.byteOffset, cuePcm16.byteOffset + cuePcm16.byteLength);
},
};
function emit(): void {
for (const listener of listeners) {
listener();
}
}
function emitTelemetry(): void {
for (const listener of telemetryListeners) {
listener();
}
}
function patchSnapshot(
patch:
| Partial<VoiceRuntimeSnapshot>
| ((previous: VoiceRuntimeSnapshot) => VoiceRuntimeSnapshot),
): void {
const next =
typeof patch === "function" ? patch(state.snapshot) : { ...state.snapshot, ...patch };
if (snapshotsEqual(next, state.snapshot)) {
return;
}
state.snapshot = next;
emit();
}
function patchTelemetry(
patch:
| Partial<VoiceRuntimeTelemetrySnapshot>
| ((previous: VoiceRuntimeTelemetrySnapshot) => VoiceRuntimeTelemetrySnapshot),
): void {
const next =
typeof patch === "function" ? patch(state.telemetry) : { ...state.telemetry, ...patch };
if (telemetryEqual(next, state.telemetry)) {
return;
}
state.telemetry = next;
emitTelemetry();
}
function getActiveSession(): RuntimeSessionState | null {
if (!state.snapshot.activeServerId) {
return null;
}
return sessions.get(state.snapshot.activeServerId) ?? null;
}
function decodeAudioChunk(base64: string): Uint8Array {
return Buffer.from(base64, "base64");
}
function toPlaybackSource(
bytes: Uint8Array,
format: string,
): { arrayBuffer(): Promise<ArrayBuffer>; size: number; type: string } {
let mimeType: string;
if (format === "pcm") mimeType = "audio/pcm;rate=24000;bits=16";
else if (format === "mp3") mimeType = "audio/mpeg";
else mimeType = `audio/${format}`;
return {
size: bytes.byteLength,
type: mimeType,
async arrayBuffer() {
return Uint8Array.from(bytes).buffer;
},
};
}
function resetPlaybackState(): void {
playback.generation += 1;
playback.groups.clear();
playback.orderedGroupIds = [];
playback.activeGroupId = null;
playback.processing = false;
}
function activateNextPlaybackGroup(): void {
while (playback.orderedGroupIds.length > 0) {
const groupId = playback.orderedGroupIds[0];
if (playback.groups.has(groupId)) {
playback.activeGroupId = groupId;
return;
}
playback.orderedGroupIds.shift();
}
playback.activeGroupId = null;
}
function retireFinishedGroup(
group: { groupId: string; started: boolean; isVoiceMode: boolean },
serverId: string,
): void {
playback.groups.delete(group.groupId);
if (playback.orderedGroupIds[0] === group.groupId) {
playback.orderedGroupIds.shift();
} else {
playback.orderedGroupIds = playback.orderedGroupIds.filter(
(value) => value !== group.groupId,
);
}
if (group.started && group.isVoiceMode) {
api.onAssistantAudioFinished(serverId);
}
}
async function acknowledgeChunk(chunkId: string): Promise<void> {
const activeSession = getActiveSession();
if (!activeSession) {
return;
}
await activeSession.adapter.audioPlayed(chunkId);
}
async function processPlaybackQueue(serverId: string): Promise<void> {
if (playback.processing) {
return;
}
playback.processing = true;
const generation = playback.generation;
try {
while (playback.activeGroupId) {
if (generation !== playback.generation) {
return;
}
const group = playback.groups.get(playback.activeGroupId);
if (!group) {
activateNextPlaybackGroup();
continue;
}
const nextChunk = group.chunks.get(group.nextChunkToPlay);
if (!nextChunk) {
const groupIsFinished =
group.finalChunkIndex !== null && group.nextChunkToPlay > group.finalChunkIndex;
if (!groupIsFinished) {
return;
}
retireFinishedGroup(group, serverId);
activateNextPlaybackGroup();
continue;
}
group.chunks.delete(group.nextChunkToPlay);
if (group.shouldPlay && !group.started && group.isVoiceMode) {
group.started = true;
api.onAssistantAudioStarted(serverId);
}
try {
if (group.shouldPlay) {
await deps.engine.play(nextChunk.source);
}
} catch (error) {
if (generation !== playback.generation) {
return;
}
console.error(`[VoiceRuntime] play error chunk=${group.nextChunkToPlay}:`, error);
}
if (generation !== playback.generation) {
return;
}
if (!group.ackedChunkIds.has(nextChunk.id)) {
group.ackedChunkIds.add(nextChunk.id);
void acknowledgeChunk(nextChunk.id).catch((error) => {
console.warn("[VoiceRuntime] Failed to confirm audio playback:", error);
});
}
group.nextChunkToPlay += 1;
}
} finally {
if (generation === playback.generation) {
playback.processing = false;
}
}
}
function clearSegmentDurationTimer(): void {
if (state.segmentDurationTimer) {
clearInterval(state.segmentDurationTimer);
state.segmentDurationTimer = null;
}
}
function reconcileSegmentDurationTimer(): void {
if (!state.serverSpeechDetected) {
clearSegmentDurationTimer();
patchTelemetry((prev) => ({ ...prev, segmentDuration: 0 }));
return;
}
if (state.segmentDurationTimer) {
return;
}
state.segmentDurationTimer = setInterval(() => {
const startedAt = state.serverSpeechStartedAt;
patchTelemetry((prev) => ({
...prev,
segmentDuration: startedAt ? Date.now() - startedAt : 0,
}));
}, 100);
}
function canPlayCue(): boolean {
return (
state.snapshot.isVoiceMode &&
state.snapshot.phase === "waiting" &&
!state.telemetry.isSpeaking
);
}
function stopCue(): void {
const hadActive = cue.active || cue.timeout !== null || cue.playing;
cue.active = false;
cue.token += 1;
if (cue.timeout) {
clearTimeout(cue.timeout);
cue.timeout = null;
}
cue.playing = false;
if (hadActive) {
deps.engine.stop();
deps.engine.clearQueue();
}
}
function resetCaptureTelemetry(): void {
clearSegmentDurationTimer();
state.serverSpeechStartedAt = null;
patchTelemetry({ ...INITIAL_TELEMETRY });
}
function reconcileCue(): void {
if (!canPlayCue()) {
stopCue();
return;
}
if (cue.active) {
return;
}
cue.active = true;
cue.token += 1;
const token = cue.token;
const playNext = () => {
if (!cue.active || cue.token !== token) {
return;
}
cue.playing = true;
void deps.engine
.play(cueSource)
.catch((error) => {
if (cue.token !== token) {
return;
}
console.warn(`[VoiceRuntime#${instanceId}] Cue playback failed:`, error);
})
.finally(() => {
cue.playing = false;
if (!cue.active || cue.token !== token) {
return;
}
cue.timeout = setTimeout(
playNext,
THINKING_TONE_NATIVE_PCM_DURATION_MS + THINKING_TONE_REPEAT_GAP_MS,
);
});
};
playNext();
}
const uploader: ContinuousVoiceUploader = {
reset() {},
pushPcmChunk(chunk) {
const activeSession = getActiveSession();
if (
!activeSession ||
!state.transportReady ||
!state.snapshot.isVoiceMode ||
chunk.byteLength === 0
) {
return;
}
const base64 = Buffer.from(chunk).toString("base64");
void activeSession.adapter.sendVoiceAudioChunk(base64, PCM_MIME_TYPE).catch((error) => {
console.error(`[VoiceRuntime#${instanceId}] Failed to send audio chunk:`, error);
});
},
};
function resetToDisabledState(): void {
state.transportReady = false;
state.turnInProgress = false;
state.serverSpeechDetected = false;
state.lastDisplayVolumePublishMs = 0;
uploader.reset();
resetCaptureTelemetry();
patchSnapshot({ ...INITIAL_SNAPSHOT });
}
function publishDisplayVolume(level: number, nowMs: number): void {
const previousVolume = state.telemetry.volume;
const smoothing = level >= previousVolume ? DISPLAY_VOLUME_ATTACK : DISPLAY_VOLUME_RELEASE;
const nextVolume = Math.max(
0,
Math.min(1, previousVolume + (level - previousVolume) * smoothing),
);
const enoughTimeElapsed =
nowMs - state.lastDisplayVolumePublishMs >= DISPLAY_VOLUME_PUBLISH_INTERVAL_MS;
const enoughChange = Math.abs(nextVolume - previousVolume) >= DISPLAY_VOLUME_CHANGE_EPSILON;
if (!enoughTimeElapsed && !enoughChange) {
return;
}
state.lastDisplayVolumePublishMs = nowMs;
patchTelemetry((prev) => ({
...prev,
volume: Number(nextVolume.toFixed(3)),
}));
}
async function performLocalStop(): Promise<void> {
stopCue();
uploader.reset();
resetPlaybackState();
deps.engine.stop();
deps.engine.clearQueue();
await deps.engine.stopCapture().catch(() => undefined);
await deps.deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
getActiveSession()?.adapter.setAssistantAudioPlaying(false);
resetToDisabledState();
}
async function resyncVoiceMode(serverId: string): Promise<void> {
if (
!state.snapshot.isVoiceMode ||
state.snapshot.activeServerId !== serverId ||
!state.snapshot.activeAgentId
) {
return;
}
const activeSession = getActiveSession();
if (!activeSession || !activeSession.connected) {
return;
}
patchSnapshot((prev) => ({ ...prev, isVoiceSwitching: true }));
try {
await activeSession.adapter.setVoiceMode(true, state.snapshot.activeAgentId);
state.transportReady = true;
} finally {
patchSnapshot((prev) => ({ ...prev, isVoiceSwitching: false }));
}
}
const api: VoiceRuntime = {
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot() {
return state.snapshot;
},
subscribeTelemetry(listener) {
telemetryListeners.add(listener);
return () => {
telemetryListeners.delete(listener);
};
},
getTelemetrySnapshot() {
return state.telemetry;
},
registerSession(adapter) {
sessions.set(adapter.serverId, {
adapter,
connected: true,
});
return () => {
const activeServerId = state.snapshot.activeServerId;
sessions.delete(adapter.serverId);
if (activeServerId === adapter.serverId) {
void performLocalStop();
}
};
},
updateSessionConnection(serverId, connected) {
const session = sessions.get(serverId);
if (!session) {
return;
}
session.connected = connected;
if (state.snapshot.activeServerId !== serverId) {
return;
}
if (!connected) {
state.transportReady = false;
return;
}
void resyncVoiceMode(serverId);
},
handleCapturePcm(chunk) {
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
return;
}
uploader.pushPcmChunk(chunk);
},
handleCaptureVolume(level) {
const nowMs = Date.now();
const displayLevel = state.snapshot.isMuted ? 0 : level;
publishDisplayVolume(displayLevel, nowMs);
if (!state.snapshot.isVoiceMode || state.snapshot.isMuted) {
patchTelemetry((prev) => ({
...prev,
isSpeaking: false,
segmentDuration: 0,
}));
return;
}
patchTelemetry((prev) => ({
...prev,
isSpeaking: state.serverSpeechDetected,
}));
reconcileSegmentDurationTimer();
reconcileCue();
},
handleAudioOutput(serverId, payload) {
if (
serverId !== state.snapshot.activeServerId ||
!state.snapshot.isVoiceMode ||
!payload.isVoiceMode
) {
return;
}
const groupId = payload.groupId ?? payload.id;
const chunkIndex = payload.chunkIndex ?? 0;
const decoded = decodeAudioChunk(payload.audio);
let group = playback.groups.get(groupId);
if (!group) {
group = {
groupId,
isVoiceMode: payload.isVoiceMode,
shouldPlay: api.shouldPlayVoiceAudio(serverId),
chunks: new Map(),
nextChunkToPlay: 0,
finalChunkIndex: null,
started: false,
ackedChunkIds: new Set(),
};
playback.groups.set(groupId, group);
playback.orderedGroupIds.push(groupId);
if (!playback.activeGroupId) {
playback.activeGroupId = groupId;
}
}
group.chunks.set(chunkIndex, {
id: payload.id,
chunkIndex,
source: toPlaybackSource(decoded, payload.format),
});
if (payload.isLastChunk) {
group.finalChunkIndex = chunkIndex;
}
void processPlaybackQueue(serverId);
},
async startVoice(serverId, agentId) {
const session = sessions.get(serverId);
if (!session) {
throw new Error(`Voice runtime is not ready for host ${serverId}`);
}
if (!session.connected) {
throw new Error(`Host ${serverId} is not connected`);
}
const serverInfo = deps.getServerInfo(serverId);
const unavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
mode: "voice",
});
if (unavailableMessage) {
throw new Error(unavailableMessage);
}
const previousServerId = state.snapshot.activeServerId;
const previousAgentId = state.snapshot.activeAgentId;
const generation = state.generation + 1;
let enabledCurrentVoiceMode = false;
state.generation = generation;
state.transportReady = false;
patchSnapshot((prev) => ({
...prev,
isVoiceSwitching: true,
phase: "starting",
activeServerId: serverId,
activeAgentId: agentId,
}));
try {
if (
state.snapshot.isVoiceMode &&
previousServerId &&
(previousServerId !== serverId || previousAgentId !== agentId)
) {
const previousSession = sessions.get(previousServerId);
if (previousSession) {
previousSession.adapter.setAssistantAudioPlaying(false);
await previousSession.adapter.setVoiceMode(false);
}
}
await deps.activateKeepAwake(KEEP_AWAKE_TAG).catch((error) => {
console.warn("[VoiceRuntime] Failed to activate keep-awake:", error);
});
await deps.engine.initialize();
await session.adapter.setVoiceMode(true, agentId);
enabledCurrentVoiceMode = true;
await deps.engine.startCapture();
if (state.generation !== generation) {
return;
}
state.transportReady = true;
state.turnInProgress = false;
uploader.reset();
resetCaptureTelemetry();
patchSnapshot((prev) => ({
...prev,
isVoiceMode: true,
isVoiceSwitching: false,
phase: "listening",
isMuted: deps.engine.isMuted(),
}));
} catch (error) {
if (enabledCurrentVoiceMode) {
await session.adapter.setVoiceMode(false).catch(() => undefined);
}
await performLocalStop();
throw error;
}
},
async stopVoice() {
const activeSession = getActiveSession();
const generation = state.generation + 1;
state.generation = generation;
patchSnapshot((prev) => ({
...prev,
isVoiceSwitching: true,
phase: "stopping",
}));
try {
stopCue();
uploader.reset();
state.transportReady = false;
resetPlaybackState();
deps.engine.stop();
deps.engine.clearQueue();
activeSession?.adapter.setAssistantAudioPlaying(false);
if (activeSession) {
await activeSession.adapter.setVoiceMode(false);
}
await deps.engine.stopCapture();
await deps.deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => undefined);
} finally {
if (state.generation === generation) {
resetToDisabledState();
}
}
},
async destroy() {
await this.stopVoice().catch(() => undefined);
await deps.engine.destroy();
listeners.clear();
telemetryListeners.clear();
sessions.clear();
},
toggleMute() {
const nextMuted = deps.engine.toggleMute();
if (nextMuted) {
uploader.reset();
resetCaptureTelemetry();
patchSnapshot((prev) => ({
...prev,
isMuted: true,
}));
reconcileCue();
return;
}
patchSnapshot((prev) => ({ ...prev, isMuted: false }));
},
isVoiceModeForAgent(serverId, agentId) {
return (
state.snapshot.isVoiceMode &&
state.snapshot.activeServerId === serverId &&
state.snapshot.activeAgentId === agentId
);
},
shouldPlayVoiceAudio(serverId) {
return (
state.snapshot.isVoiceMode &&
state.snapshot.activeServerId === serverId &&
state.snapshot.phase !== "stopping" &&
state.snapshot.phase !== "disabled"
);
},
onAssistantAudioStarted(serverId) {
if (!state.snapshot.isVoiceMode || state.snapshot.activeServerId !== serverId) {
return;
}
stopCue();
getActiveSession()?.adapter.setAssistantAudioPlaying(true);
patchSnapshot((prev) => ({ ...prev, phase: "playing" }));
},
onAssistantAudioFinished(serverId) {
if (state.snapshot.activeServerId !== serverId) {
return;
}
getActiveSession()?.adapter.setAssistantAudioPlaying(false);
if (!state.snapshot.isVoiceMode) {
return;
}
if (state.turnInProgress) {
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
reconcileCue();
return;
}
patchSnapshot((prev) => ({ ...prev, phase: "listening" }));
reconcileCue();
},
onTranscriptionResult(serverId, text) {
if (serverId !== state.snapshot.activeServerId || !state.snapshot.isVoiceMode) {
return;
}
if (text.trim()) {
state.turnInProgress = true;
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
reconcileCue();
return;
}
state.turnInProgress = false;
patchSnapshot((prev) => ({ ...prev, phase: "listening" }));
stopCue();
},
onServerSpeechStateChanged(serverId, isSpeaking) {
if (serverId !== state.snapshot.activeServerId || !state.snapshot.isVoiceMode) {
return;
}
state.serverSpeechDetected = isSpeaking;
state.serverSpeechStartedAt = isSpeaking ? (state.serverSpeechStartedAt ?? Date.now()) : null;
if (isSpeaking) {
const shouldInterruptPlayback =
state.snapshot.phase === "playing" || playback.groups.size > 0;
const hadCue = cue.active || cue.timeout !== null || cue.playing;
resetPlaybackState();
stopCue();
if (shouldInterruptPlayback && !hadCue) {
deps.engine.stop();
deps.engine.clearQueue();
}
getActiveSession()?.adapter.setAssistantAudioPlaying(false);
}
patchTelemetry((prev) => ({
...prev,
isSpeaking,
}));
reconcileSegmentDurationTimer();
reconcileCue();
},
onTurnEvent(serverId, agentId, eventType) {
if (
!state.snapshot.isVoiceMode ||
state.snapshot.activeServerId !== serverId ||
state.snapshot.activeAgentId !== agentId
) {
return;
}
if (eventType === "turn_started") {
state.turnInProgress = true;
if (state.snapshot.phase !== "playing") {
patchSnapshot((prev) => ({ ...prev, phase: "waiting" }));
reconcileCue();
}
return;
}
state.turnInProgress = false;
if (state.snapshot.phase !== "playing") {
patchSnapshot((prev) => ({ ...prev, phase: "listening" }));
}
stopCue();
},
};
return api;
}
+82
View File
@@ -111,6 +111,10 @@ import type {
AgentSkillSelection,
AgentSkillsStatus,
AgentSkillsSaveResult,
VoiceAppContext,
VoiceCallClientTransportMessage,
VoiceCallTransport,
VoiceCallTransportOffer,
} from "@getpaseo/protocol/messages";
import type {
AgentPermissionRequest,
@@ -3395,6 +3399,84 @@ export class DaemonClient {
// Audio / Voice
// ============================================================================
async startVoiceCall(
context: VoiceAppContext,
transports: VoiceCallTransportOffer[],
): Promise<{ callId: string; transport: VoiceCallTransport }> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "voice.call.start.request",
requestId,
context,
transports,
});
const response = await this.sendRequest({
requestId,
message,
select: (candidate) => {
if (candidate.type !== "voice.call.start.response") return null;
if (candidate.payload.requestId !== requestId) return null;
return candidate.payload;
},
});
if (!response.accepted || !response.callId || !response.transport) {
throw new Error(response.error ?? "Failed to start voice chat");
}
return { callId: response.callId, transport: response.transport };
}
async stopVoiceCall(callId: string): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "voice.call.stop.request",
requestId,
callId,
});
const response = await this.sendRequest({
requestId,
message,
select: (candidate) => {
if (candidate.type !== "voice.call.stop.response") return null;
if (candidate.payload.requestId !== requestId) return null;
return candidate.payload;
},
});
if (!response.accepted) {
throw new Error(response.error ?? "Failed to stop voice chat");
}
}
async updateVoiceCallContext(callId: string, context: VoiceAppContext): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "voice.call.context.update.request",
requestId,
callId,
context,
});
const response = await this.sendRequest({
requestId,
message,
select: (candidate) => {
if (candidate.type !== "voice.call.context.update.response") return null;
if (candidate.payload.requestId !== requestId) return null;
return candidate.payload;
},
});
if (!response.accepted) {
throw new Error(response.error ?? "Failed to update voice chat context");
}
}
sendVoiceCallTransportMessage(callId: string, data: unknown): void {
const message: VoiceCallClientTransportMessage = {
type: "voice.call.transport.client",
callId,
data,
};
this.sendSessionMessageStrict(message);
}
async setVoiceMode(enabled: boolean, agentId?: string): Promise<SetVoiceModePayload> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
+45
View File
@@ -60,6 +60,19 @@ import {
BrowserAutomationExecuteResponseSchema,
} from "./browser-automation/rpc-schemas.js";
import { BrowserAutomationHostCapabilitySchema } from "./browser-automation/capabilities.js";
import {
VoiceCallClientTransportMessageSchema,
VoiceCallContextUpdateRequestSchema,
VoiceCallContextUpdateResponseSchema,
VoiceCallEventMessageSchema,
VoiceCallStartRequestSchema,
VoiceCallStartResponseSchema,
VoiceCallStateMessageSchema,
VoiceCallStopRequestSchema,
VoiceCallStopResponseSchema,
VoiceCallServerTransportMessageSchema,
} from "./voice-chat.js";
export * from "./voice-chat.js";
import {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -211,6 +224,18 @@ export const AgentSkillSelectionSchema = z.discriminatedUnion("mode", [
]);
export type AgentSkillSelection = z.infer<typeof AgentSkillSelectionSchema>;
export const MutableManualVoiceOrchestratorConfigSchema = z
.object({
provider: z.string().nullable(),
model: z.string().nullable(),
modeId: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
})
.strict();
export type MutableManualVoiceOrchestratorConfig = z.infer<
typeof MutableManualVoiceOrchestratorConfigSchema
>;
export const MutableDaemonConfigSchema = z
.object({
// COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported.
@@ -246,6 +271,10 @@ export const MutableDaemonConfigSchema = z
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
manualVoice: z
.object({ orchestrator: MutableManualVoiceOrchestratorConfigSchema })
.strict()
.optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
})
@@ -266,6 +295,10 @@ export const MutableDaemonConfigPatchSchema = z
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
manualVoice: z
.object({ orchestrator: MutableManualVoiceOrchestratorConfigSchema.partial() })
.strict()
.optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
})
@@ -3040,6 +3073,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentValidateRequestSchema,
HubExecutionControlRequestSchema,
BrowserAutomationExecuteResponseSchema,
VoiceCallStartRequestSchema,
VoiceCallStopRequestSchema,
VoiceCallContextUpdateRequestSchema,
VoiceCallClientTransportMessageSchema,
VoiceAudioChunkMessageSchema,
AbortRequestMessageSchema,
AudioPlayedMessageSchema,
@@ -3399,6 +3436,8 @@ export const ServerInfoStatusPayloadSchema = z
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
features: z
.object({
// COMPAT(voiceChat): added in v0.7.0, remove gate after 2027-08-29.
voiceChat: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
@@ -6402,6 +6441,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
HubExecutionAgentUpdateSchema,
HubExecutionAgentStreamSchema,
BrowserAutomationExecuteRequestSchema,
VoiceCallStartResponseSchema,
VoiceCallStopResponseSchema,
VoiceCallContextUpdateResponseSchema,
VoiceCallStateMessageSchema,
VoiceCallEventMessageSchema,
VoiceCallServerTransportMessageSchema,
PluginCatalogGetResponseSchema,
PluginListResponseSchema,
PluginLogsGetResponseSchema,
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
import {
VoiceCallEventMessageSchema,
VoiceCallClientTransportMessageSchema,
VoiceCallServerTransportMessageSchema,
VoiceCallStartRequestSchema,
VoiceCallStartResponseSchema,
VoiceCallStateMessageSchema,
} from "./voice-chat.js";
describe("voice chat protocol", () => {
it("accepts a fresh call start through the session protocol", () => {
const message = {
type: "voice.call.start.request",
requestId: "request-1",
context: { workspaceId: "workspace-1", agentId: null },
transports: [{ kind: "webrtc", offerSdp: "offer" }],
};
expect(VoiceCallStartRequestSchema.parse(message)).toEqual(message);
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
});
it("negotiates transport independently of the selected provider", () => {
const message = {
type: "voice.call.start.response",
payload: {
requestId: "request-1",
accepted: true,
callId: "call-1",
transport: { kind: "webrtc", answerSdp: "answer" },
error: null,
},
};
expect(VoiceCallStartResponseSchema.parse(message)).toEqual(message);
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("keeps server state limited to lifecycle and provider-owned input", () => {
const message = {
type: "voice.call.state",
payload: {
callId: "call-1",
lifecycle: "active",
input: "speech_detected",
error: null,
},
};
expect(VoiceCallStateMessageSchema.parse(message)).toEqual(message);
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("carries provider-owned transport data through opaque call envelopes", () => {
const clientMessage = {
type: "voice.call.transport.client",
callId: "call-1",
data: { type: "candidate", value: "client-data" },
};
const serverMessage = {
type: "voice.call.transport.server",
payload: {
callId: "call-1",
data: { type: "candidate", value: "server-data" },
},
};
expect(VoiceCallClientTransportMessageSchema.parse(clientMessage)).toEqual(clientMessage);
expect(SessionInboundMessageSchema.parse(clientMessage)).toEqual(clientMessage);
expect(VoiceCallServerTransportMessageSchema.parse(serverMessage)).toEqual(serverMessage);
expect(SessionOutboundMessageSchema.parse(serverMessage)).toEqual(serverMessage);
});
it("accepts provider-neutral partial transcript events", () => {
const message = {
type: "voice.call.event",
payload: {
callId: "call-1",
event: {
type: "transcript",
id: "transcript-1",
speaker: "user",
text: "Start an agent",
final: false,
},
},
};
expect(VoiceCallEventMessageSchema.parse(message)).toEqual(message);
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
});
+166
View File
@@ -0,0 +1,166 @@
import { z } from "zod";
export const VoiceAppContextSchema = z.object({
workspaceId: z.string().nullable(),
agentId: z.string().nullable(),
});
export const VoiceCallStartRequestSchema = z.object({
type: z.literal("voice.call.start.request"),
requestId: z.string(),
context: VoiceAppContextSchema,
// COMPAT(voiceCallTransportOffers): added in v0.3, optional so old clients still parse.
transports: z
.array(
z.discriminatedUnion("kind", [
z.object({ kind: z.literal("daemon-audio") }),
z.object({ kind: z.literal("webrtc"), offerSdp: z.string().min(1) }),
]),
)
.optional(),
});
export const VoiceCallTransportSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("daemon-audio") }),
z.object({ kind: z.literal("webrtc"), answerSdp: z.string().min(1) }),
]);
export const VoiceCallStartResponseSchema = z.object({
type: z.literal("voice.call.start.response"),
payload: z.object({
requestId: z.string(),
accepted: z.boolean(),
callId: z.string().nullable(),
transport: VoiceCallTransportSchema.optional(),
error: z.string().nullable(),
}),
});
export const VoiceCallStopRequestSchema = z.object({
type: z.literal("voice.call.stop.request"),
requestId: z.string(),
callId: z.string(),
});
export const VoiceCallStopResponseSchema = z.object({
type: z.literal("voice.call.stop.response"),
payload: z.object({
requestId: z.string(),
callId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const VoiceCallContextUpdateRequestSchema = z.object({
type: z.literal("voice.call.context.update.request"),
requestId: z.string(),
callId: z.string(),
context: VoiceAppContextSchema,
});
export const VoiceCallContextUpdateResponseSchema = z.object({
type: z.literal("voice.call.context.update.response"),
payload: z.object({
requestId: z.string(),
callId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const VoiceCallClientTransportMessageSchema = z.object({
type: z.literal("voice.call.transport.client"),
callId: z.string(),
data: z.unknown(),
});
export const VoiceCallInboundMessageSchema = z.discriminatedUnion("type", [
VoiceCallStartRequestSchema,
VoiceCallStopRequestSchema,
VoiceCallContextUpdateRequestSchema,
VoiceCallClientTransportMessageSchema,
]);
export const VoiceCallStateSchema = z.object({
callId: z.string(),
lifecycle: z.enum(["active", "ended", "failed"]),
input: z.enum(["idle", "listening", "speech_detected"]),
error: z.string().nullable(),
});
export const VoiceCallStateMessageSchema = z.object({
type: z.literal("voice.call.state"),
payload: VoiceCallStateSchema,
});
export const VoiceCallTranscriptEventSchema = z.object({
type: z.literal("transcript"),
id: z.string(),
speaker: z.enum(["user", "assistant"]),
text: z.string(),
final: z.boolean(),
});
export const VoiceCallActivityEventSchema = z.object({
type: z.literal("activity"),
id: z.string(),
label: z.string(),
state: z.enum(["running", "completed", "failed"]),
});
export const VoiceCallNoticeEventSchema = z.object({
type: z.literal("notice"),
id: z.string(),
text: z.string(),
});
export const VoiceCallErrorEventSchema = z.object({
type: z.literal("error"),
id: z.string(),
message: z.string(),
});
export const VoiceCallEventSchema = z.discriminatedUnion("type", [
VoiceCallTranscriptEventSchema,
VoiceCallActivityEventSchema,
VoiceCallNoticeEventSchema,
VoiceCallErrorEventSchema,
]);
export const VoiceCallEventMessageSchema = z.object({
type: z.literal("voice.call.event"),
payload: z.object({
callId: z.string(),
event: VoiceCallEventSchema,
}),
});
export const VoiceCallServerTransportMessageSchema = z.object({
type: z.literal("voice.call.transport.server"),
payload: z.object({
callId: z.string(),
data: z.unknown(),
}),
});
export const VoiceCallOutboundMessageSchema = z.discriminatedUnion("type", [
VoiceCallStartResponseSchema,
VoiceCallStopResponseSchema,
VoiceCallContextUpdateResponseSchema,
VoiceCallStateMessageSchema,
VoiceCallEventMessageSchema,
VoiceCallServerTransportMessageSchema,
]);
export type VoiceAppContext = z.infer<typeof VoiceAppContextSchema>;
export type VoiceCallInboundMessage = z.infer<typeof VoiceCallInboundMessageSchema>;
export type VoiceCallOutboundMessage = z.infer<typeof VoiceCallOutboundMessageSchema>;
export type VoiceCallState = z.infer<typeof VoiceCallStateSchema>;
export type VoiceCallEvent = z.infer<typeof VoiceCallEventSchema>;
export type VoiceCallTransport = z.infer<typeof VoiceCallTransportSchema>;
export type VoiceCallTransportOffer = NonNullable<
z.infer<typeof VoiceCallStartRequestSchema>["transports"]
>[number];
export type VoiceCallClientTransportMessage = z.infer<typeof VoiceCallClientTransportMessageSchema>;
export type VoiceCallServerTransportMessage = z.infer<typeof VoiceCallServerTransportMessageSchema>;
@@ -3110,6 +3110,141 @@ test("keeps the global Paseo-tools gate outside provider policy and MCP injectio
rmSync(workdir, { recursive: true, force: true });
});
test.each([true, false])(
"createAgent injects required Paseo tools with ordinary tools enabled=%s",
async (paseoToolsEnabled) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
class CaptureClient extends TestAgentClient {
lastConfig: AgentSessionConfig | null = null;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.lastConfig = config;
return new McpCapableTestAgentSession(config);
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
paseoToolsEnabled,
resolvePaseoToolPolicy: () => ({ enabled: false }),
requiredPaseoToolsBaseUrl: "http://127.0.0.1:6767/mcp/agents",
mcpAuthToken: "voice-capability-token",
idFactory: () => "00000000-0000-4000-8000-000000000117",
});
try {
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
paseoTools: "required",
});
expect(client.lastConfig?.mcpServers?.paseo).toEqual({
type: "http",
url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`,
headers: { Authorization: "Bearer voice-capability-token" },
});
expect(snapshot.config.mcpServers).toBeUndefined();
expect(manager.getPaseoToolPolicy(snapshot.id)).toBeUndefined();
} finally {
rmSync(workdir, { recursive: true, force: true });
}
},
);
test("internal-agent capability owns tool context and preserves delegated children on cleanup", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-internal-agent-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentId = "00000000-0000-4000-8000-000000000119";
const childId = "00000000-0000-4000-8000-000000000120";
const client = new McpCapableTestAgentClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
appendSystemPrompt: "Ordinary daemon instructions.",
requiredPaseoToolsBaseUrl: "http://127.0.0.1:6767/mcp/agents",
});
const tools = {
caller: { allowCustomCwd: false },
extension: { tools: [] },
};
try {
const internal = await manager.launchInternalAgentWithRequiredPaseoTools({
agentId: parentId,
config: {
provider: "codex",
cwd: workdir,
internal: true,
systemPrompt: "Authoritative internal instructions.",
},
workspaceId: undefined,
tools,
systemPromptBehavior: "authoritative",
});
await manager.createAgent({ provider: "codex", cwd: workdir }, childId, {
workspaceId: undefined,
labels: { [PARENT_AGENT_ID_LABEL]: parentId },
});
expect(manager.getInternalAgentPaseoTools(parentId)).toBe(tools);
expect(client.createdConfigs[0]?.systemPrompt).toBe("Authoritative internal instructions.");
expect(client.createdConfigs[0]).not.toHaveProperty("daemonAppendSystemPrompt");
await Promise.all([internal.dispose(), internal.dispose()]);
expect(manager.getInternalAgentPaseoTools(parentId)).toBeNull();
expect((await storage.get(parentId))?.archivedAt).toBeTruthy();
const child = await storage.get(childId);
expect(child?.archivedAt).toBeUndefined();
expect(child?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("createAgent rejects a voice orchestrator that cannot receive required Paseo tools", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let sessionClosed = false;
class RequiredToolsSession extends TestAgentSession {
override async close(): Promise<void> {
sessionClosed = true;
}
}
class RequiredToolsClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new RequiredToolsSession(config);
}
}
const manager = new AgentManager({
clients: { codex: new RequiredToolsClient() },
registry: storage,
logger,
requiredPaseoToolsBaseUrl: "http://127.0.0.1:6767/mcp/agents",
idFactory: () => "00000000-0000-4000-8000-000000000118",
});
try {
await expect(
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
paseoTools: "required",
}),
).rejects.toThrow("cannot receive required Paseo tools");
expect(sessionClosed).toBe(true);
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
+135 -14
View File
@@ -76,7 +76,11 @@ import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
import type {
PaseoCallerContext,
PaseoToolCatalogFactory,
PaseoToolExtension,
} from "./tools/types.js";
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
import {
ProviderSubagentStore,
@@ -283,6 +287,28 @@ export interface CreateAgentOptions {
// undefined is an explicit decision: the agent never appears in the sidebar.
workspaceId: string | undefined;
owner?: AgentOwner;
paseoTools?: "required";
systemPromptBehavior?: "append-daemon" | "authoritative";
}
export interface InternalAgentPaseoTools {
caller: PaseoCallerContext;
extension?: PaseoToolExtension;
}
export interface LaunchInternalAgentOptions {
agentId?: string;
config: AgentSessionConfig;
workspaceId: string | undefined;
labels?: Record<string, string>;
initialTitle?: string | null;
tools: InternalAgentPaseoTools;
systemPromptBehavior: "authoritative";
}
export interface InternalAgentHandle {
agent: ManagedAgent;
dispose(): Promise<void>;
}
export interface AgentManagerOptions {
@@ -295,6 +321,7 @@ export interface AgentManagerOptions {
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
requiredPaseoToolsBaseUrl?: string;
mcpAuthToken?: string;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
@@ -309,6 +336,10 @@ export interface AgentManagerOptions {
logger: Logger;
}
function resolveRequiredPaseoToolsBaseUrl(options: AgentManagerOptions): string | null {
return options.requiredPaseoToolsBaseUrl ?? null;
}
export type ActiveTurnSteerDispatchResult =
| { status: "inactive" | "steered" }
| { status: "replaced"; iterator: AsyncGenerator<AgentStreamEvent> };
@@ -707,6 +738,7 @@ export class AgentManager {
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private requiredPaseoToolsBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
private paseoToolsEnabled = true;
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
@@ -714,6 +746,7 @@ export class AgentManager {
private readonly resolvePaseoToolPolicy: (
provider: AgentProvider,
) => ProviderPaseoToolsPolicy | undefined;
private readonly internalAgentTools = new Map<string, InternalAgentPaseoTools>();
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private onAgentArchived?: AgentArchivedCallback;
@@ -730,6 +763,7 @@ export class AgentManager {
this.onAgentAttention = options?.onAgentAttention;
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.requiredPaseoToolsBaseUrl = resolveRequiredPaseoToolsBaseUrl(options);
this.mcpAuthToken = options?.mcpAuthToken ?? null;
this.configurePaseoTools(options);
this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined);
@@ -802,6 +836,10 @@ export class AgentManager {
this.mcpBaseUrl = url;
}
setRequiredPaseoToolsBaseUrl(url: string | null): void {
this.requiredPaseoToolsBaseUrl = url;
}
prepareForShutdown(): void {
this.acceptingAgentRegistrations = false;
}
@@ -818,6 +856,10 @@ export class AgentManager {
return this.paseoToolPolicies.get(agentId);
}
getInternalAgentPaseoTools(agentId: string): InternalAgentPaseoTools | null {
return this.internalAgentTools.get(agentId) ?? null;
}
/**
* Capability token the daemon's own MCP clients must present to the Agent MCP
* endpoint when a daemon password is configured. Read by the per-client
@@ -1182,6 +1224,49 @@ export class AgentManager {
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
}
async launchInternalAgentWithRequiredPaseoTools(
options: LaunchInternalAgentOptions,
): Promise<InternalAgentHandle> {
const agentId = validateAgentId(options.agentId ?? this.idFactory(), "launchInternalAgent");
if (this.internalAgentTools.has(agentId)) {
throw new Error(`Internal agent tool context already exists for ${agentId}`);
}
this.internalAgentTools.set(agentId, options.tools);
let agent: ManagedAgent;
try {
agent = await this.createAgent(options.config, agentId, {
workspaceId: options.workspaceId,
labels: options.labels,
initialTitle: options.initialTitle,
paseoTools: "required",
systemPromptBehavior: options.systemPromptBehavior,
});
} catch (error) {
this.internalAgentTools.delete(agentId);
throw error;
}
let disposed = false;
let disposal: Promise<void> | null = null;
return {
agent,
dispose: async () => {
if (disposed) return;
disposal ??= (async () => {
await this.detachDelegatedChildren(agentId);
if (this.agents.has(agentId)) await this.archiveAgent(agentId);
this.internalAgentTools.delete(agentId);
disposed = true;
})();
try {
await disposal;
} finally {
if (!disposed) disposal = null;
}
},
};
}
private async createAgentInternal(
config: AgentSessionConfig,
agentId: string | undefined,
@@ -1194,6 +1279,8 @@ export class AgentManager {
config,
resolvedAgentId,
options?.env,
options.paseoTools,
options.systemPromptBehavior,
);
this.requireEnabledProvider(storedConfig.provider);
const client = await this.requireAvailableClient({
@@ -1206,10 +1293,17 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
options?.env,
options.paseoTools,
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requirePaseoToolsSupport(
session,
providerLaunchConfig,
launchContext,
options.paseoTools,
);
await this.requireExternalMcpSupport(session, storedConfig);
return this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels,
@@ -1704,6 +1798,15 @@ export class AgentManager {
}
}
private async detachDelegatedChildren(parentAgentId: string): Promise<void> {
if (!this.registry) return;
const records = await this.registry.list();
for (const record of records) {
if (record.archivedAt || record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) continue;
await this.detachAgent(record.id);
}
}
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const registry = this.requireRegistry();
const archivedAt = new Date().toISOString();
@@ -3338,6 +3441,18 @@ export class AgentManager {
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`);
}
private async requirePaseoToolsSupport(
session: AgentSession,
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
requirement: CreateAgentOptions["paseoTools"],
): Promise<void> {
if (requirement !== "required" || launchContext.paseoTools) return;
if (launchConfig.mcpServers?.paseo && session.capabilities.supportsMcpServers === true) return;
await this.closeUnregisteredSession(session);
throw new Error(`Provider '${launchConfig.provider}' cannot receive required Paseo tools`);
}
private async initializeAgentTimelineForRegister(params: {
agentId: string;
now: Date;
@@ -4883,22 +4998,27 @@ export class AgentManager {
config: AgentSessionConfig,
agentId: string,
env?: Record<string, string>,
paseoTools?: CreateAgentOptions["paseoTools"],
systemPromptBehavior: CreateAgentOptions["systemPromptBehavior"] = "append-daemon",
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
const paseoToolPolicy = this.paseoToolsEnabled
let paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
: { enabled: false };
const launchConfig = this.applyDaemonAppendSystemPrompt(
withRuntimePaseoMcpServer({
config: storedConfig,
agentId,
mcpBaseUrl:
this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy)
? this.mcpBaseUrl
: null,
mcpAuthToken: this.mcpAuthToken,
}),
);
if (paseoTools === "required") paseoToolPolicy = undefined;
const toolsEnabled = isPaseoToolPolicyEnabled(paseoToolPolicy);
let mcpBaseUrl = toolsEnabled ? this.mcpBaseUrl : null;
if (paseoTools === "required") mcpBaseUrl = this.requiredPaseoToolsBaseUrl;
const runtimeConfig = withRuntimePaseoMcpServer({
config: storedConfig,
agentId,
mcpBaseUrl,
mcpAuthToken: this.mcpAuthToken,
});
const launchConfig =
systemPromptBehavior === "authoritative"
? runtimeConfig
: this.applyDaemonAppendSystemPrompt(runtimeConfig);
return { storedConfig, launchConfig, paseoToolPolicy };
}
@@ -4921,6 +5041,7 @@ export class AgentManager {
cwd: string,
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined,
env?: Record<string, string>,
paseoTools?: CreateAgentOptions["paseoTools"],
): Promise<AgentLaunchContext> {
const context: AgentLaunchContext = {
agentId,
@@ -4931,7 +5052,7 @@ export class AgentManager {
},
};
if (
this.paseoToolsEnabled &&
(this.paseoToolsEnabled || paseoTools === "required") &&
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
@@ -5175,51 +5175,40 @@ describe("provider MCP tools", () => {
});
});
describe("speak MCP tool", () => {
describe("agent tool extensions", () => {
const logger = createTestLogger();
it("invokes registered speak handler for caller agent", async () => {
it("registers provider-owned tools for the caller agent", async () => {
const { agentManager, agentStorage } = createTestDeps();
const speak = vi.fn().mockResolvedValue(undefined);
const handler = vi.fn().mockResolvedValue({ content: [], structuredContent: { ok: true } });
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "voice-agent-1",
enableVoiceTools: true,
resolveSpeakHandler: () => speak,
resolveToolExtension: () => ({
tools: [
{
name: "speak",
description: "Speak",
inputSchema: z.object({ text: z.string() }),
handler,
},
],
}),
logger,
});
const tool = registeredTool(server, "speak");
expect(tool).toBeDefined();
await tool.handler({ text: "Hello from voice agent." });
expect(speak).toHaveBeenCalledWith(
expect.objectContaining({
text: "Hello from voice agent.",
callerAgentId: "voice-agent-1",
}),
expect(handler).toHaveBeenCalledWith(
{ text: "Hello from voice agent." },
expect.objectContaining({}),
);
});
it("fails when no speak handler exists", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
callerAgentId: "voice-agent-2",
enableVoiceTools: true,
resolveSpeakHandler: () => null,
logger,
});
const tool = registeredTool(server, "speak");
await expect(tool.handler({ text: "Hello." })).rejects.toThrow(
"No speak handler registered for your session",
);
});
it("does not register speak tool unless voice tools are enabled", async () => {
it("does not register provider tools without an extension", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -32,7 +32,6 @@ import {
type ArchiveDependencies,
} from "../../workspace-archive-service.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
import type { FirstAgentContext } from "../../messages.js";
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
@@ -86,10 +85,12 @@ import {
import { registerBrowserTools } from "../../browser-tools/tools.js";
import type { BrowserToolsBroker } from "../../browser-tools/broker.js";
import type {
PaseoCallerContext,
PaseoToolCatalog,
PaseoToolConfig,
PaseoToolDefinition,
PaseoToolExecutionContext,
PaseoToolExtension,
PaseoToolResult,
} from "./types.js";
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
@@ -138,14 +139,8 @@ export interface PaseoToolHostDependencies {
* Used for cwd/mode inheritance when agents spawn child agents.
*/
callerAgentId?: string;
/**
* Optional resolver for session-bound speak handlers.
* Used by hidden voice agents to narrate through daemon-managed TTS.
*/
resolveSpeakHandler?: (callerAgentId: string) => VoiceSpeakHandler | null;
resolveCallerContext?: (callerAgentId: string) => VoiceCallerContext | null;
enableVoiceTools?: boolean;
voiceOnly?: boolean;
resolveCallerContext?: (callerAgentId: string) => PaseoCallerContext | null;
resolveToolExtension?: (callerAgentId: string) => PaseoToolExtension | null;
logger: Logger;
}
@@ -551,8 +546,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
providerSnapshotManager,
daemonConfigStore,
callerAgentId,
resolveSpeakHandler,
resolveCallerContext,
resolveToolExtension,
logger,
} = options;
const childLogger = logger.child({ module: "agent", component: "paseo-tool-catalog" });
@@ -609,6 +604,13 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
});
if (callerAgentId) {
const extension = resolveToolExtension?.(callerAgentId) ?? null;
for (const tool of extension?.tools ?? []) {
tools.set(tool.name, tool);
}
}
const buildCronScheduleCadence = (input: {
cron: string | undefined;
timezone?: string;
@@ -1162,49 +1164,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
type TopLevelCreateAgentArgs = z.infer<typeof canonicalTopLevelCreateAgentArgsSchema>;
type LegacyTopLevelCreateAgentArgs = z.infer<typeof legacyTopLevelCreateAgentArgsSchema>;
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
registerTool(
"speak",
{
title: "Speak",
description:
"Speak text to the user via daemon-managed voice output. Blocks until playback completes.",
inputSchema: {
text: z
.string()
.trim()
.min(1, "text is required")
.max(4000, "text must be 4000 characters or fewer"),
},
outputSchema: {
ok: z.boolean(),
},
},
async (args, context) => {
if (!callerAgentId) {
throw new Error("speak is only available to agent-scoped tool sessions");
}
const handler = resolveSpeakHandler?.(callerAgentId) ?? null;
if (!handler) {
throw new Error(`No speak handler registered for your session '${callerAgentId}'`);
}
await handler({
text: args.text,
callerAgentId,
signal: context?.signal,
});
return {
content: [],
structuredContent: ensureValidJson({ ok: true }),
};
},
);
}
if (options.voiceOnly) {
return toCatalog();
}
if (options.browserToolsEnabled && options.browserToolsBroker) {
registerBrowserTools({
registerTool,
@@ -35,11 +35,19 @@ export interface PaseoToolCatalog {
): Promise<PaseoToolResult>;
}
export interface PaseoCallerContext {
childAgentDefaultLabels?: Record<string, string>;
lockedCwd?: string;
allowCustomCwd?: boolean;
}
export interface PaseoToolExtension {
tools: readonly PaseoToolDefinition[];
}
export interface PaseoToolRuntimeContext {
callerAgentId?: string;
paseoToolPolicy?: ProviderPaseoToolsPolicy;
enableVoiceTools?: boolean;
voiceOnly?: boolean;
}
export type PaseoToolCatalogFactory = (
+12 -9
View File
@@ -148,15 +148,18 @@ export async function isAgentMcpRequestAuthorized(input: {
if (!input.password) {
return true;
}
if (isAgentMcpCapabilityToken(input)) return true;
const token = extractHttpBearerToken(input.authorizationHeader);
if (input.capabilityToken !== null && token !== null) {
// Constant-time compare; length-guard first because timingSafeEqual throws
// on differing buffer lengths.
const provided = Buffer.from(token);
const expected = Buffer.from(input.capabilityToken);
if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
return true;
}
}
return isBearerTokenValidAsync({ password: input.password, token });
}
export function isAgentMcpCapabilityToken(input: {
capabilityToken: string | null;
authorizationHeader: string | undefined;
}): boolean {
const token = extractHttpBearerToken(input.authorizationHeader);
if (input.capabilityToken === null || token === null) return false;
const provided = Buffer.from(token);
const expected = Buffer.from(input.capabilityToken);
return provided.length === expected.length && timingSafeEqual(provided, expected);
}
@@ -181,6 +181,10 @@ const INBOUND_PERMISSION = {
unsubscribe_terminals_request: "workspace.read",
update_agent_request: "workspace.write",
validate_branch_request: "workspace.read",
"voice.call.context.update.request": "workspace.write",
"voice.call.start.request": "workspace.write",
"voice.call.stop.request": "workspace.write",
"voice.call.transport.client": "workspace.write",
voice_audio_chunk: "workspace.write",
wait_for_finish_request: "workspace.read",
"workspace.clear_attention.request": "workspace.write",
@@ -391,6 +395,12 @@ const OUTBOUND_PERMISSION = {
transcription_result: "workspace.write",
update_agent_response: "workspace.write",
validate_branch_response: "workspace.read",
"voice.call.context.update.response": "workspace.write",
"voice.call.event": "workspace.write",
"voice.call.start.response": "workspace.write",
"voice.call.state": "workspace.write",
"voice.call.stop.response": "workspace.write",
"voice.call.transport.server": "workspace.write",
voice_input_state: "workspace.write",
wait_for_finish_response: "workspace.read",
"workspace.clear_attention.response": "workspace.write",
+62 -16
View File
@@ -128,6 +128,12 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { createSpeechService } from "./speech/speech-runtime.js";
import { createVoiceChat, type VoiceChat } from "./voice-chat/index.js";
import { createManualVoiceProvider } from "./voice-chat/providers/manual/provider.js";
import {
resolveManualVoiceConfig,
type ManualVoiceConfig,
} from "./voice-chat/providers/manual/config.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
@@ -179,6 +185,7 @@ import type {
PluginSource,
TerminalProfile,
} from "@getpaseo/protocol/messages";
import { MutableManualVoiceOrchestratorConfigSchema } from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -201,6 +208,7 @@ import { terminateWithTreeKill } from "../utils/tree-kill.js";
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
import {
createRequireBearerMiddleware,
isAgentMcpCapabilityToken,
isAgentMcpRequestAuthorized,
type DaemonAuthConfig,
} from "./auth.js";
@@ -430,9 +438,7 @@ export interface PaseoDaemonConfig {
auth?: DaemonAuthConfig;
openai?: PaseoOpenAIConfig;
speech?: PaseoSpeechConfig;
voiceLlmProvider?: AgentProvider | null;
voiceLlmProviderExplicit?: boolean;
voiceLlmModel?: string | null;
manualVoice?: ManualVoiceConfig;
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
@@ -523,8 +529,24 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function isAgentMcpEndpointAvailable(enabled: boolean, hasInternalCapability: boolean): boolean {
return enabled || hasInternalCapability;
}
function resolveDaemonManualVoiceConfig(config: PaseoDaemonConfig): ManualVoiceConfig {
return (
config.manualVoice ??
resolveManualVoiceConfig({
paseoHome: config.paseoHome,
persisted: undefined,
providers: undefined,
})
);
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
const manualVoice = resolveDaemonManualVoiceConfig(config);
const initialConfig: MutableDaemonConfig = {
relay: { enabled: config.relayEnabled ?? true },
@@ -551,6 +573,9 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
pluginsEnabled: config.pluginsEnabled ?? false,
plugins: config.plugins ?? {},
skills: { selection: config.skillSelection },
manualVoice: {
orchestrator: manualVoice.orchestrator,
},
};
if (config.terminalProfiles !== undefined) {
@@ -1343,11 +1368,10 @@ export async function createPaseoDaemon(
{ elapsed: elapsed() },
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`,
);
logger.info(
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
);
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
let voiceChat: VoiceChat | null = null;
const createAgentToolHostDependencies = (
runtime: PaseoToolRuntimeContext,
): PaseoToolHostDependencies => ({
@@ -1407,10 +1431,10 @@ export async function createPaseoDaemon(
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
callerAgentId: runtime.callerAgentId,
enableVoiceTools: runtime.enableVoiceTools,
voiceOnly: runtime.voiceOnly,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
resolveCallerContext: (agentId) =>
agentManager.getInternalAgentPaseoTools(agentId)?.caller ?? null,
resolveToolExtension: (agentId) =>
agentManager.getInternalAgentPaseoTools(agentId)?.extension ?? null,
logger,
});
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
@@ -1462,7 +1486,11 @@ export async function createPaseoDaemon(
req: express.Request,
res: express.Response,
): Promise<void> => {
if (!mcpEnabled) {
const hasInternalCapability = isAgentMcpCapabilityToken({
capabilityToken: agentMcpAuthToken,
authorizationHeader: req.header("authorization"),
});
if (!isAgentMcpEndpointAvailable(mcpEnabled, hasInternalCapability)) {
res.status(404).json({ error: "Agent MCP endpoint disabled" });
return;
}
@@ -1550,11 +1578,25 @@ export async function createPaseoDaemon(
logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
}
const speechService = createSpeechService({
const dictationSpeechService = createSpeechService({
logger,
openaiConfig: config.openai,
speechConfig: config.speech,
});
const manualVoiceProvider = createManualVoiceProvider({
config: resolveDaemonManualVoiceConfig(config),
agentManager,
agentStorage,
workspaceRegistry,
logger,
});
daemonConfigStore.onFieldChange("manualVoice.orchestrator", (value) => {
const orchestrator = MutableManualVoiceOrchestratorConfigSchema.parse(value);
manualVoiceProvider.configureOrchestrator(orchestrator);
});
voiceChat = createVoiceChat({
provider: manualVoiceProvider,
});
logger.info({ elapsed: elapsed() }, "Speech service created");
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
@@ -1589,6 +1631,7 @@ export async function createPaseoDaemon(
const logAndResolve = async () => {
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
const mcpBaseUrl = createAgentMcpBaseUrl(boundListenTarget);
agentManager.setRequiredPaseoToolsBaseUrl(mcpBaseUrl);
agentMcpBaseUrl =
!mcpEnabled || config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
@@ -1656,7 +1699,7 @@ export async function createPaseoDaemon(
},
workspaceAutoName,
config.auth,
speechService,
dictationSpeechService,
terminalManager,
{
finalTimeoutMs: config.dictationFinalTimeoutMs,
@@ -1706,6 +1749,7 @@ export async function createPaseoDaemon(
pluginRuntime,
orchestrationSkills,
workspaceLabelService,
voiceChat,
);
pluginRuntime.bindPaseoSessionHost(wsServer);
await pluginRuntime.start();
@@ -1747,9 +1791,9 @@ export async function createPaseoDaemon(
}
});
// Start speech service after listening so synchronous Sherpa native
// Start dictation after listening so synchronous Sherpa native
// model loading doesn't block the server from accepting connections.
speechService.start();
dictationSpeechService.start();
scriptHealthMonitor.start();
} catch (error) {
unsubscribePluginProviders();
@@ -1779,7 +1823,9 @@ export async function createPaseoDaemon(
await agentStorage.flush().catch(() => undefined);
await agentProviderRuntime.shutdown();
terminalManager.killAll();
await speechService.stop();
await voiceChat?.close();
await manualVoiceProvider.close();
await dictationSpeechService.stop();
await scheduleService.stop().catch(() => undefined);
await relayRuntime?.stop().catch(() => undefined);
if (wsServer) {
+49 -44
View File
@@ -69,7 +69,6 @@ describe("server config", () => {
PASEO_TRUSTED_PROXIES: "true",
PASEO_WEB_UI_ENABLED: "true",
PASEO_LOG_FILE_PATH: "custom.log",
PASEO_VOICE_LLM_PROVIDER: "codex",
},
cli: { relayUseTls: false },
});
@@ -80,67 +79,73 @@ describe("server config", () => {
"daemon.relay.endpoint",
"daemon.relay.useTls",
"daemon.trustedProxies",
"features.voiceMode.llm.provider",
"features.webUi.enabled",
"log.file.path",
]);
expect(config.listen).toBe("127.0.0.1:7000");
expect(config.trustedProxies).toBe(true);
expect(config.log?.file?.path).toBe("custom.log");
expect(config.voiceLlmProvider).toBe("codex");
});
test.each([
{
name: "local speech providers",
providers: { dictation: "local", voiceStt: "local", voiceTts: "local" },
expected: [
"features.dictation.stt.model",
"features.voiceMode.stt.model",
"features.voiceMode.tts.model",
],
},
{
name: "OpenAI speech providers",
providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" },
expected: [
"features.dictation.stt.confidenceThreshold",
"features.dictation.stt.model",
"features.voiceMode.stt.model",
"features.voiceMode.tts.model",
"features.voiceMode.tts.voice",
],
},
{
name: "mixed local and OpenAI speech providers",
providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" },
expected: [
"features.dictation.stt.confidenceThreshold",
"features.dictation.stt.model",
"features.voiceMode.stt.model",
"features.voiceMode.tts.model",
],
},
])("classifies speech overrides for $name", ({ providers, expected }) => {
test("resolves the complete manual voice orchestrator selection", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-voice-agent-"));
roots.push(paseoHome);
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({
manualVoice: {
orchestrator: {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
},
stt: { provider: "openai", model: "gpt-4o-mini-transcribe", language: "de" },
turnDetection: { provider: "local" },
tts: { provider: "openai", model: "tts-1-hd", voice: "nova" },
},
providers: {
openai: { apiKey: "manual-speech-key" },
},
}),
);
const config = loadConfig(paseoHome, { env: {} });
expect(config.manualVoice.orchestrator).toEqual({
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
});
expect(config.manualVoice.speech.providers.voiceStt.provider).toBe("openai");
expect(config.manualVoice.speech.providers.voiceTurnDetection.provider).toBe("local");
expect(config.manualVoice.speech.providers.voiceTts.provider).toBe("openai");
expect(config.manualVoice.speech.sttLanguages.voice).toBe("de");
expect(config.manualVoice.openai?.stt).toMatchObject({
apiKey: "manual-speech-key",
model: "gpt-4o-mini-transcribe",
});
expect(config.manualVoice.openai?.tts).toMatchObject({
apiKey: "manual-speech-key",
model: "tts-1-hd",
voice: "nova",
});
});
test("classifies only dictation speech overrides in shared config", () => {
const config = resolveConfigFromPersisted(
"/tmp/paseo-speech-override-classification",
{
version: 1,
features: {
dictation: { enabled: true, stt: { provider: providers.dictation } },
voiceMode: {
enabled: true,
stt: { provider: providers.voiceStt },
tts: { provider: providers.voiceTts },
},
dictation: { enabled: true, stt: { provider: "local" } },
},
},
{
env: {
OPENAI_API_KEY: "test-api-key",
PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
PASEO_VOICE_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
PASEO_VOICE_LOCAL_TTS_MODEL: "kokoro-en-v0_19",
STT_CONFIDENCE_THRESHOLD: "0.5",
STT_MODEL: "whisper-1",
TTS_MODEL: "tts-1",
@@ -149,7 +154,7 @@ describe("server config", () => {
},
);
expect(config.configReload?.overrideControlledPaths).toEqual(expected);
expect(config.configReload?.overrideControlledPaths).toEqual(["features.dictation.stt.model"]);
});
test("resolves bundled web UI path from source-tree modules", () => {
+8 -68
View File
@@ -12,7 +12,6 @@ import {
LogLevelSchema,
type PersistedConfig,
} from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -24,6 +23,7 @@ import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js";
import { resolveManualVoiceConfig } from "./voice-chat/providers/manual/config.js";
const DEFAULT_PORT = 6767;
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
@@ -193,18 +193,6 @@ function parsePositiveIntegerEnv(value: string | undefined): number | undefined
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
const OptionalVoiceLlmProviderSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((value): string | null =>
typeof value === "string" ? value.trim().toLowerCase() : null,
)
.pipe(z.union([AgentProviderSchema, z.null()]));
function parseOptionalVoiceLlmProvider(value: unknown): AgentProvider | null {
const parsed = OptionalVoiceLlmProviderSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
function extractProviderOverrides(
providers: Record<string, unknown> | undefined,
): Record<string, ProviderOverride> | undefined {
@@ -326,12 +314,6 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay {
};
}
interface ResolvedVoiceLlm {
provider: AgentProvider | null;
providerExplicit: boolean;
model: string | null;
}
function resolveServiceProxyPublicBaseUrl(value: string | null): string | null {
if (value === null) {
return null;
@@ -394,21 +376,6 @@ function resolveWebUiConfig(
};
}
function resolveVoiceLlmConfig(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
): ResolvedVoiceLlm {
const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER);
const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider(
persisted.features?.voiceMode?.llm?.provider,
);
return {
provider: envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null,
providerExplicit: envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null,
model: persisted.features?.voiceMode?.llm?.model ?? null,
};
}
function resolveCorsAllowedOrigins(
env: NodeJS.ProcessEnv,
persisted: ReturnType<typeof loadPersistedConfig>,
@@ -589,7 +556,11 @@ export function resolveConfigFromPersisted(
persisted,
});
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
const manualVoice = resolveManualVoiceConfig({
paseoHome,
persisted: persisted.manualVoice,
providers: persisted.providers,
});
const providerOverrides = extractProviderOverrides(
persisted.agents?.providers as Record<string, unknown> | undefined,
);
@@ -633,9 +604,7 @@ export function resolveConfigFromPersisted(
auth: resolveAuthConfig(env, persisted),
openai,
speech,
voiceLlmProvider: voiceLlm.provider,
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
voiceLlmModel: voiceLlm.model,
manualVoice,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration,
@@ -808,42 +777,13 @@ function resolveSpeechOverrideControlledPaths(
paths.push("features.dictation.stt.model");
}
add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language");
add("PASEO_VOICE_MODE_ENABLED", "features.voiceMode.enabled");
add("PASEO_VOICE_LLM_PROVIDER", "features.voiceMode.llm.provider");
add("PASEO_VOICE_STT_PROVIDER", "features.voiceMode.stt.provider");
if (
env.PASEO_VOICE_LOCAL_STT_MODEL !== undefined &&
isEnabledSpeechProvider(providers.voiceStt, "local")
) {
paths.push("features.voiceMode.stt.model");
}
add("PASEO_VOICE_LANGUAGE", "features.voiceMode.stt.language");
add("PASEO_VOICE_TURN_DETECTION_PROVIDER", "features.voiceMode.turnDetection.provider");
add("PASEO_VOICE_TTS_PROVIDER", "features.voiceMode.tts.provider");
if (
env.PASEO_VOICE_LOCAL_TTS_MODEL !== undefined &&
isEnabledSpeechProvider(providers.voiceTts, "local")
) {
paths.push("features.voiceMode.tts.model");
}
add("PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", "features.voiceMode.tts.speakerId");
add("PASEO_VOICE_LOCAL_TTS_SPEED", "features.voiceMode.tts.speed");
add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir");
const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai");
const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai");
if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) {
if (env.STT_CONFIDENCE_THRESHOLD !== undefined && openAiDictationStt) {
paths.push("features.dictation.stt.confidenceThreshold");
}
if (env.STT_MODEL !== undefined) {
if (openAiDictationStt) paths.push("features.dictation.stt.model");
if (openAiVoiceStt) paths.push("features.voiceMode.stt.model");
}
if (isEnabledSpeechProvider(providers.voiceTts, "openai")) {
add("TTS_MODEL", "features.voiceMode.tts.model");
add("TTS_VOICE", "features.voiceMode.tts.voice");
}
if (env.PASEO_DICTATION_LANGUAGE !== undefined && env.PASEO_VOICE_LANGUAGE === undefined) {
paths.push("features.voiceMode.stt.language");
}
return paths;
}
@@ -120,6 +120,55 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("patch persists and emits the complete voice orchestrator selection", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
manualVoice: {
orchestrator: {
provider: null,
model: null,
modeId: null,
thinkingOptionId: null,
},
},
});
const changes: unknown[] = [];
store.onFieldChange("manualVoice.orchestrator", (value) => changes.push(value));
const next = store.patch({
manualVoice: {
orchestrator: {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
},
},
});
expect(next.manualVoice?.orchestrator).toEqual({
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
});
expect(changes).toEqual([next.manualVoice?.orchestrator]);
expect(loadPersistedConfig(paseoHome).manualVoice?.orchestrator).toEqual({
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
});
});
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -29,6 +29,7 @@ interface SupportedMutableConfigPatch {
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
agentProfiles?: MutableDaemonConfig["agentProfiles"];
skills?: MutableDaemonConfig["skills"];
manualVoice?: MutableDaemonConfigPatch["manualVoice"];
pluginsEnabled?: boolean;
plugins?: MutableDaemonConfig["plugins"];
}
@@ -188,6 +189,7 @@ const RELOADABLE_PATHS = [
"agents.catalogRefreshTimeoutMs",
"agents.metadataGeneration",
"agents.skills.selection",
"manualVoice.orchestrator",
"pluginsEnabled",
] as const;
@@ -211,6 +213,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
["agents.metadataGeneration", "metadataGeneration"],
["agents.skills.selection", "skills.selection"],
["manualVoice.orchestrator", "manualVoice.orchestrator"],
["pluginsEnabled", "pluginsEnabled"],
]);
@@ -274,6 +277,9 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
: {}),
...(patch.terminalProfiles !== undefined ? { terminalProfiles: patch.terminalProfiles } : {}),
...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
...(patch.manualVoice?.orchestrator !== undefined
? { manualVoice: { orchestrator: patch.manualVoice.orchestrator } }
: {}),
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
};
@@ -584,15 +590,34 @@ function mergeMutablePatchIntoPersistedConfig(params: {
const { persisted, patch, removeProviders, persistRelayEnabled } = params;
const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders);
const manualVoice = mergeMutableManualVoicePatch(persisted.manualVoice, patch);
return {
...persisted,
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
...(daemon ? { daemon } : { daemon: undefined }),
...(agents ? { agents } : { agents: undefined }),
...(manualVoice ? { manualVoice } : { manualVoice: undefined }),
} as PersistedConfig;
}
function mergeMutableManualVoicePatch(
persistedManualVoice: PersistedConfig["manualVoice"],
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
): PersistedConfig["manualVoice"] {
const orchestrator = patch.manualVoice?.orchestrator;
if (!orchestrator) return persistedManualVoice;
const nextOrchestrator = { ...persistedManualVoice?.orchestrator };
for (const [key, value] of Object.entries(orchestrator)) {
if (value === null) delete nextOrchestrator[key as keyof typeof nextOrchestrator];
else if (value !== undefined) Object.assign(nextOrchestrator, { [key]: value });
}
return {
...persistedManualVoice,
orchestrator: nextOrchestrator,
};
}
function mergeMutableAgentPatch(
persistedAgents: PersistedConfig["agents"],
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
@@ -625,39 +625,55 @@ describe("PersistedConfigSchema logging config", () => {
});
});
describe("PersistedConfigSchema voice mode config", () => {
test("accepts a dedicated turn detection provider", () => {
describe("PersistedConfigSchema manual voice config", () => {
test("accepts the provider-owned manual voice configuration", () => {
const parsed = PersistedConfigSchema.parse({
manualVoice: {
orchestrator: {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
},
stt: { provider: "openai", model: "gpt-4o-mini-transcribe", language: " de " },
turnDetection: { provider: "local" },
tts: { provider: "openai", model: "tts-1-hd", voice: "nova" },
},
});
expect(parsed.manualVoice).toEqual({
orchestrator: {
provider: "codex",
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
},
stt: { provider: "openai", model: "gpt-4o-mini-transcribe", language: "de" },
turnDetection: { provider: "local" },
tts: { provider: "openai", model: "tts-1-hd", voice: "nova" },
});
});
test("rejects the removed feature voice-mode path", () => {
const parsed = PersistedConfigSchema.safeParse({
features: {
voiceMode: {
turnDetection: {
provider: "local",
},
llm: { provider: "codex", modeId: "full-access" },
},
},
});
expect(parsed.features?.voiceMode?.turnDetection?.provider).toBe("local");
expect(parsed.success).toBe(false);
});
});
test("accepts trimmed STT language fields", () => {
describe("PersistedConfigSchema dictation language", () => {
test("accepts a trimmed STT language", () => {
const parsed = PersistedConfigSchema.parse({
features: {
dictation: {
stt: {
language: " fr ",
},
},
voiceMode: {
stt: {
language: " de ",
},
},
},
features: { dictation: { stt: { language: " fr " } } },
});
expect(parsed.features?.dictation?.stt?.language).toBe("fr");
expect(parsed.features?.voiceMode?.stt?.language).toBe("de");
});
});
@@ -734,6 +750,37 @@ describe("loadPersistedConfig", () => {
rmSync(home, { recursive: true, force: true });
}
});
test("loads a config that still contains the removed feature voice-mode block", () => {
const home = createTempHome();
const configPath = path.join(home, "config.json");
try {
writeFileSync(
configPath,
`${JSON.stringify(
{
version: 1,
features: {
dictation: { enabled: true },
voiceMode: {
enabled: true,
llm: { provider: "codex", modeId: "full-access" },
},
},
},
null,
2,
)}\n`,
);
const config = loadPersistedConfig(home);
expect(config.features?.dictation?.enabled).toBe(true);
expect((config.features as Record<string, unknown>)?.voiceMode).toBeUndefined();
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
+13 -41
View File
@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { ManualVoicePersistedConfigSchema } from "./voice-chat/providers/manual/config.js";
import {
AgentProviderRuntimeSettingsMapSchema,
@@ -119,43 +120,6 @@ const FeatureDictationSchema = z
})
.strict();
const FeatureVoiceModeSchema = z
.object({
enabled: z.boolean().optional(),
llm: z
.object({
provider: z.string().optional(),
model: z.string().min(1).optional(),
})
.strict()
.optional(),
stt: z
.object({
provider: SpeechProviderIdSchema.optional(),
model: z.string().min(1).optional(),
language: z.string().trim().min(1).optional(),
})
.strict()
.optional(),
turnDetection: z
.object({
provider: SpeechProviderIdSchema.optional(),
})
.strict()
.optional(),
tts: z
.object({
provider: SpeechProviderIdSchema.optional(),
model: z.string().min(1).optional(),
voice: z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]).optional(),
speakerId: z.number().int().optional(),
speed: z.number().optional(),
})
.strict()
.optional(),
})
.strict();
const FeatureWebUiSchema = z
.object({
enabled: z.boolean().optional(),
@@ -310,6 +274,7 @@ export const PersistedConfigSchema = z
.optional(),
providers: ProvidersSchema.optional(),
manualVoice: ManualVoicePersistedConfigSchema.optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
worktrees: WorktreesConfigSchema.optional(),
@@ -325,7 +290,6 @@ export const PersistedConfigSchema = z
features: z
.object({
dictation: FeatureDictationSchema.optional(),
voiceMode: FeatureVoiceModeSchema.optional(),
webUi: FeatureWebUiSchema.optional(),
})
.strict()
@@ -374,15 +338,23 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
}
// Removed config fields are stripped before parsing so the strict schema does not
// reject a config written by an older release. The stripped values are discarded,
// not migrated — there is no back-compat for the removed `providers.openai.voice`
// block (use `providers.openai.stt` / `providers.openai.tts`).
// reject a config written by an older release. The values are discarded, not
// migrated; this only keeps removed settings from preventing daemon startup.
function stripRemovedConfigFields(parsed: unknown): unknown {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return parsed;
}
const root = { ...(parsed as Record<string, unknown>) };
const features = root.features;
if (features && typeof features === "object" && !Array.isArray(features)) {
const featuresRecord = { ...(features as Record<string, unknown>) };
// COMPAT(removedVoiceModeConfig): added in v0.7.0, remove after 2027-02-28.
delete featuresRecord.voiceMode;
root.features = featuresRecord;
}
const providers = root.providers;
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
return root;
@@ -314,6 +314,7 @@ interface SessionForTestOptions {
hubExecutionAgents?: SessionOptions["hubExecutionAgents"];
stt?: SessionOptions["stt"];
voice?: SessionOptions["voice"];
voiceChat?: SessionOptions["voiceChat"];
paseoHome?: string;
serverId?: SessionOptions["serverId"];
daemonVersion?: SessionOptions["daemonVersion"];
@@ -425,6 +426,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
getDaemonTcpPort: options.getDaemonTcpPort,
getDaemonTcpHost: options.getDaemonTcpHost,
voice: options.voice,
voiceChat: options.voiceChat,
serverId: options.serverId,
daemonVersion: options.daemonVersion,
daemonRuntimeConfig: options.daemonRuntimeConfig,
@@ -433,6 +435,20 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
return new Session(sessionOptions);
}
test("transport disconnect terminates voice without cleaning up the reconnectable session", async () => {
const close = vi.fn().mockResolvedValue(undefined);
const session = createSessionForTest({
voiceChat: {
openClientSession: vi.fn(() => ({ handle: vi.fn(), disconnect: close, close: vi.fn() })),
close: vi.fn(),
},
});
await session.handleTransportDisconnect();
expect(close).toHaveBeenCalledOnce();
});
test("routes host-scoped agent skills requests through the daemon owner", async () => {
const messages: SessionOutboundMessage[] = [];
const status = {
+68 -149
View File
@@ -8,7 +8,6 @@ import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-re
import {
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type AgentAttachment,
type FirstAgentContext,
type SessionInboundMessage,
type SessionOutboundMessage,
@@ -33,8 +32,7 @@ import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index";
import { CursorError } from "./pagination/cursor.js";
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
import { describeAgentHistoryMatches, rankAgentHistoryCandidates } from "./agent-history-search.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import type { SpeechToTextProvider } from "./speech/speech-provider.js";
import {
buildConfigOverrides,
isStoredAgentProviderAvailable,
@@ -51,7 +49,6 @@ import {
resolveFirstAgentPromptTitle,
} from "./agent/create-agent-title.js";
import { respondToAgentPermission } from "./agent/permission-response.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
@@ -127,7 +124,6 @@ import {
getAgentStreamEventTurnId,
type AgentPersistenceHandle,
type AgentPermissionResponse,
type AgentRunOptions,
type AgentSessionConfig,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
@@ -155,14 +151,20 @@ import {
type WorkspaceMutation,
type WorkspaceRegistry,
} from "./workspace-registry.js";
import { wrapSpokenInput } from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import {
rejectLegacyVoiceMode,
type VoiceChat,
type VoiceChatClientSession,
} from "./voice-chat/index.js";
import {
ProjectIconReader,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "../utils/project-custom-icon.js";
import { VoiceSession } from "./session/voice/voice-session.js";
import {
createDictationSession,
type DictationSession,
} from "./session/dictation/dictation-session.js";
import { CheckoutSession } from "./session/checkout/checkout-session.js";
import {
createWorkspaceGitObserverService,
@@ -505,9 +507,6 @@ export interface SessionOptions {
};
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
mcpBaseUrl?: string | null;
stt: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
providerUsageService: ProviderUsageService;
@@ -526,15 +525,7 @@ export interface SessionOptions {
getDaemonTcpHost?: () => string | null;
serviceProxyPublicBaseUrl?: string | null;
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
voice?: {
turnDetection?: Resolvable<TurnDetectionProvider | null>;
};
voiceBridge?: {
registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
unregisterVoiceSpeakHandler?: (agentId: string) => void;
registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
unregisterVoiceCallerContext?: (agentId: string) => void;
};
voiceChat?: VoiceChat | null;
dictation?: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
@@ -561,6 +552,31 @@ export type SessionLifecycleIntent =
reason: string;
};
function createClientDictationSession(input: {
options: SessionOptions["dictation"];
logger: pino.Logger;
sessionId: string;
emit(message: SessionOutboundMessage): void;
}): DictationSession {
return createDictationSession({
logger: input.logger,
sessionId: input.sessionId,
emit: input.emit,
stt: input.options?.stt ?? null,
language: input.options?.sttLanguage,
finalTimeoutMs: input.options?.finalTimeoutMs,
getSpeechReadiness: input.options?.getSpeechReadiness,
});
}
function openVoiceChatSession(
voiceChat: VoiceChat | null | undefined,
input: { emit(message: SessionOutboundMessage): void },
): VoiceChatClientSession | null {
if (!voiceChat) return null;
return voiceChat.openClientSession(input);
}
function parseClientCapabilities(
capabilities: Record<string, unknown> | null | undefined,
): ReadonlySet<ClientCapability> {
@@ -735,7 +751,8 @@ export class Session {
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
private readonly workspaceGitObserver: WorkspaceGitObserverService;
private readonly workspaceDirectory: WorkspaceDirectory;
private readonly voiceSession: VoiceSession;
private readonly dictationSession: DictationSession;
private readonly voiceChatSession: VoiceChatClientSession | null;
private readonly checkoutSession: CheckoutSession;
private readonly scheduleSession: ScheduleSession;
private readonly providerCatalogSession: ProviderCatalogSession;
@@ -781,9 +798,6 @@ export class Session {
daemonConfigStore,
pluginRuntime,
orchestrationSkills,
stt,
sttLanguage,
tts,
terminalManager,
providerSnapshotManager,
providerUsageService,
@@ -796,8 +810,7 @@ export class Session {
getDaemonTcpHost,
serviceProxyPublicBaseUrl,
resolveScriptHealth,
voice,
voiceBridge,
voiceChat,
dictation,
serverId,
daemonVersion,
@@ -1075,39 +1088,14 @@ export class Session {
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
});
this.voiceSession = new VoiceSession({
host: {
emit: (msg) => this.emit(msg),
loadAgent: (agentId) =>
ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
}),
reloadAgentSession: (agentId, overrides) =>
this.agentManager.reloadAgentSession(agentId, overrides),
sendSpokenInput: async (agentId, text) => {
await this.handleSendAgentMessage(
agentId,
text,
undefined,
undefined,
undefined,
undefined,
{ spokenInput: true },
);
},
interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId),
hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId),
},
this.dictationSession = createClientDictationSession({
options: dictation,
logger: this.sessionLogger,
sessionId: this.sessionId,
sttLanguage,
tts,
stt,
voice,
voiceBridge,
dictation,
emit: (message) => this.emit(message),
});
this.voiceChatSession = openVoiceChatSession(voiceChat, {
emit: (message) => this.emit(message),
});
this.subscribeToAgentEvents();
@@ -1467,13 +1455,6 @@ export class Session {
}
}
private hasActiveAgentRun(agentId: string | null): boolean {
if (!agentId) {
return false;
}
return this.agentManager.hasInFlightRun(agentId);
}
private handleAgentRunError(agentId: string, error: unknown, context: string): void {
const message = errorToFriendlyMessage(error);
this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`);
@@ -1719,28 +1700,6 @@ export class Session {
return;
}
if (
this.voiceSession.isActiveForAgent(event.agentId) &&
event.event.type === "permission_requested" &&
isVoicePermissionAllowed(event.event.request)
) {
const requestId = event.event.request.id;
void this.agentManager
.respondToPermission(event.agentId, requestId, {
behavior: "allow",
})
.catch((error) => {
this.sessionLogger.warn(
{
err: error,
agentId: event.agentId,
requestId,
},
"Failed to auto-allow speak tool permission in voice mode",
);
});
}
const serializedEvent = serializeAgentStreamEvent(event.event);
if (!serializedEvent) {
return;
@@ -2222,28 +2181,37 @@ export class Session {
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "voice.call.start.request":
case "voice.call.stop.request":
case "voice.call.context.update.request":
case "voice.call.transport.client":
if (!this.voiceChatSession) {
throw new Error("Voice chat is unavailable");
}
return this.voiceChatSession.handle(msg);
case "voice_audio_chunk":
return this.voiceSession.handleAudioChunk(msg);
return undefined;
case "abort_request":
return this.voiceSession.handleAbort();
return undefined;
case "audio_played":
this.voiceSession.handleAudioPlayed(msg.id);
return undefined;
case "set_voice_mode":
return this.voiceSession.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
// COMPAT(legacyVoiceMode): added in v0.7.0, remove after 2027-08-29.
rejectLegacyVoiceMode({ requestId: msg.requestId, emit: (message) => this.emit(message) });
return undefined;
case "dictation_stream_start":
return this.voiceSession.handleDictationStreamStart(msg);
return this.dictationSession.start(msg);
case "dictation_stream_chunk":
return this.voiceSession.handleDictationChunk({
return this.dictationSession.append({
dictationId: msg.dictationId,
seq: msg.seq,
audioBase64: msg.audio,
format: msg.format,
});
case "dictation_stream_finish":
return this.voiceSession.handleDictationFinish(msg.dictationId, msg.finalSeq);
return this.dictationSession.finish(msg.dictationId, msg.finalSeq);
case "dictation_stream_cancel":
this.voiceSession.handleDictationCancel(msg.dictationId);
this.dictationSession.cancel(msg.dictationId);
return undefined;
case "restart_server_request":
return this.handleRestartServerRequest(msg.requestId, msg.reason);
@@ -3467,60 +3435,6 @@ export class Session {
}
}
/**
* Handle text message to agent (with optional image attachments)
*/
private async handleSendAgentMessage(
agentId: string,
text: string,
messageId?: string,
images?: Array<{ data: string; mimeType: string }>,
attachments?: AgentAttachment[],
runOptions?: AgentRunOptions,
options?: { spokenInput?: boolean },
): Promise<{ ok: true } | { ok: false; error: string }> {
this.sessionLogger.info(
{
agentId,
textPreview: text.substring(0, 50),
imageCount: images?.length ?? 0,
attachmentCount: attachments?.length ?? 0,
},
`Sending text to agent ${agentId}${
images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""
}${
attachments && attachments.length > 0
? ` and ${attachments.length} structured attachment(s)`
: ""
}`,
);
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
const prompt = buildAgentPrompt(promptText, images, attachments);
try {
await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
prompt,
messageId,
runOptions,
// A typed or spoken message from the human answers any permission the
// agent is blocked on.
clearPendingPermissions: true,
logger: this.sessionLogger,
});
return { ok: true };
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to send agent message");
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Handle create agent request
*/
@@ -7740,7 +7654,8 @@ export class Session {
}
this.providerCatalogSession.dispose();
await this.voiceSession.cleanup();
await this.dictationSession.close();
await this.voiceChatSession?.close();
this.terminalController.dispose();
@@ -7749,6 +7664,10 @@ export class Session {
this.workspaceGitObserver.dispose();
this.workspaceFilesSession.dispose();
}
public async handleTransportDisconnect(): Promise<void> {
await this.voiceChatSession?.disconnect();
}
}
interface CloneRepositoryInput {
@@ -1,78 +0,0 @@
import { describe, expect, test } from "vitest";
import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
} from "./voice-config.js";
describe("voice MCP stdio config", () => {
test("builds stdio MCP config for voice agent", () => {
const config = buildVoiceAgentMcpServerConfig({
command: "/usr/local/bin/node",
baseArgs: ["/tmp/mcp-stdio-socket-bridge-cli.mjs"],
socketPath: "/tmp/paseo-voice.sock",
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: "/tmp/paseo-home",
},
});
expect(config.type).toBe("stdio");
expect(config.command).toBe("/usr/local/bin/node");
expect(config.args).toEqual([
"/tmp/mcp-stdio-socket-bridge-cli.mjs",
"--socket",
"/tmp/paseo-voice.sock",
]);
expect(config.env).toEqual({
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: "/tmp/paseo-home",
});
});
});
describe("voice mode prompt instructions", () => {
test("builds enabled voice instructions and preserves base prompt", () => {
const prompt = buildVoiceModeSystemPrompt("Base system prompt", true);
expect(prompt).toContain("Base system prompt");
expect(prompt).toContain("<paseo_voice_mode>");
expect(prompt).toContain("Paseo voice mode is now on.");
expect(prompt).toContain("Always use the speak tool for all user-facing communication.");
expect(prompt).toContain("</paseo_voice_mode>");
});
test("builds disabled voice instructions and supersedes previous voice block", () => {
const existing = [
"Base system prompt",
"<paseo_voice_mode>",
"legacy voice instruction",
"</paseo_voice_mode>",
].join("\n\n");
const prompt = buildVoiceModeSystemPrompt(existing, false);
expect(prompt).toContain("Base system prompt");
expect(prompt).toContain("Paseo voice mode is now off.");
expect(prompt).toContain("Ignore any earlier Paseo voice mode instructions in this thread.");
expect(prompt.match(/<paseo_voice_mode>/g)?.length ?? 0).toBe(1);
expect(prompt).not.toContain("legacy voice instruction");
});
test("strips voice blocks from persisted prompt", () => {
const existing = [
"Base system prompt",
"<paseo_voice_mode>",
"legacy voice instruction",
"</paseo_voice_mode>",
].join("\n\n");
expect(stripVoiceModeSystemPrompt(existing)).toBe("Base system prompt");
expect(
stripVoiceModeSystemPrompt(
["<paseo_voice_mode>", "legacy voice instruction", "</paseo_voice_mode>"].join("\n\n"),
),
).toBeUndefined();
});
});
@@ -0,0 +1,79 @@
import type { Logger } from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
} from "../../dictation/dictation-stream-manager.js";
import type { SpeechToTextProvider } from "../../speech/speech-provider.js";
import type { Resolvable } from "../../speech/provider-resolver.js";
import type { SpeechReadinessSnapshot } from "../../speech/speech-runtime.js";
function resolveUnavailableSpeech(readiness: SpeechReadinessSnapshot | undefined) {
if (!readiness) return null;
if (!readiness.voiceFeature.available) return readiness.voiceFeature;
if (!readiness.dictation.enabled || !readiness.dictation.available) {
return readiness.dictation;
}
return null;
}
export interface DictationSession {
start(message: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>): Promise<void>;
append(input: {
dictationId: string;
seq: number;
audioBase64: string;
format: string;
}): Promise<void>;
finish(dictationId: string, finalSeq: number): Promise<void>;
cancel(dictationId: string): void;
close(): Promise<void>;
}
export function createDictationSession(options: {
logger: Logger;
sessionId: string;
emit(message: SessionOutboundMessage): void;
stt: Resolvable<SpeechToTextProvider | null>;
language?: string;
finalTimeoutMs?: number;
getSpeechReadiness?: () => SpeechReadinessSnapshot;
}): DictationSession {
const manager = new DictationStreamManager({
logger: options.logger,
sessionId: options.sessionId,
emit(message: DictationStreamOutboundMessage) {
options.emit(message as SessionOutboundMessage);
},
stt: options.stt,
language: options.language,
finalTimeoutMs: options.finalTimeoutMs,
});
return {
async start(message) {
const readiness = options.getSpeechReadiness?.();
const unavailable = resolveUnavailableSpeech(readiness);
if (unavailable) {
options.emit({
type: "dictation_stream_error",
payload: {
dictationId: message.dictationId,
error: unavailable.message,
retryable: unavailable.retryable,
reasonCode: unavailable.reasonCode,
missingModelIds: [...unavailable.missingModelIds],
},
});
return;
}
await manager.handleStart(message.dictationId, message.format);
},
append: (input) => manager.handleChunk(input),
finish: (dictationId, finalSeq) => manager.handleFinish(dictationId, finalSeq),
cancel: (dictationId) => manager.handleCancel(dictationId),
async close() {
manager.cleanupAll();
},
};
}
@@ -126,9 +126,7 @@ function buildLocalSpeechLanguageResolutionInput(params: {
DEFAULT_STT_LANGUAGE,
]),
voiceLanguage: firstNonEmptyString([
env.PASEO_VOICE_LANGUAGE,
env.PASEO_DICTATION_LANGUAGE,
persisted.features?.voiceMode?.stt?.language,
persisted.features?.dictation?.stt?.language,
DEFAULT_STT_LANGUAGE,
]),
@@ -159,33 +157,11 @@ function buildLocalSpeechResolutionInput(params: {
),
DEFAULT_LOCAL_STT_MODEL,
]),
voiceLocalSttModel: firstDefinedValue<string>([
env.PASEO_VOICE_LOCAL_STT_MODEL,
persistedLocalFeatureModel(
providers.voiceStt.provider,
providers.voiceStt.enabled,
persisted.features?.voiceMode?.stt?.model,
),
DEFAULT_LOCAL_STT_MODEL,
]),
voiceLocalTtsModel: firstDefinedValue<string>([
env.PASEO_VOICE_LOCAL_TTS_MODEL,
persistedLocalFeatureModel(
providers.voiceTts.provider,
providers.voiceTts.enabled,
persisted.features?.voiceMode?.tts?.model,
),
DEFAULT_LOCAL_TTS_MODEL,
]),
voiceLocalSttModel: firstDefinedValue<string>([DEFAULT_LOCAL_STT_MODEL]),
voiceLocalTtsModel: firstDefinedValue<string>([DEFAULT_LOCAL_TTS_MODEL]),
...buildLocalSpeechLanguageResolutionInput({ env, persisted }),
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([
env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID,
persisted.features?.voiceMode?.tts?.speakerId,
]),
voiceLocalTtsSpeed: firstDefinedValue<string | number>([
env.PASEO_VOICE_LOCAL_TTS_SPEED,
persisted.features?.voiceMode?.tts?.speed,
]),
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([]),
voiceLocalTtsSpeed: firstDefinedValue<string | number>([]),
};
}
@@ -93,29 +93,15 @@ function buildOpenAiSttInput(params: {
]),
sttModel: firstDefined<string>([
env.STT_MODEL,
pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model),
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
]),
};
}
function buildOpenAiTtsInput(params: {
env: NodeJS.ProcessEnv;
persisted: PersistedConfig;
providers: RequestedSpeechProviders;
}): Record<string, unknown> {
const { env, persisted, providers } = params;
function buildOpenAiTtsInput(env: NodeJS.ProcessEnv): Record<string, unknown> {
return {
ttsVoice: firstDefined<string>([
env.TTS_VOICE,
pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.voice),
"alloy",
]),
ttsModel: firstDefined<string>([
env.TTS_MODEL,
pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.model),
DEFAULT_OPENAI_TTS_MODEL,
]),
ttsVoice: firstDefined<string>([env.TTS_VOICE, "alloy"]),
ttsModel: firstDefined<string>([env.TTS_MODEL, DEFAULT_OPENAI_TTS_MODEL]),
};
}
@@ -152,7 +138,7 @@ function buildOpenAiResolutionInput(params: {
env.OPENAI_BASE_URL,
]),
...buildOpenAiSttInput(params),
...buildOpenAiTtsInput(params),
...buildOpenAiTtsInput(params.env),
};
}
@@ -23,21 +23,9 @@ describe("resolveSpeechConfig", () => {
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceTurnDetection.enabled).toBe(false);
expect(result.speech.providers.voiceStt.enabled).toBe(false);
expect(result.speech.providers.voiceTts.enabled).toBe(false);
expect(result.speech.local).toEqual({
modelsDir: path.join(paseoHome, "models", "local-speech"),
models: {
@@ -48,40 +36,23 @@ describe("resolveSpeechConfig", () => {
},
});
expect(result.speech.local?.models.dictationStt).toBe("parakeet-tdt-0.6b-v2-int8");
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v2-int8");
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(0);
expect(result.speech.sttLanguages).toEqual({
dictation: "en",
voice: "en",
});
});
test("resolves feature-scoped local speech settings", () => {
test("resolves dictation settings without reading manual voice configuration", () => {
const persisted = PersistedConfigSchema.parse({
features: {
voiceMode: {
turnDetection: { provider: "local" },
stt: { provider: "openai", model: "gpt-4o-transcribe" },
},
},
providers: {
openai: { apiKey: "persisted-key" },
dictation: { stt: { provider: "local", language: "fr" } },
},
});
const env = {
PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
PASEO_VOICE_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
PASEO_VOICE_LOCAL_TTS_MODEL: "kokoro-en-v0_19",
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
PASEO_DICTATION_LANGUAGE: "es",
PASEO_VOICE_LANGUAGE: "pt",
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
OPENAI_API_KEY: "env-key",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_DICTATION_STT_PROVIDER: "local",
PASEO_VOICE_TTS_PROVIDER: "local",
} as NodeJS.ProcessEnv;
const result = resolveSpeechConfig({
@@ -96,8 +67,7 @@ describe("resolveSpeechConfig", () => {
dictationStt: "parakeet-tdt-0.6b-v2-int8",
voiceStt: "parakeet-tdt-0.6b-v2-int8",
voiceTts: "kokoro-en-v0_19",
voiceTtsSpeakerId: 5,
voiceTtsSpeed: 1.35,
voiceTtsSpeakerId: 0,
},
});
expect(result.speech.providers.dictationStt).toEqual({
@@ -105,71 +75,19 @@ describe("resolveSpeechConfig", () => {
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "openai",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: true,
enabled: true,
});
expect(result.speech.local?.models.dictationStt).toBe("parakeet-tdt-0.6b-v2-int8");
expect(result.speech.local?.models.voiceStt).toBe("parakeet-tdt-0.6b-v2-int8");
expect(result.speech.local?.models.voiceTts).toBe("kokoro-en-v0_19");
expect(result.speech.local?.models.voiceTtsSpeakerId).toBe(5);
expect(result.speech.local?.models.voiceTtsSpeed).toBe(1.35);
expect(result.speech.sttLanguages).toEqual({
dictation: "es",
voice: "pt",
});
expect(result.openai?.stt?.apiKey).toBe("persisted-key");
expect(result.openai?.tts?.apiKey).toBe("persisted-key");
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
});
test("resolves STT language from env, settings, and voice-to-dictation fallback", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: {
stt: {
language: "fr",
},
},
voiceMode: {
stt: {
language: "de",
},
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {
PASEO_DICTATION_LANGUAGE: "es",
PASEO_VOICE_LANGUAGE: " ",
} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.voiceStt.enabled).toBe(false);
expect(result.speech.providers.voiceTurnDetection.enabled).toBe(false);
expect(result.speech.providers.voiceTts.enabled).toBe(false);
expect(result.speech.sttLanguages).toEqual({
dictation: "es",
voice: "es",
});
});
test("respects disabled dictation and voice mode feature flags", () => {
test("respects disabled dictation", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: { enabled: false },
voiceMode: { enabled: false },
},
});
@@ -184,20 +102,8 @@ describe("resolveSpeechConfig", () => {
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.providers.voiceTurnDetection.enabled).toBe(false);
expect(result.speech.providers.voiceStt.enabled).toBe(false);
expect(result.speech.providers.voiceTts.enabled).toBe(false);
});
});
@@ -65,12 +65,6 @@ function buildFeatureProviderInputs(params: {
env: NodeJS.ProcessEnv;
persisted: PersistedConfig;
}): Record<keyof RequestedSpeechProviders, FeatureProviderInputs> {
const voiceModeEnabled = resolveOptionalBooleanFlag(
firstSpeechDefinedValue<string | boolean>([
params.env.PASEO_VOICE_MODE_ENABLED,
params.persisted.features?.voiceMode?.enabled,
]),
);
return {
dictationStt: {
configuredValue: firstSpeechDefinedValue<string>([
@@ -85,25 +79,16 @@ function buildFeatureProviderInputs(params: {
),
},
voiceTurnDetection: {
configuredValue: firstSpeechDefinedValue<string>([
params.env.PASEO_VOICE_TURN_DETECTION_PROVIDER,
params.persisted.features?.voiceMode?.turnDetection?.provider,
]),
enabled: voiceModeEnabled,
configuredValue: undefined,
enabled: false,
},
voiceStt: {
configuredValue: firstSpeechDefinedValue<string>([
params.env.PASEO_VOICE_STT_PROVIDER,
params.persisted.features?.voiceMode?.stt?.provider,
]),
enabled: voiceModeEnabled,
configuredValue: undefined,
enabled: false,
},
voiceTts: {
configuredValue: firstSpeechDefinedValue<string>([
params.env.PASEO_VOICE_TTS_PROVIDER,
params.persisted.features?.voiceMode?.tts?.provider,
]),
enabled: voiceModeEnabled,
configuredValue: undefined,
enabled: false,
},
};
}
@@ -37,9 +37,7 @@ interface TestPaseoDaemonOptions {
cleanup?: boolean;
openai?: PaseoOpenAIConfig;
speech?: PaseoSpeechConfig;
voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"];
voiceLlmProviderExplicit?: boolean;
voiceLlmModel?: string | null;
manualVoice?: PaseoDaemonConfig["manualVoice"];
dictationFinalTimeoutMs?: number;
auth?: PaseoDaemonConfig["auth"];
pushNotificationSender?: PushNotificationSender;
@@ -194,9 +192,7 @@ async function prepareTestDaemonConfig(
trustedProxies: options.trustedProxies,
openai: options.openai,
speech: options.speech,
voiceLlmProvider: options.voiceLlmProvider ?? null,
voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false,
voiceLlmModel: options.voiceLlmModel ?? null,
manualVoice: options.manualVoice,
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
downloadTokenTtlMs: options.downloadTokenTtlMs,
agentProfiles: options.agentProfiles,
@@ -0,0 +1,249 @@
import { describe, expect, test, vi } from "vitest";
import type {
SessionOutboundMessage,
VoiceAppContext,
VoiceCallOutboundMessage,
} from "@getpaseo/protocol/messages";
import { createVoiceChat, rejectLegacyVoiceMode } from "./index.js";
import type {
VoiceCallProvider,
VoiceProviderCall,
VoiceProviderTerminal,
} from "./internal/provider.js";
function deferred<T>() {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createProviderCall(input?: {
terminal?: ReturnType<typeof deferred<VoiceProviderTerminal>>;
onStop?: () => void;
}): VoiceProviderCall {
const terminal = input?.terminal ?? deferred<VoiceProviderTerminal>();
let stopped = false;
return {
transport: { kind: "webrtc", answerSdp: "answer-sdp" },
closed: terminal.promise,
async updateContext() {},
async handleTransportMessage() {},
async stop() {
if (stopped) return;
stopped = true;
input?.onStop?.();
terminal.resolve({ type: "closed" });
},
};
}
function startRequest(requestId = "start-1") {
return {
type: "voice.call.start.request" as const,
requestId,
context: { workspaceId: "workspace-1", agentId: null },
transports: [{ kind: "webrtc" as const, offerSdp: "offer-sdp" }],
};
}
function createClient(provider: VoiceCallProvider) {
const messages: VoiceCallOutboundMessage[] = [];
const voice = createVoiceChat({ provider });
const client = voice.openClientSession({
emit: (message) => messages.push(message),
});
return { voice, client, messages };
}
function acceptedCallId(messages: VoiceCallOutboundMessage[], requestId = "start-1"): string {
const response = messages.find(
(message) =>
message.type === "voice.call.start.response" && message.payload.requestId === requestId,
);
if (response?.type !== "voice.call.start.response" || !response.payload.callId) {
throw new Error(`Expected accepted start ${requestId}`);
}
return response.payload.callId;
}
describe("voice call provider boundary", () => {
test("runs a WebRTC-shaped provider through the provider-neutral core", async () => {
const starts: VoiceAppContext[] = [];
const contexts: VoiceAppContext[] = [];
const transportMessages: unknown[] = [];
let stops = 0;
const provider: VoiceCallProvider = {
getReadiness: () => ({ ready: true }),
async start(input) {
starts.push(input.context);
input.emit({
type: "event",
event: { type: "notice", id: "provider-ready", text: "Provider ready" },
});
input.emit({ type: "transport", data: { type: "candidate", value: "server-data" } });
const call = createProviderCall({ onStop: () => (stops += 1) });
return {
...call,
async handleTransportMessage(data) {
transportMessages.push(data);
},
async updateContext(context) {
contexts.push(context);
},
};
},
};
const { client, messages } = createClient(provider);
await client.handle(startRequest());
const callId = acceptedCallId(messages);
await client.handle({
type: "voice.call.transport.client",
callId,
data: { type: "candidate", value: "client-data" },
});
await client.handle({
type: "voice.call.context.update.request",
requestId: "context-1",
callId,
context: { workspaceId: "workspace-2", agentId: "agent-2" },
});
await client.handle({ type: "voice.call.stop.request", requestId: "stop-1", callId });
expect(starts).toEqual([{ workspaceId: "workspace-1", agentId: null }]);
expect(contexts).toEqual([{ workspaceId: "workspace-2", agentId: "agent-2" }]);
expect(transportMessages).toEqual([{ type: "candidate", value: "client-data" }]);
expect(stops).toBe(1);
expect(messages).toContainEqual(
expect.objectContaining({
type: "voice.call.event",
payload: expect.objectContaining({
event: expect.objectContaining({ id: "provider-ready" }),
}),
}),
);
expect(messages).toContainEqual({
type: "voice.call.transport.server",
payload: { callId, data: { type: "candidate", value: "server-data" } },
});
});
test("reserves startup before awaiting the provider", async () => {
const gate = deferred<void>();
const start = vi.fn(async () => {
await gate.promise;
return createProviderCall();
});
const { client, messages } = createClient({ getReadiness: () => ({ ready: true }), start });
const first = client.handle(startRequest("start-1"));
await client.handle(startRequest("start-2"));
gate.resolve();
await first;
expect(start).toHaveBeenCalledTimes(1);
expect(messages).toContainEqual({
type: "voice.call.start.response",
payload: {
requestId: "start-2",
accepted: false,
callId: null,
error: "A voice call is already active.",
},
});
});
test("disposes a provider call that finishes starting after client close", async () => {
const gate = deferred<void>();
let sawAbort = false;
let stops = 0;
const provider: VoiceCallProvider = {
getReadiness: () => ({ ready: true }),
async start(input) {
await gate.promise;
sawAbort = input.signal.aborted;
return createProviderCall({ onStop: () => (stops += 1) });
},
};
const { client } = createClient(provider);
const starting = client.handle(startRequest());
const closing = client.disconnect();
gate.resolve();
await Promise.all([starting, closing]);
expect(sawAbort).toBe(true);
expect(stops).toBe(1);
});
test.each([
[{ type: "closed" } as const, "ended", null],
[{ type: "failed", error: "upstream expired" } as const, "failed", "upstream expired"],
])("ends the call when the provider signals %s", async (terminal, lifecycle, error) => {
const providerTerminal = deferred<VoiceProviderTerminal>();
const provider: VoiceCallProvider = {
getReadiness: () => ({ ready: true }),
async start() {
return createProviderCall({ terminal: providerTerminal });
},
};
const { client, messages } = createClient(provider);
await client.handle(startRequest());
const callId = acceptedCallId(messages);
providerTerminal.resolve(terminal);
await vi.waitFor(() => {
expect(messages).toContainEqual({
type: "voice.call.state",
payload: { callId, lifecycle, input: "idle", error },
});
});
await client.handle(startRequest("start-2"));
expect(acceptedCallId(messages, "start-2")).toBeTruthy();
});
test("cleanup is idempotent across concurrent stop and close", async () => {
let stops = 0;
const provider: VoiceCallProvider = {
getReadiness: () => ({ ready: true }),
async start() {
return createProviderCall({ onStop: () => (stops += 1) });
},
};
const { client, messages } = createClient(provider);
await client.handle(startRequest());
const callId = acceptedCallId(messages);
await Promise.all([
client.handle({ type: "voice.call.stop.request", requestId: "stop-1", callId }),
client.disconnect(),
client.disconnect(),
]);
expect(stops).toBe(1);
await client.handle(startRequest("start-2"));
expect(acceptedCallId(messages, "start-2")).toBeTruthy();
});
});
describe("legacy voice mode tombstone", () => {
test("rejects without starting a compatibility path", () => {
const messages: SessionOutboundMessage[] = [];
rejectLegacyVoiceMode({ requestId: "request-1", emit: (message) => messages.push(message) });
expect(messages).toEqual([
{
type: "set_voice_mode_response",
payload: {
requestId: "request-1",
enabled: false,
agentId: null,
accepted: false,
error: "Update Paseo to use voice chat.",
},
},
]);
});
});
@@ -0,0 +1,421 @@
import { v4 as uuidv4 } from "uuid";
import type {
SessionOutboundMessage,
VoiceAppContext,
VoiceCallInboundMessage,
VoiceCallOutboundMessage,
VoiceCallState,
VoiceCallTransportOffer,
} from "@getpaseo/protocol/messages";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import type {
VoiceCallProvider,
VoiceProviderCall,
VoiceProviderOutput,
VoiceProviderTerminal,
} from "./internal/provider.js";
const UPDATE_APP_MESSAGE = "Update Paseo to use voice chat.";
const CALL_ENDED_MESSAGE = "Voice call is no longer active.";
export interface VoiceChatClientSession {
handle(message: VoiceCallInboundMessage): Promise<void>;
disconnect(): Promise<void>;
close(): Promise<void>;
}
export interface VoiceChat {
openClientSession(input: {
emit(message: VoiceCallOutboundMessage): void;
}): VoiceChatClientSession;
close(): Promise<void>;
}
export interface CreateVoiceChatOptions {
provider: VoiceCallProvider | null;
}
interface ActiveCall {
callId: string;
context: VoiceAppContext;
providerCall: VoiceProviderCall;
state: VoiceCallState;
}
interface StartingCall {
kind: "starting";
callId: string;
controller: AbortController;
completion: Promise<void>;
}
interface ActiveCallLifecycle {
kind: "active";
call: ActiveCall;
}
interface StoppingCall {
kind: "stopping";
call: ActiveCall;
completion: Promise<void>;
}
type CallLifecycle = { kind: "idle" } | StartingCall | ActiveCallLifecycle | StoppingCall;
export function createVoiceChat(options: CreateVoiceChatOptions): VoiceChat {
const clients = new Set<VoiceChatClientSession>();
function openClientSession(input: {
emit(message: VoiceCallOutboundMessage): void;
}): VoiceChatClientSession {
const client = createClientSession({ options, input });
clients.add(client);
return {
handle: client.handle,
disconnect: client.disconnect,
async close() {
clients.delete(client);
await client.close();
},
};
}
return {
openClientSession,
async close() {
const activeClients = [...clients];
clients.clear();
await Promise.all(activeClients.map((client) => client.close()));
},
};
}
function createClientSession(params: {
options: CreateVoiceChatOptions;
input: {
emit(message: VoiceCallOutboundMessage): void;
};
}): VoiceChatClientSession {
const { options, input } = params;
let lifecycle: CallLifecycle = { kind: "idle" };
function isCurrentCall(call: ActiveCall): boolean {
return lifecycle.kind === "active" && lifecycle.call === call;
}
function emitState(call: ActiveCall, patch: Partial<VoiceCallState>): void {
call.state = { ...call.state, ...patch };
input.emit({ type: "voice.call.state", payload: call.state });
}
function emitProviderOutput(call: ActiveCall, output: VoiceProviderOutput): void {
if (!isCurrentCall(call)) return;
if (output.type === "state") {
emitState(call, {
...(output.input ? { input: output.input } : {}),
...(output.error !== undefined ? { error: output.error } : {}),
});
return;
}
if (output.type === "event") {
input.emit({
type: "voice.call.event",
payload: { callId: call.callId, event: output.event },
});
return;
}
input.emit({
type: "voice.call.transport.server",
payload: { callId: call.callId, data: output.data },
});
}
function emitStartRejected(requestId: string, error: string): void {
input.emit({
type: "voice.call.start.response",
payload: { requestId, accepted: false, callId: null, error },
});
}
function validateStart(
message: Extract<VoiceCallInboundMessage, { type: "voice.call.start.request" }>,
): { provider: VoiceCallProvider; transportOffers: VoiceCallTransportOffer[] } | null {
if (lifecycle.kind !== "idle") {
emitStartRejected(message.requestId, "A voice call is already active.");
return null;
}
const provider = options.provider;
if (!provider) {
emitStartRejected(message.requestId, "Configure a voice call provider to use voice chat.");
return null;
}
const readiness = provider.getReadiness();
if (!readiness.ready) {
emitStartRejected(message.requestId, readiness.reason);
return null;
}
if (!message.transports || message.transports.length === 0) {
emitStartRejected(message.requestId, UPDATE_APP_MESSAGE);
return null;
}
return { provider, transportOffers: message.transports };
}
async function finishStart(inputParams: {
provider: VoiceCallProvider;
transportOffers: VoiceCallTransportOffer[];
message: Extract<VoiceCallInboundMessage, { type: "voice.call.start.request" }>;
starting: StartingCall;
}): Promise<void> {
const { provider, transportOffers, message, starting } = inputParams;
const pendingOutputs: VoiceProviderOutput[] = [];
let call: ActiveCall | null = null;
try {
const providerCall = await provider.start({
callId: starting.callId,
context: message.context,
transportOffers,
signal: starting.controller.signal,
emit(output) {
if (call) emitProviderOutput(call, output);
else pendingOutputs.push(output);
},
});
const selectedWasOffered = transportOffers.some(
(offer) => offer.kind === providerCall.transport.kind,
);
const startWasCanceled = starting.controller.signal.aborted || lifecycle !== starting;
if (!selectedWasOffered || startWasCanceled) {
await providerCall.stop();
if (!selectedWasOffered) {
throw new Error(
`Voice provider selected an unoffered '${providerCall.transport.kind}' transport`,
);
}
throw new Error("Voice call start was canceled.");
}
call = {
callId: starting.callId,
context: message.context,
providerCall,
state: {
callId: starting.callId,
lifecycle: "active",
input: "idle",
error: null,
},
};
lifecycle = { kind: "active", call };
input.emit({
type: "voice.call.start.response",
payload: {
requestId: message.requestId,
accepted: true,
callId: call.callId,
transport: providerCall.transport,
error: null,
},
});
input.emit({ type: "voice.call.state", payload: call.state });
for (const output of pendingOutputs) emitProviderOutput(call, output);
watchProviderTerminal(call);
} catch (error) {
if (lifecycle === starting) lifecycle = { kind: "idle" };
emitStartRejected(message.requestId, getErrorMessage(error));
}
}
async function start(
message: Extract<VoiceCallInboundMessage, { type: "voice.call.start.request" }>,
): Promise<void> {
const validated = validateStart(message);
if (!validated) return;
const starting: StartingCall = {
kind: "starting",
callId: uuidv4(),
controller: new AbortController(),
completion: Promise.resolve(),
};
lifecycle = starting;
starting.completion = finishStart({ ...validated, message, starting });
await starting.completion;
}
function watchProviderTerminal(call: ActiveCall): void {
void call.providerCall.closed.then(
(terminal) => finishProviderTerminal(call, terminal),
(error) => finishProviderTerminal(call, { type: "failed", error: getErrorMessage(error) }),
);
}
async function finishProviderTerminal(
call: ActiveCall,
terminal: VoiceProviderTerminal,
): Promise<void> {
if (lifecycle.kind !== "active" || lifecycle.call !== call) return;
const completion = Promise.resolve().then(async () => {
try {
await call.providerCall.stop();
} catch (error) {
terminal = { type: "failed", error: getErrorMessage(error) };
}
finishStoppedCall(call, terminal);
return undefined;
});
lifecycle = { kind: "stopping", call, completion };
await completion;
}
async function stopCurrentCall(): Promise<void> {
if (lifecycle.kind === "idle") return;
if (lifecycle.kind === "starting") {
const starting = lifecycle;
starting.controller.abort();
await starting.completion;
return;
}
if (lifecycle.kind === "stopping") {
await lifecycle.completion;
return;
}
const { call } = lifecycle;
const completion = Promise.resolve().then(async () => {
let terminal: VoiceProviderTerminal = { type: "closed" };
try {
await call.providerCall.stop();
} catch (error) {
terminal = { type: "failed", error: getErrorMessage(error) };
}
finishStoppedCall(call, terminal);
return undefined;
});
lifecycle = { kind: "stopping", call, completion };
await completion;
}
function finishStoppedCall(call: ActiveCall, terminal: VoiceProviderTerminal): void {
if (lifecycle.kind !== "stopping" || lifecycle.call !== call) return;
lifecycle = { kind: "idle" };
emitState(
call,
terminal.type === "failed"
? { lifecycle: "failed", input: "idle", error: terminal.error }
: { lifecycle: "ended", input: "idle", error: null },
);
}
function activeCallFor(message: VoiceCallInboundMessage): ActiveCall | null {
if (!("callId" in message)) return null;
if (lifecycle.kind !== "active" || lifecycle.call.callId !== message.callId) return null;
return lifecycle.call;
}
async function handle(message: VoiceCallInboundMessage): Promise<void> {
if (message.type === "voice.call.start.request") {
await start(message);
return;
}
const call = activeCallFor(message);
if (!call) {
emitInactiveCallResponse(message);
return;
}
if (message.type === "voice.call.transport.client") {
await call.providerCall.handleTransportMessage(message.data);
return;
}
if (message.type === "voice.call.context.update.request") {
try {
await call.providerCall.updateContext(message.context);
call.context = message.context;
input.emit({
type: "voice.call.context.update.response",
payload: {
requestId: message.requestId,
callId: call.callId,
accepted: true,
error: null,
},
});
} catch (error) {
input.emit({
type: "voice.call.context.update.response",
payload: {
requestId: message.requestId,
callId: call.callId,
accepted: false,
error: getErrorMessage(error),
},
});
}
return;
}
try {
await stopCurrentCall();
input.emit({
type: "voice.call.stop.response",
payload: {
requestId: message.requestId,
callId: message.callId,
accepted: true,
error: null,
},
});
} catch (error) {
input.emit({
type: "voice.call.stop.response",
payload: {
requestId: message.requestId,
callId: message.callId,
accepted: false,
error: getErrorMessage(error),
},
});
}
}
function emitInactiveCallResponse(message: VoiceCallInboundMessage): void {
if (message.type === "voice.call.stop.request") {
input.emit({
type: "voice.call.stop.response",
payload: {
requestId: message.requestId,
callId: message.callId,
accepted: false,
error: CALL_ENDED_MESSAGE,
},
});
}
if (message.type === "voice.call.context.update.request") {
input.emit({
type: "voice.call.context.update.response",
payload: {
requestId: message.requestId,
callId: message.callId,
accepted: false,
error: CALL_ENDED_MESSAGE,
},
});
}
}
return { handle, disconnect: stopCurrentCall, close: stopCurrentCall };
}
export function rejectLegacyVoiceMode(input: {
requestId: string | undefined;
emit(message: SessionOutboundMessage): void;
}): void {
if (!input.requestId) return;
input.emit({
type: "set_voice_mode_response",
payload: {
requestId: input.requestId,
enabled: false,
agentId: null,
accepted: false,
error: UPDATE_APP_MESSAGE,
},
});
}
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import type { AgentPermissionRequest } from "../../agent/agent-sdk-types.js";
import { isManualVoicePermissionAllowed } from "../providers/manual/permission.js";
function buildRequest(partial: Partial<AgentPermissionRequest>): AgentPermissionRequest {
return {
@@ -13,30 +13,34 @@ function buildRequest(partial: Partial<AgentPermissionRequest>): AgentPermission
};
}
describe("isVoicePermissionAllowed", () => {
describe("isManualVoicePermissionAllowed", () => {
test("allows direct speak tool names across provider conventions", () => {
const result = isVoicePermissionAllowed(buildRequest({ name: "speak" }));
const result = isManualVoicePermissionAllowed(buildRequest({ name: "speak" }));
expect(result).toBe(true);
expect(isVoicePermissionAllowed(buildRequest({ name: "paseo_voice.speak" }))).toBe(true);
expect(isVoicePermissionAllowed(buildRequest({ name: "mcp__paseo_voice__speak" }))).toBe(true);
expect(isManualVoicePermissionAllowed(buildRequest({ name: "paseo_voice.speak" }))).toBe(true);
expect(isManualVoicePermissionAllowed(buildRequest({ name: "mcp__paseo_voice__speak" }))).toBe(
true,
);
});
test("denies non-speak tool names", () => {
expect(isVoicePermissionAllowed(buildRequest({ name: "mcp__paseo__create_agent" }))).toBe(
expect(isManualVoicePermissionAllowed(buildRequest({ name: "mcp__paseo__create_agent" }))).toBe(
false,
);
expect(isManualVoicePermissionAllowed(buildRequest({ name: "paseo_create_agent" }))).toBe(
false,
);
expect(isVoicePermissionAllowed(buildRequest({ name: "paseo_create_agent" }))).toBe(false);
});
test("denies non-tool permission kinds", () => {
const result = isVoicePermissionAllowed(
const result = isManualVoicePermissionAllowed(
buildRequest({ kind: "mode", name: "mcp__paseo__create_agent" }),
);
expect(result).toBe(false);
});
test("denies wrapper tools even when metadata references speak", () => {
const denied = isVoicePermissionAllowed(
const denied = isManualVoicePermissionAllowed(
buildRequest({
name: "codextool",
metadata: {
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import { buildManualVoiceSystemPrompt, wrapSpokenInput } from "../providers/manual/prompt.js";
describe("manual voice prompt", () => {
test("requires user-facing output to use the provider-owned speak tool", () => {
const prompt = buildManualVoiceSystemPrompt();
expect(prompt).toContain("Paseo voice orchestrator");
expect(prompt).toContain("Use the Paseo tools as your primary way");
expect(prompt).toContain("delegate to an appropriate agent by default");
expect(prompt).toContain("Always use the speak tool for all user-facing communication.");
});
test("marks transcribed input without changing the conversation owner", () => {
expect(wrapSpokenInput("Please check the build")).toContain(
"<spoken-input>\nPlease check the build\n</spoken-input>",
);
});
});
@@ -0,0 +1,40 @@
import type {
VoiceAppContext,
VoiceCallEvent,
VoiceCallTransport,
VoiceCallTransportOffer,
} from "@getpaseo/protocol/messages";
export type VoiceProviderReadiness = { ready: true } | { ready: false; reason: string };
export type VoiceProviderOutput =
| {
type: "state";
input?: "idle" | "listening" | "speech_detected";
error?: string | null;
}
| { type: "event"; event: VoiceCallEvent }
| { type: "transport"; data: unknown };
export type VoiceProviderTerminal = { type: "closed" } | { type: "failed"; error: string };
export interface VoiceProviderCall {
readonly transport: VoiceCallTransport;
readonly closed: Promise<VoiceProviderTerminal>;
updateContext(context: VoiceAppContext): Promise<void>;
handleTransportMessage(data: unknown): Promise<void>;
stop(): Promise<void>;
}
export interface VoiceProviderStartInput {
callId: string;
context: VoiceAppContext;
transportOffers: readonly VoiceCallTransportOffer[];
signal: AbortSignal;
emit(output: VoiceProviderOutput): void;
}
export interface VoiceCallProvider {
getReadiness(): VoiceProviderReadiness;
start(input: VoiceProviderStartInput): Promise<VoiceProviderCall>;
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { resolveManualVoiceOrchestratorConfig } from "./config.js";
describe("manual voice orchestrator configuration", () => {
it("uses every configured value exactly", () => {
expect(
resolveManualVoiceOrchestratorConfig({
provider: "codex",
model: "gpt-exact",
modeId: "full-access",
thinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
model: "gpt-exact",
modeId: "full-access",
thinkingOptionId: "high",
});
});
it.each([
{ provider: null, model: null, modeId: null, thinkingOptionId: null },
{ provider: "codex", model: "gpt-exact", modeId: null, thinkingOptionId: "high" },
{ provider: null, model: null, modeId: "full-access", thinkingOptionId: null },
])("rejects missing or partial required selection", (settings) => {
expect(resolveManualVoiceOrchestratorConfig(settings)).toBeNull();
});
});
@@ -0,0 +1,254 @@
import path from "node:path";
import { z } from "zod";
import type { AgentSessionConfig } from "../../../agent/agent-sdk-types.js";
import type { OpenAiSpeechProviderConfig } from "../../../speech/providers/openai/config.js";
import type { LocalSpeechProviderConfig } from "../../../speech/providers/local/config.js";
import {
DEFAULT_LOCAL_STT_MODEL,
DEFAULT_LOCAL_TTS_MODEL,
LocalSttModelIdSchema,
LocalTtsModelIdSchema,
} from "../../../speech/providers/local/models.js";
import type { RequestedSpeechProviders } from "../../../speech/speech-types.js";
const SpeechProviderSchema = z.enum(["local", "openai"]);
const OpenAiVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
const OpenAiTtsModelSchema = z.enum(["tts-1", "tts-1-hd"]);
export const ManualVoicePersistedConfigSchema = z
.object({
orchestrator: z
.object({
provider: z.string().optional(),
model: z.string().min(1).optional(),
modeId: z.string().trim().min(1).optional(),
thinkingOptionId: z.string().trim().min(1).optional(),
})
.strict()
.optional(),
stt: z
.object({
provider: SpeechProviderSchema.optional(),
model: z.string().min(1).optional(),
language: z.string().trim().min(1).optional(),
})
.strict()
.optional(),
turnDetection: z.object({ provider: SpeechProviderSchema.optional() }).strict().optional(),
tts: z
.object({
provider: SpeechProviderSchema.optional(),
model: OpenAiTtsModelSchema.or(LocalTtsModelIdSchema).optional(),
voice: OpenAiVoiceSchema.optional(),
speakerId: z.number().int().optional(),
speed: z.number().finite().optional(),
})
.strict()
.optional(),
})
.strict();
export type ManualVoicePersistedConfig = z.infer<typeof ManualVoicePersistedConfigSchema>;
export interface ManualVoiceOrchestratorConfig {
provider: AgentSessionConfig["provider"];
model?: string;
modeId: string;
thinkingOptionId?: string;
}
export interface ManualVoiceOrchestratorSettings {
provider: AgentSessionConfig["provider"] | null;
model: string | null;
modeId: string | null;
thinkingOptionId: string | null;
}
export interface ManualVoiceConfig {
orchestrator: ManualVoiceOrchestratorSettings;
speech: {
providers: RequestedSpeechProviders;
sttLanguages: { dictation: string; voice: string };
local?: LocalSpeechProviderConfig;
};
openai?: OpenAiSpeechProviderConfig;
}
interface ManualVoiceProviderCredentials {
openai?: {
apiKey?: string;
baseUrl?: string;
stt?: { apiKey?: string; baseUrl?: string };
tts?: { apiKey?: string; baseUrl?: string };
};
local?: { modelsDir?: string };
}
interface ManualVoiceResolutionInput {
paseoHome: string;
persisted: ManualVoicePersistedConfig | undefined;
providers: ManualVoiceProviderCredentials | undefined;
}
interface SelectedManualSpeechProviders {
stt: z.infer<typeof SpeechProviderSchema>;
turnDetection: z.infer<typeof SpeechProviderSchema>;
tts: z.infer<typeof SpeechProviderSchema>;
}
function selectManualSpeechProviders(
persisted: ManualVoicePersistedConfig | undefined,
): SelectedManualSpeechProviders {
return {
stt: persisted?.stt?.provider ?? "local",
turnDetection: persisted?.turnDetection?.provider ?? "local",
tts: persisted?.tts?.provider ?? "local",
};
}
function resolveManualLocalConfig(
input: ManualVoiceResolutionInput,
selected: SelectedManualSpeechProviders,
): LocalSpeechProviderConfig | undefined {
if (![selected.stt, selected.turnDetection, selected.tts].includes("local")) return undefined;
const voiceStt = LocalSttModelIdSchema.parse(
selected.stt === "local"
? (input.persisted?.stt?.model ?? DEFAULT_LOCAL_STT_MODEL)
: DEFAULT_LOCAL_STT_MODEL,
);
const voiceTts = LocalTtsModelIdSchema.parse(
selected.tts === "local"
? (input.persisted?.tts?.model ?? DEFAULT_LOCAL_TTS_MODEL)
: DEFAULT_LOCAL_TTS_MODEL,
);
const models: LocalSpeechProviderConfig["models"] = {
dictationStt: DEFAULT_LOCAL_STT_MODEL,
voiceStt,
voiceTts,
};
if (input.persisted?.tts?.speakerId !== undefined) {
models.voiceTtsSpeakerId = input.persisted.tts.speakerId;
} else if (voiceTts === "kokoro-en-v0_19") {
models.voiceTtsSpeakerId = 0;
}
if (input.persisted?.tts?.speed !== undefined) {
models.voiceTtsSpeed = input.persisted.tts.speed;
}
return {
modelsDir:
input.providers?.local?.modelsDir ?? path.join(input.paseoHome, "models", "local-speech"),
models,
};
}
function resolveManualOpenAiStt(
input: ManualVoiceResolutionInput,
provider: string,
): NonNullable<OpenAiSpeechProviderConfig["stt"]> | undefined {
if (provider !== "openai") return undefined;
const credentials = input.providers?.openai;
const sttApiKey = credentials?.stt?.apiKey ?? credentials?.apiKey;
if (!sttApiKey) return undefined;
const config: NonNullable<OpenAiSpeechProviderConfig["stt"]> = { apiKey: sttApiKey };
const baseUrl = credentials?.stt?.baseUrl ?? credentials?.baseUrl;
if (baseUrl) config.baseUrl = baseUrl;
if (input.persisted?.stt?.model) config.model = input.persisted.stt.model;
return config;
}
function resolveManualOpenAiTts(
input: ManualVoiceResolutionInput,
provider: string,
): NonNullable<OpenAiSpeechProviderConfig["tts"]> | undefined {
if (provider !== "openai") return undefined;
const credentials = input.providers?.openai;
const apiKey = credentials?.tts?.apiKey ?? credentials?.apiKey;
if (!apiKey) return undefined;
const config: NonNullable<OpenAiSpeechProviderConfig["tts"]> = {
apiKey,
model: OpenAiTtsModelSchema.parse(input.persisted?.tts?.model ?? "tts-1"),
voice: input.persisted?.tts?.voice ?? "alloy",
responseFormat: "pcm",
};
const baseUrl = credentials?.tts?.baseUrl ?? credentials?.baseUrl;
if (baseUrl) config.baseUrl = baseUrl;
return config;
}
function resolveManualOpenAiConfig(
input: ManualVoiceResolutionInput,
selected: SelectedManualSpeechProviders,
): OpenAiSpeechProviderConfig | undefined {
const stt = resolveManualOpenAiStt(input, selected.stt);
const tts = resolveManualOpenAiTts(input, selected.tts);
if (!stt && !tts) return undefined;
return { stt, tts };
}
function resolveManualSpeechConfig(
input: ManualVoiceResolutionInput,
selected: SelectedManualSpeechProviders,
local: LocalSpeechProviderConfig | undefined,
): ManualVoiceConfig["speech"] {
const persisted = input.persisted;
const speech: ManualVoiceConfig["speech"] = {
providers: {
dictationStt: { provider: "local", explicit: false, enabled: false },
voiceTurnDetection: {
provider: selected.turnDetection,
explicit: persisted?.turnDetection?.provider !== undefined,
},
voiceStt: {
provider: selected.stt,
explicit: persisted?.stt?.provider !== undefined,
},
voiceTts: {
provider: selected.tts,
explicit: persisted?.tts?.provider !== undefined,
},
},
sttLanguages: { dictation: "en", voice: persisted?.stt?.language ?? "en" },
};
if (local) speech.local = local;
return speech;
}
function resolveManualOrchestrator(
persisted: ManualVoicePersistedConfig | undefined,
): ManualVoiceOrchestratorSettings {
return {
provider: persisted?.orchestrator?.provider ?? null,
model: persisted?.orchestrator?.model ?? null,
modeId: persisted?.orchestrator?.modeId ?? null,
thinkingOptionId: persisted?.orchestrator?.thinkingOptionId ?? null,
};
}
export function resolveManualVoiceConfig(input: {
paseoHome: string;
persisted: ManualVoicePersistedConfig | undefined;
providers: ManualVoiceProviderCredentials | undefined;
}): ManualVoiceConfig {
const selected = selectManualSpeechProviders(input.persisted);
const local = resolveManualLocalConfig(input, selected);
const openai = resolveManualOpenAiConfig(input, selected);
const config: ManualVoiceConfig = {
orchestrator: resolveManualOrchestrator(input.persisted),
speech: resolveManualSpeechConfig(input, selected, local),
};
if (openai) config.openai = openai;
return config;
}
export function resolveManualVoiceOrchestratorConfig(
settings: ManualVoiceOrchestratorSettings,
): ManualVoiceOrchestratorConfig | null {
if (!settings.provider || !settings.modeId) return null;
return {
provider: settings.provider,
modeId: settings.modeId,
...(settings.model ? { model: settings.model } : {}),
...(settings.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
};
}
@@ -2,19 +2,19 @@ import { EventEmitter } from "node:events";
import pino from "pino";
import { describe, expect, test, vi } from "vitest";
import { VoiceSession, type VoiceSessionHost } from "./voice-session.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { SessionOutboundMessage } from "../../messages.js";
import { ManualVoiceCall, type ManualVoiceCallHost } from "./index.js";
import type { ManagedAgent } from "../../../agent/agent-manager.js";
import type { SessionOutboundMessage } from "../../../messages.js";
import type {
SpeechToTextProvider,
StreamingTranscriptionCommittedEvent,
StreamingTranscriptionEvent,
StreamingTranscriptionSession,
} from "../../speech/speech-provider.js";
} from "../../../speech/speech-provider.js";
import type {
TurnDetectionProvider,
TurnDetectionSession,
} from "../../speech/turn-detection-provider.js";
} from "../../../speech/turn-detection-provider.js";
const VOICE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
@@ -54,7 +54,7 @@ class FakeVoiceSttSession extends EventEmitter implements StreamingTranscription
}
}
interface FakeVoiceHost extends VoiceSessionHost {
interface FakeVoiceHost extends ManualVoiceCallHost {
readonly emitted: SessionOutboundMessage[];
readonly spokenInput: Array<{ agentId: string; text: string }>;
}
@@ -70,7 +70,6 @@ function createFakeHost(): FakeVoiceHost {
},
loadAgent: async (agentId) =>
({ id: agentId, config: { systemPrompt: undefined } }) as unknown as ManagedAgent,
reloadAgentSession: async (agentId) => ({ id: agentId }) as unknown as ManagedAgent,
sendSpokenInput: async (agentId, text) => {
spokenInput.push({ agentId, text });
},
@@ -91,7 +90,7 @@ function createVoiceSession() {
createSession: vi.fn(() => detector),
};
const host = createFakeHost();
const voiceSession = new VoiceSession({
const voiceSession = new ManualVoiceCall({
host,
logger: pino({ level: "silent" }),
sessionId: "voice-session-test",
@@ -109,14 +108,14 @@ async function settle(): Promise<void> {
await Promise.resolve();
}
describe("VoiceSession streaming transcription", () => {
describe("manual voice call streaming transcription", () => {
test("surfaces a refused voice-mode agent interruption", async () => {
const { voiceSession, host } = createVoiceSession();
host.interruptAgentIfRunning = vi.fn(async () => {
throw new Error("active run cancellation was not acknowledged");
});
await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID);
await voiceSession.start(VOICE_AGENT_ID);
await expect(voiceSession.handleAbort()).rejects.toThrow(
"active run cancellation was not acknowledged",
@@ -139,7 +138,7 @@ describe("VoiceSession streaming transcription", () => {
test("delivers the streaming final transcript to the agent exactly once", async () => {
const { voiceSession, detector, sttSession, host } = createVoiceSession();
await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID);
await voiceSession.start(VOICE_AGENT_ID);
detector.emit("speech_started");
await settle();
detector.emit("speech_stopped");
@@ -178,7 +177,7 @@ describe("VoiceSession streaming transcription", () => {
try {
const { voiceSession, detector, sttSession, host } = createVoiceSession();
await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID);
await voiceSession.start(VOICE_AGENT_ID);
detector.emit("speech_started");
await settle();
detector.emit("speech_stopped");
@@ -205,7 +204,7 @@ describe("VoiceSession streaming transcription", () => {
test("filters a low-confidence streaming final without submitting to the agent", async () => {
const { voiceSession, detector, sttSession, host } = createVoiceSession();
await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID);
await voiceSession.start(VOICE_AGENT_ID);
detector.emit("speech_started");
await settle();
detector.emit("speech_stopped");
@@ -2,25 +2,25 @@ import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import type pino from "pino";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import { TTSManager } from "../../agent/tts-manager.js";
import { STTManager } from "../../agent/stt-manager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech/speech-provider.js";
import type { TurnDetectionProvider } from "../../speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "../../agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "../../agent/recordings-debug.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
} from "../../dictation/dictation-stream-manager.js";
import { createVoiceTurnController, type VoiceTurnController } from "./voice-turn-controller.js";
import { buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt } from "../../voice-config.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js";
import type { LocalSpeechModelId } from "../../speech/providers/local/models.js";
import { toResolver, type Resolvable } from "../../speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "../../speech/speech-runtime.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../../messages.js";
import { TTSManager } from "./synthesis.js";
import { STTManager } from "./transcription.js";
import type {
SpeechToTextProvider,
TextToSpeechProvider,
} from "../../../speech/speech-provider.js";
import type { TurnDetectionProvider } from "../../../speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "../../../agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "../../../agent/recordings-debug.js";
import { createVoiceTurnController, type VoiceTurnController } from "./turn.js";
import type { VoiceSpeakHandler } from "./speak.js";
import type { ManagedAgent } from "../../../agent/agent-manager.js";
import type { LocalSpeechModelId } from "../../../speech/providers/local/models.js";
import { toResolver, type Resolvable } from "../../../speech/provider-resolver.js";
import type {
SpeechReadinessSnapshot,
SpeechReadinessState,
} from "../../../speech/speech-runtime.js";
const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1;
@@ -34,10 +34,6 @@ const AgentIdSchema = z.guid();
type ProcessingPhase = "idle" | "transcribing";
interface VoiceModeBaseConfig {
systemPrompt?: string;
}
interface AudioBufferState {
chunks: Buffer[];
format: string;
@@ -64,26 +60,6 @@ interface VoiceFeatureUnavailableContext {
missingModelIds: LocalSpeechModelId[];
}
interface VoiceFeatureUnavailableResponseMetadata {
reasonCode?: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"];
retryable?: boolean;
missingModelIds?: LocalSpeechModelId[];
}
class VoiceFeatureUnavailableError extends Error {
readonly reasonCode: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"];
readonly retryable: boolean;
readonly missingModelIds: LocalSpeechModelId[];
constructor(context: VoiceFeatureUnavailableContext) {
super(context.message);
this.name = "VoiceFeatureUnavailableError";
this.reasonCode = context.reasonCode;
this.retryable = context.retryable;
this.missingModelIds = [...context.missingModelIds];
}
}
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
@@ -114,25 +90,21 @@ function convertPCMToWavBuffer(
}
/**
* The agent-facing operations VoiceSession needs from the session that owns it.
* VoiceSession owns all voice/audio state; it reaches back through this narrow
* The agent-facing operations the manual call needs from its owner.
* ManualVoiceCall owns all voice/audio state; it reaches back through this narrow
* seam only to deliver transcripts to the agent, drive TTS playback, and
* abort/inspect the active agent run.
*/
export interface VoiceSessionHost {
export interface ManualVoiceCallHost {
emit(msg: SessionOutboundMessage): void;
loadAgent(agentId: string): Promise<ManagedAgent>;
reloadAgentSession(
agentId: string,
overrides: Partial<AgentSessionConfig>,
): Promise<ManagedAgent>;
sendSpokenInput(agentId: string, text: string): Promise<void>;
interruptAgentIfRunning(agentId: string): Promise<void>;
hasActiveAgentRun(agentId: string | null): boolean;
}
export interface VoiceSessionOptions {
host: VoiceSessionHost;
export interface ManualVoiceCallOptions {
host: ManualVoiceCallHost;
logger: pino.Logger;
sessionId: string;
sttLanguage?: string;
@@ -144,26 +116,20 @@ export interface VoiceSessionOptions {
voiceBridge?: {
registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
unregisterVoiceSpeakHandler?: (agentId: string) => void;
registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
unregisterVoiceCallerContext?: (agentId: string) => void;
};
dictation?: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
getSpeechReadiness?: () => SpeechReadinessSnapshot;
};
getSpeechReadiness?: () => SpeechReadinessSnapshot;
onFatalError?: (error: Error) => void;
}
/**
* Owns the voice half of a client session: speech-to-text/text-to-speech
* managers, dictation streaming, the barge-in audio-buffering state machine,
* managers, the barge-in audio-buffering state machine,
* voice-turn detection, and the MCP voice bridge. The session delegates the
* voice/dictation/abort message types here and otherwise knows nothing about
* voice/abort message types here and otherwise knows nothing about
* audio buffering or processing phases.
*/
export class VoiceSession {
private readonly host: VoiceSessionHost;
export class ManualVoiceCall {
private readonly host: ManualVoiceCallHost;
private readonly sessionLogger: pino.Logger;
private readonly sessionId: string;
private readonly sttLanguage: string;
@@ -174,7 +140,6 @@ export class VoiceSession {
private isVoiceMode = false;
private speechInProgress = false;
private readonly dictationStreamManager: DictationStreamManager;
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
private voiceTurnController: VoiceTurnController | null = null;
private voiceInputChunkCount = 0;
@@ -197,19 +162,13 @@ export class VoiceSession {
handler: VoiceSpeakHandler,
) => void;
private readonly unregisterVoiceSpeakHandler?: (agentId: string) => void;
private readonly registerVoiceCallerContext?: (
agentId: string,
context: VoiceCallerContext,
) => void;
private readonly unregisterVoiceCallerContext?: (agentId: string) => void;
private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot;
private readonly onFatalError?: (error: Error) => void;
private voiceModeAgentId: string | null = null;
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
constructor(options: VoiceSessionOptions) {
const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge, dictation } =
options;
constructor(options: ManualVoiceCallOptions) {
const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge } = options;
this.host = host;
this.sessionLogger = logger;
this.sessionId = sessionId;
@@ -219,63 +178,13 @@ export class VoiceSession {
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext;
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
this.getSpeechReadiness = dictation?.getSpeechReadiness;
this.getSpeechReadiness = options.getSpeechReadiness;
this.onFatalError = options.onFatalError;
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, {
language: sttLanguage,
});
this.dictationStreamManager = new DictationStreamManager({
logger: this.sessionLogger,
sessionId: this.sessionId,
emit: (msg) => this.handleDictationManagerMessage(msg),
stt: dictation?.stt ?? null,
language: dictation?.sttLanguage,
finalTimeoutMs: dictation?.finalTimeoutMs,
});
}
isActiveForAgent(agentId: string): boolean {
return this.isVoiceMode && this.voiceModeAgentId === agentId;
}
handleDictationChunk(params: {
dictationId: string;
seq: number;
audioBase64: string;
format: string;
}): Promise<void> {
return this.dictationStreamManager.handleChunk(params);
}
handleDictationFinish(dictationId: string, finalSeq: number): Promise<void> {
return this.dictationStreamManager.handleFinish(dictationId, finalSeq);
}
handleDictationCancel(dictationId: string): void {
this.dictationStreamManager.handleCancel(dictationId);
}
async handleDictationStreamStart(
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
): Promise<void> {
const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation");
if (unavailable) {
this.emit({
type: "dictation_stream_error",
payload: {
dictationId: msg.dictationId,
error: unavailable.message,
retryable: unavailable.retryable,
reasonCode: unavailable.reasonCode,
missingModelIds: unavailable.missingModelIds,
},
});
return;
}
await this.dictationStreamManager.handleStart(msg.dictationId, msg.format);
}
private toVoiceFeatureUnavailableContext(
@@ -289,38 +198,13 @@ export class VoiceSession {
};
}
private resolveModeReadinessState(
readiness: SpeechReadinessSnapshot,
mode: "voice_mode" | "dictation",
): SpeechReadinessState {
if (mode === "voice_mode") {
return readiness.realtimeVoice;
}
return readiness.dictation;
}
private getVoiceFeatureUnavailableResponseMetadata(
error: unknown,
): VoiceFeatureUnavailableResponseMetadata {
if (!(error instanceof VoiceFeatureUnavailableError)) {
return {};
}
return {
reasonCode: error.reasonCode,
retryable: error.retryable,
missingModelIds: error.missingModelIds,
};
}
private resolveVoiceFeatureUnavailableContext(
mode: "voice_mode" | "dictation",
): VoiceFeatureUnavailableContext | null {
private resolveVoiceFeatureUnavailableContext(): VoiceFeatureUnavailableContext | null {
const readiness = this.getSpeechReadiness?.();
if (!readiness) {
return null;
}
const modeReadiness = this.resolveModeReadinessState(readiness, mode);
const modeReadiness = readiness.realtimeVoice;
if (!modeReadiness.enabled) {
return this.toVoiceFeatureUnavailableContext(modeReadiness);
}
@@ -333,134 +217,27 @@ export class VoiceSession {
return null;
}
/**
* Handle voice mode toggle
*/
async handleSetVoiceMode(enabled: boolean, agentId?: string, requestId?: string): Promise<void> {
const startedAt = Date.now();
async start(agentId: string): Promise<void> {
if (this.isVoiceMode) return;
const unavailable = this.resolveVoiceFeatureUnavailableContext();
if (unavailable) throw new Error(unavailable.message);
const normalizedAgentId = this.parseVoiceTargetAgentId(agentId, "manual voice call");
this.voiceModeAgentId = await this.enableVoiceModeForAgent(normalizedAgentId);
try {
this.sessionLogger.info(
{ enabled, requestedAgentId: agentId ?? null, requestId: requestId ?? null },
"set_voice_mode started",
);
if (enabled) {
const unavailable = this.resolveVoiceFeatureUnavailableContext("voice_mode");
if (unavailable) {
throw new VoiceFeatureUnavailableError(unavailable);
}
const normalizedAgentId = this.parseVoiceTargetAgentId(agentId ?? "", "set_voice_mode");
if (
this.isVoiceMode &&
this.voiceModeAgentId &&
this.voiceModeAgentId !== normalizedAgentId
) {
this.sessionLogger.info(
{
previousAgentId: this.voiceModeAgentId,
nextAgentId: normalizedAgentId,
elapsedMs: Date.now() - startedAt,
},
"set_voice_mode disabling previous active voice agent",
);
await this.disableVoiceModeForActiveAgent(true);
}
if (!this.isVoiceMode || this.voiceModeAgentId !== normalizedAgentId) {
this.sessionLogger.info(
{ agentId: normalizedAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode enabling voice for agent",
);
const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId);
this.voiceModeAgentId = refreshedAgentId;
this.sessionLogger.info(
{ agentId: refreshedAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode agent enable complete",
);
}
this.sessionLogger.info(
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode starting voice turn controller",
);
await this.startVoiceTurnController();
this.sessionLogger.info(
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode voice turn controller started",
);
this.isVoiceMode = true;
this.sessionLogger.info(
{
agentId: this.voiceModeAgentId,
elapsedMs: Date.now() - startedAt,
},
"Voice mode enabled for existing agent",
);
if (requestId) {
this.emit({
type: "set_voice_mode_response",
payload: {
requestId,
enabled: true,
agentId: this.voiceModeAgentId,
accepted: true,
error: null,
},
});
}
return;
}
this.sessionLogger.info(
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode disabling active voice mode",
);
await this.disableVoiceModeForActiveAgent(true);
this.isVoiceMode = false;
this.sessionLogger.info({ elapsedMs: Date.now() - startedAt }, "Voice mode disabled");
if (requestId) {
this.emit({
type: "set_voice_mode_response",
payload: {
requestId,
enabled: false,
agentId: null,
accepted: true,
error: null,
},
});
}
await this.startVoiceTurnController();
this.isVoiceMode = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to set voice mode";
const unavailable = this.getVoiceFeatureUnavailableResponseMetadata(error);
this.sessionLogger.error(
{
err: error,
enabled,
requestedAgentId: agentId ?? null,
elapsedMs: Date.now() - startedAt,
},
"set_voice_mode failed",
);
if (requestId) {
this.emit({
type: "set_voice_mode_response",
payload: {
requestId,
enabled: this.isVoiceMode,
agentId: this.voiceModeAgentId,
accepted: false,
error: errorMessage,
...unavailable,
},
});
return;
}
await this.disableVoiceModeForActiveAgent();
throw error;
}
}
async stop(): Promise<void> {
if (!this.isVoiceMode && !this.voiceModeAgentId) return;
await this.disableVoiceModeForActiveAgent();
this.isVoiceMode = false;
}
private parseVoiceTargetAgentId(rawId: string, source: string): string {
const parsed = AgentIdSchema.safeParse(rawId.trim());
if (!parsed.success) {
@@ -480,67 +257,21 @@ export class VoiceSession {
this.registerVoiceBridgeForAgent(agentId);
const baseConfig: VoiceModeBaseConfig = {
systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt),
};
this.voiceModeBaseConfig = baseConfig;
const refreshOverrides: Partial<AgentSessionConfig> = {
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true),
};
try {
this.sessionLogger.info(
{ agentId, elapsedMs: Date.now() - startedAt },
"enableVoiceModeForAgent.reloadAgentSession.start",
);
const refreshed = await this.host.reloadAgentSession(agentId, refreshOverrides);
this.sessionLogger.info(
{ agentId, refreshedAgentId: refreshed.id, elapsedMs: Date.now() - startedAt },
"enableVoiceModeForAgent.reloadAgentSession.done",
);
return refreshed.id;
} catch (error) {
this.unregisterVoiceSpeakHandler?.(agentId);
this.unregisterVoiceCallerContext?.(agentId);
this.voiceModeBaseConfig = null;
throw error;
}
return existing.id;
}
private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise<void> {
private async disableVoiceModeForActiveAgent(): Promise<void> {
await this.stopVoiceTurnController();
const agentId = this.voiceModeAgentId;
if (!agentId) {
this.voiceModeBaseConfig = null;
return;
}
this.unregisterVoiceSpeakHandler?.(agentId);
this.unregisterVoiceCallerContext?.(agentId);
if (restoreAgentConfig && this.voiceModeBaseConfig) {
const baseConfig = this.voiceModeBaseConfig;
try {
await this.host.reloadAgentSession(agentId, {
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false),
});
} catch (error) {
this.sessionLogger.warn(
{ err: error, agentId },
"Failed to restore agent config while disabling voice mode",
);
}
}
this.voiceModeBaseConfig = null;
this.voiceModeAgentId = null;
}
private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void {
this.emit(msg as unknown as SessionOutboundMessage);
}
private async startVoiceTurnController(): Promise<void> {
if (this.voiceTurnController) {
this.sessionLogger.info("startVoiceTurnController skipped: already running");
@@ -631,6 +362,7 @@ export class VoiceSession {
},
onError: (error) => {
this.sessionLogger.error({ err: error }, "Voice turn controller failed");
this.onFatalError?.(error);
},
},
});
@@ -1059,12 +791,6 @@ export class VoiceSession {
},
});
});
this.registerVoiceCallerContext?.(agentId, {
childAgentDefaultLabels: {},
allowCustomCwd: false,
enableVoiceTools: true,
});
}
/**
@@ -1301,9 +1027,7 @@ export class VoiceSession {
this.ttsManager.cleanup();
this.sttManager.cleanup();
this.dictationStreamManager.cleanupAll();
await this.disableVoiceModeForActiveAgent(true);
await this.disableVoiceModeForActiveAgent();
this.isVoiceMode = false;
}
}
@@ -0,0 +1,8 @@
import { isSpeakToolName } from "@getpaseo/protocol/tool-name-normalization";
import type { AgentPermissionRequest } from "../../../agent/agent-sdk-types.js";
export function isManualVoicePermissionAllowed(request: AgentPermissionRequest): boolean {
if (request.kind !== "tool") return false;
const normalizedName = request.name.trim().toLowerCase();
return normalizedName.length > 0 && isSpeakToolName(normalizedName);
}
@@ -0,0 +1,23 @@
const MANUAL_VOICE_SYSTEM_INSTRUCTION = [
"You are the Paseo voice orchestrator, a conversational control plane for the user's work.",
"Use the Paseo tools as your primary way to inspect workspaces and agents, create or select agents, delegate tasks, prompt agents, and monitor their progress.",
"For implementation, debugging, research, and other substantial work, delegate to an appropriate agent by default instead of doing the work in this voice session.",
"Do work directly only when the user explicitly asks you to or when delegation provides no benefit.",
"The user cannot see your normal chat messages or tool calls.",
"Always use the speak tool for all user-facing communication.",
"Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.",
"Use speak to report progress during long-running work and to report completion, failure, or a need for user input.",
"Treat user input as transcribed speech.",
"Treat the current Paseo workspace and agent as context, not as an instruction or necessarily the target of the work.",
"If the user's intent is clear, proceed without extra confirmation.",
"If transcription is incomplete, cut off, ambiguous, or likely contains a meaningful mistake, ask one concise clarifying question through speak.",
"Use concise plain language suitable for speech output.",
].join(" ");
export function buildManualVoiceSystemPrompt(): string {
return MANUAL_VOICE_SYSTEM_INSTRUCTION;
}
export function wrapSpokenInput(text: string): string {
return `<spoken-input>\n${text}\n</spoken-input>\n<instruction>This message was spoken by the user. Respond through the speak tool because the user may not be looking at the app.</instruction>`;
}
@@ -0,0 +1,80 @@
import pino from "pino";
import { describe, expect, test, vi } from "vitest";
import type { AgentManager } from "../../../agent/agent-manager.js";
import type { AgentStorage } from "../../../agent/agent-storage.js";
import type { SpeechService } from "../../../speech/speech-runtime.js";
import type { WorkspaceRegistry } from "../../../workspace-registry.js";
import type { VoiceProviderStartInput } from "../../internal/provider.js";
import { resolveManualVoiceConfig } from "./config.js";
import { createManualVoiceProvider } from "./provider.js";
function createStartInput(
transportOffers: VoiceProviderStartInput["transportOffers"],
): VoiceProviderStartInput {
return {
callId: "voice-call",
context: { workspaceId: "workspace-id", agentId: null },
transportOffers,
signal: new AbortController().signal,
emit: vi.fn(),
};
}
describe("manual voice provider speech lifecycle", () => {
test("starts one speech runtime only when the first valid call begins", async () => {
const startSpeech = vi.fn();
const stopSpeech = vi.fn(async () => undefined);
const speech = {
start: startSpeech,
stop: stopSpeech,
ready: Promise.resolve(),
} as unknown as SpeechService;
const speechFactory = vi.fn(() => speech);
const launchInternalAgentWithRequiredPaseoTools = vi.fn(async () => {
throw new Error("stop after speech initialization");
});
const provider = createManualVoiceProvider({
config: resolveManualVoiceConfig({
paseoHome: "/tmp/manual-voice-provider-test",
persisted: {
orchestrator: { provider: "codex", modeId: "full-access" },
},
providers: undefined,
}),
agentManager: {
getAgent: vi.fn(() => undefined),
launchInternalAgentWithRequiredPaseoTools,
} as unknown as AgentManager,
agentStorage: {} as AgentStorage,
workspaceRegistry: {
get: vi.fn(async () => ({
workspaceId: "workspace-id",
cwd: "/tmp/workspace",
archivedAt: null,
})),
} as unknown as Pick<WorkspaceRegistry, "get">,
logger: pino({ level: "silent" }),
speechFactory,
});
expect(provider.getReadiness()).toEqual({ ready: true });
expect(speechFactory).not.toHaveBeenCalled();
await expect(provider.start(createStartInput([]))).rejects.toThrow(
"The client does not support daemon audio",
);
expect(speechFactory).not.toHaveBeenCalled();
const validInput = createStartInput([{ kind: "daemon-audio" }]);
await expect(provider.start(validInput)).rejects.toThrow("stop after speech initialization");
await expect(provider.start(validInput)).rejects.toThrow("stop after speech initialization");
expect(speechFactory).toHaveBeenCalledOnce();
expect(startSpeech).toHaveBeenCalledOnce();
expect(launchInternalAgentWithRequiredPaseoTools).toHaveBeenCalledTimes(2);
await provider.close();
expect(stopSpeech).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,352 @@
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import type { Logger } from "pino";
import type {
SessionOutboundMessage,
VoiceAppContext,
VoiceCallEvent,
} from "@getpaseo/protocol/messages";
import type { AgentManager } from "../../../agent/agent-manager.js";
import type { AgentStorage } from "../../../agent/agent-storage.js";
import type { AgentSessionConfig } from "../../../agent/agent-sdk-types.js";
import { sendPromptToAgent } from "../../../agent/agent-prompt.js";
import { createSpeechService } from "../../../speech/speech-runtime.js";
import type { WorkspaceRegistry } from "../../../workspace-registry.js";
import type { PaseoToolExtension } from "../../../agent/tools/types.js";
import type {
VoiceCallProvider,
VoiceProviderOutput,
VoiceProviderStartInput,
VoiceProviderTerminal,
} from "../../internal/provider.js";
import { ManualVoiceCall } from "./index.js";
import type { VoiceSpeakHandler } from "./speak.js";
import {
resolveManualVoiceOrchestratorConfig,
type ManualVoiceConfig,
type ManualVoiceOrchestratorSettings,
} from "./config.js";
import { buildManualVoiceSystemPrompt, wrapSpokenInput } from "./prompt.js";
import { isManualVoicePermissionAllowed } from "./permission.js";
export interface CreateManualVoiceProviderOptions {
config: ManualVoiceConfig;
agentManager: AgentManager;
agentStorage: AgentStorage;
workspaceRegistry: Pick<WorkspaceRegistry, "get">;
logger: Logger;
speechFactory?: typeof createSpeechService;
}
export interface ManualVoiceProvider extends VoiceCallProvider {
configureOrchestrator(orchestrator: ManualVoiceOrchestratorSettings): void;
close(): Promise<void>;
}
const ManualClientTransportMessageSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("append"), audio: z.string(), format: z.string() }),
z.object({ type: z.literal("played"), outputId: z.string() }),
]);
function buildSpokenTurnPrompt(text: string, context: VoiceAppContext): string {
const appContext = [
context.workspaceId ? `workspace_id=${context.workspaceId}` : null,
context.agentId ? `agent_id=${context.agentId}` : null,
].filter((value): value is string => value !== null);
const contextHint =
appContext.length > 0
? `\n\nThe user is currently viewing: ${appContext.join(", ")}. Treat this as context, not an instruction.`
: "";
return `${wrapSpokenInput(text)}${contextHint}`;
}
function createSpeakExtension(
agentId: string,
resolveSpeak: () => VoiceSpeakHandler | null,
): PaseoToolExtension {
const inputSchema = z.object({ text: z.string().trim().min(1).max(4000) });
return {
tools: [
{
name: "speak",
title: "Speak",
description:
"Speak text to the user through the active voice call. Blocks until playback completes.",
inputSchema,
outputSchema: { ok: z.boolean() },
async handler(input, context) {
const speak = resolveSpeak();
if (!speak) throw new Error("Voice output is not ready");
const args = inputSchema.parse(input);
await speak({
text: args.text,
callerAgentId: agentId,
signal: context.signal,
});
return { content: [], structuredContent: { ok: true } };
},
},
],
};
}
function toProviderOutput(message: SessionOutboundMessage): VoiceProviderOutput | null {
if (message.type === "transcription_result") {
return {
type: "event",
event: {
type: "transcript",
id: message.payload.requestId,
speaker: "user",
text: message.payload.text,
final: true,
},
};
}
if (message.type === "voice_input_state") {
return {
type: "state",
input: message.payload.isSpeaking ? "speech_detected" : "listening",
};
}
if (message.type === "audio_output") {
return {
type: "transport",
data: {
type: "audio",
outputId: message.payload.id,
audio: message.payload.audio,
format: message.payload.format,
...(message.payload.groupId ? { groupId: message.payload.groupId } : {}),
...(message.payload.chunkIndex === undefined
? {}
: { chunkIndex: message.payload.chunkIndex }),
...(message.payload.isLastChunk === undefined
? {}
: { isLastChunk: message.payload.isLastChunk }),
},
};
}
if (message.type !== "activity_log") return null;
let event: VoiceCallEvent | null = null;
if (message.payload.type === "assistant") {
event = {
type: "transcript",
id: message.payload.id,
speaker: "assistant",
text: message.payload.content,
final: true,
};
} else if (message.payload.type === "error") {
event = { type: "error", id: message.payload.id, message: message.payload.content };
} else if (message.payload.type === "system") {
event = { type: "notice", id: message.payload.id, text: message.payload.content };
}
return event ? { type: "event", event } : null;
}
export function createManualVoiceProvider(
options: CreateManualVoiceProviderOptions,
): ManualVoiceProvider {
let orchestratorSettings = options.config.orchestrator;
let speech: ReturnType<typeof createSpeechService> | null = null;
function getSpeech() {
if (!speech) {
speech = (options.speechFactory ?? createSpeechService)({
logger: options.logger,
openaiConfig: options.config.openai,
speechConfig: options.config.speech,
});
speech.start();
}
return speech;
}
return {
configureOrchestrator(orchestrator) {
orchestratorSettings = orchestrator;
},
async close() {
await speech?.stop();
speech = null;
},
getReadiness() {
if (!resolveManualVoiceOrchestratorConfig(orchestratorSettings)) {
return { ready: false, reason: "Configure the manual voice orchestrator and mode." };
}
return { ready: true };
},
async start(input) {
return startManualVoiceCall(options, getSpeech, orchestratorSettings, input);
},
};
}
async function startManualVoiceCall(
options: CreateManualVoiceProviderOptions,
getSpeech: () => ReturnType<typeof createSpeechService>,
orchestratorSettings: ManualVoiceOrchestratorSettings,
input: VoiceProviderStartInput,
) {
if (!input.transportOffers.some((transport) => transport.kind === "daemon-audio")) {
throw new Error("The client does not support daemon audio for manual voice chat");
}
const contextAgent = input.context.agentId
? options.agentManager.getAgent(input.context.agentId)
: null;
const workspaceId = input.context.workspaceId ?? contextAgent?.workspaceId ?? null;
if (!workspaceId) throw new Error("Open a workspace before starting voice chat.");
const workspace = await options.workspaceRegistry.get(workspaceId);
if (!workspace || workspace.archivedAt) throw new Error("The selected workspace is unavailable.");
const orchestrator = resolveManualVoiceOrchestratorConfig(orchestratorSettings);
if (!orchestrator) throw new Error("Configure the manual voice orchestrator and mode.");
if (input.signal.aborted) throw new Error("Voice call start was canceled.");
const speech = getSpeech();
await speech.ready;
if (input.signal.aborted) throw new Error("Voice call start was canceled.");
const agentId = uuidv4();
let context = input.context;
let speak: VoiceSpeakHandler | null = null;
const config: AgentSessionConfig = {
provider: orchestrator.provider,
cwd: workspace.cwd,
internal: true,
systemPrompt: buildManualVoiceSystemPrompt(),
modeId: orchestrator.modeId,
...(orchestrator.model ? { model: orchestrator.model } : {}),
...(orchestrator.thinkingOptionId ? { thinkingOptionId: orchestrator.thinkingOptionId } : {}),
};
let manual: ManualVoiceCall | null = null;
let unsubscribeAgent: (() => void) | null = null;
let internalAgent: Awaited<
ReturnType<AgentManager["launchInternalAgentWithRequiredPaseoTools"]>
> | null = null;
let terminal: VoiceProviderTerminal | null = null;
let resolveClosed: (terminal: VoiceProviderTerminal) => void = () => undefined;
const closed = new Promise<VoiceProviderTerminal>((resolve) => {
resolveClosed = resolve;
});
function settle(next: VoiceProviderTerminal): void {
if (terminal) return;
terminal = next;
resolveClosed(next);
}
try {
internalAgent = await options.agentManager.launchInternalAgentWithRequiredPaseoTools({
agentId,
config,
workspaceId: workspace.workspaceId,
labels: { surface: "voice" },
initialTitle: "Voice chat",
tools: {
caller: { childAgentDefaultLabels: {}, allowCustomCwd: false },
extension: createSpeakExtension(agentId, () => speak),
},
systemPromptBehavior: "authoritative",
});
const agent = internalAgent.agent;
if (input.signal.aborted) throw new Error("Voice call start was canceled.");
manual = new ManualVoiceCall({
host: {
emit(message) {
const output = toProviderOutput(message);
if (output) input.emit(output);
},
async loadAgent() {
const loaded = options.agentManager.getAgent(agent.id);
if (!loaded) throw new Error(`Voice agent ${agent.id} is unavailable`);
return loaded;
},
async sendSpokenInput(id, text) {
await sendPromptToAgent({
agentManager: options.agentManager,
agentStorage: options.agentStorage,
agentId: id,
prompt: buildSpokenTurnPrompt(text, context),
clearPendingPermissions: true,
logger: options.logger,
});
},
async interruptAgentIfRunning(id) {
await options.agentManager.cancelAgentRun(id);
},
hasActiveAgentRun: (id) => (id ? options.agentManager.hasInFlightRun(id) : false),
},
logger: options.logger.child({ module: "voice-chat", provider: "manual" }),
sessionId: input.callId,
sttLanguage: speech.resolveSttLanguage(),
tts: speech.resolveTts,
stt: speech.resolveStt,
voice: { turnDetection: speech.resolveTurnDetection },
onFatalError(error) {
settle({ type: "failed", error: error.message });
},
voiceBridge: {
registerVoiceSpeakHandler(_id, handler) {
speak = handler;
},
unregisterVoiceSpeakHandler() {
speak = null;
},
},
getSpeechReadiness: speech.getReadiness,
});
unsubscribeAgent = options.agentManager.subscribe(
(event) => {
if (event.type !== "agent_stream" || event.event.type !== "permission_requested") return;
if (!isManualVoicePermissionAllowed(event.event.request)) return;
void options.agentManager
.respondToPermission(agent.id, event.event.request.id, { behavior: "allow" })
.catch((error) =>
options.logger.warn({ err: error }, "Failed to allow voice speak tool"),
);
},
{ agentId: agent.id, replayState: false },
);
await manual.start(agent.id);
if (input.signal.aborted) throw new Error("Voice call start was canceled.");
input.emit({ type: "state", input: "listening" });
let stopped = false;
async function stop(): Promise<void> {
if (stopped) return;
unsubscribeAgent?.();
unsubscribeAgent = null;
await manual?.stop();
await manual?.cleanup();
await internalAgent?.dispose();
stopped = true;
settle({ type: "closed" });
}
return {
transport: { kind: "daemon-audio" as const },
closed,
async handleTransportMessage(data: unknown) {
const message = ManualClientTransportMessageSchema.parse(data);
if (message.type === "append") {
await manual?.handleAudioChunk({
type: "voice_audio_chunk",
audio: message.audio,
format: message.format,
isLast: false,
});
return;
}
manual?.handleAudioPlayed(message.outputId);
},
async updateContext(nextContext: VoiceAppContext) {
context = nextContext;
},
stop,
};
} catch (error) {
unsubscribeAgent?.();
await manual?.cleanup().catch(() => undefined);
await internalAgent?.dispose().catch(() => undefined);
throw error;
}
}
@@ -0,0 +1,5 @@
export type VoiceSpeakHandler = (params: {
text: string;
callerAgentId: string;
signal?: AbortSignal;
}) => Promise<void>;
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest";
import pino from "pino";
import { Readable } from "node:stream";
import { TTSManager } from "./tts-manager.js";
import type { TextToSpeechProvider } from "../speech/speech-provider.js";
import type { SessionOutboundMessage } from "../messages.js";
import { TTSManager } from "./synthesis.js";
import type { TextToSpeechProvider } from "../../../speech/speech-provider.js";
import type { SessionOutboundMessage } from "../../../messages.js";
type AudioOutputMessage = Extract<SessionOutboundMessage, { type: "audio_output" }>;
@@ -1,9 +1,9 @@
import type pino from "pino";
import type { Readable } from "node:stream";
import { v4 as uuidv4 } from "uuid";
import type { TextToSpeechProvider } from "../speech/speech-provider.js";
import { toResolver, type Resolvable } from "../speech/provider-resolver.js";
import type { SessionOutboundMessage } from "../messages.js";
import type { TextToSpeechProvider } from "../../../speech/speech-provider.js";
import { toResolver, type Resolvable } from "../../../speech/provider-resolver.js";
import type { SessionOutboundMessage } from "../../../messages.js";
interface PendingPlayback {
resolve: () => void;
@@ -2,14 +2,12 @@ import { describe, expect, it } from "vitest";
import pino from "pino";
import { EventEmitter } from "node:events";
import { STTManager } from "./stt-manager.js";
import { PersistedConfigSchema } from "../persisted-config.js";
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
import { STTManager } from "./transcription.js";
import type {
SpeechToTextProvider,
StreamingTranscriptionSession,
TranscriptionResult,
} from "../speech/speech-provider.js";
} from "../../../speech/speech-provider.js";
type SessionParams = Parameters<SpeechToTextProvider["createSession"]>[0];
type StreamingOn = StreamingTranscriptionSession["on"];
@@ -96,116 +94,16 @@ class SequencedFakeStt implements SpeechToTextProvider {
}
describe("STTManager", () => {
function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string {
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: params.env ?? ({} as NodeJS.ProcessEnv),
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
});
return result.speech.sttLanguages.voice;
}
async function transcribeWithResolvedVoiceLanguage(params: {
env?: NodeJS.ProcessEnv;
persisted?: unknown;
}): Promise<FakeStt> {
it("passes the configured provider language to STT", async () => {
const fakeStt = new FakeStt({ text: "hi", isLowConfidence: false });
const manager = new STTManager("s1", pino({ level: "silent" }), fakeStt, {
language: resolveVoiceLanguage(params),
language: "pt",
});
await manager.transcribe(Buffer.alloc(2), "audio/pcm;rate=24000");
return fakeStt;
}
it("defaults to English when no voice language config is set", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({});
expect(fakeStt.lastLanguage).toBe("en");
});
it("uses PASEO_VOICE_LANGUAGE over PASEO_DICTATION_LANGUAGE", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_VOICE_LANGUAGE: "pt",
PASEO_DICTATION_LANGUAGE: "es",
} as NodeJS.ProcessEnv,
});
expect(fakeStt.lastLanguage).toBe("pt");
});
it("uses PASEO_DICTATION_LANGUAGE when PASEO_VOICE_LANGUAGE is unset", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_DICTATION_LANGUAGE: "pt",
} as NodeJS.ProcessEnv,
});
expect(fakeStt.lastLanguage).toBe("pt");
});
it("treats empty voice language env vars as unset", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_VOICE_LANGUAGE: "",
PASEO_DICTATION_LANGUAGE: " ",
} as NodeJS.ProcessEnv,
});
expect(fakeStt.lastLanguage).toBe("en");
});
it("uses settings voice STT language when no env var is set", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
persisted: {
features: {
voiceMode: {
stt: {
language: "fr",
},
},
},
},
});
expect(fakeStt.lastLanguage).toBe("fr");
});
it("uses env voice language over settings voice STT language", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
env: {
PASEO_VOICE_LANGUAGE: "pt",
} as NodeJS.ProcessEnv,
persisted: {
features: {
voiceMode: {
stt: {
language: "fr",
},
},
},
},
});
expect(fakeStt.lastLanguage).toBe("pt");
});
it("falls back to settings dictation STT language when voice language is unset", async () => {
const fakeStt = await transcribeWithResolvedVoiceLanguage({
persisted: {
features: {
dictation: {
stt: {
language: "es",
},
},
},
},
});
expect(fakeStt.lastLanguage).toBe("es");
});
it("returns empty text for low-confidence transcriptions", async () => {
const manager = new STTManager(
"s1",
@@ -1,9 +1,9 @@
import type pino from "pino";
import type { SpeechToTextProvider, TranscriptionResult } from "../speech/speech-provider.js";
import { toResolver, type Resolvable } from "../speech/provider-resolver.js";
import { maybePersistDebugAudio } from "./stt-debug.js";
import { parsePcm16MonoWav, parsePcmRateFromFormat } from "../speech/audio.js";
import { Pcm16MonoResampler } from "./pcm16-resampler.js";
import type { SpeechToTextProvider, TranscriptionResult } from "../../../speech/speech-provider.js";
import { toResolver, type Resolvable } from "../../../speech/provider-resolver.js";
import { maybePersistDebugAudio } from "../../../agent/stt-debug.js";
import { parsePcm16MonoWav, parsePcmRateFromFormat } from "../../../speech/audio.js";
import { Pcm16MonoResampler } from "../../../agent/pcm16-resampler.js";
interface TranscriptionMetadata {
agentId?: string;
@@ -7,12 +7,12 @@ import type {
StreamingTranscriptionCommittedEvent,
StreamingTranscriptionEvent,
StreamingTranscriptionSession,
} from "../../speech/speech-provider.js";
} from "../../../speech/speech-provider.js";
import type {
TurnDetectionProvider,
TurnDetectionSession,
} from "../../speech/turn-detection-provider.js";
import { createVoiceTurnController } from "./voice-turn-controller.js";
} from "../../../speech/turn-detection-provider.js";
import { createVoiceTurnController } from "./turn.js";
class FakeTurnDetectionSession extends EventEmitter implements TurnDetectionSession {
public readonly requiredSampleRate = 16000;
@@ -515,7 +515,7 @@ describe("voice turn controller", () => {
}
});
it("reports STT errors and attempts one reconnect", async () => {
it("reports an STT failure only after its one reconnect is exhausted", async () => {
const harness = createControllerHarness();
await harness.controller.start();
@@ -523,12 +523,17 @@ describe("voice turn controller", () => {
firstSession?.emitError(new Error("stream failed"));
await settleSerialQueue();
expect(harness.onError).toHaveBeenCalledTimes(1);
expect(harness.onError).toHaveBeenCalledWith(
expect.objectContaining({ message: "stream failed" }),
);
expect(harness.onError).not.toHaveBeenCalled();
expect(firstSession?.closeCount).toBe(1);
expect(harness.sttSessions).toHaveLength(2);
expect(harness.sttSessions[1]?.connectCount).toBe(1);
harness.sttSessions[1]?.emitError(new Error("replacement stream failed"));
await settleSerialQueue();
expect(harness.onError).toHaveBeenCalledOnce();
expect(harness.onError).toHaveBeenCalledWith(
expect.objectContaining({ message: "replacement stream failed" }),
);
});
});
@@ -2,14 +2,14 @@ import { Buffer } from "node:buffer";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
import { Pcm16MonoResampler } from "../../agent/pcm16-resampler.js";
import { parsePcmRateFromFormat } from "../../speech/audio.js";
import { Pcm16MonoResampler } from "../../../agent/pcm16-resampler.js";
import { parsePcmRateFromFormat } from "../../../speech/audio.js";
import type {
SpeechToTextProvider,
StreamingTranscriptionEvent,
StreamingTranscriptionSession,
} from "../../speech/speech-provider.js";
import type { TurnDetectionProvider } from "../../speech/turn-detection-provider.js";
} from "../../../speech/speech-provider.js";
import type { TurnDetectionProvider } from "../../../speech/turn-detection-provider.js";
const VOICE_FINAL_TRANSCRIPT_TIMEOUT_MS = 10_000;
@@ -253,9 +253,9 @@ export function createVoiceTurnController(params: {
}
function handleSttError(error: unknown): void {
fail(error);
params.logger.warn({ err: error }, "voice_turn.stt_error");
if (reconnectAttemptedForTurn) {
fail(error);
sttSession?.close();
sttSession = null;
return;
@@ -1,77 +0,0 @@
const VOICE_PROMPT_BLOCK_START = "<paseo_voice_mode>";
const VOICE_PROMPT_BLOCK_END = "</paseo_voice_mode>";
const VOICE_AGENT_SYSTEM_INSTRUCTION = [
"Paseo voice mode is now on.",
"You are the Paseo voice assistant.",
"The user cannot see your chat messages or tool calls.",
"Always use the speak tool for all user-facing communication.",
"Before calling any non-speak tool, first call speak with a short acknowledgement of what you heard and what you will do next.",
"For long-running work, use speak to provide progress updates before and during execution.",
"Treat the user input as transcribed speech.",
"If the user intent is clear, proceed without extra confirmation.",
"If the transcription seems incomplete, cut off, ambiguous, or may contain a non-obvious mistake or misspelling, ask a clarifying question via speak before taking action.",
"Use concise plain language suitable for speech output.",
].join(" ");
const VOICE_AGENT_DISABLED_INSTRUCTION = [
"Paseo voice mode is now off.",
"Ignore any earlier Paseo voice mode instructions in this thread.",
].join(" ");
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function makeVoicePromptBlockRegex(): RegExp {
return new RegExp(
`${escapeRegExp(VOICE_PROMPT_BLOCK_START)}[\\s\\S]*?${escapeRegExp(VOICE_PROMPT_BLOCK_END)}`,
"g",
);
}
export function stripVoiceModeSystemPrompt(existing?: string): string | undefined {
const trimmed = existing?.trim();
if (!trimmed) {
return undefined;
}
const stripped = trimmed.replace(makeVoicePromptBlockRegex(), "").trim();
return stripped.length > 0 ? stripped : undefined;
}
export function buildVoiceModeSystemPrompt(existing: string | undefined, enabled: boolean): string {
const basePrompt = stripVoiceModeSystemPrompt(existing);
const voiceInstruction = enabled
? VOICE_AGENT_SYSTEM_INSTRUCTION
: VOICE_AGENT_DISABLED_INSTRUCTION;
const voiceBlock = [VOICE_PROMPT_BLOCK_START, voiceInstruction, VOICE_PROMPT_BLOCK_END].join(
"\n",
);
return [basePrompt, voiceBlock]
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
}
export function wrapSpokenInput(text: string): string {
return `<spoken-input>\n${text}\n</spoken-input>\n<instruction>This message was spoken by the user. Respond using the speak tool only, not normal messages, because the user may not be looking at the chat.</instruction>`;
}
export function buildVoiceAgentMcpServerConfig(params: {
command: string;
baseArgs: string[];
socketPath: string;
env?: Record<string, string>;
}): {
type: "stdio";
command: string;
args: string[];
env?: Record<string, string>;
} {
return {
type: "stdio",
command: params.command,
args: [...params.baseArgs, "--socket", params.socketPath],
...(params.env ? { env: params.env } : {}),
};
}
@@ -14,32 +14,28 @@ type SessionMessage<T extends SessionOutboundMessage["type"]> = Extract<
{ type: T }
>;
function makeTranscriptionHandler(resolve: (value: string) => void) {
return (msg: SessionMessage<"transcription_result">) => {
if (msg.type !== "transcription_result") return;
const text = (msg.payload.text ?? "").trim();
function makeTranscriptionHandler(callId: string, resolve: (value: string) => void) {
return (msg: SessionMessage<"voice.call.event">) => {
if (msg.payload.callId !== callId || msg.payload.event.type !== "transcript") return;
if (msg.payload.event.speaker !== "user") return;
const text = msg.payload.event.text.trim();
if (!text) return;
resolve(text);
};
}
function makeErrorHandler(reject: (error: Error) => void) {
return (msg: SessionMessage<"activity_log">) => {
if (msg.type !== "activity_log") return;
if (msg.payload.type !== "error") return;
reject(new Error(msg.payload.content));
function makeErrorHandler(callId: string, reject: (error: Error) => void) {
return (msg: SessionMessage<"voice.call.event">) => {
if (msg.payload.callId !== callId || msg.payload.event.type !== "error") return;
reject(new Error(msg.payload.event.message));
};
}
function makeSpeakToolHandler(resolve: (value: string) => void) {
return (msg: SessionMessage<"agent_stream">) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.event.type !== "timeline") return;
const item = msg.payload.event.item;
if (item.type !== "tool_call") return;
const name = item.name ?? "";
if (!name.toLowerCase().includes("speak")) return;
resolve(name);
function makeSpokenReplyHandler(callId: string, resolve: (value: string) => void) {
return (msg: SessionMessage<"voice.call.event">) => {
if (msg.payload.callId !== callId || msg.payload.event.type !== "transcript") return;
if (msg.payload.event.speaker !== "assistant") return;
resolve(msg.payload.event.text);
};
}
@@ -87,9 +83,24 @@ function waitForSignal<T>(
voiceTts: { provider: "openai", explicit: true },
},
},
voiceLlmProvider: "codex",
voiceLlmProviderExplicit: true,
voiceLlmModel: "gpt-5.4-mini",
manualVoice: {
orchestrator: {
provider: "codex",
model: "gpt-5.4-mini",
modeId: "full-access",
thinkingOptionId: null,
},
openai: { stt: { apiKey: openaiApiKey! }, tts: { apiKey: openaiApiKey! } },
speech: {
providers: {
dictationStt: { provider: "openai", explicit: false, enabled: false },
voiceTurnDetection: { provider: "local", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
sttLanguages: { dictation: "en", voice: "en" },
},
},
});
}, 120000);
@@ -109,30 +120,49 @@ function waitForSignal<T>(
);
const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-local-agent-"));
const workspaceResult = await ctx.client.createWorkspace({
source: { kind: "directory", path: voiceCwd },
title: "Voice local agent",
});
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? "Failed to create voice test workspace");
}
const targetAgent = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd: voiceCwd,
},
workspaceId: workspaceResult.workspace.workspaceId,
});
const voiceMode = await ctx.client.setVoiceMode(true, targetAgent.id);
expect(voiceMode.accepted).toBe(true);
const voiceCall = await ctx.client.startVoiceCall(
{ workspaceId: workspaceResult.workspace.workspaceId, agentId: targetAgent.id },
[{ kind: "daemon-audio" }],
);
const transcriptionPromise = waitForSignal<string>(120000, (resolve, reject) => {
const offTranscript = ctx.client.on(
"transcription_result",
makeTranscriptionHandler(resolve),
"voice.call.event",
makeTranscriptionHandler(voiceCall.callId, resolve),
);
const offError = ctx.client.on(
"voice.call.event",
makeErrorHandler(voiceCall.callId, reject),
);
const offError = ctx.client.on("activity_log", makeErrorHandler(reject));
return () => {
offTranscript();
offError();
};
});
const speakToolPromise = waitForSignal<string>(120000, (resolve, reject) => {
const offStream = ctx.client.on("agent_stream", makeSpeakToolHandler(resolve));
const offError = ctx.client.on("activity_log", makeErrorHandler(reject));
const spokenReplyPromise = waitForSignal<string>(120000, (resolve, reject) => {
const offStream = ctx.client.on(
"voice.call.event",
makeSpokenReplyHandler(voiceCall.callId, resolve),
);
const offError = ctx.client.on(
"voice.call.event",
makeErrorHandler(voiceCall.callId, reject),
);
return () => {
offStream();
offError();
@@ -150,21 +180,20 @@ function waitForSignal<T>(
const chunkBytes = 4800;
for (let offset = 0; offset < pcm.length; offset += chunkBytes) {
const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes));
const isLast = offset + chunkBytes >= pcm.length;
await ctx.client.sendVoiceAudioChunk(
chunk.toString("base64"),
"audio/pcm;rate=24000;bits=16",
isLast,
);
ctx.client.sendVoiceCallTransportMessage(voiceCall.callId, {
type: "append",
audio: chunk.toString("base64"),
format: "audio/pcm;rate=24000;bits=16",
});
}
const [transcript, speakToolName] = await Promise.all([transcriptionPromise, speakToolPromise]);
const [transcript, spokenReply] = await Promise.all([transcriptionPromise, spokenReplyPromise]);
await ctx.client.setVoiceMode(false).catch(() => undefined);
await ctx.client.stopVoiceCall(voiceCall.callId).catch(() => undefined);
rmSync(voiceCwd, { recursive: true, force: true });
expect(transcript.length).toBeGreaterThan(0);
expect(speakToolName.toLowerCase()).toContain("speak");
expect(spokenReply.toLowerCase()).toContain("local agent check");
const agents = await ctx.client.fetchAgents();
expect(agents.some((agent) => String(agent.labels?.surface ?? "") === "voice")).toBe(false);
@@ -1,16 +0,0 @@
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
import { isSpeakToolName } from "@getpaseo/protocol/tool-name-normalization";
/** Voice assistant policy: only allow the speak tool. */
export function isVoicePermissionAllowed(request: AgentPermissionRequest): boolean {
if (request.kind !== "tool") {
return false;
}
const normalizedName = request.name.trim().toLowerCase();
if (!normalizedName) {
return false;
}
return isSpeakToolName(normalizedName);
}
-12
View File
@@ -1,12 +0,0 @@
export type VoiceSpeakHandler = (params: {
text: string;
callerAgentId: string;
signal?: AbortSignal;
}) => Promise<void>;
export interface VoiceCallerContext {
childAgentDefaultLabels?: Record<string, string>;
lockedCwd?: string;
allowCustomCwd?: boolean;
enableVoiceTools?: boolean;
}
@@ -47,6 +47,7 @@ const sessionMock = vi.hoisted(() => {
class MockSession {
cleanup = vi.fn(async () => {});
handleTransportDisconnect = vi.fn(async () => {});
handleMessage = vi.fn(async () => {});
handleBinaryFrame = vi.fn((_frame: unknown) => {});
supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true);
@@ -470,6 +471,19 @@ function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): {
};
}
function holdTransportDisconnect(session: (typeof sessionMock.instances)[number]): {
finish: () => void;
} {
let finish = () => {};
session.handleTransportDisconnect.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
return { finish: () => finish() };
}
describe("relay external socket reconnect behavior", () => {
beforeEach(() => {
sessionMock.instances.length = 0;
@@ -930,6 +944,25 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("does not schedule stale cleanup when reconnect wins voice disconnect cleanup", async () => {
const server = createServer();
const clientId = "cid-reconnect-during-voice-cleanup";
const socket1 = new MockSocket();
await attachDirectAndHello({ server, socket: socket1, clientId });
const session = sessionMock.instances[0];
const disconnect = holdTransportDisconnect(session);
socket1.emit("close", 1006, "");
await vi.waitFor(() => expect(session.handleTransportDisconnect).toHaveBeenCalledOnce());
const socket2 = new MockSocket();
await attachDirectAndHello({ server, socket: socket2, clientId });
disconnect.finish();
await vi.advanceTimersByTimeAsync(20_000);
expect(session.cleanup).not.toHaveBeenCalled();
await server.close();
});
test("reuses one session when switching from direct to relay with the same clientId", async () => {
const server = createServer();
const clientId = "cid-switch-path";
@@ -1015,7 +1048,7 @@ describe("relay external socket reconnect behavior", () => {
await server.close();
});
test("includes voice capabilities in initial server_info when speech readiness exists", async () => {
test("keeps legacy voice disabled while advertising app-level voice chat", async () => {
const speechReadiness = createReadySpeechReadinessSnapshot();
const server = createServer({ speechReadiness });
@@ -1032,16 +1065,16 @@ describe("relay external socket reconnect behavior", () => {
voice?: { enabled?: unknown; reason?: unknown };
};
};
features?: { voiceChat?: unknown };
};
expect(serverInfo.version).toBe(TEST_DAEMON_VERSION);
expect(serverInfo.capabilities?.voice?.dictation?.enabled).toBe(
speechReadiness.dictation.enabled,
);
expect(serverInfo.capabilities?.voice?.dictation?.reason).toBe("");
expect(serverInfo.capabilities?.voice?.voice?.enabled).toBe(
speechReadiness.realtimeVoice.enabled,
);
expect(serverInfo.capabilities?.voice?.voice?.reason).toBe("");
expect(serverInfo.capabilities?.voice?.voice?.enabled).toBe(false);
expect(serverInfo.capabilities?.voice?.voice?.reason).toBe("Update Paseo to use voice chat.");
expect(serverInfo.features?.voiceChat).toBe(true);
await server.close();
});
@@ -1064,7 +1097,7 @@ describe("relay external socket reconnect behavior", () => {
const secondEnvelope = sentServerInfoEnvelopes(socket)[1];
const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload);
expect(secondPayload?.capabilities?.voice?.dictation.enabled).toBe(true);
expect(secondPayload?.capabilities?.voice?.voice.enabled).toBe(true);
expect(secondPayload?.capabilities?.voice?.voice.enabled).toBe(false);
// Same readiness should not produce another server_info broadcast.
server.publishSpeechReadiness(speechReadiness);
@@ -1089,9 +1122,9 @@ describe("relay external socket reconnect behavior", () => {
const envelope = sentServerInfoEnvelopes(socket)[1];
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true);
expect(payload?.capabilities?.voice?.voice.enabled).toBe(true);
expect(payload?.capabilities?.voice?.voice.enabled).toBe(false);
expect(payload?.capabilities?.voice?.dictation.reason).toContain("Try again in a few minutes.");
expect(payload?.capabilities?.voice?.voice.reason).toContain("Try again in a few minutes.");
expect(payload?.capabilities?.voice?.voice.reason).toBe("Update Paseo to use voice chat.");
await server.close();
});
+14 -36
View File
@@ -61,7 +61,7 @@ import type { ScriptHealthState } from "./script-health-monitor.js";
import type { ServiceProxySubsystem } from "./service-proxy.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
import type { VoiceChat } from "./voice-chat/index.js";
import {
computeNotificationPlan,
isPushEligibleAttentionReason,
@@ -389,11 +389,8 @@ function buildServerCapabilities(params: {
}),
}),
voice: toServerCapabilityState({
state: readiness.realtimeVoice,
reason: resolveCapabilityReason({
state: readiness.realtimeVoice,
readiness,
}),
state: { ...readiness.realtimeVoice, enabled: false },
reason: "Update Paseo to use voice chat.",
}),
},
};
@@ -572,8 +569,7 @@ export class VoiceAssistantWebSocketServer {
private dictation!: {
finalTimeoutMs?: number;
} | null;
private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly voiceChat: VoiceChat | null;
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
private readonly providerSnapshotManager: ProviderSnapshotManager;
@@ -648,6 +644,7 @@ export class VoiceAssistantWebSocketServer {
pluginRuntime?: SessionOptions["pluginRuntime"],
orchestrationSkills?: SessionOptions["orchestrationSkills"],
workspaceLabelService?: WorkspaceLabelService,
voiceChat?: VoiceChat | null,
) {
this.logger = logger.child({ module: "websocket-server" });
this.workspaceSetupRuntime = workspaceSetupRuntime;
@@ -664,6 +661,7 @@ export class VoiceAssistantWebSocketServer {
this.hubRelationships = hubRelationships ?? null;
this.pluginRuntime = pluginRuntime;
this.orchestrationSkills = orchestrationSkills;
this.voiceChat = voiceChat ?? null;
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
@@ -1422,9 +1420,6 @@ export class VoiceAssistantWebSocketServer {
pluginRuntime: this.pluginRuntime,
orchestrationSkills: this.orchestrationSkills,
mcpBaseUrl: this.mcpBaseUrl,
stt: () => this.speech?.resolveStt() ?? null,
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
providerUsageService: this.providerUsageService,
@@ -1439,23 +1434,7 @@ export class VoiceAssistantWebSocketServer {
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
voice: {
turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
},
voiceBridge: {
registerVoiceSpeakHandler: (agentId, handler) => {
this.voiceSpeakHandlers.set(agentId, handler);
},
unregisterVoiceSpeakHandler: (agentId) => {
this.voiceSpeakHandlers.delete(agentId);
},
registerVoiceCallerContext: (agentId, context) => {
this.voiceCallerContexts.set(agentId, context);
},
unregisterVoiceCallerContext: (agentId) => {
this.voiceCallerContexts.delete(agentId);
},
},
voiceChat: this.voiceChat,
dictation:
this.dictation || this.speech
? {
@@ -1628,6 +1607,8 @@ export class VoiceAssistantWebSocketServer {
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
features: {
// COMPAT(voiceChat): added in v0.7.0, remove gate after 2027-08-29.
voiceChat: true,
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
directorySync: true,
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
@@ -1825,14 +1806,6 @@ export class VoiceAssistantWebSocketServer {
});
}
public resolveVoiceSpeakHandler(callerAgentId: string): VoiceSpeakHandler | null {
return this.voiceSpeakHandlers.get(callerAgentId) ?? null;
}
public resolveVoiceCallerContext(callerAgentId: string): VoiceCallerContext | null {
return this.voiceCallerContexts.get(callerAgentId) ?? null;
}
private async detachSocket(
ws: WebSocketLike,
details: {
@@ -1884,6 +1857,11 @@ export class VoiceAssistantWebSocketServer {
if (connection.sockets.size === 0) {
this.unregisterBrowserToolsClient(connection);
await connection.session.handleTransportDisconnect();
if (connection.sockets.size > 0) {
this.finishPluginSocketCleanup(ws);
return;
}
if (connection.lifecycle === "ephemeral-plugin") {
this.pluginSocketIds.delete(ws);
await this.cleanupConnection(connection, "Plugin session disconnected");
+1 -1
View File
@@ -3,7 +3,7 @@
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"skipLibCheck": true,