mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Claude Fable 5
parent
27739ee59e
commit
c5087d83da
@@ -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 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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': 'جارٍ تحميل المعاينة…',
|
||||
|
||||
@@ -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': 'প্রিভিউ লোড হচ্ছে…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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 m’adresse à lui',
|
||||
'skills.meetingBots.activeModeDesc':
|
||||
'Activé, le bot répond à voix haute après que vous prononcez sa phrase d’activation. 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…",
|
||||
|
||||
@@ -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': 'प्रीव्यू लोड हो रहा है…',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': '미리보기 불러오는 중…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'Загрузка предпросмотра…',
|
||||
|
||||
@@ -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': '加载预览中…',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
_ => {}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user