Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2748458
feat(voice): realtime ElevenLabs voice agent alongside the classic path
YellowSnnowmann Aug 10, 2026
4469d52
fix(voice): address review on the deferred-turn path
YellowSnnowmann Aug 10, 2026
4c98670
Merge remote-tracking branch 'upstream/main' into feat/5489-merge
YellowSnnowmann Aug 10, 2026
fdb3380
chore(build): fix pre-existing fmt + gates-off breakage on the base
YellowSnnowmann Aug 10, 2026
41c6f4f
fix(voice): surface a chat message on empty deferred reply + correct …
YellowSnnowmann Aug 10, 2026
2988802
test(voice): cover the speak-back path in useRealtimeVoiceSession
YellowSnnowmann Aug 10, 2026
2fe7e2e
fix(build): gate the memory_diff capability registration on memory-git
YellowSnnowmann Aug 10, 2026
67c6345
fix(voice): update realtime-mode tests; revert gates-off scope-creep
YellowSnnowmann Aug 10, 2026
47a6fd5
fix(build): gate the memory_diff capability consistently for the slim…
YellowSnnowmann Aug 10, 2026
2e27692
fix(build): keep the memory-diff slim compile fix, drop the capabilit…
YellowSnnowmann Aug 10, 2026
1f2aad0
refactor(voice): drop always-true realtime gate; log aborted voice turn
YellowSnnowmann Aug 12, 2026
ea6c3d7
Merge upstream/main into feat/5399-voice-elevenlabs-realtime
YellowSnnowmann Aug 12, 2026
5ef91d6
build(tauri): resync app/src-tauri/Cargo.lock with the current manifests
YellowSnnowmann Aug 12, 2026
5405a1e
docs(voice): correct deliver_voice_failure_to_chat doc for the empty-…
YellowSnnowmann Aug 12, 2026
acd3bc0
fix(voice): never end a realtime turn on nothing, and stop the read-b…
YellowSnnowmann Aug 13, 2026
c152be9
feat(voice): put the Human tab's voice control behind a build flag
YellowSnnowmann Aug 13, 2026
c3d69da
fix(voice): float the realtime control in comparison mode
YellowSnnowmann Aug 13, 2026
db1d938
Merge remote-tracking branch 'upstream/main' into feat/5399-voice-ele…
YellowSnnowmann Aug 13, 2026
b3ed62c
fix(voice): deliver the promised chat notice when a voice turn dies
YellowSnnowmann Aug 13, 2026
9928d9e
chore: apply formatting
YellowSnnowmann Aug 13, 2026
630060f
fix(build): restore upstream's app/src-tauri Cargo.lock
YellowSnnowmann Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
993 changes: 78 additions & 915 deletions app/src-tauri/Cargo.lock

Large diffs are not rendered by default.

