mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1bbb7a102 | ||
|
|
20353ca208 | ||
|
|
16e8de01e3 |
+24
-2
@@ -75,6 +75,8 @@ not retain non-Git directories.
|
|||||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
| `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/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/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/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||||
| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions |
|
| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions |
|
||||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
| `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/`
|
- 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 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.
|
- 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
|
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
|
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.
|
status once at the directory boundary rather than maintaining a second activity model.
|
||||||
|
|
||||||
- Terminal subscribe/input/capture commands
|
- 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`
|
- 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
|
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
import {
|
||||||
|
Stack,
|
||||||
|
useGlobalSearchParams,
|
||||||
|
useNavigationContainerRef,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
} from "expo-router";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -63,7 +69,7 @@ import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
|||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||||
import { ToastProvider } from "@/contexts/toast-context";
|
import { ToastProvider } from "@/contexts/toast-context";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { useVoiceOptional, VoiceProvider } from "@/contexts/voice-context";
|
||||||
import {
|
import {
|
||||||
resolveStartupBlocker,
|
resolveStartupBlocker,
|
||||||
resolveStartupNavigationReady,
|
resolveStartupNavigationReady,
|
||||||
@@ -667,11 +673,35 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
|||||||
<HostSessionManager />
|
<HostSessionManager />
|
||||||
<FaviconStatusSync />
|
<FaviconStatusSync />
|
||||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||||
|
<VoiceCallContextSync />
|
||||||
</VoiceProvider>
|
</VoiceProvider>
|
||||||
</AppearanceProvider>
|
</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() {
|
function DesktopWindowControlsSync() {
|
||||||
const { isLoading } = useAppSettings();
|
const { isLoading } = useAppSettings();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { router } from "expo-router";
|
import { router, useGlobalSearchParams } from "expo-router";
|
||||||
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
import {
|
||||||
|
AudioLines,
|
||||||
|
FolderPlus,
|
||||||
|
GitBranch,
|
||||||
|
Import,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +50,9 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
|||||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
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 { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
@@ -52,6 +62,11 @@ import { openHostOverview } from "@/navigation/settings-navigation";
|
|||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
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"];
|
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||||
|
|
||||||
@@ -268,6 +283,7 @@ function sidebarHostOptionTestID(serverId: string): string {
|
|||||||
function FooterIconButton({
|
function FooterIconButton({
|
||||||
buttonRef,
|
buttonRef,
|
||||||
onPress,
|
onPress,
|
||||||
|
disabled = false,
|
||||||
testID,
|
testID,
|
||||||
label,
|
label,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -276,6 +292,7 @@ function FooterIconButton({
|
|||||||
theme,
|
theme,
|
||||||
}: {
|
}: {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
testID: string;
|
testID: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: typeof FolderPlus;
|
icon: typeof FolderPlus;
|
||||||
@@ -289,19 +306,20 @@ function FooterIconButton({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
style={styles.footerIconButton}
|
|
||||||
testID={testID}
|
testID={testID}
|
||||||
nativeID={testID}
|
nativeID={testID}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
accessible
|
accessible
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
disabled={disabled}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
style={[styles.footerIconButton, disabled && styles.footerIconButtonDisabled]}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Icon
|
<Icon
|
||||||
size={iconSize ?? theme.iconSize.md}
|
size={iconSize ?? theme.iconSize.md}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
color={hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</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({
|
function footerAddProjectButtonStyle({
|
||||||
hovered,
|
hovered,
|
||||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||||
@@ -489,6 +569,7 @@ function SidebarFooter({
|
|||||||
icon={Import}
|
icon={Import}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
|
<SidebarVoiceChatButton theme={theme} />
|
||||||
<SidebarHelpMenu />
|
<SidebarHelpMenu />
|
||||||
<FooterIconButton
|
<FooterIconButton
|
||||||
onPress={handleSettings}
|
onPress={handleSettings}
|
||||||
@@ -596,12 +677,14 @@ function MobileSidebar({
|
|||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
onImportSession={handleImportSession}
|
onImportSession={handleImportSession}
|
||||||
|
listFooterComponent={voiceWorkspaceListClearance}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
dragGestureHostActive={active}
|
dragGestureHostActive={active}
|
||||||
listHeaderComponent={workspacesSectionHeaderElement}
|
listHeaderComponent={workspacesSectionHeaderElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<VoiceCallSurface />
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
theme={theme}
|
theme={theme}
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
@@ -773,12 +856,14 @@ function DesktopSidebar({
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
onImportSession={handleImportSession}
|
onImportSession={handleImportSession}
|
||||||
|
listFooterComponent={voiceWorkspaceListClearance}
|
||||||
listHeaderComponent={workspacesSectionHeaderElement}
|
listHeaderComponent={workspacesSectionHeaderElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarCalloutSlot />
|
<SidebarCalloutSlot />
|
||||||
|
|
||||||
|
<VoiceCallSurface />
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
theme={theme}
|
theme={theme}
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
@@ -976,6 +1061,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
},
|
},
|
||||||
|
footerIconButtonDisabled: {
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
voiceWorkspaceListClearance: {
|
||||||
|
height: theme.spacing[12],
|
||||||
|
},
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Mic, MicOff, Square } from "lucide-react-native";
|
import { MessageCircle, Mic, MicOff, Square } from "lucide-react-native";
|
||||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
|
||||||
import { useVoiceTelemetry } from "@/contexts/voice-context";
|
import { useVoiceTelemetry } from "@/contexts/voice-context";
|
||||||
import { VolumeMeter } from "./volume-meter";
|
import { VolumeMeter } from "./volume-meter";
|
||||||
|
|
||||||
interface RealtimeVoiceOverlayProps {
|
interface RealtimeVoiceOverlayProps {
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
|
isTranscriptOpen: boolean;
|
||||||
|
onToggleTranscript: () => void;
|
||||||
onToggleMute: () => void;
|
onToggleMute: () => void;
|
||||||
onStop: () => void;
|
onStop: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OVERLAY_BUTTON_SIZE = 44;
|
const OVERLAY_BUTTON_SIZE = 28;
|
||||||
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
|
|
||||||
|
|
||||||
export function RealtimeVoiceOverlay({
|
export function RealtimeVoiceOverlay({
|
||||||
isMuted,
|
isMuted,
|
||||||
isSwitching,
|
isSwitching,
|
||||||
|
isTranscriptOpen,
|
||||||
|
onToggleTranscript,
|
||||||
onToggleMute,
|
onToggleMute,
|
||||||
onStop,
|
onStop,
|
||||||
}: RealtimeVoiceOverlayProps) {
|
}: RealtimeVoiceOverlayProps) {
|
||||||
@@ -42,12 +44,26 @@ export function RealtimeVoiceOverlay({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<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}>
|
<View style={styles.meterContainer}>
|
||||||
<VolumeMeter
|
<VolumeMeter
|
||||||
volume={volume}
|
volume={volume}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
isSpeaking={isSpeaking}
|
isSpeaking={isSpeaking}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -62,9 +78,9 @@ export function RealtimeVoiceOverlay({
|
|||||||
style={muteButtonStyle}
|
style={muteButtonStyle}
|
||||||
>
|
>
|
||||||
{isMuted ? (
|
{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>
|
</Pressable>
|
||||||
|
|
||||||
@@ -79,7 +95,7 @@ export function RealtimeVoiceOverlay({
|
|||||||
<LoadingSpinner size="small" color={theme.colors.palette.white} />
|
<LoadingSpinner size="small" color={theme.colors.palette.white} />
|
||||||
) : (
|
) : (
|
||||||
<Square
|
<Square
|
||||||
size={theme.iconSize.lg}
|
size={theme.iconSize.md}
|
||||||
color={theme.colors.palette.white}
|
color={theme.colors.palette.white}
|
||||||
fill={theme.colors.palette.white}
|
fill={theme.colors.palette.white}
|
||||||
strokeWidth={2.5}
|
strokeWidth={2.5}
|
||||||
@@ -96,24 +112,25 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: FOOTER_HEIGHT,
|
minHeight: 40,
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius.xl,
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: OVERLAY_VERTICAL_PADDING,
|
paddingVertical: theme.spacing[1.5],
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
meterContainer: {
|
meterContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
actionsContainer: {
|
actionsContainer: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
actionButton: {
|
actionButton: {
|
||||||
width: OVERLAY_BUTTON_SIZE,
|
width: OVERLAY_BUTTON_SIZE,
|
||||||
@@ -127,6 +144,14 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
},
|
},
|
||||||
|
transcriptButton: {
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
transcriptButtonOpen: {
|
||||||
|
backgroundColor: theme.colors.interactionHighlight,
|
||||||
|
},
|
||||||
muteButtonMuted: {
|
muteButtonMuted: {
|
||||||
backgroundColor: theme.colors.palette.red[600],
|
backgroundColor: theme.colors.palette.red[600],
|
||||||
borderColor: theme.colors.palette.red[800],
|
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",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import {
|
import { View, Pressable, Text, StyleSheet as RNStyleSheet } from "react-native";
|
||||||
View,
|
|
||||||
Pressable,
|
|
||||||
Text,
|
|
||||||
StyleSheet as RNStyleSheet,
|
|
||||||
type PressableStateCallbackType,
|
|
||||||
} from "react-native";
|
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
@@ -25,7 +19,6 @@ import {
|
|||||||
ArrowUp,
|
ArrowUp,
|
||||||
Square,
|
Square,
|
||||||
Pencil,
|
Pencil,
|
||||||
AudioLines,
|
|
||||||
CircleDot,
|
CircleDot,
|
||||||
FileText,
|
FileText,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
@@ -75,7 +68,6 @@ import {
|
|||||||
type QueueWriter,
|
type QueueWriter,
|
||||||
type QueuedComposerMessage,
|
type QueuedComposerMessage,
|
||||||
} from "@/composer/actions";
|
} from "@/composer/actions";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
@@ -182,14 +174,6 @@ function resolveIsComposerLocked(
|
|||||||
return submitBehavior === "preserve-and-lock" && isSubmitLoading;
|
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 {
|
function resolveKeyboardPriority(isMessageInputFocused: boolean): number {
|
||||||
return isMessageInputFocused ? 200 : 100;
|
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));
|
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) {
|
function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||||
return (state: ReturnType<typeof useSessionStore.getState>) => {
|
return (state: ReturnType<typeof useSessionStore.getState>) => {
|
||||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||||
@@ -487,29 +458,6 @@ function resolveErrorMessage(error: unknown): string | null {
|
|||||||
return 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: {
|
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
||||||
current: MessageInputRef | null;
|
current: MessageInputRef | null;
|
||||||
}): void {
|
}): 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) {
|
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
|
||||||
return (
|
return (
|
||||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||||
@@ -1192,8 +1058,6 @@ function ComposerContentImpl({
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const toastErrorRef = useRef(toast.error);
|
const toastErrorRef = useRef(toast.error);
|
||||||
toastErrorRef.current = toast.error;
|
toastErrorRef.current = toast.error;
|
||||||
const voice = useVoiceOptional();
|
|
||||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
|
||||||
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
|
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
|
||||||
const isDictationReady = useIsDictationReady({
|
const isDictationReady = useIsDictationReady({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1838,19 +1702,6 @@ function ComposerContentImpl({
|
|||||||
focusMessageInputWithPlatformStrategy(messageInputRef);
|
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(
|
const handleEditQueuedMessage = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const result = editQueuedComposerMessage({
|
const result = editQueuedComposerMessage({
|
||||||
@@ -1912,8 +1763,6 @@ function ComposerContentImpl({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
|
||||||
|
|
||||||
// Handle keyboard navigation for command autocomplete.
|
// Handle keyboard navigation for command autocomplete.
|
||||||
const handleCommandKeyPress = useCallback(
|
const handleCommandKeyPress = useCallback(
|
||||||
(event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event),
|
(event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event),
|
||||||
@@ -1925,14 +1774,6 @@ function ComposerContentImpl({
|
|||||||
[isConnected, isCancellingAgent],
|
[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(
|
const activeActionContent = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<ComposerCancelButton
|
<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(
|
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
|
||||||
agentState.contextWindowMaxTokens,
|
agentState.contextWindowMaxTokens,
|
||||||
agentState.contextWindowUsedTokens,
|
agentState.contextWindowUsedTokens,
|
||||||
@@ -2362,9 +2168,9 @@ function ComposerContentImpl({
|
|||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
beforeVoiceContent={beforeVoiceContent}
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
|
||||||
activeActionContent={activeActionContent}
|
activeActionContent={activeActionContent}
|
||||||
voiceServerId={serverId}
|
voiceServerId={serverId}
|
||||||
|
voiceWorkspaceId={workspaceId ?? undefined}
|
||||||
voiceAgentId={agentId}
|
voiceAgentId={agentId}
|
||||||
isAgentRunning={isAgentRunning}
|
isAgentRunning={isAgentRunning}
|
||||||
defaultSendBehavior={activeSendBehavior}
|
defaultSendBehavior={activeSendBehavior}
|
||||||
@@ -2461,11 +2267,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
marginLeft: theme.spacing[1],
|
marginLeft: theme.spacing[1],
|
||||||
},
|
},
|
||||||
rightControls: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
},
|
|
||||||
contextWindowMeterSlot: {
|
contextWindowMeterSlot: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -2473,20 +2274,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "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: {
|
iconButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
@@ -2554,7 +2341,6 @@ const ThemedPencil = withUnistyles(Pencil);
|
|||||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||||
const ThemedAudioLines = withUnistyles(AudioLines);
|
|
||||||
const ThemedPaperclip = withUnistyles(Paperclip);
|
const ThemedPaperclip = withUnistyles(Paperclip);
|
||||||
const ThemedImageIcon = withUnistyles(ImageIcon);
|
const ThemedImageIcon = withUnistyles(ImageIcon);
|
||||||
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
|
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|||||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||||
import { useDictation } from "@/hooks/use-dictation";
|
import { useDictation } from "@/hooks/use-dictation";
|
||||||
import { DictationOverlay } from "@/components/dictation-controls";
|
import { DictationOverlay } from "@/components/dictation-controls";
|
||||||
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
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. */
|
/** Primary action to render when the agent is active and the composer has no sendable content. */
|
||||||
activeActionContent?: React.ReactNode;
|
activeActionContent?: React.ReactNode;
|
||||||
voiceServerId?: string;
|
voiceServerId?: string;
|
||||||
|
voiceWorkspaceId?: string;
|
||||||
voiceAgentId?: string;
|
voiceAgentId?: string;
|
||||||
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
||||||
isAgentRunning?: boolean;
|
isAgentRunning?: boolean;
|
||||||
@@ -594,16 +594,9 @@ function MessageInputOverlay({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (showRealtimeOverlay && voice) {
|
void showRealtimeOverlay;
|
||||||
return (
|
void voice;
|
||||||
<RealtimeVoiceOverlay
|
void onRealtimeVoiceStop;
|
||||||
isMuted={voice.isMuted}
|
|
||||||
isSwitching={voice.isVoiceSwitching}
|
|
||||||
onToggleMute={voice.toggleMute}
|
|
||||||
onStop={onRealtimeVoiceStop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,41 +823,50 @@ interface ToggleRealtimeVoiceContext {
|
|||||||
voice:
|
voice:
|
||||||
| {
|
| {
|
||||||
isVoiceSwitching: boolean;
|
isVoiceSwitching: boolean;
|
||||||
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
|
isCallActiveOnServer: (serverId: string) => boolean;
|
||||||
startVoice: (serverId: string, agentId: string) => Promise<unknown>;
|
startCall: (
|
||||||
|
serverId: string,
|
||||||
|
context: { workspaceId: string | null; agentId: string | null },
|
||||||
|
) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
voiceServerId: string | undefined;
|
voiceServerId: string | undefined;
|
||||||
|
voiceWorkspaceId: string | undefined;
|
||||||
voiceAgentId: string | undefined;
|
voiceAgentId: string | undefined;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isAgentRunning: boolean;
|
|
||||||
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
||||||
toast: { error: (msg: string) => void };
|
toast: { error: (msg: string) => void };
|
||||||
interruptBeforeVoiceMessage: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.voice.isVoiceSwitching) return;
|
if (ctx.voice.isVoiceSwitching) return;
|
||||||
if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) {
|
if (ctx.voice.isCallActiveOnServer(ctx.voiceServerId)) {
|
||||||
void ctx.handleStopRealtimeVoice();
|
void ctx.handleStopRealtimeVoice();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.isAgentRunning) {
|
void ctx.voice
|
||||||
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
|
.startCall(ctx.voiceServerId, {
|
||||||
return;
|
workspaceId: ctx.voiceWorkspaceId,
|
||||||
}
|
agentId: ctx.voiceAgentId ?? null,
|
||||||
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
})
|
||||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
.catch((error) => {
|
||||||
const message = extractErrorMessage(error);
|
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||||
if (message && message.trim().length > 0) {
|
const message = extractErrorMessage(error);
|
||||||
ctx.toast.error(message);
|
if (message && message.trim().length > 0) {
|
||||||
}
|
ctx.toast.error(message);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StartDictationContext {
|
interface StartDictationContext {
|
||||||
@@ -959,12 +961,11 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function computeIsRealtimeVoiceForAgent(
|
function computeIsRealtimeVoiceForAgent(
|
||||||
voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined,
|
voice: { isCallActiveOnServer: (serverId: string) => boolean } | null | undefined,
|
||||||
voiceServerId: string | undefined,
|
voiceServerId: string | undefined,
|
||||||
voiceAgentId: string | undefined,
|
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!voice || !voiceServerId || !voiceAgentId) return false;
|
if (!voice || !voiceServerId) return false;
|
||||||
return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
|
return voice.isCallActiveOnServer(voiceServerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeShouldShowDictationOverlay(
|
function computeShouldShowDictationOverlay(
|
||||||
@@ -1068,6 +1069,7 @@ interface ResolvedMessageInputProps {
|
|||||||
rightContent: React.ReactNode;
|
rightContent: React.ReactNode;
|
||||||
activeActionContent: React.ReactNode;
|
activeActionContent: React.ReactNode;
|
||||||
voiceServerId: string | undefined;
|
voiceServerId: string | undefined;
|
||||||
|
voiceWorkspaceId: string | undefined;
|
||||||
voiceAgentId: string | undefined;
|
voiceAgentId: string | undefined;
|
||||||
isAgentRunning: boolean;
|
isAgentRunning: boolean;
|
||||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||||
@@ -1115,6 +1117,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
|||||||
rightContent: props.rightContent,
|
rightContent: props.rightContent,
|
||||||
activeActionContent: props.activeActionContent,
|
activeActionContent: props.activeActionContent,
|
||||||
voiceServerId: props.voiceServerId,
|
voiceServerId: props.voiceServerId,
|
||||||
|
voiceWorkspaceId: props.voiceWorkspaceId,
|
||||||
voiceAgentId: props.voiceAgentId,
|
voiceAgentId: props.voiceAgentId,
|
||||||
isAgentRunning: props.isAgentRunning ?? false,
|
isAgentRunning: props.isAgentRunning ?? false,
|
||||||
defaultSendBehavior: props.defaultSendBehavior,
|
defaultSendBehavior: props.defaultSendBehavior,
|
||||||
@@ -1170,6 +1173,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
rightContent,
|
rightContent,
|
||||||
activeActionContent,
|
activeActionContent,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
|
voiceWorkspaceId,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
defaultSendBehavior,
|
defaultSendBehavior,
|
||||||
@@ -1375,11 +1379,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(voice, voiceServerId);
|
||||||
voice,
|
|
||||||
voiceServerId,
|
|
||||||
voiceAgentId,
|
|
||||||
);
|
|
||||||
const showDictationOverlay = computeShouldShowDictationOverlay(
|
const showDictationOverlay = computeShouldShowDictationOverlay(
|
||||||
isDictating,
|
isDictating,
|
||||||
isDictationProcessing,
|
isDictationProcessing,
|
||||||
@@ -1452,9 +1452,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
await stopRealtimeVoice({
|
await stopRealtimeVoice({
|
||||||
voice,
|
voice,
|
||||||
isRealtimeVoiceForCurrentAgent,
|
isRealtimeVoiceForCurrentAgent,
|
||||||
isAgentRunning,
|
|
||||||
client,
|
|
||||||
voiceAgentId,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[MessageInput] Failed to stop realtime voice", error);
|
console.error("[MessageInput] Failed to stop realtime voice", error);
|
||||||
@@ -1463,30 +1460,28 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]);
|
}, [isRealtimeVoiceForCurrentAgent, toast, voice]);
|
||||||
|
|
||||||
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
|
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
|
||||||
toggleRealtimeVoiceImpl({
|
toggleRealtimeVoiceImpl({
|
||||||
voice,
|
voice,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
|
voiceWorkspaceId,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
isConnected,
|
isConnected,
|
||||||
disabled,
|
disabled,
|
||||||
isAgentRunning,
|
|
||||||
handleStopRealtimeVoice,
|
handleStopRealtimeVoice,
|
||||||
toast,
|
toast,
|
||||||
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
disabled,
|
disabled,
|
||||||
handleStopRealtimeVoice,
|
handleStopRealtimeVoice,
|
||||||
isAgentRunning,
|
|
||||||
isConnected,
|
isConnected,
|
||||||
t,
|
|
||||||
toast,
|
toast,
|
||||||
voice,
|
voice,
|
||||||
voiceAgentId,
|
voiceAgentId,
|
||||||
voiceServerId,
|
voiceServerId,
|
||||||
|
voiceWorkspaceId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const minimizeInputHeight = useCallback(() => {
|
const minimizeInputHeight = useCallback(() => {
|
||||||
|
|||||||
@@ -276,43 +276,14 @@ describe("composer send behavior", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("stopRealtimeVoice", () => {
|
describe("stopRealtimeVoice", () => {
|
||||||
it("keeps voice mode active when the running agent refuses cancellation", async () => {
|
it("stops the app-level call without touching the visible agent", async () => {
|
||||||
const cancellationError = new Error("active run cancellation was not acknowledged");
|
|
||||||
const cancelAgent = vi.fn().mockRejectedValue(cancellationError);
|
|
||||||
const stopVoice = vi.fn().mockResolvedValue(undefined);
|
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({
|
await stopRealtimeVoice({
|
||||||
voice: {
|
voice: { stopVoice },
|
||||||
stopVoice: async () => {
|
|
||||||
calls.push("stop voice");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
isRealtimeVoiceForCurrentAgent: true,
|
isRealtimeVoiceForCurrentAgent: true,
|
||||||
isAgentRunning: true,
|
|
||||||
client: {
|
|
||||||
cancelAgent: async () => {
|
|
||||||
calls.push("cancel agent");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
voiceAgentId: "agent-1",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(calls).toEqual(["cancel agent", "stop voice"]);
|
expect(stopVoice).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ export function resolveComposerSurfacePresentation(
|
|||||||
interface StopRealtimeVoiceContext {
|
interface StopRealtimeVoiceContext {
|
||||||
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
|
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
|
||||||
isRealtimeVoiceForCurrentAgent: boolean;
|
isRealtimeVoiceForCurrentAgent: boolean;
|
||||||
isAgentRunning: boolean;
|
|
||||||
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
|
|
||||||
voiceAgentId: string | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendActionContext {
|
interface SendActionContext {
|
||||||
@@ -183,12 +180,5 @@ export function runMessageInputKeyboardAction(
|
|||||||
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
||||||
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
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();
|
await ctx.voice.stopVoice();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useRef, ReactNode, useCallback, useEffect } from "react";
|
import { useRef, ReactNode, useCallback, useEffect } from "react";
|
||||||
import { Buffer } from "buffer";
|
|
||||||
import { AppState } from "react-native";
|
import { AppState } from "react-native";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { GitSetupOptions } from "@getpaseo/protocol/messages";
|
||||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
import { useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
|
||||||
import {
|
import {
|
||||||
selectAgentTimelineState,
|
selectAgentTimelineState,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
@@ -72,50 +70,11 @@ export type {
|
|||||||
AgentFileExplorerState,
|
AgentFileExplorerState,
|
||||||
} from "@/stores/session-store";
|
} 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.
|
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||||
function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode {
|
function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode {
|
||||||
return selectiveAgentTimeline ? "selective" : "legacy";
|
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 => {
|
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
||||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
@@ -186,15 +145,6 @@ type WorkspaceSetupProgressPayload = Extract<
|
|||||||
{ type: "workspace_setup_progress" }
|
{ type: "workspace_setup_progress" }
|
||||||
>["payload"];
|
>["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 {
|
interface SessionProviderSharedProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -222,7 +172,6 @@ export function SessionProvider(props: SessionProviderProps) {
|
|||||||
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const voiceRuntime = useVoiceRuntimeOptional();
|
const voiceRuntime = useVoiceRuntimeOptional();
|
||||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -255,8 +204,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const appStateRef = useRef(AppState.currentState);
|
const appStateRef = useRef(AppState.currentState);
|
||||||
const forcedTimelineTailReplacements = useRef(new Set<string>());
|
const forcedTimelineTailReplacements = useRef(new Set<string>());
|
||||||
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
|
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
|
||||||
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
|
|
||||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
|
||||||
const isAppVisible = useAppVisible();
|
const isAppVisible = useAppVisible();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -369,29 +316,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unregister = voiceRuntime?.registerSession({
|
const unregister = voiceRuntime?.registerSession({
|
||||||
serverId,
|
serverId,
|
||||||
setVoiceMode: async (enabled, agentId) => {
|
startCall: async (context, transports) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(t("common.errors.daemonUnavailable"));
|
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) {
|
if (!client) {
|
||||||
throw new Error(t("common.errors.daemonUnavailable"));
|
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) {
|
if (!client) {
|
||||||
throw new Error(t("common.errors.daemonUnavailable"));
|
throw new Error(t("common.errors.daemonUnavailable"));
|
||||||
}
|
}
|
||||||
await client.audioPlayed(chunkId);
|
await client.updateVoiceCallContext(callId, context);
|
||||||
},
|
},
|
||||||
abortRequest: async () => {
|
sendTransportMessage: (callId, data) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(t("common.errors.daemonUnavailable"));
|
throw new Error(t("common.errors.daemonUnavailable"));
|
||||||
}
|
}
|
||||||
await client.abortRequest();
|
client.sendVoiceCallTransportMessage(callId, data);
|
||||||
},
|
},
|
||||||
setAssistantAudioPlaying: (isPlaying) => {
|
setAssistantAudioPlaying: (isPlaying) => {
|
||||||
setIsPlayingAudio(serverId, isPlaying);
|
setIsPlayingAudio(serverId, isPlaying);
|
||||||
@@ -532,14 +479,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||||
const parsedTimestamp = new Date(timestamp);
|
const parsedTimestamp = new Date(timestamp);
|
||||||
const streamEvent = event;
|
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([
|
const turnLiveness = deriveAgentStreamTurnLiveness([
|
||||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||||
]);
|
]);
|
||||||
@@ -661,94 +600,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubAudioOutput = client.on("audio_output", async (message) => {
|
const unsubVoiceCallTransport = client.on("voice.call.transport.server", (message) => {
|
||||||
if (message.type !== "audio_output") return;
|
if (message.type !== "voice.call.transport.server" || !voiceRuntime) return;
|
||||||
if (!voiceAudioEngine) {
|
voiceRuntime.handleTransportMessage(serverId, message.payload.callId, message.payload.data);
|
||||||
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 unsubActivity = client.on("activity_log", (message) => {
|
const unsubVoiceCallState = client.on("voice.call.state", (message) => {
|
||||||
if (message.type !== "activity_log") return;
|
if (message.type !== "voice.call.state") return;
|
||||||
notifyVoiceAbortFailure(message.payload, toast.error);
|
voiceRuntime?.handleCallState(serverId, message.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubTranscription = client.on("transcription_result", (message) => {
|
const unsubVoiceCallEvent = client.on("voice.call.event", (message) => {
|
||||||
if (message.type !== "transcription_result") return;
|
if (message.type !== "voice.call.event") return;
|
||||||
|
voiceRuntime?.handleCallEvent(serverId, message.payload.callId, message.payload.event);
|
||||||
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 unsubTerminalAttention = client.on("terminal_attention_required", (message) => {
|
const unsubTerminalAttention = client.on("terminal_attention_required", (message) => {
|
||||||
@@ -784,10 +648,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
unsubStatus();
|
unsubStatus();
|
||||||
unsubPermissionRequest();
|
unsubPermissionRequest();
|
||||||
unsubPermissionResolved();
|
unsubPermissionResolved();
|
||||||
unsubAudioOutput();
|
unsubVoiceCallTransport();
|
||||||
unsubActivity();
|
unsubVoiceCallState();
|
||||||
unsubTranscription();
|
unsubVoiceCallEvent();
|
||||||
unsubVoiceInputState();
|
|
||||||
unsubTerminalAttention();
|
unsubTerminalAttention();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -808,7 +671,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
updateSessionServerInfo,
|
updateSessionServerInfo,
|
||||||
toast,
|
toast,
|
||||||
voiceRuntime,
|
voiceRuntime,
|
||||||
voiceAudioEngine,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const _cancelAgentRun = useCallback(
|
const _cancelAgentRun = useCallback(
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||||
|
import type { VoiceAppContext } from "@getpaseo/protocol/messages";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { createAudioEngine } from "@/voice/audio-engine";
|
import { createAudioEngine } from "@/voice/audio-engine";
|
||||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||||
import {
|
import {
|
||||||
@@ -16,12 +18,18 @@ import {
|
|||||||
type VoiceRuntime,
|
type VoiceRuntime,
|
||||||
type VoiceRuntimeSnapshot,
|
type VoiceRuntimeSnapshot,
|
||||||
type VoiceRuntimeTelemetrySnapshot,
|
type VoiceRuntimeTelemetrySnapshot,
|
||||||
} from "@/voice/voice-runtime";
|
} from "@/voice-chat/runtime";
|
||||||
|
import {
|
||||||
|
createDaemonAudioTransportFactory,
|
||||||
|
type DaemonAudioTransportFactory,
|
||||||
|
} from "@/voice-chat/transports/daemon-audio";
|
||||||
|
|
||||||
interface VoiceContextValue extends VoiceRuntimeSnapshot {
|
interface VoiceContextValue extends VoiceRuntimeSnapshot {
|
||||||
startVoice: (serverId: string, agentId: string) => Promise<void>;
|
startCall: (serverId: string, context: VoiceAppContext) => Promise<void>;
|
||||||
stopVoice: () => Promise<void>;
|
stopVoice: () => Promise<void>;
|
||||||
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
|
updateCallContext: (context: VoiceAppContext) => Promise<void>;
|
||||||
|
dismissTranscript: () => void;
|
||||||
|
isCallActiveOnServer: (serverId: string) => boolean;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +39,8 @@ const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = {
|
|||||||
isVoiceSwitching: false,
|
isVoiceSwitching: false,
|
||||||
isMuted: false,
|
isMuted: false,
|
||||||
activeServerId: null,
|
activeServerId: null,
|
||||||
activeAgentId: null,
|
activeCallId: null,
|
||||||
|
events: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||||
@@ -73,9 +82,11 @@ export function useVoiceOptional(): VoiceContextValue | null {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
startVoice: runtime.startVoice,
|
startCall: runtime.startCall,
|
||||||
stopVoice: runtime.stopVoice,
|
stopVoice: runtime.stopVoice,
|
||||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
updateCallContext: runtime.updateCallContext,
|
||||||
|
dismissTranscript: runtime.dismissTranscript,
|
||||||
|
isCallActiveOnServer: runtime.isCallActiveOnServer,
|
||||||
toggleMute: runtime.toggleMute,
|
toggleMute: runtime.toggleMute,
|
||||||
};
|
};
|
||||||
}, [snapshot, runtime]);
|
}, [snapshot, runtime]);
|
||||||
@@ -113,17 +124,24 @@ interface VoiceProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceProvider({ children }: VoiceProviderProps) {
|
export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||||
|
const toast = useToast();
|
||||||
const engineRef = useRef<AudioEngine | null>(null);
|
const engineRef = useRef<AudioEngine | null>(null);
|
||||||
const runtimeRef = useRef<VoiceRuntime | null>(null);
|
const runtimeRef = useRef<VoiceRuntime | null>(null);
|
||||||
|
|
||||||
if (!engineRef.current) {
|
if (!engineRef.current) {
|
||||||
let runtime: VoiceRuntime | null = null;
|
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({
|
const engine = createAudioEngine({
|
||||||
onCaptureData: (pcm) => {
|
onCaptureData: (pcm) => {
|
||||||
runtime?.handleCapturePcm(pcm);
|
daemonAudio?.handleCapturePcm(pcm);
|
||||||
},
|
},
|
||||||
onVolumeLevel: (level) => {
|
onVolumeLevel: (level) => {
|
||||||
runtime?.handleCaptureVolume(level);
|
daemonAudio?.handleCaptureVolume(level);
|
||||||
},
|
},
|
||||||
onInterruption: () => {
|
onInterruption: () => {
|
||||||
void runtime?.stopVoice().catch((error) => {
|
void runtime?.stopVoice().catch((error) => {
|
||||||
@@ -131,12 +149,13 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("[VoiceEngine] Capture error:", error);
|
reportMediaError(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
daemonAudio = createDaemonAudioTransportFactory({ engine, onError: reportMediaError });
|
||||||
|
|
||||||
runtime = createVoiceRuntime({
|
runtime = createVoiceRuntime({
|
||||||
engine,
|
transports: [daemonAudio],
|
||||||
getServerInfo: (serverId) =>
|
getServerInfo: (serverId) =>
|
||||||
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
|
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
|
||||||
activateKeepAwake: async (tag) => {
|
activateKeepAwake: async (tag) => {
|
||||||
@@ -156,11 +175,14 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
void runtime.destroy().catch((error) => {
|
void runtime
|
||||||
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
|
.destroy()
|
||||||
});
|
.then(() => engine.destroy())
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [runtime]);
|
}, [engine, runtime]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoiceAudioEngineContext.Provider value={engine}>
|
<VoiceAudioEngineContext.Provider value={engine}>
|
||||||
|
|||||||
@@ -1944,6 +1944,7 @@ export const ar: TranslationResources = {
|
|||||||
projects: "المشاريع",
|
projects: "المشاريع",
|
||||||
connections: "اتصالات",
|
connections: "اتصالات",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
voice: "الصوت",
|
||||||
metadata: "البيانات الوصفية",
|
metadata: "البيانات الوصفية",
|
||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
providers: "مقدمي الخدمات",
|
providers: "مقدمي الخدمات",
|
||||||
|
|||||||
@@ -1989,6 +1989,7 @@ export const en = {
|
|||||||
projects: "Projects",
|
projects: "Projects",
|
||||||
connections: "Connections",
|
connections: "Connections",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
voice: "Voice",
|
||||||
metadata: "Metadata",
|
metadata: "Metadata",
|
||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
providers: "Providers",
|
providers: "Providers",
|
||||||
|
|||||||
@@ -1990,6 +1990,7 @@ export const es: TranslationResources = {
|
|||||||
projects: "Proyectos",
|
projects: "Proyectos",
|
||||||
connections: "Conexiones",
|
connections: "Conexiones",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
voice: "Voz",
|
||||||
metadata: "Metadatos",
|
metadata: "Metadatos",
|
||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
providers: "Proveedores",
|
providers: "Proveedores",
|
||||||
|
|||||||
@@ -1995,6 +1995,7 @@ export const fr: TranslationResources = {
|
|||||||
projects: "Projets",
|
projects: "Projets",
|
||||||
connections: "Relations",
|
connections: "Relations",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
voice: "Voix",
|
||||||
metadata: "Métadonnées",
|
metadata: "Métadonnées",
|
||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
providers: "Fournisseurs",
|
providers: "Fournisseurs",
|
||||||
|
|||||||
@@ -1962,6 +1962,7 @@ export const ja: TranslationResources = {
|
|||||||
projects: "プロジェクト",
|
projects: "プロジェクト",
|
||||||
connections: "接続",
|
connections: "接続",
|
||||||
agents: "エージェント",
|
agents: "エージェント",
|
||||||
|
voice: "音声",
|
||||||
metadata: "メタデータ",
|
metadata: "メタデータ",
|
||||||
workspaces: "ワークスペース",
|
workspaces: "ワークスペース",
|
||||||
providers: "プロバイダー",
|
providers: "プロバイダー",
|
||||||
|
|||||||
@@ -1954,6 +1954,7 @@ export const ko: TranslationResources = {
|
|||||||
projects: "프로젝트",
|
projects: "프로젝트",
|
||||||
connections: "연결",
|
connections: "연결",
|
||||||
agents: "에이전트",
|
agents: "에이전트",
|
||||||
|
voice: "음성",
|
||||||
metadata: "메타데이터",
|
metadata: "메타데이터",
|
||||||
workspaces: "워크스페이스",
|
workspaces: "워크스페이스",
|
||||||
providers: "프로바이더",
|
providers: "프로바이더",
|
||||||
|
|||||||
@@ -1976,6 +1976,7 @@ export const ptBR: TranslationResources = {
|
|||||||
projects: "Projetos",
|
projects: "Projetos",
|
||||||
connections: "Conexões",
|
connections: "Conexões",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
|
voice: "Voz",
|
||||||
metadata: "Metadados",
|
metadata: "Metadados",
|
||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
providers: "Provedores",
|
providers: "Provedores",
|
||||||
|
|||||||
@@ -1975,6 +1975,7 @@ export const ru: TranslationResources = {
|
|||||||
projects: "Проекты",
|
projects: "Проекты",
|
||||||
connections: "Подключения",
|
connections: "Подключения",
|
||||||
agents: "Агенты",
|
agents: "Агенты",
|
||||||
|
voice: "Голос",
|
||||||
metadata: "Метаданные",
|
metadata: "Метаданные",
|
||||||
workspaces: "Рабочие пространства",
|
workspaces: "Рабочие пространства",
|
||||||
providers: "Провайдеры",
|
providers: "Провайдеры",
|
||||||
|
|||||||
@@ -1923,6 +1923,7 @@ export const zhCN: TranslationResources = {
|
|||||||
projects: "项目",
|
projects: "项目",
|
||||||
connections: "连接",
|
connections: "连接",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
|
voice: "语音",
|
||||||
metadata: "元数据",
|
metadata: "元数据",
|
||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
providers: "Providers",
|
providers: "Providers",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Blocks,
|
Blocks,
|
||||||
PanelsTopLeft,
|
PanelsTopLeft,
|
||||||
|
AudioLines,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
@@ -114,6 +115,7 @@ import {
|
|||||||
} from "@/screens/settings/host-page";
|
} from "@/screens/settings/host-page";
|
||||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||||
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
||||||
|
import { VoiceSettingsPage } from "@/screens/settings/voice-page";
|
||||||
import ProjectsScreen from "@/screens/projects-screen";
|
import ProjectsScreen from "@/screens/projects-screen";
|
||||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||||
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
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: "connections", labelKey: "settings.hostSections.connections", icon: Network },
|
||||||
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
|
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
|
||||||
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
|
{ 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: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
|
||||||
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
|
||||||
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
|
||||||
@@ -210,6 +213,8 @@ function renderHostSettingsContent(
|
|||||||
return <HostPairDevicePage serverId={view.serverId} />;
|
return <HostPairDevicePage serverId={view.serverId} />;
|
||||||
case "agents":
|
case "agents":
|
||||||
return <HostAgentsPage serverId={view.serverId} />;
|
return <HostAgentsPage serverId={view.serverId} />;
|
||||||
|
case "voice":
|
||||||
|
return <VoiceSettingsPage serverId={view.serverId} />;
|
||||||
case "metadata":
|
case "metadata":
|
||||||
return <MetadataGenerationPage serverId={view.serverId} />;
|
return <MetadataGenerationPage serverId={view.serverId} />;
|
||||||
case "workspaces":
|
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 };
|
||||||
|
}
|
||||||
@@ -513,6 +513,7 @@ export const HOST_SECTION_SLUGS = [
|
|||||||
"connections",
|
"connections",
|
||||||
"pair-device",
|
"pair-device",
|
||||||
"agents",
|
"agents",
|
||||||
|
"voice",
|
||||||
"metadata",
|
"metadata",
|
||||||
"workspaces",
|
"workspaces",
|
||||||
"providers",
|
"providers",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -111,6 +111,10 @@ import type {
|
|||||||
AgentSkillSelection,
|
AgentSkillSelection,
|
||||||
AgentSkillsStatus,
|
AgentSkillsStatus,
|
||||||
AgentSkillsSaveResult,
|
AgentSkillsSaveResult,
|
||||||
|
VoiceAppContext,
|
||||||
|
VoiceCallClientTransportMessage,
|
||||||
|
VoiceCallTransport,
|
||||||
|
VoiceCallTransportOffer,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -3395,6 +3399,84 @@ export class DaemonClient {
|
|||||||
// Audio / Voice
|
// 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> {
|
async setVoiceMode(enabled: boolean, agentId?: string): Promise<SetVoiceModePayload> {
|
||||||
const requestId = this.createRequestId();
|
const requestId = this.createRequestId();
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ import {
|
|||||||
BrowserAutomationExecuteResponseSchema,
|
BrowserAutomationExecuteResponseSchema,
|
||||||
} from "./browser-automation/rpc-schemas.js";
|
} from "./browser-automation/rpc-schemas.js";
|
||||||
import { BrowserAutomationHostCapabilitySchema } from "./browser-automation/capabilities.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 {
|
import {
|
||||||
PaseoConfigRawSchema,
|
PaseoConfigRawSchema,
|
||||||
PaseoLifecycleCommandRawSchema,
|
PaseoLifecycleCommandRawSchema,
|
||||||
@@ -211,6 +224,18 @@ export const AgentSkillSelectionSchema = z.discriminatedUnion("mode", [
|
|||||||
]);
|
]);
|
||||||
export type AgentSkillSelection = z.infer<typeof AgentSkillSelectionSchema>;
|
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
|
export const MutableDaemonConfigSchema = z
|
||||||
.object({
|
.object({
|
||||||
// COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported.
|
// 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(),
|
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||||
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||||
|
manualVoice: z
|
||||||
|
.object({ orchestrator: MutableManualVoiceOrchestratorConfigSchema })
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
pluginsEnabled: z.boolean().optional(),
|
pluginsEnabled: z.boolean().optional(),
|
||||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||||
})
|
})
|
||||||
@@ -266,6 +295,10 @@ export const MutableDaemonConfigPatchSchema = z
|
|||||||
appendSystemPrompt: z.string().optional(),
|
appendSystemPrompt: z.string().optional(),
|
||||||
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
terminalProfiles: z.array(TerminalProfileSchema).optional(),
|
||||||
agentProfiles: z.array(AgentProfileSchema).optional(),
|
agentProfiles: z.array(AgentProfileSchema).optional(),
|
||||||
|
manualVoice: z
|
||||||
|
.object({ orchestrator: MutableManualVoiceOrchestratorConfigSchema.partial() })
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
pluginsEnabled: z.boolean().optional(),
|
pluginsEnabled: z.boolean().optional(),
|
||||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||||
})
|
})
|
||||||
@@ -3040,6 +3073,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
HubExecutionAgentValidateRequestSchema,
|
HubExecutionAgentValidateRequestSchema,
|
||||||
HubExecutionControlRequestSchema,
|
HubExecutionControlRequestSchema,
|
||||||
BrowserAutomationExecuteResponseSchema,
|
BrowserAutomationExecuteResponseSchema,
|
||||||
|
VoiceCallStartRequestSchema,
|
||||||
|
VoiceCallStopRequestSchema,
|
||||||
|
VoiceCallContextUpdateRequestSchema,
|
||||||
|
VoiceCallClientTransportMessageSchema,
|
||||||
VoiceAudioChunkMessageSchema,
|
VoiceAudioChunkMessageSchema,
|
||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
@@ -3399,6 +3436,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
||||||
features: z
|
features: z
|
||||||
.object({
|
.object({
|
||||||
|
// COMPAT(voiceChat): added in v0.7.0, remove gate after 2027-08-29.
|
||||||
|
voiceChat: z.boolean().optional(),
|
||||||
providersSnapshot: z.boolean().optional(),
|
providersSnapshot: z.boolean().optional(),
|
||||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||||
providersSnapshotCwd: z.boolean().optional(),
|
providersSnapshotCwd: z.boolean().optional(),
|
||||||
@@ -6402,6 +6441,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
HubExecutionAgentUpdateSchema,
|
HubExecutionAgentUpdateSchema,
|
||||||
HubExecutionAgentStreamSchema,
|
HubExecutionAgentStreamSchema,
|
||||||
BrowserAutomationExecuteRequestSchema,
|
BrowserAutomationExecuteRequestSchema,
|
||||||
|
VoiceCallStartResponseSchema,
|
||||||
|
VoiceCallStopResponseSchema,
|
||||||
|
VoiceCallContextUpdateResponseSchema,
|
||||||
|
VoiceCallStateMessageSchema,
|
||||||
|
VoiceCallEventMessageSchema,
|
||||||
|
VoiceCallServerTransportMessageSchema,
|
||||||
PluginCatalogGetResponseSchema,
|
PluginCatalogGetResponseSchema,
|
||||||
PluginListResponseSchema,
|
PluginListResponseSchema,
|
||||||
PluginLogsGetResponseSchema,
|
PluginLogsGetResponseSchema,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
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 () => {
|
test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
|||||||
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
|
||||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||||
import { resolveCreateAgentTitles } from "./create-agent-title.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 { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
|
||||||
import {
|
import {
|
||||||
ProviderSubagentStore,
|
ProviderSubagentStore,
|
||||||
@@ -283,6 +287,28 @@ export interface CreateAgentOptions {
|
|||||||
// undefined is an explicit decision: the agent never appears in the sidebar.
|
// undefined is an explicit decision: the agent never appears in the sidebar.
|
||||||
workspaceId: string | undefined;
|
workspaceId: string | undefined;
|
||||||
owner?: AgentOwner;
|
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 {
|
export interface AgentManagerOptions {
|
||||||
@@ -295,6 +321,7 @@ export interface AgentManagerOptions {
|
|||||||
durableTimelineStore?: AgentTimelineStore;
|
durableTimelineStore?: AgentTimelineStore;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
mcpBaseUrl?: string;
|
mcpBaseUrl?: string;
|
||||||
|
requiredPaseoToolsBaseUrl?: string;
|
||||||
mcpAuthToken?: string;
|
mcpAuthToken?: string;
|
||||||
paseoToolsEnabled?: boolean;
|
paseoToolsEnabled?: boolean;
|
||||||
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
|
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
|
||||||
@@ -309,6 +336,10 @@ export interface AgentManagerOptions {
|
|||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRequiredPaseoToolsBaseUrl(options: AgentManagerOptions): string | null {
|
||||||
|
return options.requiredPaseoToolsBaseUrl ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ActiveTurnSteerDispatchResult =
|
export type ActiveTurnSteerDispatchResult =
|
||||||
| { status: "inactive" | "steered" }
|
| { status: "inactive" | "steered" }
|
||||||
| { status: "replaced"; iterator: AsyncGenerator<AgentStreamEvent> };
|
| { status: "replaced"; iterator: AsyncGenerator<AgentStreamEvent> };
|
||||||
@@ -707,6 +738,7 @@ export class AgentManager {
|
|||||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||||
private mcpBaseUrl: string | null;
|
private mcpBaseUrl: string | null;
|
||||||
|
private requiredPaseoToolsBaseUrl: string | null;
|
||||||
private readonly mcpAuthToken: string | null;
|
private readonly mcpAuthToken: string | null;
|
||||||
private paseoToolsEnabled = true;
|
private paseoToolsEnabled = true;
|
||||||
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
|
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
|
||||||
@@ -714,6 +746,7 @@ export class AgentManager {
|
|||||||
private readonly resolvePaseoToolPolicy: (
|
private readonly resolvePaseoToolPolicy: (
|
||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
) => ProviderPaseoToolsPolicy | undefined;
|
) => ProviderPaseoToolsPolicy | undefined;
|
||||||
|
private readonly internalAgentTools = new Map<string, InternalAgentPaseoTools>();
|
||||||
private appendSystemPrompt: string;
|
private appendSystemPrompt: string;
|
||||||
private onAgentAttention?: AgentAttentionCallback;
|
private onAgentAttention?: AgentAttentionCallback;
|
||||||
private onAgentArchived?: AgentArchivedCallback;
|
private onAgentArchived?: AgentArchivedCallback;
|
||||||
@@ -730,6 +763,7 @@ export class AgentManager {
|
|||||||
this.onAgentAttention = options?.onAgentAttention;
|
this.onAgentAttention = options?.onAgentAttention;
|
||||||
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
|
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
|
||||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||||
|
this.requiredPaseoToolsBaseUrl = resolveRequiredPaseoToolsBaseUrl(options);
|
||||||
this.mcpAuthToken = options?.mcpAuthToken ?? null;
|
this.mcpAuthToken = options?.mcpAuthToken ?? null;
|
||||||
this.configurePaseoTools(options);
|
this.configurePaseoTools(options);
|
||||||
this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined);
|
this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined);
|
||||||
@@ -802,6 +836,10 @@ export class AgentManager {
|
|||||||
this.mcpBaseUrl = url;
|
this.mcpBaseUrl = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRequiredPaseoToolsBaseUrl(url: string | null): void {
|
||||||
|
this.requiredPaseoToolsBaseUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
prepareForShutdown(): void {
|
prepareForShutdown(): void {
|
||||||
this.acceptingAgentRegistrations = false;
|
this.acceptingAgentRegistrations = false;
|
||||||
}
|
}
|
||||||
@@ -818,6 +856,10 @@ export class AgentManager {
|
|||||||
return this.paseoToolPolicies.get(agentId);
|
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
|
* 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
|
* 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));
|
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(
|
private async createAgentInternal(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string | undefined,
|
agentId: string | undefined,
|
||||||
@@ -1194,6 +1279,8 @@ export class AgentManager {
|
|||||||
config,
|
config,
|
||||||
resolvedAgentId,
|
resolvedAgentId,
|
||||||
options?.env,
|
options?.env,
|
||||||
|
options.paseoTools,
|
||||||
|
options.systemPromptBehavior,
|
||||||
);
|
);
|
||||||
this.requireEnabledProvider(storedConfig.provider);
|
this.requireEnabledProvider(storedConfig.provider);
|
||||||
const client = await this.requireAvailableClient({
|
const client = await this.requireAvailableClient({
|
||||||
@@ -1206,10 +1293,17 @@ export class AgentManager {
|
|||||||
storedConfig.cwd,
|
storedConfig.cwd,
|
||||||
paseoToolPolicy,
|
paseoToolPolicy,
|
||||||
options?.env,
|
options?.env,
|
||||||
|
options.paseoTools,
|
||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
const createOptions = this.buildCreateSessionOptions(options);
|
const createOptions = this.buildCreateSessionOptions(options);
|
||||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||||
|
await this.requirePaseoToolsSupport(
|
||||||
|
session,
|
||||||
|
providerLaunchConfig,
|
||||||
|
launchContext,
|
||||||
|
options.paseoTools,
|
||||||
|
);
|
||||||
await this.requireExternalMcpSupport(session, storedConfig);
|
await this.requireExternalMcpSupport(session, storedConfig);
|
||||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||||
labels: options.labels,
|
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> {
|
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
|
||||||
const registry = this.requireRegistry();
|
const registry = this.requireRegistry();
|
||||||
const archivedAt = new Date().toISOString();
|
const archivedAt = new Date().toISOString();
|
||||||
@@ -3338,6 +3441,18 @@ export class AgentManager {
|
|||||||
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`);
|
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: {
|
private async initializeAgentTimelineForRegister(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
now: Date;
|
now: Date;
|
||||||
@@ -4883,22 +4998,27 @@ export class AgentManager {
|
|||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
env?: Record<string, string>,
|
env?: Record<string, string>,
|
||||||
|
paseoTools?: CreateAgentOptions["paseoTools"],
|
||||||
|
systemPromptBehavior: CreateAgentOptions["systemPromptBehavior"] = "append-daemon",
|
||||||
): Promise<PreparedSessionConfig> {
|
): Promise<PreparedSessionConfig> {
|
||||||
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
|
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env });
|
||||||
const paseoToolPolicy = this.paseoToolsEnabled
|
let paseoToolPolicy = this.paseoToolsEnabled
|
||||||
? this.resolvePaseoToolPolicy(storedConfig.provider)
|
? this.resolvePaseoToolPolicy(storedConfig.provider)
|
||||||
: { enabled: false };
|
: { enabled: false };
|
||||||
const launchConfig = this.applyDaemonAppendSystemPrompt(
|
if (paseoTools === "required") paseoToolPolicy = undefined;
|
||||||
withRuntimePaseoMcpServer({
|
const toolsEnabled = isPaseoToolPolicyEnabled(paseoToolPolicy);
|
||||||
config: storedConfig,
|
let mcpBaseUrl = toolsEnabled ? this.mcpBaseUrl : null;
|
||||||
agentId,
|
if (paseoTools === "required") mcpBaseUrl = this.requiredPaseoToolsBaseUrl;
|
||||||
mcpBaseUrl:
|
const runtimeConfig = withRuntimePaseoMcpServer({
|
||||||
this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy)
|
config: storedConfig,
|
||||||
? this.mcpBaseUrl
|
agentId,
|
||||||
: null,
|
mcpBaseUrl,
|
||||||
mcpAuthToken: this.mcpAuthToken,
|
mcpAuthToken: this.mcpAuthToken,
|
||||||
}),
|
});
|
||||||
);
|
const launchConfig =
|
||||||
|
systemPromptBehavior === "authoritative"
|
||||||
|
? runtimeConfig
|
||||||
|
: this.applyDaemonAppendSystemPrompt(runtimeConfig);
|
||||||
return { storedConfig, launchConfig, paseoToolPolicy };
|
return { storedConfig, launchConfig, paseoToolPolicy };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4921,6 +5041,7 @@ export class AgentManager {
|
|||||||
cwd: string,
|
cwd: string,
|
||||||
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined,
|
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined,
|
||||||
env?: Record<string, string>,
|
env?: Record<string, string>,
|
||||||
|
paseoTools?: CreateAgentOptions["paseoTools"],
|
||||||
): Promise<AgentLaunchContext> {
|
): Promise<AgentLaunchContext> {
|
||||||
const context: AgentLaunchContext = {
|
const context: AgentLaunchContext = {
|
||||||
agentId,
|
agentId,
|
||||||
@@ -4931,7 +5052,7 @@ export class AgentManager {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
this.paseoToolsEnabled &&
|
(this.paseoToolsEnabled || paseoTools === "required") &&
|
||||||
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
|
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
|
||||||
client.capabilities.supportsNativePaseoTools &&
|
client.capabilities.supportsNativePaseoTools &&
|
||||||
this.paseoToolCatalogFactory
|
this.paseoToolCatalogFactory
|
||||||
|
|||||||
@@ -5175,51 +5175,40 @@ describe("provider MCP tools", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("speak MCP tool", () => {
|
describe("agent tool extensions", () => {
|
||||||
const logger = createTestLogger();
|
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 { agentManager, agentStorage } = createTestDeps();
|
||||||
const speak = vi.fn().mockResolvedValue(undefined);
|
const handler = vi.fn().mockResolvedValue({ content: [], structuredContent: { ok: true } });
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
providerSnapshotManager: createOpenCodeManager().manager,
|
providerSnapshotManager: createOpenCodeManager().manager,
|
||||||
callerAgentId: "voice-agent-1",
|
callerAgentId: "voice-agent-1",
|
||||||
enableVoiceTools: true,
|
resolveToolExtension: () => ({
|
||||||
resolveSpeakHandler: () => speak,
|
tools: [
|
||||||
|
{
|
||||||
|
name: "speak",
|
||||||
|
description: "Speak",
|
||||||
|
inputSchema: z.object({ text: z.string() }),
|
||||||
|
handler,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const tool = registeredTool(server, "speak");
|
const tool = registeredTool(server, "speak");
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
await tool.handler({ text: "Hello from voice agent." });
|
await tool.handler({ text: "Hello from voice agent." });
|
||||||
expect(speak).toHaveBeenCalledWith(
|
expect(handler).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
{ text: "Hello from voice agent." },
|
||||||
text: "Hello from voice agent.",
|
expect.objectContaining({}),
|
||||||
callerAgentId: "voice-agent-1",
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails when no speak handler exists", async () => {
|
it("does not register provider tools without an extension", 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 () => {
|
|
||||||
const { agentManager, agentStorage } = createTestDeps();
|
const { agentManager, agentStorage } = createTestDeps();
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
type ArchiveDependencies,
|
type ArchiveDependencies,
|
||||||
} from "../../workspace-archive-service.js";
|
} from "../../workspace-archive-service.js";
|
||||||
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.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 type { FirstAgentContext } from "../../messages.js";
|
||||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||||
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
|
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
|
||||||
@@ -86,10 +85,12 @@ import {
|
|||||||
import { registerBrowserTools } from "../../browser-tools/tools.js";
|
import { registerBrowserTools } from "../../browser-tools/tools.js";
|
||||||
import type { BrowserToolsBroker } from "../../browser-tools/broker.js";
|
import type { BrowserToolsBroker } from "../../browser-tools/broker.js";
|
||||||
import type {
|
import type {
|
||||||
|
PaseoCallerContext,
|
||||||
PaseoToolCatalog,
|
PaseoToolCatalog,
|
||||||
PaseoToolConfig,
|
PaseoToolConfig,
|
||||||
PaseoToolDefinition,
|
PaseoToolDefinition,
|
||||||
PaseoToolExecutionContext,
|
PaseoToolExecutionContext,
|
||||||
|
PaseoToolExtension,
|
||||||
PaseoToolResult,
|
PaseoToolResult,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
|
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.
|
* Used for cwd/mode inheritance when agents spawn child agents.
|
||||||
*/
|
*/
|
||||||
callerAgentId?: string;
|
callerAgentId?: string;
|
||||||
/**
|
resolveCallerContext?: (callerAgentId: string) => PaseoCallerContext | null;
|
||||||
* Optional resolver for session-bound speak handlers.
|
resolveToolExtension?: (callerAgentId: string) => PaseoToolExtension | null;
|
||||||
* 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;
|
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,8 +546,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
providerSnapshotManager,
|
providerSnapshotManager,
|
||||||
daemonConfigStore,
|
daemonConfigStore,
|
||||||
callerAgentId,
|
callerAgentId,
|
||||||
resolveSpeakHandler,
|
|
||||||
resolveCallerContext,
|
resolveCallerContext,
|
||||||
|
resolveToolExtension,
|
||||||
logger,
|
logger,
|
||||||
} = options;
|
} = options;
|
||||||
const childLogger = logger.child({ module: "agent", component: "paseo-tool-catalog" });
|
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: {
|
const buildCronScheduleCadence = (input: {
|
||||||
cron: string | undefined;
|
cron: string | undefined;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
@@ -1162,49 +1164,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
|||||||
type TopLevelCreateAgentArgs = z.infer<typeof canonicalTopLevelCreateAgentArgsSchema>;
|
type TopLevelCreateAgentArgs = z.infer<typeof canonicalTopLevelCreateAgentArgsSchema>;
|
||||||
type LegacyTopLevelCreateAgentArgs = z.infer<typeof legacyTopLevelCreateAgentArgsSchema>;
|
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) {
|
if (options.browserToolsEnabled && options.browserToolsBroker) {
|
||||||
registerBrowserTools({
|
registerBrowserTools({
|
||||||
registerTool,
|
registerTool,
|
||||||
|
|||||||
@@ -35,11 +35,19 @@ export interface PaseoToolCatalog {
|
|||||||
): Promise<PaseoToolResult>;
|
): Promise<PaseoToolResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaseoCallerContext {
|
||||||
|
childAgentDefaultLabels?: Record<string, string>;
|
||||||
|
lockedCwd?: string;
|
||||||
|
allowCustomCwd?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaseoToolExtension {
|
||||||
|
tools: readonly PaseoToolDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaseoToolRuntimeContext {
|
export interface PaseoToolRuntimeContext {
|
||||||
callerAgentId?: string;
|
callerAgentId?: string;
|
||||||
paseoToolPolicy?: ProviderPaseoToolsPolicy;
|
paseoToolPolicy?: ProviderPaseoToolsPolicy;
|
||||||
enableVoiceTools?: boolean;
|
|
||||||
voiceOnly?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaseoToolCatalogFactory = (
|
export type PaseoToolCatalogFactory = (
|
||||||
|
|||||||
@@ -148,15 +148,18 @@ export async function isAgentMcpRequestAuthorized(input: {
|
|||||||
if (!input.password) {
|
if (!input.password) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (isAgentMcpCapabilityToken(input)) return true;
|
||||||
const token = extractHttpBearerToken(input.authorizationHeader);
|
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 });
|
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",
|
unsubscribe_terminals_request: "workspace.read",
|
||||||
update_agent_request: "workspace.write",
|
update_agent_request: "workspace.write",
|
||||||
validate_branch_request: "workspace.read",
|
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",
|
voice_audio_chunk: "workspace.write",
|
||||||
wait_for_finish_request: "workspace.read",
|
wait_for_finish_request: "workspace.read",
|
||||||
"workspace.clear_attention.request": "workspace.write",
|
"workspace.clear_attention.request": "workspace.write",
|
||||||
@@ -391,6 +395,12 @@ const OUTBOUND_PERMISSION = {
|
|||||||
transcription_result: "workspace.write",
|
transcription_result: "workspace.write",
|
||||||
update_agent_response: "workspace.write",
|
update_agent_response: "workspace.write",
|
||||||
validate_branch_response: "workspace.read",
|
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",
|
voice_input_state: "workspace.write",
|
||||||
wait_for_finish_response: "workspace.read",
|
wait_for_finish_response: "workspace.read",
|
||||||
"workspace.clear_attention.response": "workspace.write",
|
"workspace.clear_attention.response": "workspace.write",
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi
|
|||||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||||
import { createSpeechService } from "./speech/speech-runtime.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 { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { AgentStorage } from "./agent/agent-storage.js";
|
import { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||||
@@ -179,6 +185,7 @@ import type {
|
|||||||
PluginSource,
|
PluginSource,
|
||||||
TerminalProfile,
|
TerminalProfile,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
|
import { MutableManualVoiceOrchestratorConfigSchema } from "@getpaseo/protocol/messages";
|
||||||
import type {
|
import type {
|
||||||
AgentProviderRuntimeSettingsMap,
|
AgentProviderRuntimeSettingsMap,
|
||||||
ProviderOverride,
|
ProviderOverride,
|
||||||
@@ -201,6 +208,7 @@ import { terminateWithTreeKill } from "../utils/tree-kill.js";
|
|||||||
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
||||||
import {
|
import {
|
||||||
createRequireBearerMiddleware,
|
createRequireBearerMiddleware,
|
||||||
|
isAgentMcpCapabilityToken,
|
||||||
isAgentMcpRequestAuthorized,
|
isAgentMcpRequestAuthorized,
|
||||||
type DaemonAuthConfig,
|
type DaemonAuthConfig,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
@@ -430,9 +438,7 @@ export interface PaseoDaemonConfig {
|
|||||||
auth?: DaemonAuthConfig;
|
auth?: DaemonAuthConfig;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
speech?: PaseoSpeechConfig;
|
speech?: PaseoSpeechConfig;
|
||||||
voiceLlmProvider?: AgentProvider | null;
|
manualVoice?: ManualVoiceConfig;
|
||||||
voiceLlmProviderExplicit?: boolean;
|
|
||||||
voiceLlmModel?: string | null;
|
|
||||||
dictationFinalTimeoutMs?: number;
|
dictationFinalTimeoutMs?: number;
|
||||||
downloadTokenTtlMs?: number;
|
downloadTokenTtlMs?: number;
|
||||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||||
@@ -523,8 +529,24 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
|
|||||||
return config.trustedProxies ?? ["loopback"];
|
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 {
|
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
|
||||||
const providers = config.providerOverrides ?? {};
|
const providers = config.providerOverrides ?? {};
|
||||||
|
const manualVoice = resolveDaemonManualVoiceConfig(config);
|
||||||
|
|
||||||
const initialConfig: MutableDaemonConfig = {
|
const initialConfig: MutableDaemonConfig = {
|
||||||
relay: { enabled: config.relayEnabled ?? true },
|
relay: { enabled: config.relayEnabled ?? true },
|
||||||
@@ -551,6 +573,9 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
|
|||||||
pluginsEnabled: config.pluginsEnabled ?? false,
|
pluginsEnabled: config.pluginsEnabled ?? false,
|
||||||
plugins: config.plugins ?? {},
|
plugins: config.plugins ?? {},
|
||||||
skills: { selection: config.skillSelection },
|
skills: { selection: config.skillSelection },
|
||||||
|
manualVoice: {
|
||||||
|
orchestrator: manualVoice.orchestrator,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (config.terminalProfiles !== undefined) {
|
if (config.terminalProfiles !== undefined) {
|
||||||
@@ -1343,11 +1368,10 @@ export async function createPaseoDaemon(
|
|||||||
{ elapsed: elapsed() },
|
{ elapsed: elapsed() },
|
||||||
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`,
|
`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");
|
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
|
||||||
|
|
||||||
|
let voiceChat: VoiceChat | null = null;
|
||||||
|
|
||||||
const createAgentToolHostDependencies = (
|
const createAgentToolHostDependencies = (
|
||||||
runtime: PaseoToolRuntimeContext,
|
runtime: PaseoToolRuntimeContext,
|
||||||
): PaseoToolHostDependencies => ({
|
): PaseoToolHostDependencies => ({
|
||||||
@@ -1407,10 +1431,10 @@ export async function createPaseoDaemon(
|
|||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
worktreesRoot: config.worktreesRoot,
|
worktreesRoot: config.worktreesRoot,
|
||||||
callerAgentId: runtime.callerAgentId,
|
callerAgentId: runtime.callerAgentId,
|
||||||
enableVoiceTools: runtime.enableVoiceTools,
|
resolveCallerContext: (agentId) =>
|
||||||
voiceOnly: runtime.voiceOnly,
|
agentManager.getInternalAgentPaseoTools(agentId)?.caller ?? null,
|
||||||
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
resolveToolExtension: (agentId) =>
|
||||||
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
agentManager.getInternalAgentPaseoTools(agentId)?.extension ?? null,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
|
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
|
||||||
@@ -1462,7 +1486,11 @@ export async function createPaseoDaemon(
|
|||||||
req: express.Request,
|
req: express.Request,
|
||||||
res: express.Response,
|
res: express.Response,
|
||||||
): Promise<void> => {
|
): 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" });
|
res.status(404).json({ error: "Agent MCP endpoint disabled" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1550,11 +1578,25 @@ export async function createPaseoDaemon(
|
|||||||
logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
|
logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
|
||||||
}
|
}
|
||||||
|
|
||||||
const speechService = createSpeechService({
|
const dictationSpeechService = createSpeechService({
|
||||||
logger,
|
logger,
|
||||||
openaiConfig: config.openai,
|
openaiConfig: config.openai,
|
||||||
speechConfig: config.speech,
|
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() }, "Speech service created");
|
||||||
|
|
||||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||||
@@ -1589,6 +1631,7 @@ export async function createPaseoDaemon(
|
|||||||
const logAndResolve = async () => {
|
const logAndResolve = async () => {
|
||||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||||
const mcpBaseUrl = createAgentMcpBaseUrl(boundListenTarget);
|
const mcpBaseUrl = createAgentMcpBaseUrl(boundListenTarget);
|
||||||
|
agentManager.setRequiredPaseoToolsBaseUrl(mcpBaseUrl);
|
||||||
agentMcpBaseUrl =
|
agentMcpBaseUrl =
|
||||||
!mcpEnabled || config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
!mcpEnabled || config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
||||||
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
||||||
@@ -1656,7 +1699,7 @@ export async function createPaseoDaemon(
|
|||||||
},
|
},
|
||||||
workspaceAutoName,
|
workspaceAutoName,
|
||||||
config.auth,
|
config.auth,
|
||||||
speechService,
|
dictationSpeechService,
|
||||||
terminalManager,
|
terminalManager,
|
||||||
{
|
{
|
||||||
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
||||||
@@ -1706,6 +1749,7 @@ export async function createPaseoDaemon(
|
|||||||
pluginRuntime,
|
pluginRuntime,
|
||||||
orchestrationSkills,
|
orchestrationSkills,
|
||||||
workspaceLabelService,
|
workspaceLabelService,
|
||||||
|
voiceChat,
|
||||||
);
|
);
|
||||||
pluginRuntime.bindPaseoSessionHost(wsServer);
|
pluginRuntime.bindPaseoSessionHost(wsServer);
|
||||||
await pluginRuntime.start();
|
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.
|
// model loading doesn't block the server from accepting connections.
|
||||||
speechService.start();
|
dictationSpeechService.start();
|
||||||
scriptHealthMonitor.start();
|
scriptHealthMonitor.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
unsubscribePluginProviders();
|
unsubscribePluginProviders();
|
||||||
@@ -1779,7 +1823,9 @@ export async function createPaseoDaemon(
|
|||||||
await agentStorage.flush().catch(() => undefined);
|
await agentStorage.flush().catch(() => undefined);
|
||||||
await agentProviderRuntime.shutdown();
|
await agentProviderRuntime.shutdown();
|
||||||
terminalManager.killAll();
|
terminalManager.killAll();
|
||||||
await speechService.stop();
|
await voiceChat?.close();
|
||||||
|
await manualVoiceProvider.close();
|
||||||
|
await dictationSpeechService.stop();
|
||||||
await scheduleService.stop().catch(() => undefined);
|
await scheduleService.stop().catch(() => undefined);
|
||||||
await relayRuntime?.stop().catch(() => undefined);
|
await relayRuntime?.stop().catch(() => undefined);
|
||||||
if (wsServer) {
|
if (wsServer) {
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ describe("server config", () => {
|
|||||||
PASEO_TRUSTED_PROXIES: "true",
|
PASEO_TRUSTED_PROXIES: "true",
|
||||||
PASEO_WEB_UI_ENABLED: "true",
|
PASEO_WEB_UI_ENABLED: "true",
|
||||||
PASEO_LOG_FILE_PATH: "custom.log",
|
PASEO_LOG_FILE_PATH: "custom.log",
|
||||||
PASEO_VOICE_LLM_PROVIDER: "codex",
|
|
||||||
},
|
},
|
||||||
cli: { relayUseTls: false },
|
cli: { relayUseTls: false },
|
||||||
});
|
});
|
||||||
@@ -80,67 +79,73 @@ describe("server config", () => {
|
|||||||
"daemon.relay.endpoint",
|
"daemon.relay.endpoint",
|
||||||
"daemon.relay.useTls",
|
"daemon.relay.useTls",
|
||||||
"daemon.trustedProxies",
|
"daemon.trustedProxies",
|
||||||
"features.voiceMode.llm.provider",
|
|
||||||
"features.webUi.enabled",
|
"features.webUi.enabled",
|
||||||
"log.file.path",
|
"log.file.path",
|
||||||
]);
|
]);
|
||||||
expect(config.listen).toBe("127.0.0.1:7000");
|
expect(config.listen).toBe("127.0.0.1:7000");
|
||||||
expect(config.trustedProxies).toBe(true);
|
expect(config.trustedProxies).toBe(true);
|
||||||
expect(config.log?.file?.path).toBe("custom.log");
|
expect(config.log?.file?.path).toBe("custom.log");
|
||||||
expect(config.voiceLlmProvider).toBe("codex");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test("resolves the complete manual voice orchestrator selection", async () => {
|
||||||
{
|
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-voice-agent-"));
|
||||||
name: "local speech providers",
|
roots.push(paseoHome);
|
||||||
providers: { dictation: "local", voiceStt: "local", voiceTts: "local" },
|
await writeFile(
|
||||||
expected: [
|
path.join(paseoHome, "config.json"),
|
||||||
"features.dictation.stt.model",
|
JSON.stringify({
|
||||||
"features.voiceMode.stt.model",
|
manualVoice: {
|
||||||
"features.voiceMode.tts.model",
|
orchestrator: {
|
||||||
],
|
provider: "codex",
|
||||||
},
|
model: "gpt-5.4",
|
||||||
{
|
modeId: "full-access",
|
||||||
name: "OpenAI speech providers",
|
thinkingOptionId: "high",
|
||||||
providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" },
|
},
|
||||||
expected: [
|
stt: { provider: "openai", model: "gpt-4o-mini-transcribe", language: "de" },
|
||||||
"features.dictation.stt.confidenceThreshold",
|
turnDetection: { provider: "local" },
|
||||||
"features.dictation.stt.model",
|
tts: { provider: "openai", model: "tts-1-hd", voice: "nova" },
|
||||||
"features.voiceMode.stt.model",
|
},
|
||||||
"features.voiceMode.tts.model",
|
providers: {
|
||||||
"features.voiceMode.tts.voice",
|
openai: { apiKey: "manual-speech-key" },
|
||||||
],
|
},
|
||||||
},
|
}),
|
||||||
{
|
);
|
||||||
name: "mixed local and OpenAI speech providers",
|
|
||||||
providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" },
|
const config = loadConfig(paseoHome, { env: {} });
|
||||||
expected: [
|
|
||||||
"features.dictation.stt.confidenceThreshold",
|
expect(config.manualVoice.orchestrator).toEqual({
|
||||||
"features.dictation.stt.model",
|
provider: "codex",
|
||||||
"features.voiceMode.stt.model",
|
model: "gpt-5.4",
|
||||||
"features.voiceMode.tts.model",
|
modeId: "full-access",
|
||||||
],
|
thinkingOptionId: "high",
|
||||||
},
|
});
|
||||||
])("classifies speech overrides for $name", ({ providers, expected }) => {
|
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(
|
const config = resolveConfigFromPersisted(
|
||||||
"/tmp/paseo-speech-override-classification",
|
"/tmp/paseo-speech-override-classification",
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
features: {
|
features: {
|
||||||
dictation: { enabled: true, stt: { provider: providers.dictation } },
|
dictation: { enabled: true, stt: { provider: "local" } },
|
||||||
voiceMode: {
|
|
||||||
enabled: true,
|
|
||||||
stt: { provider: providers.voiceStt },
|
|
||||||
tts: { provider: providers.voiceTts },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
OPENAI_API_KEY: "test-api-key",
|
OPENAI_API_KEY: "test-api-key",
|
||||||
PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
|
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_CONFIDENCE_THRESHOLD: "0.5",
|
||||||
STT_MODEL: "whisper-1",
|
STT_MODEL: "whisper-1",
|
||||||
TTS_MODEL: "tts-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", () => {
|
test("resolves bundled web UI path from source-tree modules", () => {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
LogLevelSchema,
|
LogLevelSchema,
|
||||||
type PersistedConfig,
|
type PersistedConfig,
|
||||||
} from "./persisted-config.js";
|
} from "./persisted-config.js";
|
||||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
|
||||||
import type {
|
import type {
|
||||||
AgentProviderRuntimeSettingsMap,
|
AgentProviderRuntimeSettingsMap,
|
||||||
ProviderOverride,
|
ProviderOverride,
|
||||||
@@ -24,6 +23,7 @@ import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
|||||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||||
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
|
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
|
||||||
import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js";
|
import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js";
|
||||||
|
import { resolveManualVoiceConfig } from "./voice-chat/providers/manual/config.js";
|
||||||
|
|
||||||
const DEFAULT_PORT = 6767;
|
const DEFAULT_PORT = 6767;
|
||||||
const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
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;
|
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(
|
function extractProviderOverrides(
|
||||||
providers: Record<string, unknown> | undefined,
|
providers: Record<string, unknown> | undefined,
|
||||||
): Record<string, ProviderOverride> | 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 {
|
function resolveServiceProxyPublicBaseUrl(value: string | null): string | null {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return 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(
|
function resolveCorsAllowedOrigins(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
persisted: ReturnType<typeof loadPersistedConfig>,
|
persisted: ReturnType<typeof loadPersistedConfig>,
|
||||||
@@ -589,7 +556,11 @@ export function resolveConfigFromPersisted(
|
|||||||
persisted,
|
persisted,
|
||||||
});
|
});
|
||||||
|
|
||||||
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
|
const manualVoice = resolveManualVoiceConfig({
|
||||||
|
paseoHome,
|
||||||
|
persisted: persisted.manualVoice,
|
||||||
|
providers: persisted.providers,
|
||||||
|
});
|
||||||
const providerOverrides = extractProviderOverrides(
|
const providerOverrides = extractProviderOverrides(
|
||||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
@@ -633,9 +604,7 @@ export function resolveConfigFromPersisted(
|
|||||||
auth: resolveAuthConfig(env, persisted),
|
auth: resolveAuthConfig(env, persisted),
|
||||||
openai,
|
openai,
|
||||||
speech,
|
speech,
|
||||||
voiceLlmProvider: voiceLlm.provider,
|
manualVoice,
|
||||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
|
||||||
voiceLlmModel: voiceLlm.model,
|
|
||||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||||
@@ -808,42 +777,13 @@ function resolveSpeechOverrideControlledPaths(
|
|||||||
paths.push("features.dictation.stt.model");
|
paths.push("features.dictation.stt.model");
|
||||||
}
|
}
|
||||||
add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language");
|
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");
|
add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir");
|
||||||
const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai");
|
const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai");
|
||||||
const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai");
|
if (env.STT_CONFIDENCE_THRESHOLD !== undefined && openAiDictationStt) {
|
||||||
if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) {
|
|
||||||
paths.push("features.dictation.stt.confidenceThreshold");
|
paths.push("features.dictation.stt.confidenceThreshold");
|
||||||
}
|
}
|
||||||
if (env.STT_MODEL !== undefined) {
|
if (env.STT_MODEL !== undefined) {
|
||||||
if (openAiDictationStt) paths.push("features.dictation.stt.model");
|
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;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,55 @@ describe("DaemonConfigStore", () => {
|
|||||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
|
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", () => {
|
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
|
||||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
tempDirs.push(paseoHome);
|
tempDirs.push(paseoHome);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface SupportedMutableConfigPatch {
|
|||||||
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
||||||
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
||||||
skills?: MutableDaemonConfig["skills"];
|
skills?: MutableDaemonConfig["skills"];
|
||||||
|
manualVoice?: MutableDaemonConfigPatch["manualVoice"];
|
||||||
pluginsEnabled?: boolean;
|
pluginsEnabled?: boolean;
|
||||||
plugins?: MutableDaemonConfig["plugins"];
|
plugins?: MutableDaemonConfig["plugins"];
|
||||||
}
|
}
|
||||||
@@ -188,6 +189,7 @@ const RELOADABLE_PATHS = [
|
|||||||
"agents.catalogRefreshTimeoutMs",
|
"agents.catalogRefreshTimeoutMs",
|
||||||
"agents.metadataGeneration",
|
"agents.metadataGeneration",
|
||||||
"agents.skills.selection",
|
"agents.skills.selection",
|
||||||
|
"manualVoice.orchestrator",
|
||||||
"pluginsEnabled",
|
"pluginsEnabled",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -211,6 +213,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
|||||||
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
||||||
["agents.metadataGeneration", "metadataGeneration"],
|
["agents.metadataGeneration", "metadataGeneration"],
|
||||||
["agents.skills.selection", "skills.selection"],
|
["agents.skills.selection", "skills.selection"],
|
||||||
|
["manualVoice.orchestrator", "manualVoice.orchestrator"],
|
||||||
["pluginsEnabled", "pluginsEnabled"],
|
["pluginsEnabled", "pluginsEnabled"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -274,6 +277,9 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
|
|||||||
: {}),
|
: {}),
|
||||||
...(patch.terminalProfiles !== undefined ? { terminalProfiles: patch.terminalProfiles } : {}),
|
...(patch.terminalProfiles !== undefined ? { terminalProfiles: patch.terminalProfiles } : {}),
|
||||||
...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
|
...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
|
||||||
|
...(patch.manualVoice?.orchestrator !== undefined
|
||||||
|
? { manualVoice: { orchestrator: patch.manualVoice.orchestrator } }
|
||||||
|
: {}),
|
||||||
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
||||||
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
||||||
};
|
};
|
||||||
@@ -584,15 +590,34 @@ function mergeMutablePatchIntoPersistedConfig(params: {
|
|||||||
const { persisted, patch, removeProviders, persistRelayEnabled } = params;
|
const { persisted, patch, removeProviders, persistRelayEnabled } = params;
|
||||||
const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
|
const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
|
||||||
const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders);
|
const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders);
|
||||||
|
const manualVoice = mergeMutableManualVoicePatch(persisted.manualVoice, patch);
|
||||||
return {
|
return {
|
||||||
...persisted,
|
...persisted,
|
||||||
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
||||||
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
||||||
...(daemon ? { daemon } : { daemon: undefined }),
|
...(daemon ? { daemon } : { daemon: undefined }),
|
||||||
...(agents ? { agents } : { agents: undefined }),
|
...(agents ? { agents } : { agents: undefined }),
|
||||||
|
...(manualVoice ? { manualVoice } : { manualVoice: undefined }),
|
||||||
} as PersistedConfig;
|
} 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(
|
function mergeMutableAgentPatch(
|
||||||
persistedAgents: PersistedConfig["agents"],
|
persistedAgents: PersistedConfig["agents"],
|
||||||
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
|
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
|
||||||
|
|||||||
@@ -625,39 +625,55 @@ describe("PersistedConfigSchema logging config", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PersistedConfigSchema voice mode config", () => {
|
describe("PersistedConfigSchema manual voice config", () => {
|
||||||
test("accepts a dedicated turn detection provider", () => {
|
test("accepts the provider-owned manual voice configuration", () => {
|
||||||
const parsed = PersistedConfigSchema.parse({
|
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: {
|
features: {
|
||||||
voiceMode: {
|
voiceMode: {
|
||||||
turnDetection: {
|
llm: { provider: "codex", modeId: "full-access" },
|
||||||
provider: "local",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
const parsed = PersistedConfigSchema.parse({
|
||||||
features: {
|
features: { dictation: { stt: { language: " fr " } } },
|
||||||
dictation: {
|
|
||||||
stt: {
|
|
||||||
language: " fr ",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
voiceMode: {
|
|
||||||
stt: {
|
|
||||||
language: " de ",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(parsed.features?.dictation?.stt?.language).toBe("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 });
|
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", () => {
|
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { ManualVoicePersistedConfigSchema } from "./voice-chat/providers/manual/config.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AgentProviderRuntimeSettingsMapSchema,
|
AgentProviderRuntimeSettingsMapSchema,
|
||||||
@@ -119,43 +120,6 @@ const FeatureDictationSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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
|
const FeatureWebUiSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@@ -310,6 +274,7 @@ export const PersistedConfigSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
providers: ProvidersSchema.optional(),
|
providers: ProvidersSchema.optional(),
|
||||||
|
manualVoice: ManualVoicePersistedConfigSchema.optional(),
|
||||||
pluginsEnabled: z.boolean().optional(),
|
pluginsEnabled: z.boolean().optional(),
|
||||||
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
|
||||||
worktrees: WorktreesConfigSchema.optional(),
|
worktrees: WorktreesConfigSchema.optional(),
|
||||||
@@ -325,7 +290,6 @@ export const PersistedConfigSchema = z
|
|||||||
features: z
|
features: z
|
||||||
.object({
|
.object({
|
||||||
dictation: FeatureDictationSchema.optional(),
|
dictation: FeatureDictationSchema.optional(),
|
||||||
voiceMode: FeatureVoiceModeSchema.optional(),
|
|
||||||
webUi: FeatureWebUiSchema.optional(),
|
webUi: FeatureWebUiSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.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
|
// 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,
|
// reject a config written by an older release. The values are discarded, not
|
||||||
// not migrated — there is no back-compat for the removed `providers.openai.voice`
|
// migrated; this only keeps removed settings from preventing daemon startup.
|
||||||
// block (use `providers.openai.stt` / `providers.openai.tts`).
|
|
||||||
function stripRemovedConfigFields(parsed: unknown): unknown {
|
function stripRemovedConfigFields(parsed: unknown): unknown {
|
||||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = { ...(parsed as Record<string, unknown>) };
|
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;
|
const providers = root.providers;
|
||||||
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
|
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
|
||||||
return root;
|
return root;
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ interface SessionForTestOptions {
|
|||||||
hubExecutionAgents?: SessionOptions["hubExecutionAgents"];
|
hubExecutionAgents?: SessionOptions["hubExecutionAgents"];
|
||||||
stt?: SessionOptions["stt"];
|
stt?: SessionOptions["stt"];
|
||||||
voice?: SessionOptions["voice"];
|
voice?: SessionOptions["voice"];
|
||||||
|
voiceChat?: SessionOptions["voiceChat"];
|
||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
serverId?: SessionOptions["serverId"];
|
serverId?: SessionOptions["serverId"];
|
||||||
daemonVersion?: SessionOptions["daemonVersion"];
|
daemonVersion?: SessionOptions["daemonVersion"];
|
||||||
@@ -425,6 +426,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
getDaemonTcpPort: options.getDaemonTcpPort,
|
getDaemonTcpPort: options.getDaemonTcpPort,
|
||||||
getDaemonTcpHost: options.getDaemonTcpHost,
|
getDaemonTcpHost: options.getDaemonTcpHost,
|
||||||
voice: options.voice,
|
voice: options.voice,
|
||||||
|
voiceChat: options.voiceChat,
|
||||||
serverId: options.serverId,
|
serverId: options.serverId,
|
||||||
daemonVersion: options.daemonVersion,
|
daemonVersion: options.daemonVersion,
|
||||||
daemonRuntimeConfig: options.daemonRuntimeConfig,
|
daemonRuntimeConfig: options.daemonRuntimeConfig,
|
||||||
@@ -433,6 +435,20 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
|||||||
return new Session(sessionOptions);
|
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 () => {
|
test("routes host-scoped agent skills requests through the daemon owner", async () => {
|
||||||
const messages: SessionOutboundMessage[] = [];
|
const messages: SessionOutboundMessage[] = [];
|
||||||
const status = {
|
const status = {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-re
|
|||||||
import {
|
import {
|
||||||
serializeAgentStreamEvent,
|
serializeAgentStreamEvent,
|
||||||
type AgentSnapshotPayload,
|
type AgentSnapshotPayload,
|
||||||
type AgentAttachment,
|
|
||||||
type FirstAgentContext,
|
type FirstAgentContext,
|
||||||
type SessionInboundMessage,
|
type SessionInboundMessage,
|
||||||
type SessionOutboundMessage,
|
type SessionOutboundMessage,
|
||||||
@@ -33,8 +32,7 @@ import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index";
|
|||||||
import { CursorError } from "./pagination/cursor.js";
|
import { CursorError } from "./pagination/cursor.js";
|
||||||
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
||||||
import { describeAgentHistoryMatches, rankAgentHistoryCandidates } from "./agent-history-search.js";
|
import { describeAgentHistoryMatches, rankAgentHistoryCandidates } from "./agent-history-search.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
import type { SpeechToTextProvider } from "./speech/speech-provider.js";
|
||||||
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
|
||||||
import {
|
import {
|
||||||
buildConfigOverrides,
|
buildConfigOverrides,
|
||||||
isStoredAgentProviderAvailable,
|
isStoredAgentProviderAvailable,
|
||||||
@@ -51,7 +49,6 @@ import {
|
|||||||
resolveFirstAgentPromptTitle,
|
resolveFirstAgentPromptTitle,
|
||||||
} from "./agent/create-agent-title.js";
|
} from "./agent/create-agent-title.js";
|
||||||
import { respondToAgentPermission } from "./agent/permission-response.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 type { ScriptHealthState } from "./script-health-monitor.js";
|
||||||
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
|
||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
@@ -127,7 +124,6 @@ import {
|
|||||||
getAgentStreamEventTurnId,
|
getAgentStreamEventTurnId,
|
||||||
type AgentPersistenceHandle,
|
type AgentPersistenceHandle,
|
||||||
type AgentPermissionResponse,
|
type AgentPermissionResponse,
|
||||||
type AgentRunOptions,
|
|
||||||
type AgentSessionConfig,
|
type AgentSessionConfig,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
@@ -155,14 +151,20 @@ import {
|
|||||||
type WorkspaceMutation,
|
type WorkspaceMutation,
|
||||||
type WorkspaceRegistry,
|
type WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import { wrapSpokenInput } from "./voice-config.js";
|
import {
|
||||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
rejectLegacyVoiceMode,
|
||||||
|
type VoiceChat,
|
||||||
|
type VoiceChatClientSession,
|
||||||
|
} from "./voice-chat/index.js";
|
||||||
import {
|
import {
|
||||||
ProjectIconReader,
|
ProjectIconReader,
|
||||||
removeProjectCustomIcon,
|
removeProjectCustomIcon,
|
||||||
setProjectCustomIcon,
|
setProjectCustomIcon,
|
||||||
} from "../utils/project-custom-icon.js";
|
} 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 { CheckoutSession } from "./session/checkout/checkout-session.js";
|
||||||
import {
|
import {
|
||||||
createWorkspaceGitObserverService,
|
createWorkspaceGitObserverService,
|
||||||
@@ -505,9 +507,6 @@ export interface SessionOptions {
|
|||||||
};
|
};
|
||||||
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
|
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
|
||||||
mcpBaseUrl?: string | null;
|
mcpBaseUrl?: string | null;
|
||||||
stt: Resolvable<SpeechToTextProvider | null>;
|
|
||||||
sttLanguage?: string;
|
|
||||||
tts: Resolvable<TextToSpeechProvider | null>;
|
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
providerSnapshotManager: ProviderSnapshotManager;
|
providerSnapshotManager: ProviderSnapshotManager;
|
||||||
providerUsageService: ProviderUsageService;
|
providerUsageService: ProviderUsageService;
|
||||||
@@ -526,15 +525,7 @@ export interface SessionOptions {
|
|||||||
getDaemonTcpHost?: () => string | null;
|
getDaemonTcpHost?: () => string | null;
|
||||||
serviceProxyPublicBaseUrl?: string | null;
|
serviceProxyPublicBaseUrl?: string | null;
|
||||||
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
|
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
|
||||||
voice?: {
|
voiceChat?: VoiceChat | null;
|
||||||
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;
|
|
||||||
};
|
|
||||||
dictation?: {
|
dictation?: {
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
stt?: Resolvable<SpeechToTextProvider | null>;
|
stt?: Resolvable<SpeechToTextProvider | null>;
|
||||||
@@ -561,6 +552,31 @@ export type SessionLifecycleIntent =
|
|||||||
reason: string;
|
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(
|
function parseClientCapabilities(
|
||||||
capabilities: Record<string, unknown> | null | undefined,
|
capabilities: Record<string, unknown> | null | undefined,
|
||||||
): ReadonlySet<ClientCapability> {
|
): ReadonlySet<ClientCapability> {
|
||||||
@@ -735,7 +751,8 @@ export class Session {
|
|||||||
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
|
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
|
||||||
private readonly workspaceGitObserver: WorkspaceGitObserverService;
|
private readonly workspaceGitObserver: WorkspaceGitObserverService;
|
||||||
private readonly workspaceDirectory: WorkspaceDirectory;
|
private readonly workspaceDirectory: WorkspaceDirectory;
|
||||||
private readonly voiceSession: VoiceSession;
|
private readonly dictationSession: DictationSession;
|
||||||
|
private readonly voiceChatSession: VoiceChatClientSession | null;
|
||||||
private readonly checkoutSession: CheckoutSession;
|
private readonly checkoutSession: CheckoutSession;
|
||||||
private readonly scheduleSession: ScheduleSession;
|
private readonly scheduleSession: ScheduleSession;
|
||||||
private readonly providerCatalogSession: ProviderCatalogSession;
|
private readonly providerCatalogSession: ProviderCatalogSession;
|
||||||
@@ -781,9 +798,6 @@ export class Session {
|
|||||||
daemonConfigStore,
|
daemonConfigStore,
|
||||||
pluginRuntime,
|
pluginRuntime,
|
||||||
orchestrationSkills,
|
orchestrationSkills,
|
||||||
stt,
|
|
||||||
sttLanguage,
|
|
||||||
tts,
|
|
||||||
terminalManager,
|
terminalManager,
|
||||||
providerSnapshotManager,
|
providerSnapshotManager,
|
||||||
providerUsageService,
|
providerUsageService,
|
||||||
@@ -796,8 +810,7 @@ export class Session {
|
|||||||
getDaemonTcpHost,
|
getDaemonTcpHost,
|
||||||
serviceProxyPublicBaseUrl,
|
serviceProxyPublicBaseUrl,
|
||||||
resolveScriptHealth,
|
resolveScriptHealth,
|
||||||
voice,
|
voiceChat,
|
||||||
voiceBridge,
|
|
||||||
dictation,
|
dictation,
|
||||||
serverId,
|
serverId,
|
||||||
daemonVersion,
|
daemonVersion,
|
||||||
@@ -1075,39 +1088,14 @@ export class Session {
|
|||||||
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.voiceSession = new VoiceSession({
|
this.dictationSession = createClientDictationSession({
|
||||||
host: {
|
options: dictation,
|
||||||
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),
|
|
||||||
},
|
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
sttLanguage,
|
emit: (message) => this.emit(message),
|
||||||
tts,
|
});
|
||||||
stt,
|
this.voiceChatSession = openVoiceChatSession(voiceChat, {
|
||||||
voice,
|
emit: (message) => this.emit(message),
|
||||||
voiceBridge,
|
|
||||||
dictation,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.subscribeToAgentEvents();
|
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 {
|
private handleAgentRunError(agentId: string, error: unknown, context: string): void {
|
||||||
const message = errorToFriendlyMessage(error);
|
const message = errorToFriendlyMessage(error);
|
||||||
this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`);
|
this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`);
|
||||||
@@ -1719,28 +1700,6 @@ export class Session {
|
|||||||
return;
|
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);
|
const serializedEvent = serializeAgentStreamEvent(event.event);
|
||||||
if (!serializedEvent) {
|
if (!serializedEvent) {
|
||||||
return;
|
return;
|
||||||
@@ -2222,28 +2181,37 @@ export class Session {
|
|||||||
|
|
||||||
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||||
switch (msg.type) {
|
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":
|
case "voice_audio_chunk":
|
||||||
return this.voiceSession.handleAudioChunk(msg);
|
return undefined;
|
||||||
case "abort_request":
|
case "abort_request":
|
||||||
return this.voiceSession.handleAbort();
|
return undefined;
|
||||||
case "audio_played":
|
case "audio_played":
|
||||||
this.voiceSession.handleAudioPlayed(msg.id);
|
|
||||||
return undefined;
|
return undefined;
|
||||||
case "set_voice_mode":
|
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":
|
case "dictation_stream_start":
|
||||||
return this.voiceSession.handleDictationStreamStart(msg);
|
return this.dictationSession.start(msg);
|
||||||
case "dictation_stream_chunk":
|
case "dictation_stream_chunk":
|
||||||
return this.voiceSession.handleDictationChunk({
|
return this.dictationSession.append({
|
||||||
dictationId: msg.dictationId,
|
dictationId: msg.dictationId,
|
||||||
seq: msg.seq,
|
seq: msg.seq,
|
||||||
audioBase64: msg.audio,
|
audioBase64: msg.audio,
|
||||||
format: msg.format,
|
format: msg.format,
|
||||||
});
|
});
|
||||||
case "dictation_stream_finish":
|
case "dictation_stream_finish":
|
||||||
return this.voiceSession.handleDictationFinish(msg.dictationId, msg.finalSeq);
|
return this.dictationSession.finish(msg.dictationId, msg.finalSeq);
|
||||||
case "dictation_stream_cancel":
|
case "dictation_stream_cancel":
|
||||||
this.voiceSession.handleDictationCancel(msg.dictationId);
|
this.dictationSession.cancel(msg.dictationId);
|
||||||
return undefined;
|
return undefined;
|
||||||
case "restart_server_request":
|
case "restart_server_request":
|
||||||
return this.handleRestartServerRequest(msg.requestId, msg.reason);
|
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
|
* Handle create agent request
|
||||||
*/
|
*/
|
||||||
@@ -7740,7 +7654,8 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.providerCatalogSession.dispose();
|
this.providerCatalogSession.dispose();
|
||||||
|
|
||||||
await this.voiceSession.cleanup();
|
await this.dictationSession.close();
|
||||||
|
await this.voiceChatSession?.close();
|
||||||
|
|
||||||
this.terminalController.dispose();
|
this.terminalController.dispose();
|
||||||
|
|
||||||
@@ -7749,6 +7664,10 @@ export class Session {
|
|||||||
this.workspaceGitObserver.dispose();
|
this.workspaceGitObserver.dispose();
|
||||||
this.workspaceFilesSession.dispose();
|
this.workspaceFilesSession.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async handleTransportDisconnect(): Promise<void> {
|
||||||
|
await this.voiceChatSession?.disconnect();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CloneRepositoryInput {
|
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,
|
DEFAULT_STT_LANGUAGE,
|
||||||
]),
|
]),
|
||||||
voiceLanguage: firstNonEmptyString([
|
voiceLanguage: firstNonEmptyString([
|
||||||
env.PASEO_VOICE_LANGUAGE,
|
|
||||||
env.PASEO_DICTATION_LANGUAGE,
|
env.PASEO_DICTATION_LANGUAGE,
|
||||||
persisted.features?.voiceMode?.stt?.language,
|
|
||||||
persisted.features?.dictation?.stt?.language,
|
persisted.features?.dictation?.stt?.language,
|
||||||
DEFAULT_STT_LANGUAGE,
|
DEFAULT_STT_LANGUAGE,
|
||||||
]),
|
]),
|
||||||
@@ -159,33 +157,11 @@ function buildLocalSpeechResolutionInput(params: {
|
|||||||
),
|
),
|
||||||
DEFAULT_LOCAL_STT_MODEL,
|
DEFAULT_LOCAL_STT_MODEL,
|
||||||
]),
|
]),
|
||||||
voiceLocalSttModel: firstDefinedValue<string>([
|
voiceLocalSttModel: firstDefinedValue<string>([DEFAULT_LOCAL_STT_MODEL]),
|
||||||
env.PASEO_VOICE_LOCAL_STT_MODEL,
|
voiceLocalTtsModel: firstDefinedValue<string>([DEFAULT_LOCAL_TTS_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,
|
|
||||||
]),
|
|
||||||
...buildLocalSpeechLanguageResolutionInput({ env, persisted }),
|
...buildLocalSpeechLanguageResolutionInput({ env, persisted }),
|
||||||
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([
|
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([]),
|
||||||
env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID,
|
voiceLocalTtsSpeed: firstDefinedValue<string | number>([]),
|
||||||
persisted.features?.voiceMode?.tts?.speakerId,
|
|
||||||
]),
|
|
||||||
voiceLocalTtsSpeed: firstDefinedValue<string | number>([
|
|
||||||
env.PASEO_VOICE_LOCAL_TTS_SPEED,
|
|
||||||
persisted.features?.voiceMode?.tts?.speed,
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,29 +93,15 @@ function buildOpenAiSttInput(params: {
|
|||||||
]),
|
]),
|
||||||
sttModel: firstDefined<string>([
|
sttModel: firstDefined<string>([
|
||||||
env.STT_MODEL,
|
env.STT_MODEL,
|
||||||
pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model),
|
|
||||||
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
|
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
|
||||||
]),
|
]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpenAiTtsInput(params: {
|
function buildOpenAiTtsInput(env: NodeJS.ProcessEnv): Record<string, unknown> {
|
||||||
env: NodeJS.ProcessEnv;
|
|
||||||
persisted: PersistedConfig;
|
|
||||||
providers: RequestedSpeechProviders;
|
|
||||||
}): Record<string, unknown> {
|
|
||||||
const { env, persisted, providers } = params;
|
|
||||||
return {
|
return {
|
||||||
ttsVoice: firstDefined<string>([
|
ttsVoice: firstDefined<string>([env.TTS_VOICE, "alloy"]),
|
||||||
env.TTS_VOICE,
|
ttsModel: firstDefined<string>([env.TTS_MODEL, DEFAULT_OPENAI_TTS_MODEL]),
|
||||||
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,
|
|
||||||
]),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +138,7 @@ function buildOpenAiResolutionInput(params: {
|
|||||||
env.OPENAI_BASE_URL,
|
env.OPENAI_BASE_URL,
|
||||||
]),
|
]),
|
||||||
...buildOpenAiSttInput(params),
|
...buildOpenAiSttInput(params),
|
||||||
...buildOpenAiTtsInput(params),
|
...buildOpenAiTtsInput(params.env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,21 +23,9 @@ describe("resolveSpeechConfig", () => {
|
|||||||
explicit: false,
|
explicit: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
expect(result.speech.providers.voiceTurnDetection.enabled).toBe(false);
|
||||||
provider: "local",
|
expect(result.speech.providers.voiceStt.enabled).toBe(false);
|
||||||
explicit: false,
|
expect(result.speech.providers.voiceTts.enabled).toBe(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.local).toEqual({
|
expect(result.speech.local).toEqual({
|
||||||
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||||
models: {
|
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.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({
|
expect(result.speech.sttLanguages).toEqual({
|
||||||
dictation: "en",
|
dictation: "en",
|
||||||
voice: "en",
|
voice: "en",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves feature-scoped local speech settings", () => {
|
test("resolves dictation settings without reading manual voice configuration", () => {
|
||||||
const persisted = PersistedConfigSchema.parse({
|
const persisted = PersistedConfigSchema.parse({
|
||||||
features: {
|
features: {
|
||||||
voiceMode: {
|
dictation: { stt: { provider: "local", language: "fr" } },
|
||||||
turnDetection: { provider: "local" },
|
|
||||||
stt: { provider: "openai", model: "gpt-4o-transcribe" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
openai: { apiKey: "persisted-key" },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const env = {
|
const env = {
|
||||||
PASEO_DICTATION_LOCAL_STT_MODEL: "parakeet-tdt-0.6b-v2-int8",
|
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_DICTATION_LANGUAGE: "es",
|
||||||
PASEO_VOICE_LANGUAGE: "pt",
|
|
||||||
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
||||||
OPENAI_API_KEY: "env-key",
|
|
||||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
|
||||||
PASEO_DICTATION_STT_PROVIDER: "local",
|
PASEO_DICTATION_STT_PROVIDER: "local",
|
||||||
PASEO_VOICE_TTS_PROVIDER: "local",
|
|
||||||
} as NodeJS.ProcessEnv;
|
} as NodeJS.ProcessEnv;
|
||||||
|
|
||||||
const result = resolveSpeechConfig({
|
const result = resolveSpeechConfig({
|
||||||
@@ -96,8 +67,7 @@ describe("resolveSpeechConfig", () => {
|
|||||||
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
||||||
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
||||||
voiceTts: "kokoro-en-v0_19",
|
voiceTts: "kokoro-en-v0_19",
|
||||||
voiceTtsSpeakerId: 5,
|
voiceTtsSpeakerId: 0,
|
||||||
voiceTtsSpeed: 1.35,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.dictationStt).toEqual({
|
expect(result.speech.providers.dictationStt).toEqual({
|
||||||
@@ -105,71 +75,19 @@ describe("resolveSpeechConfig", () => {
|
|||||||
explicit: true,
|
explicit: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.voiceStt).toEqual({
|
expect(result.speech.providers.voiceStt.enabled).toBe(false);
|
||||||
provider: "openai",
|
expect(result.speech.providers.voiceTurnDetection.enabled).toBe(false);
|
||||||
explicit: true,
|
expect(result.speech.providers.voiceTts.enabled).toBe(false);
|
||||||
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.sttLanguages).toEqual({
|
expect(result.speech.sttLanguages).toEqual({
|
||||||
dictation: "es",
|
dictation: "es",
|
||||||
voice: "es",
|
voice: "es",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("respects disabled dictation and voice mode feature flags", () => {
|
test("respects disabled dictation", () => {
|
||||||
const persisted = PersistedConfigSchema.parse({
|
const persisted = PersistedConfigSchema.parse({
|
||||||
features: {
|
features: {
|
||||||
dictation: { enabled: false },
|
dictation: { enabled: false },
|
||||||
voiceMode: { enabled: false },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,20 +102,8 @@ describe("resolveSpeechConfig", () => {
|
|||||||
explicit: false,
|
explicit: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
expect(result.speech.providers.voiceTurnDetection).toEqual({
|
expect(result.speech.providers.voiceTurnDetection.enabled).toBe(false);
|
||||||
provider: "local",
|
expect(result.speech.providers.voiceStt.enabled).toBe(false);
|
||||||
explicit: false,
|
expect(result.speech.providers.voiceTts.enabled).toBe(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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,12 +65,6 @@ function buildFeatureProviderInputs(params: {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
persisted: PersistedConfig;
|
persisted: PersistedConfig;
|
||||||
}): Record<keyof RequestedSpeechProviders, FeatureProviderInputs> {
|
}): Record<keyof RequestedSpeechProviders, FeatureProviderInputs> {
|
||||||
const voiceModeEnabled = resolveOptionalBooleanFlag(
|
|
||||||
firstSpeechDefinedValue<string | boolean>([
|
|
||||||
params.env.PASEO_VOICE_MODE_ENABLED,
|
|
||||||
params.persisted.features?.voiceMode?.enabled,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
dictationStt: {
|
dictationStt: {
|
||||||
configuredValue: firstSpeechDefinedValue<string>([
|
configuredValue: firstSpeechDefinedValue<string>([
|
||||||
@@ -85,25 +79,16 @@ function buildFeatureProviderInputs(params: {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
voiceTurnDetection: {
|
voiceTurnDetection: {
|
||||||
configuredValue: firstSpeechDefinedValue<string>([
|
configuredValue: undefined,
|
||||||
params.env.PASEO_VOICE_TURN_DETECTION_PROVIDER,
|
enabled: false,
|
||||||
params.persisted.features?.voiceMode?.turnDetection?.provider,
|
|
||||||
]),
|
|
||||||
enabled: voiceModeEnabled,
|
|
||||||
},
|
},
|
||||||
voiceStt: {
|
voiceStt: {
|
||||||
configuredValue: firstSpeechDefinedValue<string>([
|
configuredValue: undefined,
|
||||||
params.env.PASEO_VOICE_STT_PROVIDER,
|
enabled: false,
|
||||||
params.persisted.features?.voiceMode?.stt?.provider,
|
|
||||||
]),
|
|
||||||
enabled: voiceModeEnabled,
|
|
||||||
},
|
},
|
||||||
voiceTts: {
|
voiceTts: {
|
||||||
configuredValue: firstSpeechDefinedValue<string>([
|
configuredValue: undefined,
|
||||||
params.env.PASEO_VOICE_TTS_PROVIDER,
|
enabled: false,
|
||||||
params.persisted.features?.voiceMode?.tts?.provider,
|
|
||||||
]),
|
|
||||||
enabled: voiceModeEnabled,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ interface TestPaseoDaemonOptions {
|
|||||||
cleanup?: boolean;
|
cleanup?: boolean;
|
||||||
openai?: PaseoOpenAIConfig;
|
openai?: PaseoOpenAIConfig;
|
||||||
speech?: PaseoSpeechConfig;
|
speech?: PaseoSpeechConfig;
|
||||||
voiceLlmProvider?: PaseoDaemonConfig["voiceLlmProvider"];
|
manualVoice?: PaseoDaemonConfig["manualVoice"];
|
||||||
voiceLlmProviderExplicit?: boolean;
|
|
||||||
voiceLlmModel?: string | null;
|
|
||||||
dictationFinalTimeoutMs?: number;
|
dictationFinalTimeoutMs?: number;
|
||||||
auth?: PaseoDaemonConfig["auth"];
|
auth?: PaseoDaemonConfig["auth"];
|
||||||
pushNotificationSender?: PushNotificationSender;
|
pushNotificationSender?: PushNotificationSender;
|
||||||
@@ -194,9 +192,7 @@ async function prepareTestDaemonConfig(
|
|||||||
trustedProxies: options.trustedProxies,
|
trustedProxies: options.trustedProxies,
|
||||||
openai: options.openai,
|
openai: options.openai,
|
||||||
speech: options.speech,
|
speech: options.speech,
|
||||||
voiceLlmProvider: options.voiceLlmProvider ?? null,
|
manualVoice: options.manualVoice,
|
||||||
voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false,
|
|
||||||
voiceLlmModel: options.voiceLlmModel ?? null,
|
|
||||||
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
|
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
|
||||||
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
||||||
agentProfiles: options.agentProfiles,
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+14
-10
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import type { AgentPermissionRequest } from "./agent/agent-sdk-types.js";
|
import type { AgentPermissionRequest } from "../../agent/agent-sdk-types.js";
|
||||||
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
import { isManualVoicePermissionAllowed } from "../providers/manual/permission.js";
|
||||||
|
|
||||||
function buildRequest(partial: Partial<AgentPermissionRequest>): AgentPermissionRequest {
|
function buildRequest(partial: Partial<AgentPermissionRequest>): AgentPermissionRequest {
|
||||||
return {
|
return {
|
||||||
@@ -13,30 +13,34 @@ function buildRequest(partial: Partial<AgentPermissionRequest>): AgentPermission
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("isVoicePermissionAllowed", () => {
|
describe("isManualVoicePermissionAllowed", () => {
|
||||||
test("allows direct speak tool names across provider conventions", () => {
|
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(result).toBe(true);
|
||||||
expect(isVoicePermissionAllowed(buildRequest({ name: "paseo_voice.speak" }))).toBe(true);
|
expect(isManualVoicePermissionAllowed(buildRequest({ name: "paseo_voice.speak" }))).toBe(true);
|
||||||
expect(isVoicePermissionAllowed(buildRequest({ name: "mcp__paseo_voice__speak" }))).toBe(true);
|
expect(isManualVoicePermissionAllowed(buildRequest({ name: "mcp__paseo_voice__speak" }))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("denies non-speak tool names", () => {
|
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,
|
false,
|
||||||
);
|
);
|
||||||
expect(isVoicePermissionAllowed(buildRequest({ name: "paseo_create_agent" }))).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("denies non-tool permission kinds", () => {
|
test("denies non-tool permission kinds", () => {
|
||||||
const result = isVoicePermissionAllowed(
|
const result = isManualVoicePermissionAllowed(
|
||||||
buildRequest({ kind: "mode", name: "mcp__paseo__create_agent" }),
|
buildRequest({ kind: "mode", name: "mcp__paseo__create_agent" }),
|
||||||
);
|
);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("denies wrapper tools even when metadata references speak", () => {
|
test("denies wrapper tools even when metadata references speak", () => {
|
||||||
const denied = isVoicePermissionAllowed(
|
const denied = isManualVoicePermissionAllowed(
|
||||||
buildRequest({
|
buildRequest({
|
||||||
name: "codextool",
|
name: "codextool",
|
||||||
metadata: {
|
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 } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
+12
-13
@@ -2,19 +2,19 @@ import { EventEmitter } from "node:events";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { VoiceSession, type VoiceSessionHost } from "./voice-session.js";
|
import { ManualVoiceCall, type ManualVoiceCallHost } from "./index.js";
|
||||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
import type { ManagedAgent } from "../../../agent/agent-manager.js";
|
||||||
import type { SessionOutboundMessage } from "../../messages.js";
|
import type { SessionOutboundMessage } from "../../../messages.js";
|
||||||
import type {
|
import type {
|
||||||
SpeechToTextProvider,
|
SpeechToTextProvider,
|
||||||
StreamingTranscriptionCommittedEvent,
|
StreamingTranscriptionCommittedEvent,
|
||||||
StreamingTranscriptionEvent,
|
StreamingTranscriptionEvent,
|
||||||
StreamingTranscriptionSession,
|
StreamingTranscriptionSession,
|
||||||
} from "../../speech/speech-provider.js";
|
} from "../../../speech/speech-provider.js";
|
||||||
import type {
|
import type {
|
||||||
TurnDetectionProvider,
|
TurnDetectionProvider,
|
||||||
TurnDetectionSession,
|
TurnDetectionSession,
|
||||||
} from "../../speech/turn-detection-provider.js";
|
} from "../../../speech/turn-detection-provider.js";
|
||||||
|
|
||||||
const VOICE_AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
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 emitted: SessionOutboundMessage[];
|
||||||
readonly spokenInput: Array<{ agentId: string; text: string }>;
|
readonly spokenInput: Array<{ agentId: string; text: string }>;
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,6 @@ function createFakeHost(): FakeVoiceHost {
|
|||||||
},
|
},
|
||||||
loadAgent: async (agentId) =>
|
loadAgent: async (agentId) =>
|
||||||
({ id: agentId, config: { systemPrompt: undefined } }) as unknown as ManagedAgent,
|
({ id: agentId, config: { systemPrompt: undefined } }) as unknown as ManagedAgent,
|
||||||
reloadAgentSession: async (agentId) => ({ id: agentId }) as unknown as ManagedAgent,
|
|
||||||
sendSpokenInput: async (agentId, text) => {
|
sendSpokenInput: async (agentId, text) => {
|
||||||
spokenInput.push({ agentId, text });
|
spokenInput.push({ agentId, text });
|
||||||
},
|
},
|
||||||
@@ -91,7 +90,7 @@ function createVoiceSession() {
|
|||||||
createSession: vi.fn(() => detector),
|
createSession: vi.fn(() => detector),
|
||||||
};
|
};
|
||||||
const host = createFakeHost();
|
const host = createFakeHost();
|
||||||
const voiceSession = new VoiceSession({
|
const voiceSession = new ManualVoiceCall({
|
||||||
host,
|
host,
|
||||||
logger: pino({ level: "silent" }),
|
logger: pino({ level: "silent" }),
|
||||||
sessionId: "voice-session-test",
|
sessionId: "voice-session-test",
|
||||||
@@ -109,14 +108,14 @@ async function settle(): Promise<void> {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VoiceSession streaming transcription", () => {
|
describe("manual voice call streaming transcription", () => {
|
||||||
test("surfaces a refused voice-mode agent interruption", async () => {
|
test("surfaces a refused voice-mode agent interruption", async () => {
|
||||||
const { voiceSession, host } = createVoiceSession();
|
const { voiceSession, host } = createVoiceSession();
|
||||||
host.interruptAgentIfRunning = vi.fn(async () => {
|
host.interruptAgentIfRunning = vi.fn(async () => {
|
||||||
throw new Error("active run cancellation was not acknowledged");
|
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(
|
await expect(voiceSession.handleAbort()).rejects.toThrow(
|
||||||
"active run cancellation was not acknowledged",
|
"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 () => {
|
test("delivers the streaming final transcript to the agent exactly once", async () => {
|
||||||
const { voiceSession, detector, sttSession, host } = createVoiceSession();
|
const { voiceSession, detector, sttSession, host } = createVoiceSession();
|
||||||
|
|
||||||
await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID);
|
await voiceSession.start(VOICE_AGENT_ID);
|
||||||
detector.emit("speech_started");
|
detector.emit("speech_started");
|
||||||
await settle();
|
await settle();
|
||||||
detector.emit("speech_stopped");
|
detector.emit("speech_stopped");
|
||||||
@@ -178,7 +177,7 @@ describe("VoiceSession streaming transcription", () => {
|
|||||||
try {
|
try {
|
||||||
const { voiceSession, detector, sttSession, host } = createVoiceSession();
|
const { voiceSession, detector, sttSession, host } = createVoiceSession();
|
||||||
|
|
||||||
await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID);
|
await voiceSession.start(VOICE_AGENT_ID);
|
||||||
detector.emit("speech_started");
|
detector.emit("speech_started");
|
||||||
await settle();
|
await settle();
|
||||||
detector.emit("speech_stopped");
|
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 () => {
|
test("filters a low-confidence streaming final without submitting to the agent", async () => {
|
||||||
const { voiceSession, detector, sttSession, host } = createVoiceSession();
|
const { voiceSession, detector, sttSession, host } = createVoiceSession();
|
||||||
|
|
||||||
await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID);
|
await voiceSession.start(VOICE_AGENT_ID);
|
||||||
detector.emit("speech_started");
|
detector.emit("speech_started");
|
||||||
await settle();
|
await settle();
|
||||||
detector.emit("speech_stopped");
|
detector.emit("speech_stopped");
|
||||||
+56
-332
@@ -2,25 +2,25 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "../../../messages.js";
|
||||||
import { TTSManager } from "../../agent/tts-manager.js";
|
import { TTSManager } from "./synthesis.js";
|
||||||
import { STTManager } from "../../agent/stt-manager.js";
|
import { STTManager } from "./transcription.js";
|
||||||
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech/speech-provider.js";
|
import type {
|
||||||
import type { TurnDetectionProvider } from "../../speech/turn-detection-provider.js";
|
SpeechToTextProvider,
|
||||||
import { maybePersistTtsDebugAudio } from "../../agent/tts-debug.js";
|
TextToSpeechProvider,
|
||||||
import { isPaseoDictationDebugEnabled } from "../../agent/recordings-debug.js";
|
} from "../../../speech/speech-provider.js";
|
||||||
import {
|
import type { TurnDetectionProvider } from "../../../speech/turn-detection-provider.js";
|
||||||
DictationStreamManager,
|
import { maybePersistTtsDebugAudio } from "../../../agent/tts-debug.js";
|
||||||
type DictationStreamOutboundMessage,
|
import { isPaseoDictationDebugEnabled } from "../../../agent/recordings-debug.js";
|
||||||
} from "../../dictation/dictation-stream-manager.js";
|
import { createVoiceTurnController, type VoiceTurnController } from "./turn.js";
|
||||||
import { createVoiceTurnController, type VoiceTurnController } from "./voice-turn-controller.js";
|
import type { VoiceSpeakHandler } from "./speak.js";
|
||||||
import { buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt } from "../../voice-config.js";
|
import type { ManagedAgent } from "../../../agent/agent-manager.js";
|
||||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
|
import type { LocalSpeechModelId } from "../../../speech/providers/local/models.js";
|
||||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
import { toResolver, type Resolvable } from "../../../speech/provider-resolver.js";
|
||||||
import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js";
|
import type {
|
||||||
import type { LocalSpeechModelId } from "../../speech/providers/local/models.js";
|
SpeechReadinessSnapshot,
|
||||||
import { toResolver, type Resolvable } from "../../speech/provider-resolver.js";
|
SpeechReadinessState,
|
||||||
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "../../speech/speech-runtime.js";
|
} from "../../../speech/speech-runtime.js";
|
||||||
|
|
||||||
const PCM_SAMPLE_RATE = 16000;
|
const PCM_SAMPLE_RATE = 16000;
|
||||||
const PCM_CHANNELS = 1;
|
const PCM_CHANNELS = 1;
|
||||||
@@ -34,10 +34,6 @@ const AgentIdSchema = z.guid();
|
|||||||
|
|
||||||
type ProcessingPhase = "idle" | "transcribing";
|
type ProcessingPhase = "idle" | "transcribing";
|
||||||
|
|
||||||
interface VoiceModeBaseConfig {
|
|
||||||
systemPrompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioBufferState {
|
interface AudioBufferState {
|
||||||
chunks: Buffer[];
|
chunks: Buffer[];
|
||||||
format: string;
|
format: string;
|
||||||
@@ -64,26 +60,6 @@ interface VoiceFeatureUnavailableContext {
|
|||||||
missingModelIds: LocalSpeechModelId[];
|
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(
|
function convertPCMToWavBuffer(
|
||||||
pcmBuffer: Buffer,
|
pcmBuffer: Buffer,
|
||||||
sampleRate: number,
|
sampleRate: number,
|
||||||
@@ -114,25 +90,21 @@ function convertPCMToWavBuffer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The agent-facing operations VoiceSession needs from the session that owns it.
|
* The agent-facing operations the manual call needs from its owner.
|
||||||
* VoiceSession owns all voice/audio state; it reaches back through this narrow
|
* ManualVoiceCall owns all voice/audio state; it reaches back through this narrow
|
||||||
* seam only to deliver transcripts to the agent, drive TTS playback, and
|
* seam only to deliver transcripts to the agent, drive TTS playback, and
|
||||||
* abort/inspect the active agent run.
|
* abort/inspect the active agent run.
|
||||||
*/
|
*/
|
||||||
export interface VoiceSessionHost {
|
export interface ManualVoiceCallHost {
|
||||||
emit(msg: SessionOutboundMessage): void;
|
emit(msg: SessionOutboundMessage): void;
|
||||||
loadAgent(agentId: string): Promise<ManagedAgent>;
|
loadAgent(agentId: string): Promise<ManagedAgent>;
|
||||||
reloadAgentSession(
|
|
||||||
agentId: string,
|
|
||||||
overrides: Partial<AgentSessionConfig>,
|
|
||||||
): Promise<ManagedAgent>;
|
|
||||||
sendSpokenInput(agentId: string, text: string): Promise<void>;
|
sendSpokenInput(agentId: string, text: string): Promise<void>;
|
||||||
interruptAgentIfRunning(agentId: string): Promise<void>;
|
interruptAgentIfRunning(agentId: string): Promise<void>;
|
||||||
hasActiveAgentRun(agentId: string | null): boolean;
|
hasActiveAgentRun(agentId: string | null): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceSessionOptions {
|
export interface ManualVoiceCallOptions {
|
||||||
host: VoiceSessionHost;
|
host: ManualVoiceCallHost;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sttLanguage?: string;
|
sttLanguage?: string;
|
||||||
@@ -144,26 +116,20 @@ export interface VoiceSessionOptions {
|
|||||||
voiceBridge?: {
|
voiceBridge?: {
|
||||||
registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
|
registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
|
||||||
unregisterVoiceSpeakHandler?: (agentId: string) => 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
|
* 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-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.
|
* audio buffering or processing phases.
|
||||||
*/
|
*/
|
||||||
export class VoiceSession {
|
export class ManualVoiceCall {
|
||||||
private readonly host: VoiceSessionHost;
|
private readonly host: ManualVoiceCallHost;
|
||||||
private readonly sessionLogger: pino.Logger;
|
private readonly sessionLogger: pino.Logger;
|
||||||
private readonly sessionId: string;
|
private readonly sessionId: string;
|
||||||
private readonly sttLanguage: string;
|
private readonly sttLanguage: string;
|
||||||
@@ -174,7 +140,6 @@ export class VoiceSession {
|
|||||||
private isVoiceMode = false;
|
private isVoiceMode = false;
|
||||||
private speechInProgress = false;
|
private speechInProgress = false;
|
||||||
|
|
||||||
private readonly dictationStreamManager: DictationStreamManager;
|
|
||||||
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
|
private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null;
|
||||||
private voiceTurnController: VoiceTurnController | null = null;
|
private voiceTurnController: VoiceTurnController | null = null;
|
||||||
private voiceInputChunkCount = 0;
|
private voiceInputChunkCount = 0;
|
||||||
@@ -197,19 +162,13 @@ export class VoiceSession {
|
|||||||
handler: VoiceSpeakHandler,
|
handler: VoiceSpeakHandler,
|
||||||
) => void;
|
) => void;
|
||||||
private readonly unregisterVoiceSpeakHandler?: (agentId: string) => 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 getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||||
|
private readonly onFatalError?: (error: Error) => void;
|
||||||
|
|
||||||
private voiceModeAgentId: string | null = null;
|
private voiceModeAgentId: string | null = null;
|
||||||
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
|
||||||
|
|
||||||
constructor(options: VoiceSessionOptions) {
|
constructor(options: ManualVoiceCallOptions) {
|
||||||
const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge, dictation } =
|
const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge } = options;
|
||||||
options;
|
|
||||||
this.host = host;
|
this.host = host;
|
||||||
this.sessionLogger = logger;
|
this.sessionLogger = logger;
|
||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
@@ -219,63 +178,13 @@ export class VoiceSession {
|
|||||||
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
|
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
|
||||||
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
||||||
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
|
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
|
||||||
this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext;
|
this.getSpeechReadiness = options.getSpeechReadiness;
|
||||||
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
|
this.onFatalError = options.onFatalError;
|
||||||
this.getSpeechReadiness = dictation?.getSpeechReadiness;
|
|
||||||
|
|
||||||
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
|
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
|
||||||
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, {
|
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, {
|
||||||
language: sttLanguage,
|
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(
|
private toVoiceFeatureUnavailableContext(
|
||||||
@@ -289,38 +198,13 @@ export class VoiceSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveModeReadinessState(
|
private resolveVoiceFeatureUnavailableContext(): VoiceFeatureUnavailableContext | null {
|
||||||
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 {
|
|
||||||
const readiness = this.getSpeechReadiness?.();
|
const readiness = this.getSpeechReadiness?.();
|
||||||
if (!readiness) {
|
if (!readiness) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeReadiness = this.resolveModeReadinessState(readiness, mode);
|
const modeReadiness = readiness.realtimeVoice;
|
||||||
if (!modeReadiness.enabled) {
|
if (!modeReadiness.enabled) {
|
||||||
return this.toVoiceFeatureUnavailableContext(modeReadiness);
|
return this.toVoiceFeatureUnavailableContext(modeReadiness);
|
||||||
}
|
}
|
||||||
@@ -333,134 +217,27 @@ export class VoiceSession {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async start(agentId: string): Promise<void> {
|
||||||
* Handle voice mode toggle
|
if (this.isVoiceMode) return;
|
||||||
*/
|
const unavailable = this.resolveVoiceFeatureUnavailableContext();
|
||||||
async handleSetVoiceMode(enabled: boolean, agentId?: string, requestId?: string): Promise<void> {
|
if (unavailable) throw new Error(unavailable.message);
|
||||||
const startedAt = Date.now();
|
const normalizedAgentId = this.parseVoiceTargetAgentId(agentId, "manual voice call");
|
||||||
|
this.voiceModeAgentId = await this.enableVoiceModeForAgent(normalizedAgentId);
|
||||||
try {
|
try {
|
||||||
this.sessionLogger.info(
|
await this.startVoiceTurnController();
|
||||||
{ enabled, requestedAgentId: agentId ?? null, requestId: requestId ?? null },
|
this.isVoiceMode = true;
|
||||||
"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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to set voice mode";
|
await this.disableVoiceModeForActiveAgent();
|
||||||
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;
|
|
||||||
}
|
|
||||||
throw error;
|
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 {
|
private parseVoiceTargetAgentId(rawId: string, source: string): string {
|
||||||
const parsed = AgentIdSchema.safeParse(rawId.trim());
|
const parsed = AgentIdSchema.safeParse(rawId.trim());
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -480,67 +257,21 @@ export class VoiceSession {
|
|||||||
|
|
||||||
this.registerVoiceBridgeForAgent(agentId);
|
this.registerVoiceBridgeForAgent(agentId);
|
||||||
|
|
||||||
const baseConfig: VoiceModeBaseConfig = {
|
return existing.id;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise<void> {
|
private async disableVoiceModeForActiveAgent(): Promise<void> {
|
||||||
await this.stopVoiceTurnController();
|
await this.stopVoiceTurnController();
|
||||||
|
|
||||||
const agentId = this.voiceModeAgentId;
|
const agentId = this.voiceModeAgentId;
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
this.voiceModeBaseConfig = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.unregisterVoiceSpeakHandler?.(agentId);
|
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;
|
this.voiceModeAgentId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void {
|
|
||||||
this.emit(msg as unknown as SessionOutboundMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async startVoiceTurnController(): Promise<void> {
|
private async startVoiceTurnController(): Promise<void> {
|
||||||
if (this.voiceTurnController) {
|
if (this.voiceTurnController) {
|
||||||
this.sessionLogger.info("startVoiceTurnController skipped: already running");
|
this.sessionLogger.info("startVoiceTurnController skipped: already running");
|
||||||
@@ -631,6 +362,7 @@ export class VoiceSession {
|
|||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
this.sessionLogger.error({ err: error }, "Voice turn controller failed");
|
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.ttsManager.cleanup();
|
||||||
this.sttManager.cleanup();
|
this.sttManager.cleanup();
|
||||||
this.dictationStreamManager.cleanupAll();
|
await this.disableVoiceModeForActiveAgent();
|
||||||
|
|
||||||
await this.disableVoiceModeForActiveAgent(true);
|
|
||||||
this.isVoiceMode = false;
|
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>;
|
||||||
+3
-3
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
|
|
||||||
import { TTSManager } from "./tts-manager.js";
|
import { TTSManager } from "./synthesis.js";
|
||||||
import type { TextToSpeechProvider } from "../speech/speech-provider.js";
|
import type { TextToSpeechProvider } from "../../../speech/speech-provider.js";
|
||||||
import type { SessionOutboundMessage } from "../messages.js";
|
import type { SessionOutboundMessage } from "../../../messages.js";
|
||||||
|
|
||||||
type AudioOutputMessage = Extract<SessionOutboundMessage, { type: "audio_output" }>;
|
type AudioOutputMessage = Extract<SessionOutboundMessage, { type: "audio_output" }>;
|
||||||
|
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import type { TextToSpeechProvider } from "../speech/speech-provider.js";
|
import type { TextToSpeechProvider } from "../../../speech/speech-provider.js";
|
||||||
import { toResolver, type Resolvable } from "../speech/provider-resolver.js";
|
import { toResolver, type Resolvable } from "../../../speech/provider-resolver.js";
|
||||||
import type { SessionOutboundMessage } from "../messages.js";
|
import type { SessionOutboundMessage } from "../../../messages.js";
|
||||||
|
|
||||||
interface PendingPlayback {
|
interface PendingPlayback {
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
+4
-106
@@ -2,14 +2,12 @@ import { describe, expect, it } from "vitest";
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
import { STTManager } from "./stt-manager.js";
|
import { STTManager } from "./transcription.js";
|
||||||
import { PersistedConfigSchema } from "../persisted-config.js";
|
|
||||||
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
|
|
||||||
import type {
|
import type {
|
||||||
SpeechToTextProvider,
|
SpeechToTextProvider,
|
||||||
StreamingTranscriptionSession,
|
StreamingTranscriptionSession,
|
||||||
TranscriptionResult,
|
TranscriptionResult,
|
||||||
} from "../speech/speech-provider.js";
|
} from "../../../speech/speech-provider.js";
|
||||||
|
|
||||||
type SessionParams = Parameters<SpeechToTextProvider["createSession"]>[0];
|
type SessionParams = Parameters<SpeechToTextProvider["createSession"]>[0];
|
||||||
type StreamingOn = StreamingTranscriptionSession["on"];
|
type StreamingOn = StreamingTranscriptionSession["on"];
|
||||||
@@ -96,116 +94,16 @@ class SequencedFakeStt implements SpeechToTextProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("STTManager", () => {
|
describe("STTManager", () => {
|
||||||
function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string {
|
it("passes the configured provider language to STT", async () => {
|
||||||
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> {
|
|
||||||
const fakeStt = new FakeStt({ text: "hi", isLowConfidence: false });
|
const fakeStt = new FakeStt({ text: "hi", isLowConfidence: false });
|
||||||
const manager = new STTManager("s1", pino({ level: "silent" }), fakeStt, {
|
const manager = new STTManager("s1", pino({ level: "silent" }), fakeStt, {
|
||||||
language: resolveVoiceLanguage(params),
|
language: "pt",
|
||||||
});
|
});
|
||||||
await manager.transcribe(Buffer.alloc(2), "audio/pcm;rate=24000");
|
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");
|
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 () => {
|
it("returns empty text for low-confidence transcriptions", async () => {
|
||||||
const manager = new STTManager(
|
const manager = new STTManager(
|
||||||
"s1",
|
"s1",
|
||||||
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { SpeechToTextProvider, TranscriptionResult } from "../speech/speech-provider.js";
|
import type { SpeechToTextProvider, TranscriptionResult } from "../../../speech/speech-provider.js";
|
||||||
import { toResolver, type Resolvable } from "../speech/provider-resolver.js";
|
import { toResolver, type Resolvable } from "../../../speech/provider-resolver.js";
|
||||||
import { maybePersistDebugAudio } from "./stt-debug.js";
|
import { maybePersistDebugAudio } from "../../../agent/stt-debug.js";
|
||||||
import { parsePcm16MonoWav, parsePcmRateFromFormat } from "../speech/audio.js";
|
import { parsePcm16MonoWav, parsePcmRateFromFormat } from "../../../speech/audio.js";
|
||||||
import { Pcm16MonoResampler } from "./pcm16-resampler.js";
|
import { Pcm16MonoResampler } from "../../../agent/pcm16-resampler.js";
|
||||||
|
|
||||||
interface TranscriptionMetadata {
|
interface TranscriptionMetadata {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
+13
-8
@@ -7,12 +7,12 @@ import type {
|
|||||||
StreamingTranscriptionCommittedEvent,
|
StreamingTranscriptionCommittedEvent,
|
||||||
StreamingTranscriptionEvent,
|
StreamingTranscriptionEvent,
|
||||||
StreamingTranscriptionSession,
|
StreamingTranscriptionSession,
|
||||||
} from "../../speech/speech-provider.js";
|
} from "../../../speech/speech-provider.js";
|
||||||
import type {
|
import type {
|
||||||
TurnDetectionProvider,
|
TurnDetectionProvider,
|
||||||
TurnDetectionSession,
|
TurnDetectionSession,
|
||||||
} from "../../speech/turn-detection-provider.js";
|
} from "../../../speech/turn-detection-provider.js";
|
||||||
import { createVoiceTurnController } from "./voice-turn-controller.js";
|
import { createVoiceTurnController } from "./turn.js";
|
||||||
|
|
||||||
class FakeTurnDetectionSession extends EventEmitter implements TurnDetectionSession {
|
class FakeTurnDetectionSession extends EventEmitter implements TurnDetectionSession {
|
||||||
public readonly requiredSampleRate = 16000;
|
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();
|
const harness = createControllerHarness();
|
||||||
|
|
||||||
await harness.controller.start();
|
await harness.controller.start();
|
||||||
@@ -523,12 +523,17 @@ describe("voice turn controller", () => {
|
|||||||
firstSession?.emitError(new Error("stream failed"));
|
firstSession?.emitError(new Error("stream failed"));
|
||||||
await settleSerialQueue();
|
await settleSerialQueue();
|
||||||
|
|
||||||
expect(harness.onError).toHaveBeenCalledTimes(1);
|
expect(harness.onError).not.toHaveBeenCalled();
|
||||||
expect(harness.onError).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ message: "stream failed" }),
|
|
||||||
);
|
|
||||||
expect(firstSession?.closeCount).toBe(1);
|
expect(firstSession?.closeCount).toBe(1);
|
||||||
expect(harness.sttSessions).toHaveLength(2);
|
expect(harness.sttSessions).toHaveLength(2);
|
||||||
expect(harness.sttSessions[1]?.connectCount).toBe(1);
|
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" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+5
-5
@@ -2,14 +2,14 @@ import { Buffer } from "node:buffer";
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import { Pcm16MonoResampler } from "../../agent/pcm16-resampler.js";
|
import { Pcm16MonoResampler } from "../../../agent/pcm16-resampler.js";
|
||||||
import { parsePcmRateFromFormat } from "../../speech/audio.js";
|
import { parsePcmRateFromFormat } from "../../../speech/audio.js";
|
||||||
import type {
|
import type {
|
||||||
SpeechToTextProvider,
|
SpeechToTextProvider,
|
||||||
StreamingTranscriptionEvent,
|
StreamingTranscriptionEvent,
|
||||||
StreamingTranscriptionSession,
|
StreamingTranscriptionSession,
|
||||||
} from "../../speech/speech-provider.js";
|
} from "../../../speech/speech-provider.js";
|
||||||
import type { TurnDetectionProvider } from "../../speech/turn-detection-provider.js";
|
import type { TurnDetectionProvider } from "../../../speech/turn-detection-provider.js";
|
||||||
|
|
||||||
const VOICE_FINAL_TRANSCRIPT_TIMEOUT_MS = 10_000;
|
const VOICE_FINAL_TRANSCRIPT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
@@ -253,9 +253,9 @@ export function createVoiceTurnController(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSttError(error: unknown): void {
|
function handleSttError(error: unknown): void {
|
||||||
fail(error);
|
|
||||||
params.logger.warn({ err: error }, "voice_turn.stt_error");
|
params.logger.warn({ err: error }, "voice_turn.stt_error");
|
||||||
if (reconnectAttemptedForTurn) {
|
if (reconnectAttemptedForTurn) {
|
||||||
|
fail(error);
|
||||||
sttSession?.close();
|
sttSession?.close();
|
||||||
sttSession = null;
|
sttSession = null;
|
||||||
return;
|
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 }
|
{ type: T }
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function makeTranscriptionHandler(resolve: (value: string) => void) {
|
function makeTranscriptionHandler(callId: string, resolve: (value: string) => void) {
|
||||||
return (msg: SessionMessage<"transcription_result">) => {
|
return (msg: SessionMessage<"voice.call.event">) => {
|
||||||
if (msg.type !== "transcription_result") return;
|
if (msg.payload.callId !== callId || msg.payload.event.type !== "transcript") return;
|
||||||
const text = (msg.payload.text ?? "").trim();
|
if (msg.payload.event.speaker !== "user") return;
|
||||||
|
const text = msg.payload.event.text.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
resolve(text);
|
resolve(text);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeErrorHandler(reject: (error: Error) => void) {
|
function makeErrorHandler(callId: string, reject: (error: Error) => void) {
|
||||||
return (msg: SessionMessage<"activity_log">) => {
|
return (msg: SessionMessage<"voice.call.event">) => {
|
||||||
if (msg.type !== "activity_log") return;
|
if (msg.payload.callId !== callId || msg.payload.event.type !== "error") return;
|
||||||
if (msg.payload.type !== "error") return;
|
reject(new Error(msg.payload.event.message));
|
||||||
reject(new Error(msg.payload.content));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSpeakToolHandler(resolve: (value: string) => void) {
|
function makeSpokenReplyHandler(callId: string, resolve: (value: string) => void) {
|
||||||
return (msg: SessionMessage<"agent_stream">) => {
|
return (msg: SessionMessage<"voice.call.event">) => {
|
||||||
if (msg.type !== "agent_stream") return;
|
if (msg.payload.callId !== callId || msg.payload.event.type !== "transcript") return;
|
||||||
if (msg.payload.event.type !== "timeline") return;
|
if (msg.payload.event.speaker !== "assistant") return;
|
||||||
const item = msg.payload.event.item;
|
resolve(msg.payload.event.text);
|
||||||
if (item.type !== "tool_call") return;
|
|
||||||
const name = item.name ?? "";
|
|
||||||
if (!name.toLowerCase().includes("speak")) return;
|
|
||||||
resolve(name);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,9 +83,24 @@ function waitForSignal<T>(
|
|||||||
voiceTts: { provider: "openai", explicit: true },
|
voiceTts: { provider: "openai", explicit: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
voiceLlmProvider: "codex",
|
manualVoice: {
|
||||||
voiceLlmProviderExplicit: true,
|
orchestrator: {
|
||||||
voiceLlmModel: "gpt-5.4-mini",
|
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);
|
}, 120000);
|
||||||
|
|
||||||
@@ -109,30 +120,49 @@ function waitForSignal<T>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-local-agent-"));
|
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({
|
const targetAgent = await ctx.client.createAgent({
|
||||||
config: {
|
config: {
|
||||||
...getFullAccessConfig("codex"),
|
...getFullAccessConfig("codex"),
|
||||||
cwd: voiceCwd,
|
cwd: voiceCwd,
|
||||||
},
|
},
|
||||||
|
workspaceId: workspaceResult.workspace.workspaceId,
|
||||||
});
|
});
|
||||||
const voiceMode = await ctx.client.setVoiceMode(true, targetAgent.id);
|
const voiceCall = await ctx.client.startVoiceCall(
|
||||||
expect(voiceMode.accepted).toBe(true);
|
{ workspaceId: workspaceResult.workspace.workspaceId, agentId: targetAgent.id },
|
||||||
|
[{ kind: "daemon-audio" }],
|
||||||
|
);
|
||||||
|
|
||||||
const transcriptionPromise = waitForSignal<string>(120000, (resolve, reject) => {
|
const transcriptionPromise = waitForSignal<string>(120000, (resolve, reject) => {
|
||||||
const offTranscript = ctx.client.on(
|
const offTranscript = ctx.client.on(
|
||||||
"transcription_result",
|
"voice.call.event",
|
||||||
makeTranscriptionHandler(resolve),
|
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 () => {
|
return () => {
|
||||||
offTranscript();
|
offTranscript();
|
||||||
offError();
|
offError();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const speakToolPromise = waitForSignal<string>(120000, (resolve, reject) => {
|
const spokenReplyPromise = waitForSignal<string>(120000, (resolve, reject) => {
|
||||||
const offStream = ctx.client.on("agent_stream", makeSpeakToolHandler(resolve));
|
const offStream = ctx.client.on(
|
||||||
const offError = ctx.client.on("activity_log", makeErrorHandler(reject));
|
"voice.call.event",
|
||||||
|
makeSpokenReplyHandler(voiceCall.callId, resolve),
|
||||||
|
);
|
||||||
|
const offError = ctx.client.on(
|
||||||
|
"voice.call.event",
|
||||||
|
makeErrorHandler(voiceCall.callId, reject),
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
offStream();
|
offStream();
|
||||||
offError();
|
offError();
|
||||||
@@ -150,21 +180,20 @@ function waitForSignal<T>(
|
|||||||
const chunkBytes = 4800;
|
const chunkBytes = 4800;
|
||||||
for (let offset = 0; offset < pcm.length; offset += chunkBytes) {
|
for (let offset = 0; offset < pcm.length; offset += chunkBytes) {
|
||||||
const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes));
|
const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes));
|
||||||
const isLast = offset + chunkBytes >= pcm.length;
|
ctx.client.sendVoiceCallTransportMessage(voiceCall.callId, {
|
||||||
await ctx.client.sendVoiceAudioChunk(
|
type: "append",
|
||||||
chunk.toString("base64"),
|
audio: chunk.toString("base64"),
|
||||||
"audio/pcm;rate=24000;bits=16",
|
format: "audio/pcm;rate=24000;bits=16",
|
||||||
isLast,
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 });
|
rmSync(voiceCwd, { recursive: true, force: true });
|
||||||
|
|
||||||
expect(transcript.length).toBeGreaterThan(0);
|
expect(transcript.length).toBeGreaterThan(0);
|
||||||
expect(speakToolName.toLowerCase()).toContain("speak");
|
expect(spokenReply.toLowerCase()).toContain("local agent check");
|
||||||
|
|
||||||
const agents = await ctx.client.fetchAgents();
|
const agents = await ctx.client.fetchAgents();
|
||||||
expect(agents.some((agent) => String(agent.labels?.surface ?? "") === "voice")).toBe(false);
|
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);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
class MockSession {
|
||||||
cleanup = vi.fn(async () => {});
|
cleanup = vi.fn(async () => {});
|
||||||
|
handleTransportDisconnect = vi.fn(async () => {});
|
||||||
handleMessage = vi.fn(async () => {});
|
handleMessage = vi.fn(async () => {});
|
||||||
handleBinaryFrame = vi.fn((_frame: unknown) => {});
|
handleBinaryFrame = vi.fn((_frame: unknown) => {});
|
||||||
supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true);
|
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", () => {
|
describe("relay external socket reconnect behavior", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionMock.instances.length = 0;
|
sessionMock.instances.length = 0;
|
||||||
@@ -930,6 +944,25 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
await server.close();
|
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 () => {
|
test("reuses one session when switching from direct to relay with the same clientId", async () => {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
const clientId = "cid-switch-path";
|
const clientId = "cid-switch-path";
|
||||||
@@ -1015,7 +1048,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
await server.close();
|
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 speechReadiness = createReadySpeechReadinessSnapshot();
|
||||||
const server = createServer({ speechReadiness });
|
const server = createServer({ speechReadiness });
|
||||||
|
|
||||||
@@ -1032,16 +1065,16 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
voice?: { enabled?: unknown; reason?: unknown };
|
voice?: { enabled?: unknown; reason?: unknown };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
features?: { voiceChat?: unknown };
|
||||||
};
|
};
|
||||||
expect(serverInfo.version).toBe(TEST_DAEMON_VERSION);
|
expect(serverInfo.version).toBe(TEST_DAEMON_VERSION);
|
||||||
expect(serverInfo.capabilities?.voice?.dictation?.enabled).toBe(
|
expect(serverInfo.capabilities?.voice?.dictation?.enabled).toBe(
|
||||||
speechReadiness.dictation.enabled,
|
speechReadiness.dictation.enabled,
|
||||||
);
|
);
|
||||||
expect(serverInfo.capabilities?.voice?.dictation?.reason).toBe("");
|
expect(serverInfo.capabilities?.voice?.dictation?.reason).toBe("");
|
||||||
expect(serverInfo.capabilities?.voice?.voice?.enabled).toBe(
|
expect(serverInfo.capabilities?.voice?.voice?.enabled).toBe(false);
|
||||||
speechReadiness.realtimeVoice.enabled,
|
expect(serverInfo.capabilities?.voice?.voice?.reason).toBe("Update Paseo to use voice chat.");
|
||||||
);
|
expect(serverInfo.features?.voiceChat).toBe(true);
|
||||||
expect(serverInfo.capabilities?.voice?.voice?.reason).toBe("");
|
|
||||||
|
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
@@ -1064,7 +1097,7 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
const secondEnvelope = sentServerInfoEnvelopes(socket)[1];
|
const secondEnvelope = sentServerInfoEnvelopes(socket)[1];
|
||||||
const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload);
|
const secondPayload = parseServerInfoStatusPayload(secondEnvelope.message?.payload);
|
||||||
expect(secondPayload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
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.
|
// Same readiness should not produce another server_info broadcast.
|
||||||
server.publishSpeechReadiness(speechReadiness);
|
server.publishSpeechReadiness(speechReadiness);
|
||||||
@@ -1089,9 +1122,9 @@ describe("relay external socket reconnect behavior", () => {
|
|||||||
const envelope = sentServerInfoEnvelopes(socket)[1];
|
const envelope = sentServerInfoEnvelopes(socket)[1];
|
||||||
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
const payload = parseServerInfoStatusPayload(envelope.message?.payload);
|
||||||
expect(payload?.capabilities?.voice?.dictation.enabled).toBe(true);
|
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?.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();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import type { ScriptHealthState } from "./script-health-monitor.js";
|
|||||||
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
||||||
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
||||||
import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.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 {
|
import {
|
||||||
computeNotificationPlan,
|
computeNotificationPlan,
|
||||||
isPushEligibleAttentionReason,
|
isPushEligibleAttentionReason,
|
||||||
@@ -389,11 +389,8 @@ function buildServerCapabilities(params: {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
voice: toServerCapabilityState({
|
voice: toServerCapabilityState({
|
||||||
state: readiness.realtimeVoice,
|
state: { ...readiness.realtimeVoice, enabled: false },
|
||||||
reason: resolveCapabilityReason({
|
reason: "Update Paseo to use voice chat.",
|
||||||
state: readiness.realtimeVoice,
|
|
||||||
readiness,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -572,8 +569,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private dictation!: {
|
private dictation!: {
|
||||||
finalTimeoutMs?: number;
|
finalTimeoutMs?: number;
|
||||||
} | null;
|
} | null;
|
||||||
private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>();
|
private readonly voiceChat: VoiceChat | null;
|
||||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
|
||||||
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
|
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
|
||||||
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
|
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
|
||||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||||
@@ -648,6 +644,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
pluginRuntime?: SessionOptions["pluginRuntime"],
|
pluginRuntime?: SessionOptions["pluginRuntime"],
|
||||||
orchestrationSkills?: SessionOptions["orchestrationSkills"],
|
orchestrationSkills?: SessionOptions["orchestrationSkills"],
|
||||||
workspaceLabelService?: WorkspaceLabelService,
|
workspaceLabelService?: WorkspaceLabelService,
|
||||||
|
voiceChat?: VoiceChat | null,
|
||||||
) {
|
) {
|
||||||
this.logger = logger.child({ module: "websocket-server" });
|
this.logger = logger.child({ module: "websocket-server" });
|
||||||
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
this.workspaceSetupRuntime = workspaceSetupRuntime;
|
||||||
@@ -664,6 +661,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
this.hubRelationships = hubRelationships ?? null;
|
this.hubRelationships = hubRelationships ?? null;
|
||||||
this.pluginRuntime = pluginRuntime;
|
this.pluginRuntime = pluginRuntime;
|
||||||
this.orchestrationSkills = orchestrationSkills;
|
this.orchestrationSkills = orchestrationSkills;
|
||||||
|
this.voiceChat = voiceChat ?? null;
|
||||||
this.agentManager = agentManager;
|
this.agentManager = agentManager;
|
||||||
this.agentStorage = agentStorage;
|
this.agentStorage = agentStorage;
|
||||||
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
|
||||||
@@ -1422,9 +1420,6 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
pluginRuntime: this.pluginRuntime,
|
pluginRuntime: this.pluginRuntime,
|
||||||
orchestrationSkills: this.orchestrationSkills,
|
orchestrationSkills: this.orchestrationSkills,
|
||||||
mcpBaseUrl: this.mcpBaseUrl,
|
mcpBaseUrl: this.mcpBaseUrl,
|
||||||
stt: () => this.speech?.resolveStt() ?? null,
|
|
||||||
sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
|
|
||||||
tts: () => this.speech?.resolveTts() ?? null,
|
|
||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
providerSnapshotManager: this.providerSnapshotManager,
|
providerSnapshotManager: this.providerSnapshotManager,
|
||||||
providerUsageService: this.providerUsageService,
|
providerUsageService: this.providerUsageService,
|
||||||
@@ -1439,23 +1434,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
|
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
|
||||||
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
||||||
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
|
resolveScriptHealth: this.resolveScriptHealth ?? undefined,
|
||||||
voice: {
|
voiceChat: this.voiceChat,
|
||||||
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);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dictation:
|
dictation:
|
||||||
this.dictation || this.speech
|
this.dictation || this.speech
|
||||||
? {
|
? {
|
||||||
@@ -1628,6 +1607,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
|
||||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||||
features: {
|
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.
|
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||||
directorySync: true,
|
directorySync: true,
|
||||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
// 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(
|
private async detachSocket(
|
||||||
ws: WebSocketLike,
|
ws: WebSocketLike,
|
||||||
details: {
|
details: {
|
||||||
@@ -1884,6 +1857,11 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
|
|
||||||
if (connection.sockets.size === 0) {
|
if (connection.sockets.size === 0) {
|
||||||
this.unregisterBrowserToolsClient(connection);
|
this.unregisterBrowserToolsClient(connection);
|
||||||
|
await connection.session.handleTransportDisconnect();
|
||||||
|
if (connection.sockets.size > 0) {
|
||||||
|
this.finishPluginSocketCleanup(ws);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (connection.lifecycle === "ephemeral-plugin") {
|
if (connection.lifecycle === "ephemeral-plugin") {
|
||||||
this.pluginSocketIds.delete(ws);
|
this.pluginSocketIds.delete(ws);
|
||||||
await this.cleanupConnection(connection, "Plugin session disconnected");
|
await this.cleanupConnection(connection, "Plugin session disconnected");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2023"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user