feat(meet): in-call meeting agency (active replies, streaming, approvals) (#3677)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
YellowSnnowmann
2026-06-15 11:34:01 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 27739ee59e
commit c5087d83da
27 changed files with 1813 additions and 28 deletions
+51 -4
View File
@@ -202,7 +202,15 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
const { t } = useT();
const dispatch = useAppDispatch();
const [meetUrl, setMeetUrl] = useState('');
const [respondTo] = useState('');
// The participant the bot answers to (authorized speaker). Wired to the
// backend join payload as `respondToParticipant` → `respondTo`, which the
// meeting stream uses to gate in-call requests to this speaker only.
const [respondTo, setRespondTo] = useState('');
// Active (respond when addressed) vs listen-only (transcribe only). Defaults
// to active; the bot still only replies after being addressed by the wake
// phrase. Forwarded to the backend as `listenOnly` and mirrored into the
// meet slice so the active view shows the right status.
const [listenOnly, setListenOnly] = useState(false);
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
const personaDescription = useAppSelector(selectPersonaDescription);
const selectedMascotId = useAppSelector(selectSelectedMascotId);
@@ -234,7 +242,7 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
}, [refreshRecentCalls]);
const selectedLabel = t('skills.meetingBots.platforms.gmeet');
const agentName = personaDisplayName.trim() || 'OpenHuman';
const agentName = personaDisplayName.trim() || 'Tiny';
const systemPrompt = personaDescription.trim() || undefined;
const mascotId = selectedMascotId ?? (mascotColor === 'custom' ? undefined : mascotColor);
const riveColors =
@@ -264,7 +272,7 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
hasSubmittedRef.current = true;
try {
const meetingId = crypto.randomUUID();
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId }));
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim(), meetingId, listenOnly }));
await joinMeetViaBackendBot({
meetUrl,
displayName: agentName,
@@ -275,6 +283,7 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
riveColors,
correlationId: meetingId,
respondToParticipant: respondTo.trim() || undefined,
listenOnly,
});
} catch (err) {
const message = err instanceof Error ? err.message : t('skills.meetingBots.failedToStart');
@@ -315,6 +324,44 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
/>
</label>
<label className="block">
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.respondToParticipant')}
</span>
<input
type="text"
autoComplete="off"
spellCheck={false}
value={respondTo}
onChange={e => setRespondTo(e.target.value)}
placeholder={t('skills.meetingBots.respondToParticipantHint')}
disabled={submitting}
required
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
/>
<p className="mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.respondToParticipantDesc')}
</p>
</label>
<label className="flex items-start gap-3 rounded-xl border border-stone-200 dark:border-neutral-800 px-3 py-2.5">
<input
type="checkbox"
checked={!listenOnly}
onChange={e => setListenOnly(!e.target.checked)}
disabled={submitting}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-stone-300 text-primary-500 focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-stone-800 dark:text-neutral-100">
{t('skills.meetingBots.activeMode')}
</span>
<span className="mt-0.5 block text-[10px] leading-relaxed text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.activeModeDesc')}
</span>
</span>
</label>
{error && (
<div
role="alert"
@@ -326,7 +373,7 @@ function MeetingBotsInline({ onToast, hasSubmittedRef }: MeetingBotsInlineProps)
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="submit"
disabled={submitting || !meetUrl.trim()}
disabled={submitting || !meetUrl.trim() || !respondTo.trim()}
className="rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-stone-200 dark:disabled:bg-neutral-700 disabled:text-stone-400 dark:disabled:text-neutral-500">
{submitting
? t('skills.meetingBots.starting')
@@ -49,6 +49,9 @@ describe('MeetingBotsCard', () => {
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
target: { value: 'Alice' },
});
const form = document.querySelector('form')!;
fireEvent.submit(form);
@@ -56,9 +59,13 @@ describe('MeetingBotsCard', () => {
expect(joinMock).toHaveBeenCalledWith(
expect.objectContaining({
meetUrl: 'https://meet.google.com/abc-defg-hij',
displayName: 'OpenHuman',
displayName: 'Tiny',
platform: 'gmeet',
agentName: 'OpenHuman',
agentName: 'Tiny',
// Participant-name field is wired to the backend authorized-speaker gate.
respondToParticipant: 'Alice',
// Active toggle defaults to checked → listen-only false.
listenOnly: false,
})
);
});
@@ -169,10 +176,32 @@ describe('MeetingBotsCard', () => {
expect(screen.getByRole('button', { name: /send to google meet/i })).toBeInTheDocument();
});
it('only asks for the meeting link in passive mode', () => {
it('asks for the meeting link and the participant the bot answers to', () => {
renderWithProviders(<MeetingBotsCard />);
expect(screen.getByLabelText(/meeting link/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/your name in this meeting/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/your name in this meeting/i)).toBeInTheDocument();
});
it('forwards listen-only when the active toggle is unchecked', async () => {
joinMock.mockResolvedValueOnce({
meetUrl: 'https://meet.google.com/abc-defg-hij',
platform: 'gmeet',
});
renderWithProviders(<MeetingBotsCard />);
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
fireEvent.change(screen.getByLabelText(/your name in this meeting/i), {
target: { value: 'Alice' },
});
// Active toggle is checked by default; unchecking it selects listen-only.
fireEvent.click(screen.getByRole('checkbox'));
fireEvent.submit(document.querySelector('form')!);
await vi.waitFor(() => {
expect(joinMock).toHaveBeenCalledWith(expect.objectContaining({ listenOnly: true }));
});
});
});
+3
View File
@@ -4492,6 +4492,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'عبارة التنشيط',
'skills.meetingBots.wakePhraseHint': 'مرحباً OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'يجب أن يقول المشارك هذا قبل أن يرد البوت.',
'skills.meetingBots.activeMode': 'الرد عندما أناديه',
'skills.meetingBots.activeModeDesc':
'عند التفعيل، يرد البوت بصوت مسموع بعد أن تقول عبارة التنبيه. عند الإيقاف، يكتفي بالاستماع وتدوين النص.',
'skills.resource.preview.closeAriaLabel': 'إغلاق المعاينة',
'skills.resource.preview.failed': 'فشلت المعاينة',
'skills.resource.preview.loading': 'جارٍ تحميل المعاينة…',
+3
View File
@@ -4584,6 +4584,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'ওয়েক ফ্রেজ',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'বট সাড়া দেওয়ার আগে অংশগ্রহণকারীকে এটি বলতে হবে।',
'skills.meetingBots.activeMode': 'আমি ডাকলে উত্তর দেবে',
'skills.meetingBots.activeModeDesc':
'চালু থাকলে, আপনি ওয়েক ফ্রেজ বললে বটটি সশব্দে উত্তর দেয়। বন্ধ থাকলে, এটি শুধু শোনে ও প্রতিলিপি তৈরি করে।',
'skills.resource.preview.closeAriaLabel': 'প্রিভিউ বন্ধ করুন',
'skills.resource.preview.failed': 'প্রিভিউ ব্যর্থ',
'skills.resource.preview.loading': 'প্রিভিউ লোড হচ্ছে…',
+3
View File
@@ -4701,6 +4701,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Wake-Phrase',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Der Teilnehmer muss dies sagen, bevor der Bot antwortet.',
'skills.meetingBots.activeMode': 'Antworten, wenn ich es anspreche',
'skills.meetingBots.activeModeDesc':
'Wenn aktiviert, antwortet der Bot hörbar, nachdem du seinen Weckruf gesagt hast. Wenn deaktiviert, hört er nur zu und transkribiert.',
'skills.resource.preview.closeAriaLabel': 'Vorschau schließen',
'skills.resource.preview.failed': 'Vorschau fehlgeschlagen',
'skills.resource.preview.loading': 'Vorschau wird geladen…',
+3
View File
@@ -5136,6 +5136,9 @@ const en: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Wake Phrase',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Participant must say this before the bot responds.',
'skills.meetingBots.activeMode': 'Respond when I address it',
'skills.meetingBots.activeModeDesc':
'When on, the bot speaks a reply after you say its wake phrase. When off, it only listens and transcribes.',
'skills.resource.preview.closeAriaLabel': 'Close preview',
'skills.resource.preview.failed': 'Preview failed',
'skills.resource.preview.loading': 'Loading preview…',
+3
View File
@@ -4672,6 +4672,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc':
'El participante debe decir esto antes de que el bot responda.',
'skills.meetingBots.activeMode': 'Responder cuando me dirija a él',
'skills.meetingBots.activeModeDesc':
'Si está activado, el bot responde en voz alta después de que digas su frase de activación. Si está desactivado, solo escucha y transcribe.',
'skills.resource.preview.closeAriaLabel': 'Cerrar vista previa',
'skills.resource.preview.failed': 'Vista previa fallida',
'skills.resource.preview.loading': 'Cargando vista previa…',
+3
View File
@@ -4692,6 +4692,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Phrase de réveil',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Le participant doit dire ceci avant que le bot réponde.',
'skills.meetingBots.activeMode': 'Répondre quand je madresse à lui',
'skills.meetingBots.activeModeDesc':
'Activé, le bot répond à voix haute après que vous prononcez sa phrase dactivation. Désactivé, il se contente d’écouter et de transcrire.',
'skills.resource.preview.closeAriaLabel': "Fermer l'aperçu",
'skills.resource.preview.failed': "Échec de l'aperçu",
'skills.resource.preview.loading': "Chargement de l'aperçu…",
+3
View File
@@ -4588,6 +4588,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'वेक फ्रेज़',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'बोट के जवाब देने से पहले प्रतिभागी को यह कहना होगा।',
'skills.meetingBots.activeMode': 'जब मैं बुलाऊँ तब जवाब दे',
'skills.meetingBots.activeModeDesc':
'चालू होने पर, वेक फ़्रेज़ कहने के बाद बॉट बोलकर जवाब देता है। बंद होने पर, यह सिर्फ़ सुनता और ट्रांसक्राइब करता है।',
'skills.resource.preview.closeAriaLabel': 'प्रीव्यू बंद करें',
'skills.resource.preview.failed': 'पूर्वावलोकन विफल',
'skills.resource.preview.loading': 'प्रीव्यू लोड हो रहा है…',
+3
View File
@@ -4601,6 +4601,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Frasa Bangun',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Peserta harus mengucapkan ini sebelum bot merespons.',
'skills.meetingBots.activeMode': 'Tanggapi saat saya menyapa',
'skills.meetingBots.activeModeDesc':
'Saat aktif, bot menjawab dengan suara setelah Anda mengucapkan frasa pemicunya. Saat nonaktif, bot hanya mendengarkan dan mentranskripsikan.',
'skills.resource.preview.closeAriaLabel': 'Tutup pratinjau',
'skills.resource.preview.failed': 'Pratinjau gagal',
'skills.resource.preview.loading': 'Memuat pratinjau...',
+3
View File
@@ -4662,6 +4662,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Frase di attivazione',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Il partecipante deve dirlo prima che il bot risponda.',
'skills.meetingBots.activeMode': 'Rispondi quando mi rivolgo a lui',
'skills.meetingBots.activeModeDesc':
'Se attivo, il bot risponde ad alta voce dopo che pronunci la sua frase di attivazione. Se disattivato, si limita ad ascoltare e trascrivere.',
'skills.resource.preview.closeAriaLabel': 'Chiudi anteprima',
'skills.resource.preview.failed': 'Anteprima fallita',
'skills.resource.preview.loading': 'Caricamento anteprima…',
+3
View File
@@ -4540,6 +4540,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': '웨이크 구문',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': '참가자가 봇이 응답하기 전에 이것을 말해야 합니다.',
'skills.meetingBots.activeMode': '부르면 응답하기',
'skills.meetingBots.activeModeDesc':
'켜면 호출 문구를 말한 뒤 봇이 소리 내어 답합니다. 끄면 듣고 기록만 합니다.',
'skills.resource.preview.closeAriaLabel': '미리보기 닫기',
'skills.resource.preview.failed': '미리보기 실패',
'skills.resource.preview.loading': '미리보기 불러오는 중…',
+3
View File
@@ -4656,6 +4656,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Fraza aktywacji',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Uczestnik musi to powiedzieć, zanim bot odpowie.',
'skills.meetingBots.activeMode': 'Odpowiadaj, gdy się do niego zwracam',
'skills.meetingBots.activeModeDesc':
'Gdy włączone, bot odpowiada na głos po wypowiedzeniu frazy aktywującej. Gdy wyłączone, tylko słucha i transkrybuje.',
'skills.resource.preview.closeAriaLabel': 'Zamknij podgląd',
'skills.resource.preview.failed': 'Podgląd nie powiódł się',
'skills.resource.preview.loading': 'Wczytywanie podglądu…',
+3
View File
@@ -4664,6 +4664,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Frase de ativação',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'O participante deve dizer isso antes de o bot responder.',
'skills.meetingBots.activeMode': 'Responder quando eu falar com ele',
'skills.meetingBots.activeModeDesc':
'Quando ativado, o bot responde em voz alta depois que você diz a frase de ativação. Quando desativado, ele apenas ouve e transcreve.',
'skills.resource.preview.closeAriaLabel': 'Fechar visualização',
'skills.resource.preview.failed': 'Falha na pré-visualização',
'skills.resource.preview.loading': 'Carregando visualização…',
+3
View File
@@ -4627,6 +4627,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': 'Фраза активации',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Участник должен произнести это, прежде чем бот ответит.',
'skills.meetingBots.activeMode': 'Отвечать, когда я обращаюсь',
'skills.meetingBots.activeModeDesc':
'Когда включено, бот отвечает вслух после того, как вы произнесёте фразу-обращение. Когда выключено, он только слушает и расшифровывает.',
'skills.resource.preview.closeAriaLabel': 'Закрыть предпросмотр',
'skills.resource.preview.failed': 'Не удалось показать превью',
'skills.resource.preview.loading': 'Загрузка предпросмотра…',
+3
View File
@@ -4357,6 +4357,9 @@ const messages: TranslationMap = {
'skills.meetingBots.wakePhrase': '唤醒词',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': '参与者必须先说出此词,机器人才会回复。',
'skills.meetingBots.activeMode': '当我呼叫时回应',
'skills.meetingBots.activeModeDesc':
'开启后,说出唤醒词后机器人会出声回答。关闭后,它只聆听并转写。',
'skills.resource.preview.closeAriaLabel': '关闭预览',
'skills.resource.preview.failed': '预览失败',
'skills.resource.preview.loading': '加载预览中…',
+20
View File
@@ -1022,6 +1022,22 @@ pub enum DomainEvent {
recent_transcript: Vec<BackendMeetTurn>,
timestamp_ms: u64,
},
/// Core asked the backend bot to speak into the call (`bot:speak`).
/// Published for observability after the Socket.IO emit succeeds.
BackendMeetSpeak {
text: String,
correlation_id: Option<String>,
},
/// An approval was parked during a live-meeting orchestrator turn
/// (issue #3513). The meeting bus speaks the prompt into the call;
/// the decision arrives by voice ("Hey Tiny, approve") or the
/// standard thread approval card — first response wins.
InCallApprovalRequested {
request_id: String,
tool_name: String,
action_summary: String,
correlation_id: Option<String>,
},
/// A Google Calendar event with a Meet link was detected and the
/// auto-join policy is "ask" — the UI should prompt the user.
MeetAutoJoinPrompt {
@@ -1177,6 +1193,8 @@ impl DomainEvent {
| Self::BackendMeetTranscript { .. }
| Self::BackendMeetError { .. }
| Self::BackendMeetInCallRequest { .. }
| Self::BackendMeetSpeak { .. }
| Self::InCallApprovalRequested { .. }
| Self::MeetAutoJoinPrompt { .. }
| Self::MeetingSummaryGenerated { .. } => "agent_meetings",
}
@@ -1295,6 +1313,8 @@ impl DomainEvent {
Self::BackendMeetTranscript { .. } => "BackendMeetTranscript",
Self::BackendMeetError { .. } => "BackendMeetError",
Self::BackendMeetInCallRequest { .. } => "BackendMeetInCallRequest",
Self::BackendMeetSpeak { .. } => "BackendMeetSpeak",
Self::InCallApprovalRequested { .. } => "InCallApprovalRequested",
Self::MeetAutoJoinPrompt { .. } => "MeetAutoJoinPrompt",
Self::MeetingSummaryGenerated { .. } => "MeetingSummaryGenerated",
Self::Voice(_) => "Voice",
+76
View File
@@ -103,6 +103,25 @@ impl EventHandler for MeetingEventSubscriber {
correlation_id = ?correlation_id,
"{LOG_PREFIX} bot joined meeting"
);
// Pre-warm the per-meeting orchestrator so the first
// wake-phrase command doesn't pay the 5-10s cold build.
// Spawned (the build is slow) and gated on agency being
// enabled, so listen-only / agency-off meetings don't build
// an agent they'll never use.
let correlation_id = correlation_id.clone();
tokio::spawn(async move {
let agency_on = crate::openhuman::config::Config::load_or_init()
.await
.map(|c| c.meet.enable_in_call_agency)
.unwrap_or(false);
// Also pre-warm for meetings joined in active mode via the
// per-meeting toggle, so they get the same first-command
// latency win as globally-enabled agency.
let active = super::in_call::is_meeting_active(correlation_id.as_deref()).await;
if agency_on || active {
super::in_call::prewarm_agent(correlation_id.as_deref()).await;
}
});
}
DomainEvent::BackendMeetLeft {
@@ -114,6 +133,63 @@ impl EventHandler for MeetingEventSubscriber {
correlation_id = ?correlation_id,
"{LOG_PREFIX} bot left meeting"
);
// Free the per-meeting orchestrator built for in-call agency.
super::in_call::clear_meeting_agent(correlation_id.as_deref()).await;
}
DomainEvent::InCallApprovalRequested {
request_id,
tool_name,
action_summary,
correlation_id,
} => {
tracing::info!(
request_id = %request_id,
tool = %tool_name,
correlation_id = ?correlation_id,
"{LOG_PREFIX} in-call approval parked — speaking prompt into call"
);
let action_summary = action_summary.clone();
let correlation_id = correlation_id.clone();
tokio::spawn(async move {
super::in_call::speak_approval_prompt(
&action_summary,
correlation_id.as_deref(),
)
.await;
});
}
DomainEvent::BackendMeetInCallRequest {
correlation_id,
speaker,
command_text,
recent_transcript,
timestamp_ms,
} => {
tracing::info!(
correlation_id = ?correlation_id,
speaker = %speaker,
cmd_len = command_text.len(),
"{LOG_PREFIX} in-call request received"
);
// The orchestrator turn can run for tens of seconds (tools,
// integrations) — spawn so the event bus isn't blocked.
let correlation_id = correlation_id.clone();
let speaker = speaker.clone();
let command_text = command_text.clone();
let recent_transcript = recent_transcript.clone();
let timestamp_ms = *timestamp_ms;
tokio::spawn(async move {
super::in_call::handle_in_call_request(
correlation_id,
speaker,
command_text,
recent_transcript,
timestamp_ms,
)
.await;
});
}
_ => {}
+70 -2
View File
@@ -26,6 +26,13 @@ use crate::core::event_bus::{
publish_global, subscribe_global, DomainEvent, EventHandler, SubscriptionHandle,
};
use crate::openhuman::config::rpc as config_rpc;
use crate::openhuman::notifications::bus::publish_core_notification;
use crate::openhuman::notifications::types::{
CoreNotificationAction, CoreNotificationCategory, CoreNotificationEvent,
};
use super::store;
use super::types::{AutoJoinSource, MeetingSession, MeetingSessionStatus};
static MEET_CALENDAR_HANDLE: OnceLock<SubscriptionHandle> = OnceLock::new();
@@ -166,12 +173,73 @@ impl EventHandler for MeetCalendarSubscriber {
return;
}
crate::openhuman::config::schema::AutoJoinPolicy::AskEachTime => {
// Default: ask — publish a prompt for the UI.
// Default: ask — create a Pending session and surface an
// actionable notification (issue #3507). The buttons route
// through `agent_meetings_notification_action`.
tracing::info!(
meet_url = %meet_url,
title = %event_title,
"[meet:calendar] auto_join_policy=ask_each_time, prompting user"
);
// Dedupe: one prompt per meeting URL while a session is
// still open (Composio can re-fire the trigger on event
// updates).
if let Ok(Some(existing)) = store::get_session_by_meet_url(&config, &meet_url) {
if existing.status != MeetingSessionStatus::Ended {
tracing::debug!(
meeting_id = %existing.id,
"[meet:calendar] open session already exists — skipping re-prompt"
);
return;
}
}
let meeting_id = uuid::Uuid::new_v4().to_string();
let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
let session = MeetingSession {
id: meeting_id.clone(),
meet_url: meet_url.clone(),
title: Some(event_title.clone()),
calendar_event_id: None,
status: MeetingSessionStatus::Pending,
source: AutoJoinSource::Calendar,
thread_id: None,
transcript_received: false,
summary_generated: false,
created_at_ms: now_ms,
updated_at_ms: now_ms,
};
if let Err(e) = store::create_session(&config, &session) {
tracing::warn!("[meet:calendar] session create failed (non-fatal): {e}");
}
let action_payload = serde_json::json!({
"meetingId": meeting_id,
"meetUrl": meet_url,
"title": event_title,
});
let action = |action_id: &str, label: &str| CoreNotificationAction {
action_id: action_id.to_string(),
label: label.to_string(),
payload: Some(action_payload.clone()),
};
publish_core_notification(CoreNotificationEvent {
id: format!("meet-auto-join:{meeting_id}"),
category: CoreNotificationCategory::Meetings,
title: format!("Meeting starting: {event_title}"),
body: "Add Tiny to this meeting?".to_string(),
deep_link: None,
timestamp_ms: now_ms,
actions: Some(vec![
action("join_listen", "Join (listen only)"),
action("join_active", "Join & reply"),
action("skip", "Not this one"),
action("always_join", "Always join"),
]),
});
// Legacy prompt event kept for existing consumers.
publish_global(DomainEvent::MeetAutoJoinPrompt {
meet_url,
event_title,
@@ -350,7 +418,7 @@ async fn auto_join_meeting(
let payload = json!({
"meetUrl": meet_url,
"displayName": "OpenHuman",
"displayName": "Tiny",
"correlationId": correlation_id,
"listenOnly": listen_only,
});
File diff suppressed because it is too large Load Diff
+2
View File
@@ -12,9 +12,11 @@
//! - [`ops`] — RPC handlers that emit Socket.IO events
//! - [`schemas`] — controller schema + registered handler wrappers
//! - [`store`] — SQLite persistence for meeting sessions
//! - [`in_call`] — Phase 2 in-call agency: wake-phrase command → orchestrator → `bot:speak`
pub mod bus;
pub mod calendar;
pub mod in_call;
pub mod ops;
pub mod schemas;
pub mod store;
+158 -4
View File
@@ -15,7 +15,7 @@ use crate::rpc::RpcOutcome;
use super::types::{
BackendMeetHarnessResponseRequest, BackendMeetJoinRequest, BackendMeetJoinResponse,
BackendMeetLeaveRequest, BackendMeetSpeakRequest,
BackendMeetLeaveRequest, BackendMeetSpeakRequest, MeetingSessionStatus,
};
const ALLOWED_HOSTS: &[(&str, &str)] = &[
@@ -48,7 +48,7 @@ fn transcript_turns_to_chat_batch(
continue;
}
let author = if turn.role.eq_ignore_ascii_case("assistant") {
"OpenHuman"
"Tiny"
} else {
"Meeting participant"
};
@@ -293,7 +293,7 @@ pub async fn handle_join(params: Map<String, Value>) -> Result<Value, String> {
let display_name = match &req.display_name {
Some(name) => validate_display_name(name).map_err(|e| format!("[agent_meetings] {e}"))?,
None => "OpenHuman".to_string(),
None => "Tiny".to_string(),
};
let inferred = infer_platform(&normalized_url);
@@ -327,6 +327,14 @@ pub async fn handle_join(params: Map<String, Value>) -> Result<Value, String> {
.await
.map_err(|e| format!("[agent_meetings] emit failed: {e}"))?;
// Active mode (listen_only = false, the modal's "respond when addressed"
// toggle) enables in-call agency for just this meeting, so the toggle
// "just works" without flipping the global config. Passive joins leave
// the meeting unmarked (default: listen-only / transcribe-only).
if req.listen_only == Some(false) {
super::in_call::mark_meeting_active(req.correlation_id.as_deref()).await;
}
let response = BackendMeetJoinResponse {
ok: true,
meet_url: normalized_url.to_string(),
@@ -429,6 +437,119 @@ pub async fn handle_speak(params: Map<String, Value>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
/// Handle `openhuman.agent_meetings_notification_action` — a click on one
/// of the calendar auto-join notification buttons (issue #3507).
///
/// Actions:
/// - `join_listen` → join muted (transcript-only).
/// - `join_active` → join in reply mode with the "Hey Tiny" wake phrase.
/// - `skip` → mark the meeting session Ended; no join.
/// - `always_join` → persist `auto_join_policy = Always`, then join with
/// the configured `listen_only_default`.
///
/// `payload` carries `{ meetingId, meetUrl, title }` from the notification
/// plus an optional user-edited `displayName`.
pub async fn handle_notification_action(params: Map<String, Value>) -> Result<Value, String> {
let action_id = params
.get("action_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if action_id.is_empty() {
return Err("[agent_meetings] action_id is required".to_string());
}
let payload = params.get("payload").cloned().unwrap_or(Value::Null);
let meeting_id = payload
.get("meetingId")
.and_then(|v| v.as_str())
.map(String::from);
let meet_url = payload
.get("meetUrl")
.and_then(|v| v.as_str())
.map(String::from);
let display_name = payload
.get("displayName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from);
tracing::info!(
action_id = %action_id,
meeting_id = ?meeting_id,
has_meet_url = meet_url.is_some(),
"[agent_meetings] notification action received"
);
match action_id.as_str() {
"skip" => {
if let Some(id) = &meeting_id {
match crate::openhuman::config::ops::load_config_with_timeout().await {
Ok(config) => {
let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
if let Err(e) = super::store::update_session_status(
&config,
id,
MeetingSessionStatus::Ended,
now_ms,
) {
tracing::debug!("[agent_meetings] skip: session update failed: {e}");
}
}
Err(e) => {
tracing::debug!("[agent_meetings] skip: config load failed: {e}");
}
}
}
let outcome = RpcOutcome::new(json!({ "ok": true }), vec![]);
outcome.into_cli_compatible_json()
}
"join_listen" | "join_active" | "always_join" => {
let meet_url = meet_url
.ok_or_else(|| "[agent_meetings] payload.meetUrl is required".to_string())?;
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
if action_id == "always_join" {
let mut cfg = config.clone();
cfg.meet.auto_join_policy =
crate::openhuman::config::schema::AutoJoinPolicy::Always;
if let Err(e) = cfg.save().await {
// Join anyway — the policy flip failing must not block
// the join the user just asked for.
tracing::warn!("[agent_meetings] persisting always-join policy failed: {e}");
}
}
let listen_only = match action_id.as_str() {
"join_listen" => true,
"join_active" => false,
_ => config.meet.listen_only_default,
};
let mut join = Map::new();
join.insert("meet_url".to_string(), json!(meet_url));
join.insert(
"correlation_id".to_string(),
json!(meeting_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())),
);
join.insert("listen_only".to_string(), json!(listen_only));
if let Some(name) = display_name {
join.insert("display_name".to_string(), json!(name));
}
if !listen_only {
// Reply mode: the participant addresses the bot as "Hey Tiny";
// the wake phrase is always required (no implicit address).
join.insert("wake_phrase".to_string(), json!("Hey Tiny"));
}
handle_join(join).await
}
other => Err(format!("[agent_meetings] unknown action_id: {other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -460,6 +581,39 @@ mod tests {
assert!(validate_meeting_url("https://example.com/meeting").is_err());
}
#[tokio::test]
async fn notification_action_requires_action_id() {
let err = handle_notification_action(Map::new()).await.unwrap_err();
assert!(err.contains("action_id"));
}
#[tokio::test]
async fn notification_action_rejects_unknown_action() {
let mut params = Map::new();
params.insert("action_id".to_string(), json!("explode"));
let err = handle_notification_action(params).await.unwrap_err();
assert!(err.contains("unknown action_id"));
}
#[tokio::test]
async fn notification_action_join_requires_meet_url() {
let mut params = Map::new();
params.insert("action_id".to_string(), json!("join_listen"));
params.insert("payload".to_string(), json!({ "meetingId": "m-1" }));
let err = handle_notification_action(params).await.unwrap_err();
assert!(err.contains("meetUrl"));
}
#[tokio::test]
async fn notification_action_skip_without_meeting_id_is_ok() {
// No meetingId → nothing to update; must succeed without touching
// config or the session store.
let mut params = Map::new();
params.insert("action_id".to_string(), json!("skip"));
let value = handle_notification_action(params).await.unwrap();
assert_eq!(value.get("ok"), Some(&json!(true)));
}
#[test]
fn infers_platform_from_host() {
let url = url::Url::parse("https://meet.google.com/abc-defg-hij").unwrap();
@@ -498,7 +652,7 @@ mod tests {
assert_eq!(batch.platform, "backend_meet");
assert_eq!(batch.messages.len(), 2);
assert_eq!(batch.messages[0].author, "Meeting participant");
assert_eq!(batch.messages[1].author, "OpenHuman");
assert_eq!(batch.messages[1].author, "Tiny");
assert!(batch.messages[0].text.contains("summarize"));
}
+48 -1
View File
@@ -36,6 +36,11 @@ const DEFS: &[BackendMeetControllerDef] = &[
schema: schema_speak,
handler: handle_speak_wrap,
},
BackendMeetControllerDef {
function: "notification_action",
schema: schema_notification_action,
handler: handle_notification_action_wrap,
},
];
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
@@ -238,6 +243,42 @@ fn handle_speak_wrap(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::ops::handle_speak(params).await })
}
fn schema_notification_action() -> ControllerSchema {
ControllerSchema {
namespace: "agent_meetings",
function: "notification_action",
description: "Handle a click on a calendar auto-join notification button. \
Actions: join_listen (muted), join_active (reply mode with the \
'Hey Tiny' wake phrase), skip (dismiss this meeting), always_join \
(persist auto_join_policy=always, then join).",
inputs: vec![
FieldSchema {
name: "action_id",
ty: TypeSchema::String,
comment: "One of: join_listen, join_active, skip, always_join.",
required: true,
},
FieldSchema {
name: "payload",
ty: TypeSchema::Json,
comment: "The notification action payload: { meetingId, meetUrl, title } \
plus an optional user-edited displayName for the bot.",
required: false,
},
],
outputs: vec![FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True when the action was handled (join emitted or session updated).",
required: true,
}],
}
}
fn handle_notification_action_wrap(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { super::ops::handle_notification_action(params).await })
}
#[cfg(test)]
mod tests {
use super::*;
@@ -255,7 +296,13 @@ mod tests {
assert_eq!(schema_fns, handler_fns);
assert_eq!(
schema_fns,
vec!["join", "leave", "harness_response", "speak"]
vec![
"join",
"leave",
"harness_response",
"speak",
"notification_action"
]
);
}
+210 -11
View File
@@ -50,6 +50,11 @@ use super::types::{ApprovalDecision, ExecutionOutcome, GateOutcome, PendingAppro
/// written into the persisted row.
const DEFAULT_APPROVAL_TTL: Duration = Duration::from_secs(60 * 10);
/// Shorter park window for approvals raised mid-call (issue #3513): a
/// live meeting can't idle on a parked tool for the default ten
/// minutes — if nobody approves within two, deny and move on.
const IN_CALL_APPROVAL_TTL: Duration = Duration::from_secs(120);
/// Per-turn chat context for routing a parked approval's yes/no reply back to
/// the originating thread. The web channel scopes this task-local around the
/// agent run (`channels::providers::web`); because the `run_turn` handler, the
@@ -67,6 +72,26 @@ tokio::task_local! {
pub static APPROVAL_CHAT_CONTEXT: ApprovalChatContext;
}
/// In-call meeting context (issue #3513) — set by `agent_meetings::in_call`
/// around the orchestrator turn for a live meeting. When present, a parked
/// approval additionally:
/// - publishes [`DomainEvent::InCallApprovalRequested`] so the meeting bus
/// can speak the approval prompt into the call (`bot:speak`),
/// - registers a meeting → request mapping so a spoken
/// "Hey Tiny, approve" can be routed to [`ApprovalGate::decide`], and
/// - clamps the park window to [`IN_CALL_APPROVAL_TTL`].
#[derive(Clone, Debug)]
pub struct InCallApprovalContext {
/// Stable per-meeting key (the correlation id, or `"default"`).
pub meeting_key: String,
/// Original correlation id, echoed on spoken prompts.
pub correlation_id: Option<String>,
}
tokio::task_local! {
pub static APPROVAL_IN_CALL_CONTEXT: InCallApprovalContext;
}
/// Parse a chat reply to a parked approval into a binary decision (v1). Only an
/// explicit yes/no answer maps to a decision; anything else returns `None` — the
/// web channel treats `None` as "not an answer", cancels the parked turn, and
@@ -138,6 +163,11 @@ pub struct ApprovalGate {
/// In-memory only (session-scoped — a parked approval doesn't survive a
/// restart, and the oneshot waiter is in-memory anyway).
thread_to_request: Mutex<HashMap<String, String>>,
/// meeting_key → request_id for the approval currently parked on a live
/// meeting, so a spoken "Hey Tiny, approve" can be routed to a decision
/// (issue #3513). Same in-memory/session-scoped semantics as
/// `thread_to_request`.
meeting_to_request: Mutex<HashMap<String, String>>,
}
impl ApprovalGate {
@@ -185,6 +215,7 @@ impl ApprovalGate {
ttl,
waiters: Mutex::new(HashMap::new()),
thread_to_request: Mutex::new(HashMap::new()),
meeting_to_request: Mutex::new(HashMap::new()),
}
}
@@ -268,6 +299,11 @@ impl ApprovalGate {
let chat_thread_id = chat_ctx.as_ref().map(|c| c.thread_id.clone());
let chat_client_id = chat_ctx.as_ref().map(|c| c.client_id.clone());
// In-call meeting context — set by agent_meetings::in_call around a
// live-meeting orchestrator turn. Enables the spoken approval
// channel alongside the thread card (issue #3513).
let in_call_ctx = APPROVAL_IN_CALL_CONTEXT.try_with(|c| c.clone()).ok();
// Branch by origin. Web chat parks for an in-app approval; external
// channel persists an audit row and TTL-denies (no routable approval
// surface yet); trusted automation (cron, internal-only subconscious)
@@ -291,14 +327,16 @@ impl ApprovalGate {
sender = %sender.as_deref().unwrap_or("<unknown>"),
reply_target = %reply_target,
message_id = %message_id,
"[approval::gate] external channel turn — persisting audit row and parking \
(will TTL-deny until a routable channel approval surface ships)"
in_call = in_call_ctx.is_some(),
"[approval::gate] external channel turn — persisting audit row and parking"
);
// Fall through to the parking flow: a `pending_approvals` row
// is persisted (audit trail) and the future TTL-denies. We do
// NOT short-circuit to Allow here — remote inputs are
// untrusted, and there is no UI surface to route a yes/no on
// a non-web channel right now.
// is persisted (audit trail) and the future parks. We do NOT
// short-circuit to Allow here — remote inputs are untrusted.
// Without a routable surface the park TTL-denies; with the
// in-call context set (live meeting, issue #3513) a decision
// can arrive via the spoken channel (`pending_for_meeting` →
// `decide`) or the thread card before the (clamped) TTL.
}
AgentTurnOrigin::TrustedAutomation {
source: TrustedAutomationSource::Cron,
@@ -398,6 +436,13 @@ impl ApprovalGate {
.lock()
.insert(thread_id.clone(), request_id.clone());
}
// Record the meeting → request mapping so a spoken approval reply
// ("Hey Tiny, approve") can be routed to a decision.
if let Some(ic) = in_call_ctx.as_ref() {
self.meeting_to_request
.lock()
.insert(ic.meeting_key.clone(), request_id.clone());
}
if let Err(err) = store::insert_pending(&self.config, &pending, &self.session_id) {
self.evict_waiter(&request_id);
@@ -434,13 +479,31 @@ impl ApprovalGate {
client_id: chat_client_id.clone(),
});
// Voice channel (issue #3513): tell the meeting bus to speak the
// approval prompt into the call.
if let Some(ic) = in_call_ctx.as_ref() {
publish_global(DomainEvent::InCallApprovalRequested {
request_id: request_id.clone(),
tool_name: tool_name.to_string(),
action_summary: action_summary.to_string(),
correlation_id: ic.correlation_id.clone(),
});
}
tracing::info!(
request_id = %request_id,
tool = tool_name,
"[approval::gate] tool call parked, waiting for decision"
);
let outcome = match tokio::time::timeout(self.ttl, rx).await {
// Live meetings get a clamped park window — see IN_CALL_APPROVAL_TTL.
let effective_ttl = if in_call_ctx.is_some() {
IN_CALL_APPROVAL_TTL.min(self.ttl)
} else {
self.ttl
};
let outcome = match tokio::time::timeout(effective_ttl, rx).await {
Ok(Ok(decision)) => {
tracing::info!(
request_id = %request_id,
@@ -503,7 +566,7 @@ impl ApprovalGate {
tracing::info!(
request_id = %request_id,
tool = tool_name,
ttl_secs = self.ttl.as_secs(),
ttl_secs = effective_ttl.as_secs(),
"[approval::gate] timeout race: persisted decision was Approve, honoring approval"
);
// Fall through (no early return) so `clear_thread` below runs
@@ -515,7 +578,7 @@ impl ApprovalGate {
tracing::warn!(
request_id = %request_id,
tool = tool_name,
ttl_secs = self.ttl.as_secs(),
ttl_secs = effective_ttl.as_secs(),
"[approval::gate] approval timed out, denying"
);
(
@@ -524,7 +587,7 @@ impl ApprovalGate {
"{POLICY_DENIED_MARKER} Approval for '{tool_name}' timed out after \
{}s. Do not re-request the same call this turn; take a different \
approach or stop.",
self.ttl.as_secs()
effective_ttl.as_secs()
),
},
None,
@@ -532,9 +595,10 @@ impl ApprovalGate {
}
}
};
// The thread routing mapping is only needed while parked; clear it on
// The routing mappings are only needed while parked; clear them on
// every exit (decision, channel drop, or timeout).
self.clear_thread(&chat_thread_id);
self.clear_meeting(&in_call_ctx);
outcome
}
@@ -634,12 +698,26 @@ impl ApprovalGate {
self.thread_to_request.lock().get(thread_id).cloned()
}
/// The request_id of the approval currently parked on a live meeting, if
/// any. Used by `agent_meetings::in_call` to route a spoken
/// "Hey Tiny, approve" to a decision (issue #3513).
pub fn pending_for_meeting(&self, meeting_key: &str) -> Option<String> {
self.meeting_to_request.lock().get(meeting_key).cloned()
}
/// Drop the thread → request mapping (best-effort; no-op when absent).
fn clear_thread(&self, thread_id: &Option<String>) {
if let Some(t) = thread_id {
self.thread_to_request.lock().remove(t);
}
}
/// Drop the meeting → request mapping (best-effort; no-op when absent).
fn clear_meeting(&self, ctx: &Option<InCallApprovalContext>) {
if let Some(ic) = ctx {
self.meeting_to_request.lock().remove(&ic.meeting_key);
}
}
}
#[cfg(test)]
@@ -687,6 +765,127 @@ mod tests {
}
}
/// An external-channel (live meeting) origin for the in-call fixtures.
fn meet_origin() -> AgentTurnOrigin {
AgentTurnOrigin::ExternalChannel {
channel: "meet".into(),
sender: None,
reply_target: "meet-1".into(),
message_id: "m-1".into(),
}
}
fn in_call_ctx() -> InCallApprovalContext {
InCallApprovalContext {
meeting_key: "meet-1".into(),
correlation_id: Some("meet-1".into()),
}
}
#[tokio::test]
async fn in_call_voice_approve_resolves_parked_external_channel_approval() {
let (gate, _dir) = test_gate();
let gate = Arc::new(gate);
let g = gate.clone();
let handle = tokio::spawn(async move {
turn_origin::with_origin(
meet_origin(),
APPROVAL_IN_CALL_CONTEXT.scope(
in_call_ctx(),
g.intercept("composio", "create calendar event", serde_json::json!({})),
),
)
.await
});
// The meeting → request mapping is the voice channel's lookup key.
let mut tries = 0;
let request_id = loop {
if let Some(r) = gate.pending_for_meeting("meet-1") {
break r;
}
tries += 1;
assert!(tries < 50, "meeting mapping never appeared");
tokio::time::sleep(Duration::from_millis(10)).await;
};
gate.decide(&request_id, ApprovalDecision::ApproveOnce)
.unwrap();
let outcome = handle.await.unwrap();
assert!(matches!(outcome, GateOutcome::Allow));
assert!(
gate.pending_for_meeting("meet-1").is_none(),
"meeting mapping must be cleared once the park resolves"
);
}
#[tokio::test]
async fn in_call_voice_deny_resolves_parked_approval_with_deny() {
let (gate, _dir) = test_gate();
let gate = Arc::new(gate);
let g = gate.clone();
let handle = tokio::spawn(async move {
turn_origin::with_origin(
meet_origin(),
APPROVAL_IN_CALL_CONTEXT.scope(
in_call_ctx(),
g.intercept("composio", "send email", serde_json::json!({})),
),
)
.await
});
let request_id = loop {
if let Some(r) = gate.pending_for_meeting("meet-1") {
break r;
}
tokio::time::sleep(Duration::from_millis(10)).await;
};
gate.decide(&request_id, ApprovalDecision::Deny).unwrap();
let outcome = handle.await.unwrap();
match outcome {
GateOutcome::Deny { reason } => assert!(reason.contains("composio")),
other => panic!("expected deny, got {other:?}"),
}
assert!(gate.pending_for_meeting("meet-1").is_none());
}
#[tokio::test]
async fn external_channel_without_in_call_ctx_has_no_meeting_mapping() {
// Plain external-channel turns (telegram, discord) must not gain a
// voice surface: no in-call context → no meeting mapping. Uses the
// 2s test TTL so the parked future deny-resolves quickly.
let (gate, _dir) = test_gate();
let gate = Arc::new(gate);
let g = gate.clone();
let handle = tokio::spawn(async move {
turn_origin::with_origin(
meet_origin(),
g.intercept("composio", "send email", serde_json::json!({})),
)
.await
});
// Wait for the row to park, then confirm no meeting mapping exists.
loop {
if !gate.list_pending().unwrap().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(gate.pending_for_meeting("meet-1").is_none());
// TTL-deny is the expected terminal state.
let outcome = handle.await.unwrap();
assert!(matches!(outcome, GateOutcome::Deny { .. }));
}
#[tokio::test]
async fn approve_once_returns_allow() {
let (gate, _dir) = test_gate();
+4 -1
View File
@@ -20,7 +20,10 @@ pub mod schemas;
pub mod store;
pub mod types;
pub use gate::{parse_approval_reply, ApprovalChatContext, ApprovalGate, APPROVAL_CHAT_CONTEXT};
pub use gate::{
parse_approval_reply, ApprovalChatContext, ApprovalGate, InCallApprovalContext,
APPROVAL_CHAT_CONTEXT, APPROVAL_IN_CALL_CONTEXT,
};
pub use redact::{redact_args, summarize_action};
pub use schemas::all_controller_schemas as all_approval_controller_schemas;
pub use schemas::all_registered_controllers as all_approval_registered_controllers;
+43
View File
@@ -70,6 +70,20 @@ pub struct MeetConfig {
/// When `true`, the bot joins in listen-only mode (mic muted).
#[serde(default = "default_listen_only")]
pub listen_only_default: bool,
/// Phase 2 in-call agency (epic #3505, PR-6): when `true`, wake-phrase
/// commands detected mid-call (`bot:in_call_request`) are routed
/// through the orchestrator and the reply is spoken back into the
/// call (`bot:speak`). Off by default.
#[serde(default = "default_enable_in_call_agency")]
pub enable_in_call_agency: bool,
/// When `true` (default), the in-call reply is streamed back as
/// per-sentence `bot:speak` chunks as the LLM generates them, so the
/// bot starts speaking on the first sentence instead of after the whole
/// reply. Set `false` to fall back to a single buffered `bot:speak`.
#[serde(default = "default_in_call_streaming")]
pub in_call_streaming: bool,
}
fn default_auto_orchestrator_handoff() -> bool {
@@ -84,6 +98,14 @@ fn default_listen_only() -> bool {
true
}
fn default_enable_in_call_agency() -> bool {
false
}
fn default_in_call_streaming() -> bool {
true
}
impl Default for MeetConfig {
fn default() -> Self {
Self {
@@ -92,6 +114,8 @@ impl Default for MeetConfig {
auto_join_policy: AutoJoinPolicy::default(),
auto_summarize_policy: AutoSummarizePolicy::default(),
listen_only_default: true,
enable_in_call_agency: false,
in_call_streaming: true,
}
}
}
@@ -131,6 +155,21 @@ mod tests {
assert!(cfg.listen_only_default);
}
#[test]
fn default_in_call_agency_is_off() {
let cfg = MeetConfig::default();
assert!(!cfg.enable_in_call_agency);
}
#[test]
fn default_in_call_streaming_is_on() {
let cfg = MeetConfig::default();
assert!(cfg.in_call_streaming);
// And a config that predates the field still defaults it on.
let parsed: MeetConfig = serde_json::from_value(json!({})).unwrap();
assert!(parsed.in_call_streaming);
}
#[test]
fn deserialize_missing_fields_uses_defaults() {
let cfg: MeetConfig = serde_json::from_value(json!({})).unwrap();
@@ -139,6 +178,7 @@ mod tests {
assert_eq!(cfg.auto_join_policy, AutoJoinPolicy::AskEachTime);
assert_eq!(cfg.auto_summarize_policy, AutoSummarizePolicy::Ask);
assert!(cfg.listen_only_default);
assert!(!cfg.enable_in_call_agency);
}
#[test]
@@ -162,6 +202,8 @@ mod tests {
auto_join_policy: AutoJoinPolicy::Never,
auto_summarize_policy: AutoSummarizePolicy::Always,
listen_only_default: false,
enable_in_call_agency: true,
in_call_streaming: false,
};
let s = serde_json::to_string(&original).unwrap();
let back: MeetConfig = serde_json::from_str(&s).unwrap();
@@ -170,5 +212,6 @@ mod tests {
assert_eq!(back.auto_join_policy, AutoJoinPolicy::Never);
assert_eq!(back.auto_summarize_policy, AutoSummarizePolicy::Always);
assert!(!back.listen_only_default);
assert!(back.enable_in_call_agency);
}
}
+6 -1
View File
@@ -43,6 +43,11 @@ mod turns;
pub use access::{run_grant_turn, run_soft_deny_turn};
pub use turns::{run_caption_turn, run_turn};
// Speech sanitizer shared with `agent_meetings::in_call` — both paths
// feed orchestrator replies into TTS and need the same markdown /
// reasoning-trace stripping.
pub(crate) use text::strip_for_speech;
use constants::agent_cache;
/// Drop the cached orchestrator for a meet session. Called from
@@ -66,7 +71,7 @@ pub(crate) use access::{
#[cfg(test)]
pub(crate) use llm::extract_chat_completion_text;
#[cfg(test)]
pub(crate) use text::{recent_dialog_history, strip_for_speech};
pub(crate) use text::recent_dialog_history;
#[cfg(test)]
#[path = "../brain_tests.rs"]