26 changes: 2 additions & 24 deletions app/src/components/settings/panels/VoicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ import {
type VoiceProviderView,
type VoiceSettings,
} from '../../../services/api/voiceSettingsApi';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { selectVoiceMode, setVoiceMode } from '../../../store/mascotSlice';
import { VOICE_MODE_FLAG_ENABLED } from '../../../utils/config';
import {
openhumanGetVoiceServerSettings,
openhumanUpdateVoiceServerSettings,
Expand Down Expand Up @@ -102,8 +99,6 @@ interface VoicePanelProps {

const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
const { t } = useT();
const dispatch = useAppDispatch();
const voiceMode = useAppSelector(selectVoiceMode);
const { navigateBack, navigateToSettings } = useSettingsNavigation();
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
Expand Down Expand Up @@ -582,25 +577,8 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
/>
</SettingsSection>

{/* ─── Realtime voice mode (beta, flag-gated) ──────────────────── */}
{VOICE_MODE_FLAG_ENABLED && (
<SettingsSection title={t('voice.mode.title')} description={t('voice.mode.desc')}>
<SettingsRow
htmlFor="voice-mode-realtime"
label={t('voice.mode.realtime')}
description={t('voice.mode.realtimeDesc')}
control={
<SettingsSwitch
id="voice-mode-realtime"
data-testid="voice-mode-realtime-toggle"
checked={voiceMode === 'realtime'}
onCheckedChange={next => dispatch(setVoiceMode(next ? 'realtime' : 'classic'))}
aria-label={t('voice.mode.realtime')}
/>
}
/>
</SettingsSection>
)}
{/* Realtime voice is always on now — its controls live on the Human tab,
so the former flag-gated toggle here was removed. */}

{/* ─── Section 1: Voice Provider Chips ─────────────────────────── */}
{/* Provider chips are intentional bespoke UI — kept as-is. */}
Expand Down

This file was deleted.

29 changes: 12 additions & 17 deletions app/src/features/human/HumanPage.realtimeMode.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Unit tests for HumanPage's realtime voice overlay gate (#5399). The overlay
* renders only when the build flag is on AND the persisted mascot voice mode is
* `realtime`; the classic push-to-talk path is always present. Config is mocked
* with the flag ON here (the global setup mock ships it OFF), and
* Unit test for HumanPage's realtime voice overlay (#5399). The realtime
* controls are now shown unconditionally on the Human tab — the former
* build-flag + persisted-voice-mode gate was removed so the "Start Voice Chat"
* control is always available — alongside the classic push-to-talk path.
* RealtimeVoiceControls is stubbed so the ElevenLabs SDK never loads.
*/
import { configureStore } from '@reduxjs/toolkit';
Expand All @@ -15,13 +15,6 @@ import mascotReducer, { setVoiceMode } from '../../store/mascotSlice';
import threadReducer from '../../store/threadSlice';
import HumanPage from './HumanPage';

// Flip the realtime gate ON for this file (global setup ships it OFF). Spread
// the real module so every other config export keeps its production value.
vi.mock('../../utils/config', async () => {
const actual = await vi.importActual<typeof import('../../utils/config')>('../../utils/config');
return { ...actual, VOICE_MODE_FLAG_ENABLED: true };
});

// Stub the overlay so the ElevenLabs `ConversationProvider`/SDK never mounts —
// this test only pins the render gate, not the controls (covered separately).
vi.mock('./RealtimeVoiceControls', () => ({
Expand Down Expand Up @@ -58,18 +51,20 @@ function renderWithVoiceMode(mode: 'classic' | 'realtime') {
);
}

describe('HumanPage — realtime voice overlay gate', () => {
describe('HumanPage — realtime voice overlay', () => {
beforeEach(() => {
localStorage.clear();
});

it('renders the realtime controls when voice mode is realtime and the flag is on', () => {
renderWithVoiceMode('realtime');
it('renders the realtime controls regardless of the persisted voice mode', () => {
// The gate was removed, so the controls appear even when the persisted mode
// is the classic default — the two paths now coexist on the Human tab.
renderWithVoiceMode('classic');
expect(screen.getByTestId('realtime-voice-controls-stub')).toBeInTheDocument();
});

it('hides the realtime controls when voice mode is classic', () => {
renderWithVoiceMode('classic');
expect(screen.queryByTestId('realtime-voice-controls-stub')).not.toBeInTheDocument();
it('still renders the realtime controls when the persisted mode is realtime', () => {
renderWithVoiceMode('realtime');
expect(screen.getByTestId('realtime-voice-controls-stub')).toBeInTheDocument();
});
});
17 changes: 5 additions & 12 deletions app/src/features/human/HumanPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import {
selectCustomSecondaryColor,
selectMascotColor,
selectSpeakReplies,
selectVoiceMode,
setSpeakReplies,
} from '../../store/mascotSlice';
import { VOICE_MODE_FLAG_ENABLED } from '../../utils/config';
import Conversations from '../conversations/Conversations';
import {
CustomGifMascot,
Expand All @@ -35,8 +33,6 @@ const HumanPage = () => {
const speakReplies = useAppSelector(selectSpeakReplies);

const { face, visemeCode } = useHumanMascot({ speakReplies });
const voiceMode = useAppSelector(selectVoiceMode);
const realtimeEnabled = VOICE_MODE_FLAG_ENABLED && voiceMode === 'realtime';
const mascotColor = useAppSelector(selectMascotColor);
const customPrimary = useAppSelector(selectCustomPrimaryColor);
const customSecondary = useAppSelector(selectCustomSecondaryColor);
Expand Down Expand Up @@ -101,14 +97,11 @@ const HumanPage = () => {
</div>
</div>

{/* Realtime voice-chat controls (#5399) — additive overlay shown only when
the flag + realtime mode are on; the classic push-to-talk path below
is untouched. */}
{realtimeEnabled && (
<div className="absolute bottom-8 left-0 right-[436px] z-10 flex justify-center">
<RealtimeVoiceControls />
</div>
)}
{/* Realtime voice-chat controls (#5399) — always shown; the classic
push-to-talk path below is untouched. */}
<div className="absolute bottom-8 left-0 right-[436px] z-10 flex justify-center">
<RealtimeVoiceControls />
</div>

<label className="absolute top-4 left-4 z-10 inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface/80 backdrop-blur-sm border border-line-strong text-xs text-content-secondary shadow-soft cursor-pointer select-none">
<input
Expand Down
62 changes: 61 additions & 1 deletion app/src/features/human/voice/useRealtimeVoiceSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,31 @@ interface CapturedProps {
let captured: CapturedProps | null = null;
const startSession = vi.fn();
const endSession = vi.fn();
const sendUserMessage = vi.fn();

vi.mock('@elevenlabs/react', () => ({
useConversation: (props: CapturedProps) => {
captured = props;
return { startSession, endSession, isSpeaking: false, mode: 'listening' as const };
return {
startSession,
endSession,
sendUserMessage,
isSpeaking: false,
mode: 'listening' as const,
};
},
}));

// Capture the `voice_speak` subscription so a test can drive the speak-back path.
const socketHandlers: Record<string, (payload: unknown) => void> = {};
vi.mock('../../../services/socketService', () => ({
socketService: {
on: vi.fn((event: string, handler: (payload: unknown) => void) => {
socketHandlers[event] = handler;
}),
off: vi.fn((event: string) => {
delete socketHandlers[event];
}),
},
}));

Expand All @@ -29,6 +49,7 @@ describe('useRealtimeVoiceSession', () => {
beforeEach(() => {
vi.clearAllMocks();
captured = null;
Object.keys(socketHandlers).forEach(k => delete socketHandlers[k]);
});

it('fetches a signed URL and opens a WebSocket session with the voice override', async () => {
Expand Down Expand Up @@ -137,4 +158,43 @@ describe('useRealtimeVoiceSession', () => {
unmount();
expect(endSession).not.toHaveBeenCalled();
});

it('reads a deferred result aloud when voice_speak arrives during a live call', async () => {
mockFetch.mockResolvedValueOnce({ signedUrl: 'wss://x', agentId: 'a1', userToken: 'tok-1' });
const { result } = renderHook(() => useRealtimeVoiceSession());
await act(async () => {
await result.current.start();
});
act(() => captured?.onConnect()); // liveRef becomes true
act(() => socketHandlers['voice_speak']?.({ full_response: 'Your inbox summary.' }));
expect(sendUserMessage).toHaveBeenCalledTimes(1);
// Wrapped in the verbatim read-back prefix so the agent reads it aloud.
expect(sendUserMessage.mock.calls[0][0]).toContain('Your inbox summary.');
expect(sendUserMessage.mock.calls[0][0]).toContain('Please read the following');
});

it('ignores voice_speak when no call is live', () => {
renderHook(() => useRealtimeVoiceSession()); // never connected → liveRef stays false
act(() => socketHandlers['voice_speak']?.({ full_response: 'ignored' }));
expect(sendUserMessage).not.toHaveBeenCalled();
});

it('ignores an empty or missing voice_speak payload', async () => {
mockFetch.mockResolvedValueOnce({ signedUrl: 'wss://x', agentId: 'a1', userToken: 'tok-1' });
const { result } = renderHook(() => useRealtimeVoiceSession());
await act(async () => {
await result.current.start();
});
act(() => captured?.onConnect());
act(() => socketHandlers['voice_speak']?.({ full_response: ' ' }));
act(() => socketHandlers['voice_speak']?.(undefined));
expect(sendUserMessage).not.toHaveBeenCalled();
});

it('unsubscribes from voice_speak on unmount', () => {
const { unmount } = renderHook(() => useRealtimeVoiceSession());
expect(socketHandlers['voice_speak']).toBeDefined();
unmount();
expect(socketHandlers['voice_speak']).toBeUndefined();
});
});
30 changes: 30 additions & 0 deletions app/src/features/human/voice/useRealtimeVoiceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@ import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';

import { fetchVoiceAgentSignedUrl } from '../../../services/api/voiceAgentApi';
import { socketService } from '../../../services/socketService';
import { MASCOT_VOICE_ID } from '../../../utils/config';

const log = createDebug('app:human:realtime-voice');

/**
* Instruction prefix for "speak-back". A slow voice turn (e.g. an email summary)
* is acknowledged aloud, finishes in the background, and its result is delivered
* to chat AND pushed here as a `voice_speak` event. We send it back into the live
* ElevenLabs session as a user message wrapped with this prefix so the agent reads
* it verbatim. MUST match `VOICE_READBACK_PREFIX` in `voice/realtime_harness.rs`,
* which uses it to avoid re-arming speak-back on the read-back turn (loop guard).
*/
const READBACK_PREFIX = 'Please read the following to me, word for word, and say nothing else:';

/**
* Lifecycle of a realtime ElevenLabs Agents voice session (#5399).
* `idle → connecting → active → idle`, or `→ error`.
Expand Down Expand Up @@ -125,6 +136,25 @@ export function useRealtimeVoiceSession(opts?: { voiceId?: string }): RealtimeVo
[]
);

// Speak-back: a slow voice turn (email/calendar summary) is acknowledged aloud,
// finishes in the background, and the core emits its result as a `voice_speak`
// event. While the call is still open, read it aloud by sending it back into the
// live ElevenLabs session wrapped in the verbatim prefix (a fast read-back turn).
// The result also lands in chat regardless (delivered core-side) — this is the
// spoken copy. Refs keep the subscription set up once while always seeing the
// live conversation and liveness.
useEffect(() => {
const handler = (payload: unknown) => {
if (!liveRef.current) return; // call already ended — the chat copy stands alone
const text = (payload as { full_response?: string } | undefined)?.full_response?.trim();
if (!text) return;
log('speak-back: reading deferred result aloud (%d chars)', text.length);
conversationRef.current.sendUserMessage(`${READBACK_PREFIX}\n\n${text}`);
};
socketService.on('voice_speak', handler);
return () => socketService.off('voice_speak', handler);
}, []);

return {
state,
isSpeaking: conversation.isSpeaking,
Expand Down
10 changes: 10 additions & 0 deletions src/openhuman/agent/harness/session/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ impl Agent {
&self.model_name
}

/// Override the base model this session runs its top-level turns on. Set
/// once before running: per-turn classification is disabled (the main agent
/// is pinned to its configured model for KV-cache stability — see the model
/// pin in `turn/core.rs`), so this sticks for the session and is not flipped
/// mid-conversation. The realtime voice harness uses it to pin a fast,
/// non-thinking model within the provider's response-time ceiling.
pub fn set_model_name(&mut self, model_name: impl Into<String>) {
self.model_name = model_name.into();
}

/// The agent's currently-configured temperature.
pub fn temperature(&self) -> f64 {
self.temperature
Expand Down
10 changes: 10 additions & 0 deletions src/openhuman/agent/harness/session/turn/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,16 @@ impl Agent {

let mut agent_context_prepared_sources: Vec<harness::AgentContextPreparedSource> =
Vec::new();
// Triggered memory-agent recall runs on EVERY channel, voice included:
// dropping it on voice would strip the user's remembered context
// (preferences, people, prior facts) from spoken answers — a real quality
// loss the transcript alone can't replace. Recall adds a few seconds of
// embedding + retrieval before the first model token, but on realtime
// voice that latency is already covered end-to-end: the backend relay
// streams an audible keepalive filler from t=0 so the cloud session never
// sees a silent stall, and the desktop's ~8s ack-defer closes the spoken
// turn and finishes in the background if the work runs long. So the recall
// path is byte-for-byte identical across voice and chat.
let (enriched, memory_agent_context_injected) = self
.inject_triggered_memory_agent_context(user_message, enriched, &parent_context)
.await;
Expand Down
Loading
Loading