From 5756271335dfefb2ba28b2575ad6554a8b2ffdff Mon Sep 17 00:00:00 2001
From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com>
Date: Thu, 25 Jun 2026 22:01:42 +0530
Subject: [PATCH] feat(meet): expandable recent-call detail with transcript +
summary (#4113)
Co-authored-by: Claude Opus 4.8 (1M context)
---
.../skills/RecentCallsSection.test.tsx | 169 +++++++++++
.../components/skills/RecentCallsSection.tsx | 286 ++++++++++++++++--
app/src/lib/i18n/ar.ts | 11 +
app/src/lib/i18n/bn.ts | 12 +
app/src/lib/i18n/de.ts | 12 +
app/src/lib/i18n/en.ts | 11 +
app/src/lib/i18n/es.ts | 12 +
app/src/lib/i18n/fr.ts | 12 +
app/src/lib/i18n/hi.ts | 12 +
app/src/lib/i18n/id.ts | 12 +
app/src/lib/i18n/it.ts | 12 +
app/src/lib/i18n/ko.ts | 11 +
app/src/lib/i18n/pl.ts | 12 +
app/src/lib/i18n/pt.ts | 12 +
app/src/lib/i18n/ru.ts | 11 +
app/src/lib/i18n/zh-CN.ts | 11 +
app/src/services/meetCallService.ts | 58 ++++
src/openhuman/agent_meetings/bus.rs | 45 ++-
src/openhuman/agent_meetings/ops.rs | 57 ++--
src/openhuman/agent_meetings/recent_calls.rs | 132 +++++++-
src/openhuman/agent_meetings/summary.rs | 37 +++
src/openhuman/meet_agent/rpc.rs | 43 ++-
src/openhuman/meet_agent/schemas.rs | 42 +++
src/openhuman/meet_agent/store.rs | 222 ++++++++++++++
src/openhuman/meet_agent/types.rs | 21 ++
25 files changed, 1201 insertions(+), 74 deletions(-)
create mode 100644 app/src/components/skills/RecentCallsSection.test.tsx
diff --git a/app/src/components/skills/RecentCallsSection.test.tsx b/app/src/components/skills/RecentCallsSection.test.tsx
new file mode 100644
index 000000000..c2b013a20
--- /dev/null
+++ b/app/src/components/skills/RecentCallsSection.test.tsx
@@ -0,0 +1,169 @@
+/**
+ * Tests for the recent-calls panel's expandable rows.
+ *
+ * Confirms a row lazily fetches its transcript + summary on first expand,
+ * renders both, and degrades gracefully when the core has no detail or the
+ * fetch fails (with a working retry).
+ */
+import { configureStore } from '@reduxjs/toolkit';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { Provider } from 'react-redux';
+
+import { I18nProvider } from '../../lib/i18n/I18nContext';
+import type { MeetCallDetail, MeetCallRecord } from '../../services/meetCallService';
+import localeReducer from '../../store/localeSlice';
+import { RecentCallsSection } from './RecentCallsSection';
+
+const getMeetCallDetail = vi.fn<(requestId: string) => Promise>();
+
+vi.mock('../../services/meetCallService', () => ({
+ getMeetCallDetail: (requestId: string) => getMeetCallDetail(requestId),
+}));
+
+function call(overrides: Partial = {}): MeetCallRecord {
+ return {
+ request_id: 'corr-1',
+ meet_url: 'https://meet.google.com/yfj-hcek-zyv',
+ bot_display_name: 'Tiny',
+ owner_display_name: 'Shanu',
+ started_at_ms: Date.now() - 60_000,
+ ended_at_ms: Date.now(),
+ listened_seconds: 100,
+ spoken_seconds: 20,
+ turn_count: 3,
+ participants: ['Shanu', 'Alan'],
+ ...overrides,
+ };
+}
+
+function renderSection(rows: MeetCallRecord[]) {
+ const store = configureStore({
+ reducer: { locale: localeReducer },
+ preloadedState: { locale: { current: 'en' as const } },
+ });
+ return render(
+
+
+
+
+
+ );
+}
+
+describe('RecentCallsSection', () => {
+ beforeEach(() => {
+ getMeetCallDetail.mockReset();
+ });
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('lazily loads and renders summary + transcript on expand', async () => {
+ getMeetCallDetail.mockResolvedValue({
+ request_id: 'corr-1',
+ summary: {
+ headline: 'Agreed to ship Friday.',
+ key_points: ['Ship Friday', 'QA owns sign-off'],
+ action_items: [
+ { description: 'Send release notes', kind: 'executable', tool_name: 'gmail', assignee: 'Sam' },
+ ],
+ },
+ transcript: [
+ { role: 'participant', content: '[00:51] [Shanu] your time' },
+ { role: 'assistant', content: '[00:55] [Tiny] On it.' },
+ ],
+ });
+
+ renderSection([call()]);
+ // Not fetched until the row is expanded.
+ expect(getMeetCallDetail).not.toHaveBeenCalled();
+
+ await userEvent.click(screen.getByRole('button'));
+
+ expect(getMeetCallDetail).toHaveBeenCalledExactlyOnceWith('corr-1');
+ await waitFor(() => expect(screen.getByText('Agreed to ship Friday.')).toBeInTheDocument());
+ expect(screen.getByText('Summary')).toBeInTheDocument();
+ expect(screen.getByText('Ship Friday')).toBeInTheDocument();
+ expect(screen.getByText('Transcript')).toBeInTheDocument();
+ expect(screen.getByText('[00:55] [Tiny] On it.')).toBeInTheDocument();
+ // Action item description + assignee/tool meta.
+ expect(screen.getByText('Send release notes')).toBeInTheDocument();
+ });
+
+ it('shows an empty state when the call has no recorded detail', async () => {
+ getMeetCallDetail.mockResolvedValue(null);
+ renderSection([call()]);
+
+ await userEvent.click(screen.getByRole('button'));
+
+ await waitFor(() =>
+ expect(
+ screen.getByText('No transcript or summary was captured for this call.')
+ ).toBeInTheDocument()
+ );
+ });
+
+ it('surfaces an error with a working retry', async () => {
+ getMeetCallDetail.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({
+ request_id: 'corr-1',
+ summary: null,
+ transcript: [{ role: 'participant', content: 'recovered line' }],
+ });
+
+ renderSection([call()]);
+ await userEvent.click(screen.getByRole('button'));
+
+ const retry = await screen.findByRole('button', { name: 'Retry' });
+ await userEvent.click(retry);
+
+ await waitFor(() => expect(screen.getByText('recovered line')).toBeInTheDocument());
+ expect(getMeetCallDetail).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not refetch when collapsing and re-expanding a fully-loaded call', async () => {
+ getMeetCallDetail.mockResolvedValue({
+ request_id: 'corr-1',
+ summary: { headline: 'All set.', key_points: [], action_items: [] },
+ transcript: [{ role: 'participant', content: 'cached line' }],
+ });
+
+ renderSection([call()]);
+ const toggle = screen.getByRole('button');
+ await userEvent.click(toggle); // expand → fetch
+ await waitFor(() => expect(screen.getByText('cached line')).toBeInTheDocument());
+ await userEvent.click(toggle); // collapse
+ await userEvent.click(toggle); // re-expand → reuse cache (summary already present)
+
+ expect(getMeetCallDetail).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetches on re-expand to pick up a summary generated after call-end', async () => {
+ // First load: transcript persisted, summary still generating (null).
+ getMeetCallDetail
+ .mockResolvedValueOnce({
+ request_id: 'corr-1',
+ summary: null,
+ transcript: [{ role: 'participant', content: 'early line' }],
+ })
+ // Second load: summary has since landed.
+ .mockResolvedValueOnce({
+ request_id: 'corr-1',
+ summary: { headline: 'Summary arrived.', key_points: [], action_items: [] },
+ transcript: [{ role: 'participant', content: 'early line' }],
+ });
+
+ renderSection([call()]);
+ const toggle = screen.getByRole('button');
+ await userEvent.click(toggle); // expand → first fetch (no summary yet)
+ await waitFor(() => expect(screen.getByText('early line')).toBeInTheDocument());
+ expect(screen.queryByText('Summary arrived.')).not.toBeInTheDocument();
+
+ await userEvent.click(toggle); // collapse
+ await userEvent.click(toggle); // re-expand → refetch (summary was missing)
+
+ await waitFor(() => expect(screen.getByText('Summary arrived.')).toBeInTheDocument());
+ expect(getMeetCallDetail).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/app/src/components/skills/RecentCallsSection.tsx b/app/src/components/skills/RecentCallsSection.tsx
index 22e85bffe..b04e30932 100644
--- a/app/src/components/skills/RecentCallsSection.tsx
+++ b/app/src/components/skills/RecentCallsSection.tsx
@@ -1,11 +1,24 @@
+import { useCallback, useState } from 'react';
+
import { useT } from '../../lib/i18n/I18nContext';
-import { type MeetCallRecord } from '../../services/meetCallService';
+import {
+ getMeetCallDetail,
+ type MeetCallDetail,
+ type MeetCallRecord,
+ type MeetCallSummary,
+ type MeetCallTranscriptLine,
+} from '../../services/meetCallService';
/**
* Recent-calls history shown under the meeting-bot join form. Renders the
* loading / empty / populated states and one row per completed call (meeting
* code, relative time, turn count, duration, owner, and participants).
*
+ * Each row is expandable: on first expand it lazily fetches the call's
+ * transcript + summary via `meet_agent_get_call_detail` so the list payload
+ * stays lean. Older calls recorded before the feature have no detail and show
+ * a "nothing captured" state.
+ *
* Extracted from `MeetingBotsCard` to keep that component within the repo's
* ~500-line file-size guideline.
*/
@@ -43,7 +56,7 @@ export function RecentCallsSection({
{t('skills.meetingBots.recentCallsEmpty')}
) : (
-
+
{rows.map(call => (
))}
@@ -53,8 +66,29 @@ export function RecentCallsSection({
);
}
+type DetailStatus = 'idle' | 'loading' | 'loaded' | 'error';
+
+/**
+ * True when `detail` carries a non-empty generated summary. The summary lands
+ * asynchronously after the transcript at call-end, so this gates whether a
+ * re-expand should refetch (still pending) or reuse the cache (already present).
+ */
+function hasSummaryDetail(detail: MeetCallDetail | null): boolean {
+ const summary = detail?.summary;
+ return (
+ !!summary &&
+ (summary.headline.trim().length > 0 ||
+ summary.key_points.length > 0 ||
+ summary.action_items.length > 0)
+ );
+}
+
function RecentCallRow({ call }: { call: MeetCallRecord }) {
const { t } = useT();
+ const [expanded, setExpanded] = useState(false);
+ const [status, setStatus] = useState('idle');
+ const [detail, setDetail] = useState(null);
+
const meetingCode = (() => {
try {
const parsed = new URL(call.meet_url);
@@ -67,39 +101,239 @@ function RecentCallRow({ call }: { call: MeetCallRecord }) {
const duration = Math.max(0, Math.round(call.spoken_seconds + call.listened_seconds));
const owner = call.owner_display_name?.trim();
const participants = (call.participants ?? []).map(p => p.trim()).filter(Boolean);
+
+ const loadDetail = useCallback(async () => {
+ setStatus('loading');
+ try {
+ const result = await getMeetCallDetail(call.request_id);
+ setDetail(result);
+ setStatus('loaded');
+ } catch (err) {
+ console.error('[recent-calls] failed to load call detail', call.request_id, err);
+ setStatus('error');
+ }
+ }, [call.request_id]);
+
+ const toggle = useCallback(() => {
+ setExpanded(prev => {
+ const next = !prev;
+ // Lazy-load on first expand. The transcript is persisted at call-end but
+ // the summary is generated asynchronously and patched in moments later, so
+ // re-expanding a row whose cached detail still lacks a summary refetches to
+ // pick it up. A complete (summary-present) detail is reused as-is.
+ if (next && (status === 'idle' || (status === 'loaded' && !hasSummaryDetail(detail)))) {
+ void loadDetail();
+ }
+ return next;
+ });
+ }, [status, detail, loadDetail]);
+
return (
- -
-
-
- {meetingCode}
-
-
- {formatRelativeTime(call.started_at_ms)}
-
-
-
-
- {call.turn_count} turn{call.turn_count === 1 ? '' : 's'}
-
-
{duration}s on call
- {owner && (
-
- {t('skills.meetingBots.recentCallAddedBy').replace('{name}', owner)}
+ -
+
+
+ {expanded && (
+
+
+
)}
);
}
+function RecentCallDetailBody({
+ status,
+ detail,
+ onRetry,
+}: {
+ status: DetailStatus;
+ detail: MeetCallDetail | null;
+ onRetry: () => void;
+}) {
+ const { t } = useT();
+
+ if (status === 'idle' || status === 'loading') {
+ return (
+
+ {t('skills.meetingBots.callDetailLoading')}
+
+ );
+ }
+
+ if (status === 'error') {
+ return (
+
+ {t('skills.meetingBots.callDetailError')}{' '}
+
+
+ );
+ }
+
+ const summary = detail?.summary ?? null;
+ const transcript = detail?.transcript ?? [];
+ const hasSummary = hasSummaryDetail(detail);
+
+ if (!hasSummary && transcript.length === 0) {
+ return (
+
+ {t('skills.meetingBots.callDetailEmpty')}
+
+ );
+ }
+
+ return (
+
+ {hasSummary && summary && }
+ {transcript.length > 0 && }
+
+ );
+}
+
+function CallSummary({ summary }: { summary: MeetCallSummary }) {
+ const { t } = useT();
+ return (
+
+
{t('skills.meetingBots.callSummaryHeading')}
+ {summary.headline.trim() && (
+
{summary.headline}
+ )}
+ {summary.key_points.length > 0 && (
+
+
+ {t('skills.meetingBots.callKeyPointsHeading')}
+
+
+ {summary.key_points.map((point, i) => (
+ - {point}
+ ))}
+
+
+ )}
+ {summary.action_items.length > 0 && (
+
+
+ {t('skills.meetingBots.callActionItemsHeading')}
+
+
+ {summary.action_items.map((item, i) => {
+ const meta = [
+ item.assignee?.trim() || undefined,
+ item.kind === 'executable' ? item.tool_name?.trim() || undefined : undefined,
+ ].filter(Boolean);
+ return (
+ -
+ •
+
+ {item.description}
+ {meta.length > 0 && (
+
+ {' '}
+ ({meta.join(' · ')})
+
+ )}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+function CallTranscript({ lines }: { lines: MeetCallTranscriptLine[] }) {
+ const { t } = useT();
+ return (
+
+
{t('skills.meetingBots.callTranscriptHeading')}
+
+ {lines.map((line, i) => (
+
+ {line.content}
+
+ ))}
+
+
+ );
+}
+
+function SectionLabel({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Chevron({ expanded }: { expanded: boolean }) {
+ return (
+
+ );
+}
+
function formatRelativeTime(ms: number): string {
if (!ms) return '—';
const diff = Date.now() - ms;
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 0d8fd9411..2bbf695e2 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -4918,6 +4918,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'جارٍ التحميل\u2026',
'skills.meetingBots.recentCallAddedBy': 'أضافه {name}',
'skills.meetingBots.recentCallParticipants': 'مع {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} دور',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} أدوار',
+ 'skills.meetingBots.recentCallDuration': '{seconds} ث في المكالمة',
+ 'skills.meetingBots.callDetailLoading': 'جارٍ تحميل النص…',
+ 'skills.meetingBots.callDetailError': 'تعذّر تحميل النص.',
+ 'skills.meetingBots.callDetailRetry': 'إعادة المحاولة',
+ 'skills.meetingBots.callDetailEmpty': 'لم يُسجَّل أي نص أو ملخّص لهذه المكالمة.',
+ 'skills.meetingBots.callSummaryHeading': 'الملخّص',
+ 'skills.meetingBots.callKeyPointsHeading': 'النقاط الرئيسية',
+ 'skills.meetingBots.callActionItemsHeading': 'بنود الإجراءات',
+ 'skills.meetingBots.callTranscriptHeading': 'النص',
'skills.meetingBots.liveBadge': 'مباشر',
'skills.meetingBots.liveTitle': 'في اجتماع',
'skills.meetingBots.liveStatusJoining': 'جارٍ الانضمام\u2026',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 8a777053e..21ff7d350 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -5018,6 +5018,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'লোড হচ্ছে\u2026',
'skills.meetingBots.recentCallAddedBy': '{name} যোগ করেছেন',
'skills.meetingBots.recentCallParticipants': '{names} এর সাথে',
+ 'skills.meetingBots.recentCallTurnSingular': '{count}টি পালা',
+ 'skills.meetingBots.recentCallTurnPlural': '{count}টি পালা',
+ 'skills.meetingBots.recentCallDuration': 'কলে {seconds} সেকেন্ড',
+ 'skills.meetingBots.callDetailLoading': 'ট্রান্সক্রিপ্ট লোড হচ্ছে…',
+ 'skills.meetingBots.callDetailError': 'ট্রান্সক্রিপ্ট লোড করা যায়নি।',
+ 'skills.meetingBots.callDetailRetry': 'আবার চেষ্টা করুন',
+ 'skills.meetingBots.callDetailEmpty':
+ 'এই কলের জন্য কোনো ট্রান্সক্রিপ্ট বা সারাংশ রেকর্ড করা হয়নি।',
+ 'skills.meetingBots.callSummaryHeading': 'সারাংশ',
+ 'skills.meetingBots.callKeyPointsHeading': 'মূল পয়েন্ট',
+ 'skills.meetingBots.callActionItemsHeading': 'করণীয় কাজ',
+ 'skills.meetingBots.callTranscriptHeading': 'ট্রান্সক্রিপ্ট',
'skills.meetingBots.liveBadge': 'লাইভ',
'skills.meetingBots.liveTitle': 'মিটিংয়ে',
'skills.meetingBots.liveStatusJoining': 'যোগ দিচ্ছে\u2026',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index 726ca9656..41f2bbe09 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -5146,6 +5146,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Laden\u2026',
'skills.meetingBots.recentCallAddedBy': 'Hinzugefügt von {name}',
'skills.meetingBots.recentCallParticipants': 'Mit {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} Beitrag',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} Beiträge',
+ 'skills.meetingBots.recentCallDuration': '{seconds}s im Gespräch',
+ 'skills.meetingBots.callDetailLoading': 'Transkript wird geladen…',
+ 'skills.meetingBots.callDetailError': 'Transkript konnte nicht geladen werden.',
+ 'skills.meetingBots.callDetailRetry': 'Wiederholen',
+ 'skills.meetingBots.callDetailEmpty':
+ 'Für diesen Anruf wurde kein Transkript und keine Zusammenfassung erfasst.',
+ 'skills.meetingBots.callSummaryHeading': 'Zusammenfassung',
+ 'skills.meetingBots.callKeyPointsHeading': 'Kernpunkte',
+ 'skills.meetingBots.callActionItemsHeading': 'Aktionspunkte',
+ 'skills.meetingBots.callTranscriptHeading': 'Transkript',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'Im Meeting',
'skills.meetingBots.liveStatusJoining': 'Beitritt\u2026',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index c2664a74d..53412ac6d 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -5667,6 +5667,17 @@ const en: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Loading\u2026',
'skills.meetingBots.recentCallAddedBy': 'Added by {name}',
'skills.meetingBots.recentCallParticipants': 'With {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} turn',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} turns',
+ 'skills.meetingBots.recentCallDuration': '{seconds}s on call',
+ 'skills.meetingBots.callDetailLoading': 'Loading transcript…',
+ 'skills.meetingBots.callDetailError': 'Couldn’t load the transcript.',
+ 'skills.meetingBots.callDetailRetry': 'Retry',
+ 'skills.meetingBots.callDetailEmpty': 'No transcript or summary was captured for this call.',
+ 'skills.meetingBots.callSummaryHeading': 'Summary',
+ 'skills.meetingBots.callKeyPointsHeading': 'Key points',
+ 'skills.meetingBots.callActionItemsHeading': 'Action items',
+ 'skills.meetingBots.callTranscriptHeading': 'Transcript',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'In Meeting',
'skills.meetingBots.liveStatusJoining': 'Joining\u2026',
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 9cc55210c..f8b4aa53d 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -5111,6 +5111,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Cargando\u2026',
'skills.meetingBots.recentCallAddedBy': 'Añadido por {name}',
'skills.meetingBots.recentCallParticipants': 'Con {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} turno',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} turnos',
+ 'skills.meetingBots.recentCallDuration': '{seconds}s en llamada',
+ 'skills.meetingBots.callDetailLoading': 'Cargando transcripción…',
+ 'skills.meetingBots.callDetailError': 'No se pudo cargar la transcripción.',
+ 'skills.meetingBots.callDetailRetry': 'Reintentar',
+ 'skills.meetingBots.callDetailEmpty':
+ 'No se registró ninguna transcripción ni resumen de esta llamada.',
+ 'skills.meetingBots.callSummaryHeading': 'Resumen',
+ 'skills.meetingBots.callKeyPointsHeading': 'Puntos clave',
+ 'skills.meetingBots.callActionItemsHeading': 'Tareas pendientes',
+ 'skills.meetingBots.callTranscriptHeading': 'Transcripción',
'skills.meetingBots.liveBadge': 'En vivo',
'skills.meetingBots.liveTitle': 'En reunión',
'skills.meetingBots.liveStatusJoining': 'Uniéndose\u2026',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index e1aea1dfc..3636e4b55 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -5130,6 +5130,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Chargement\u2026',
'skills.meetingBots.recentCallAddedBy': 'Ajouté par {name}',
'skills.meetingBots.recentCallParticipants': 'Avec {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} tour',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} tours',
+ 'skills.meetingBots.recentCallDuration': '{seconds}s en appel',
+ 'skills.meetingBots.callDetailLoading': 'Chargement de la transcription…',
+ 'skills.meetingBots.callDetailError': 'Impossible de charger la transcription.',
+ 'skills.meetingBots.callDetailRetry': 'Réessayer',
+ 'skills.meetingBots.callDetailEmpty':
+ 'Aucune transcription ni aucun résumé n’a été enregistré pour cet appel.',
+ 'skills.meetingBots.callSummaryHeading': 'Résumé',
+ 'skills.meetingBots.callKeyPointsHeading': 'Points clés',
+ 'skills.meetingBots.callActionItemsHeading': 'Actions à mener',
+ 'skills.meetingBots.callTranscriptHeading': 'Transcription',
'skills.meetingBots.liveBadge': 'En direct',
'skills.meetingBots.liveTitle': 'En réunion',
'skills.meetingBots.liveStatusJoining': 'Connexion\u2026',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index 1dfba806e..56f61fc92 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -5022,6 +5022,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'लोड हो रहा है\u2026',
'skills.meetingBots.recentCallAddedBy': '{name} द्वारा जोड़ा गया',
'skills.meetingBots.recentCallParticipants': '{names} के साथ',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} बारी',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} बारियां',
+ 'skills.meetingBots.recentCallDuration': 'कॉल पर {seconds} सेकंड',
+ 'skills.meetingBots.callDetailLoading': 'ट्रांसक्रिप्ट लोड हो रहा है…',
+ 'skills.meetingBots.callDetailError': 'ट्रांसक्रिप्ट लोड नहीं हो सका।',
+ 'skills.meetingBots.callDetailRetry': 'पुनः प्रयास करें',
+ 'skills.meetingBots.callDetailEmpty':
+ 'इस कॉल के लिए कोई ट्रांसक्रिप्ट या सारांश दर्ज नहीं किया गया।',
+ 'skills.meetingBots.callSummaryHeading': 'सारांश',
+ 'skills.meetingBots.callKeyPointsHeading': 'मुख्य बिंदु',
+ 'skills.meetingBots.callActionItemsHeading': 'कार्य आइटम',
+ 'skills.meetingBots.callTranscriptHeading': 'ट्रांसक्रिप्ट',
'skills.meetingBots.liveBadge': 'लाइव',
'skills.meetingBots.liveTitle': 'मीटिंग में',
'skills.meetingBots.liveStatusJoining': 'शामिल हो रहा है\u2026',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index d28b59192..e896cc4b3 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -5033,6 +5033,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Memuat\u2026',
'skills.meetingBots.recentCallAddedBy': 'Ditambahkan oleh {name}',
'skills.meetingBots.recentCallParticipants': 'Dengan {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} giliran',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} giliran',
+ 'skills.meetingBots.recentCallDuration': '{seconds}d dalam panggilan',
+ 'skills.meetingBots.callDetailLoading': 'Memuat transkrip…',
+ 'skills.meetingBots.callDetailError': 'Tidak dapat memuat transkrip.',
+ 'skills.meetingBots.callDetailRetry': 'Coba lagi',
+ 'skills.meetingBots.callDetailEmpty':
+ 'Tidak ada transkrip atau ringkasan yang direkam untuk panggilan ini.',
+ 'skills.meetingBots.callSummaryHeading': 'Ringkasan',
+ 'skills.meetingBots.callKeyPointsHeading': 'Poin penting',
+ 'skills.meetingBots.callActionItemsHeading': 'Item tindakan',
+ 'skills.meetingBots.callTranscriptHeading': 'Transkrip',
'skills.meetingBots.liveBadge': 'Langsung',
'skills.meetingBots.liveTitle': 'Dalam Rapat',
'skills.meetingBots.liveStatusJoining': 'Bergabung\u2026',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index 3174e2dab..d3a5e65d9 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -5098,6 +5098,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Caricamento\u2026',
'skills.meetingBots.recentCallAddedBy': 'Aggiunto da {name}',
'skills.meetingBots.recentCallParticipants': 'Con {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} turno',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} turni',
+ 'skills.meetingBots.recentCallDuration': '{seconds}s in chiamata',
+ 'skills.meetingBots.callDetailLoading': 'Caricamento della trascrizione…',
+ 'skills.meetingBots.callDetailError': 'Impossibile caricare la trascrizione.',
+ 'skills.meetingBots.callDetailRetry': 'Riprova',
+ 'skills.meetingBots.callDetailEmpty':
+ 'Per questa chiamata non è stata registrata alcuna trascrizione o riepilogo.',
+ 'skills.meetingBots.callSummaryHeading': 'Riepilogo',
+ 'skills.meetingBots.callKeyPointsHeading': 'Punti chiave',
+ 'skills.meetingBots.callActionItemsHeading': 'Azioni da svolgere',
+ 'skills.meetingBots.callTranscriptHeading': 'Trascrizione',
'skills.meetingBots.liveBadge': 'In diretta',
'skills.meetingBots.liveTitle': 'In Riunione',
'skills.meetingBots.liveStatusJoining': 'Partecipando\u2026',
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 6cdb0db51..e23b41e3f 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -4973,6 +4973,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': '로딩 중\u2026',
'skills.meetingBots.recentCallAddedBy': '{name}님이 추가함',
'skills.meetingBots.recentCallParticipants': '{names}님과 함께',
+ 'skills.meetingBots.recentCallTurnSingular': '{count}회 발언',
+ 'skills.meetingBots.recentCallTurnPlural': '{count}회 발언',
+ 'skills.meetingBots.recentCallDuration': '통화 {seconds}초',
+ 'skills.meetingBots.callDetailLoading': '스크립트를 불러오는 중…',
+ 'skills.meetingBots.callDetailError': '스크립트를 불러오지 못했습니다.',
+ 'skills.meetingBots.callDetailRetry': '다시 시도',
+ 'skills.meetingBots.callDetailEmpty': '이 통화에 대한 스크립트나 요약이 기록되지 않았습니다.',
+ 'skills.meetingBots.callSummaryHeading': '요약',
+ 'skills.meetingBots.callKeyPointsHeading': '핵심 사항',
+ 'skills.meetingBots.callActionItemsHeading': '실행 항목',
+ 'skills.meetingBots.callTranscriptHeading': '스크립트',
'skills.meetingBots.liveBadge': '라이브',
'skills.meetingBots.liveTitle': '회의 중',
'skills.meetingBots.liveStatusJoining': '참가 중\u2026',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index f603e130a..2f2bdf210 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -5090,6 +5090,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Ładowanie\u2026',
'skills.meetingBots.recentCallAddedBy': 'Dodane przez {name}',
'skills.meetingBots.recentCallParticipants': 'Uczestnicy: {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} tura',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} tury',
+ 'skills.meetingBots.recentCallDuration': '{seconds}s rozmowy',
+ 'skills.meetingBots.callDetailLoading': 'Ładowanie transkrypcji…',
+ 'skills.meetingBots.callDetailError': 'Nie udało się załadować transkrypcji.',
+ 'skills.meetingBots.callDetailRetry': 'Spróbuj ponownie',
+ 'skills.meetingBots.callDetailEmpty':
+ 'Dla tego połączenia nie zarejestrowano transkrypcji ani podsumowania.',
+ 'skills.meetingBots.callSummaryHeading': 'Podsumowanie',
+ 'skills.meetingBots.callKeyPointsHeading': 'Kluczowe punkty',
+ 'skills.meetingBots.callActionItemsHeading': 'Zadania do wykonania',
+ 'skills.meetingBots.callTranscriptHeading': 'Transkrypcja',
'skills.meetingBots.liveBadge': 'Na żywo',
'skills.meetingBots.liveTitle': 'Na spotkaniu',
'skills.meetingBots.liveStatusJoining': 'Dołączanie\u2026',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 0ef31b242..29935cc5f 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -5102,6 +5102,18 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Carregando\u2026',
'skills.meetingBots.recentCallAddedBy': 'Adicionado por {name}',
'skills.meetingBots.recentCallParticipants': 'Com {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} turno',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} turnos',
+ 'skills.meetingBots.recentCallDuration': '{seconds}s em chamada',
+ 'skills.meetingBots.callDetailLoading': 'Carregando transcrição…',
+ 'skills.meetingBots.callDetailError': 'Não foi possível carregar a transcrição.',
+ 'skills.meetingBots.callDetailRetry': 'Tentar novamente',
+ 'skills.meetingBots.callDetailEmpty':
+ 'Nenhuma transcrição ou resumo foi registrado para esta chamada.',
+ 'skills.meetingBots.callSummaryHeading': 'Resumo',
+ 'skills.meetingBots.callKeyPointsHeading': 'Pontos principais',
+ 'skills.meetingBots.callActionItemsHeading': 'Itens de ação',
+ 'skills.meetingBots.callTranscriptHeading': 'Transcrição',
'skills.meetingBots.liveBadge': 'Ao vivo',
'skills.meetingBots.liveTitle': 'Em reunião',
'skills.meetingBots.liveStatusJoining': 'Entrando\u2026',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index 52abf4a11..c82e15881 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -5063,6 +5063,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': 'Загрузка\u2026',
'skills.meetingBots.recentCallAddedBy': 'Добавил {name}',
'skills.meetingBots.recentCallParticipants': 'С {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} реплика',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} реплик',
+ 'skills.meetingBots.recentCallDuration': '{seconds} с в разговоре',
+ 'skills.meetingBots.callDetailLoading': 'Загрузка стенограммы…',
+ 'skills.meetingBots.callDetailError': 'Не удалось загрузить стенограмму.',
+ 'skills.meetingBots.callDetailRetry': 'Повторить',
+ 'skills.meetingBots.callDetailEmpty': 'Для этого звонка не записаны стенограмма или сводка.',
+ 'skills.meetingBots.callSummaryHeading': 'Сводка',
+ 'skills.meetingBots.callKeyPointsHeading': 'Ключевые моменты',
+ 'skills.meetingBots.callActionItemsHeading': 'Задачи',
+ 'skills.meetingBots.callTranscriptHeading': 'Стенограмма',
'skills.meetingBots.liveBadge': 'Эфир',
'skills.meetingBots.liveTitle': 'На встрече',
'skills.meetingBots.liveStatusJoining': 'Подключение\u2026',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index aeaa68e5c..a0a2281dc 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -4776,6 +4776,17 @@ const messages: TranslationMap = {
'skills.meetingBots.recentCallsLoading': '加载中\u2026',
'skills.meetingBots.recentCallAddedBy': '由 {name} 添加',
'skills.meetingBots.recentCallParticipants': '与 {names}',
+ 'skills.meetingBots.recentCallTurnSingular': '{count} 轮发言',
+ 'skills.meetingBots.recentCallTurnPlural': '{count} 轮发言',
+ 'skills.meetingBots.recentCallDuration': '通话 {seconds} 秒',
+ 'skills.meetingBots.callDetailLoading': '正在加载文字记录…',
+ 'skills.meetingBots.callDetailError': '无法加载文字记录。',
+ 'skills.meetingBots.callDetailRetry': '重试',
+ 'skills.meetingBots.callDetailEmpty': '此通话未记录任何文字记录或摘要。',
+ 'skills.meetingBots.callSummaryHeading': '摘要',
+ 'skills.meetingBots.callKeyPointsHeading': '要点',
+ 'skills.meetingBots.callActionItemsHeading': '待办事项',
+ 'skills.meetingBots.callTranscriptHeading': '文字记录',
'skills.meetingBots.liveBadge': '直播',
'skills.meetingBots.liveTitle': '会议中',
'skills.meetingBots.liveStatusJoining': '正在加入\u2026',
diff --git a/app/src/services/meetCallService.ts b/app/src/services/meetCallService.ts
index 37ea4ed90..24115d7a7 100644
--- a/app/src/services/meetCallService.ts
+++ b/app/src/services/meetCallService.ts
@@ -136,12 +136,53 @@ export interface MeetCallRecord {
participants?: string[];
}
+/** One transcript line of a recorded call. Mirrors `MeetCallTranscriptLine`. */
+export interface MeetCallTranscriptLine {
+ /** Lowercased speaker role: `'participant'` or `'assistant'`. */
+ role: string;
+ /** The line as the backend delivered it (may carry a `[MM:SS] [Name]` prefix). */
+ content: string;
+}
+
+/** One action item mined from a call. Mirrors `MeetCallActionItem`. */
+export interface MeetCallActionItem {
+ description: string;
+ /** `'executable'` or `'advisory'`. */
+ kind: string;
+ tool_name?: string | null;
+ assignee?: string | null;
+}
+
+/** Structured post-call summary. Mirrors `MeetCallSummary`. */
+export interface MeetCallSummary {
+ headline: string;
+ key_points: string[];
+ action_items: MeetCallActionItem[];
+}
+
+/**
+ * Transcript + summary for one completed call. Mirrors `MeetCallDetail` in
+ * `src/openhuman/meet_agent/store.rs`. Lazy-loaded by the recent-calls panel
+ * when a row is expanded, so the list payload stays lean. `summary` is null
+ * when summarisation failed or timed out at call-end.
+ */
+export interface MeetCallDetail {
+ request_id: string;
+ summary?: MeetCallSummary | null;
+ transcript: MeetCallTranscriptLine[];
+}
+
interface CoreListCallsResponse {
ok: boolean;
calls: MeetCallRecord[];
count: number;
}
+interface CoreGetCallDetailResponse {
+ ok: boolean;
+ detail: MeetCallDetail | null;
+}
+
/**
* Fetch the most recent completed Meet calls (newest first). Used
* by the Skills "Meeting Bots" modal to render a history list
@@ -160,6 +201,23 @@ export async function listMeetCalls(limit = 20): Promise {
return result.calls ?? [];
}
+/**
+ * Fetch the transcript + summary for one completed call. Lazy-loaded when the
+ * user expands a recent-call row. Returns `null` when the core has no detail
+ * for this call (older calls recorded before the feature, or a failed write) —
+ * the panel renders a "no transcript yet" state in that case.
+ */
+export async function getMeetCallDetail(requestId: string): Promise {
+ const result = await callCoreRpc({
+ method: 'openhuman.meet_agent_get_call_detail',
+ params: { request_id: requestId },
+ });
+ if (!result?.ok) {
+ throw new Error('Core rejected the meet_agent_get_call_detail request.');
+ }
+ return result.detail ?? null;
+}
+
// ---------------------------------------------------------------------------
// Backend Meet Bot via Core Socket.IO bridge
// ---------------------------------------------------------------------------
diff --git a/src/openhuman/agent_meetings/bus.rs b/src/openhuman/agent_meetings/bus.rs
index f93ad8108..5d635b997 100644
--- a/src/openhuman/agent_meetings/bus.rs
+++ b/src/openhuman/agent_meetings/bus.rs
@@ -61,23 +61,54 @@ impl EventHandler for MeetingEventSubscriber {
"{LOG_PREFIX} transcript received — creating meeting thread"
);
- // Record a recent-calls entry (meet id, duration, owner,
- // participants) so the meeting-bots panel shows call history.
- // Done first (before the heavier thread-creation path) so the
- // record is on disk by the time the panel refetches at call-end.
- // Best-effort: never blocks; logs on failure internally.
- super::recent_calls::record_backend_call(
+ // 1. Record a lean recent-calls entry (meet id, duration, owner,
+ // participants) first — before any LLM work — so the row is on
+ // disk by the time the panel refetches at call-end. Returns the
+ // request_id so the detail below is keyed to the same call.
+ // Best-effort: never blocks; logs on failure internally.
+ let request_id = super::recent_calls::record_backend_call(
turns,
*duration_ms,
correlation_id.as_deref(),
)
.await;
- // Create the meeting thread with transcript.
+ // 2. Persist the transcript immediately — decoupled from the
+ // (bounded, up to 30s) summary LLM call below. Without this the
+ // detail file wouldn't exist until summarisation returned, so a
+ // row expanded right after call-end showed "nothing captured"
+ // even though the transcript was already in hand. The summary is
+ // patched in by step 4 once it's ready.
+ super::recent_calls::record_backend_call_detail(&request_id, turns, None).await;
+
+ // 3. Generate the post-call summary once. Shared by the call-detail
+ // store (step 4) and the meeting thread (step 5) so the
+ // summarisation LLM call isn't paid for twice.
+ let generated = super::summary::generate_meeting_summary_bounded(
+ turns,
+ correlation_id.as_deref(),
+ )
+ .await;
+
+ // 4. Upgrade the stored detail with the summary once it's ready.
+ // Skipped when summarisation failed/timed out — the transcript
+ // written in step 2 stands on its own.
+ if generated.is_some() {
+ super::recent_calls::record_backend_call_detail(
+ &request_id,
+ turns,
+ generated.as_ref(),
+ )
+ .await;
+ }
+
+ // 5. Create the meeting thread with transcript, reusing the
+ // summary generated in step 3.
if let Err(e) = create_meeting_thread_with_transcript(
turns,
*duration_ms,
correlation_id.clone(),
+ generated.as_ref(),
)
.await
{
diff --git a/src/openhuman/agent_meetings/ops.rs b/src/openhuman/agent_meetings/ops.rs
index 72bbb964f..6aa3f24f7 100644
--- a/src/openhuman/agent_meetings/ops.rs
+++ b/src/openhuman/agent_meetings/ops.rs
@@ -25,13 +25,6 @@ const ALLOWED_HOSTS: &[(&str, &str)] = &[
("webex.com", "webex"),
];
-/// Upper bound on the best-effort post-call summarisation call. The provider
-/// has a 120s per-request timeout and the reliable wrapper retries transient
-/// failures with backoff, so without a bound a slow/flaky `summarization`
-/// provider could stall the post-call persistence pipeline for minutes. On
-/// timeout we fall back to the plain-transcript thread.
-const SUMMARY_GENERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
-
fn transcript_turns_to_chat_batch(
turns: &[BackendMeetTurn],
duration_ms: u64,
@@ -110,8 +103,10 @@ pub async fn ingest_backend_meeting_transcript(
"[agent_meetings] transcript ingested into memory tree"
);
- // Create a meeting thread with the transcript for the thread system.
- if let Err(e) = create_meeting_thread_with_transcript(&turns, duration_ms, correlation_id).await
+ // Create a meeting thread with the transcript for the thread system. This
+ // path has no pre-generated summary, so the thread generates its own.
+ if let Err(e) =
+ create_meeting_thread_with_transcript(&turns, duration_ms, correlation_id, None).await
{
tracing::warn!("[agent_meetings] meeting thread creation failed: {e}");
}
@@ -124,10 +119,15 @@ pub async fn ingest_backend_meeting_transcript(
/// The correlation_id (when present) is embedded in the transcript body as an
/// external reference for tracing — it does not deduplicate; each call creates
/// a new thread.
+/// `generated` lets the caller pass a summary it already produced so the
+/// call-end pipeline can share one generation across the recent-call detail
+/// store and this thread. When `None`, the summary is generated here (bounded)
+/// — preserving the behaviour of callers that don't pre-generate.
pub async fn create_meeting_thread_with_transcript(
turns: &[BackendMeetTurn],
duration_ms: u64,
correlation_id: Option,
+ generated: Option<&super::summary::GeneratedSummary>,
) -> Result<(), String> {
use crate::openhuman::memory::{
AppendConversationMessageRequest, ConversationMessageRecord,
@@ -203,36 +203,21 @@ pub async fn create_meeting_thread_with_transcript(
);
}
- // 3. Best-effort enrichment: generate a structured post-call summary + short
- // context label. Bounded by `SUMMARY_GENERATION_TIMEOUT` so a slow/flaky
- // provider can never dominate the path. Any failure or timeout logs a
- // warning and leaves the plain-transcript thread untouched.
- let generated = match tokio::time::timeout(
- SUMMARY_GENERATION_TIMEOUT,
- super::summary::generate_meeting_summary(turns, correlation_id.as_deref()),
- )
- .await
- {
- Ok(Ok(g)) => Some(g),
- Ok(Err(e)) => {
- tracing::warn!("[agent_meetings] summary generation failed: {e}");
- None
- }
- Err(_) => {
- tracing::warn!(
- timeout_secs = SUMMARY_GENERATION_TIMEOUT.as_secs(),
- "[agent_meetings] summary generation timed out"
- );
- None
- }
+ // 3. Best-effort enrichment: reuse a summary the caller already generated
+ // (the call-end pipeline shares one across the recent-call detail store
+ // and this thread); otherwise generate one here, bounded so a slow/flaky
+ // provider can never dominate the path. Any failure or timeout leaves the
+ // plain-transcript thread untouched.
+ let owned_generated = if generated.is_none() {
+ super::summary::generate_meeting_summary_bounded(turns, correlation_id.as_deref()).await
+ } else {
+ None
};
+ let generated = generated.or(owned_generated.as_ref());
// 3a. Title the thread with the context label (e.g. "Q3 Roadmap") so the
// meeting is identifiable in the list (default title is "Chat ").
- let context_label = generated
- .as_ref()
- .map(|g| g.label.trim())
- .filter(|l| !l.is_empty());
+ let context_label = generated.map(|g| g.label.trim()).filter(|l| !l.is_empty());
if let Some(title) = context_label {
if let Err(e) = ops::thread_update_title(UpdateConversationThreadTitleRequest {
thread_id: thread_id.clone(),
@@ -249,7 +234,7 @@ pub async fn create_meeting_thread_with_transcript(
// 3b. Append the structured summary as a closing message, so the thread ends
// with the headline / key points / action items.
- if let Some(g) = &generated {
+ if let Some(g) = generated {
let summary_body = super::summary::format_summary_markdown(&g.summary, &g.label);
let summary_msg = ConversationMessageRecord {
id: uuid::Uuid::new_v4().to_string(),
diff --git a/src/openhuman/agent_meetings/recent_calls.rs b/src/openhuman/agent_meetings/recent_calls.rs
index 902ba1506..63ccfac4f 100644
--- a/src/openhuman/agent_meetings/recent_calls.rs
+++ b/src/openhuman/agent_meetings/recent_calls.rs
@@ -22,7 +22,13 @@ use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::core::event_bus::BackendMeetTurn;
-use crate::openhuman::meet_agent::store::{self, MeetCallRecord};
+use crate::openhuman::meet_agent::store::{
+ self, MeetCallActionItem, MeetCallDetail, MeetCallRecord, MeetCallSummary,
+ MeetCallTranscriptLine,
+};
+
+use super::summary::GeneratedSummary;
+use super::types::ActionItemKind;
const LOG_PREFIX: &str = "[agent_meetings::recent_calls]";
@@ -93,16 +99,21 @@ fn take_join(correlation_id: Option<&str>) -> Option {
}
/// Build and persist a [`MeetCallRecord`] for a finished backend-meet call.
+/// Returns the `request_id` the record was keyed by so the caller can persist
+/// the matching call detail under the same id.
///
/// Best-effort: any failure is logged and swallowed — the call is already
-/// over and the UI degrades to "no record" rather than erroring.
+/// over and the UI degrades to "no record" rather than erroring. The id is
+/// returned even when the append fails, since it is deterministic and the
+/// detail write should still use it.
pub async fn record_backend_call(
turns: &[BackendMeetTurn],
duration_ms: u64,
correlation_id: Option<&str>,
-) {
+) -> String {
let meta = take_join(correlation_id);
let record = build_record(meta.as_ref(), turns, duration_ms, correlation_id, now_ms());
+ let request_id = record.request_id.clone();
match store::append_record(&record).await {
Ok(()) => log::info!(
"{LOG_PREFIX} recorded call request_id={} participants={} duration_s={:.0}",
@@ -112,6 +123,73 @@ pub async fn record_backend_call(
),
Err(e) => log::warn!("{LOG_PREFIX} append_record failed: {e}"),
}
+ request_id
+}
+
+/// Persist the per-call detail (transcript + optional summary) under the same
+/// `request_id` the lean record used, so the recent-calls panel can lazy-load
+/// it when a row is expanded. Best-effort: logs and swallows failures.
+pub async fn record_backend_call_detail(
+ request_id: &str,
+ turns: &[BackendMeetTurn],
+ generated: Option<&GeneratedSummary>,
+) {
+ let detail = build_detail(request_id, turns, generated);
+ match store::write_detail(&detail).await {
+ Ok(()) => log::info!(
+ "{LOG_PREFIX} recorded call detail request_id={} transcript_lines={} has_summary={}",
+ request_id,
+ detail.transcript.len(),
+ detail.summary.is_some()
+ ),
+ Err(e) => log::warn!("{LOG_PREFIX} write_detail failed request_id={request_id}: {e}"),
+ }
+}
+
+/// Map a finished call's turns + optional summary into a persisted
+/// [`MeetCallDetail`]. Pure (no I/O) so the field-mapping is unit-testable.
+/// Blank turns are dropped so the stored transcript matches what the summary
+/// was generated from.
+fn build_detail(
+ request_id: &str,
+ turns: &[BackendMeetTurn],
+ generated: Option<&GeneratedSummary>,
+) -> MeetCallDetail {
+ let transcript = turns
+ .iter()
+ .filter(|t| !t.content.trim().is_empty())
+ .map(|t| MeetCallTranscriptLine {
+ role: if t.role.eq_ignore_ascii_case("assistant") {
+ "assistant".to_string()
+ } else {
+ "participant".to_string()
+ },
+ content: t.content.trim().to_string(),
+ })
+ .collect();
+ let summary = generated.map(|g| MeetCallSummary {
+ headline: g.summary.headline.clone(),
+ key_points: g.summary.key_points.clone(),
+ action_items: g
+ .summary
+ .action_items
+ .iter()
+ .map(|a| MeetCallActionItem {
+ description: a.description.clone(),
+ kind: match a.kind {
+ ActionItemKind::Executable => "executable".to_string(),
+ ActionItemKind::Advisory => "advisory".to_string(),
+ },
+ tool_name: a.tool_name.clone(),
+ assignee: a.assignee.clone(),
+ })
+ .collect(),
+ });
+ MeetCallDetail {
+ request_id: request_id.to_string(),
+ summary,
+ transcript,
+ }
}
/// Map a finished call's inputs to a [`MeetCallRecord`]. Pure (takes `now_ms`,
@@ -355,4 +433,52 @@ mod tests {
assert!(take_join(None).is_none());
assert!(take_join(Some(" ")).is_none());
}
+
+ #[test]
+ fn build_detail_maps_transcript_and_summary() {
+ use super::super::types::{ActionItem, MeetingSummary};
+
+ let turns = vec![
+ turn("user", "[00:51] [Shanu] your time"),
+ turn("user", " "), // blank → dropped
+ turn("assistant", "[00:55] [Tiny] On it."),
+ ];
+ let generated = GeneratedSummary {
+ label: "Q3 Roadmap".into(),
+ summary: MeetingSummary {
+ meeting_id: "corr-9".into(),
+ headline: "Agreed to ship Friday.".into(),
+ key_points: vec!["Ship Friday".into()],
+ action_items: vec![ActionItem {
+ description: "Send release notes".into(),
+ kind: ActionItemKind::Executable,
+ tool_name: Some("gmail".into()),
+ assignee: Some("Sam".into()),
+ }],
+ generated_at_ms: 0,
+ },
+ };
+
+ let detail = build_detail("corr-9", &turns, Some(&generated));
+ assert_eq!(detail.request_id, "corr-9");
+ // Blank turn dropped; roles normalised to participant/assistant.
+ assert_eq!(detail.transcript.len(), 2);
+ assert_eq!(detail.transcript[0].role, "participant");
+ assert_eq!(detail.transcript[0].content, "[00:51] [Shanu] your time");
+ assert_eq!(detail.transcript[1].role, "assistant");
+ let summary = detail.summary.expect("summary present");
+ assert_eq!(summary.headline, "Agreed to ship Friday.");
+ assert_eq!(summary.key_points, vec!["Ship Friday".to_string()]);
+ assert_eq!(summary.action_items.len(), 1);
+ assert_eq!(summary.action_items[0].kind, "executable");
+ assert_eq!(summary.action_items[0].tool_name.as_deref(), Some("gmail"));
+ }
+
+ #[test]
+ fn build_detail_without_summary_keeps_transcript() {
+ let turns = vec![turn("user", "[00:10] [Shanu] hi")];
+ let detail = build_detail("corr-x", &turns, None);
+ assert!(detail.summary.is_none());
+ assert_eq!(detail.transcript.len(), 1);
+ }
}
diff --git a/src/openhuman/agent_meetings/summary.rs b/src/openhuman/agent_meetings/summary.rs
index e367ac01b..c6b1913e0 100644
--- a/src/openhuman/agent_meetings/summary.rs
+++ b/src/openhuman/agent_meetings/summary.rs
@@ -148,6 +148,43 @@ pub async fn generate_meeting_summary(
})
}
+/// Upper bound on the best-effort post-call summarisation call. The provider
+/// has a 120s per-request timeout and the reliable wrapper retries transient
+/// failures with backoff, so without a bound a slow/flaky `summarization`
+/// provider could stall the call-end pipeline for minutes. 30s sits well above
+/// a healthy summarisation latency while capping the worst case.
+pub const SUMMARY_GENERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
+
+/// Generate a summary bounded by [`SUMMARY_GENERATION_TIMEOUT`], returning
+/// `None` on failure or timeout. Centralises the best-effort policy so the
+/// call-end pipeline can generate a single summary and share it across the
+/// recent-call detail store and the meeting thread instead of paying for the
+/// LLM call twice.
+pub async fn generate_meeting_summary_bounded(
+ turns: &[BackendMeetTurn],
+ correlation_id: Option<&str>,
+) -> Option {
+ match tokio::time::timeout(
+ SUMMARY_GENERATION_TIMEOUT,
+ generate_meeting_summary(turns, correlation_id),
+ )
+ .await
+ {
+ Ok(Ok(g)) => Some(g),
+ Ok(Err(e)) => {
+ tracing::warn!("{LOG_PREFIX} summary generation failed: {e}");
+ None
+ }
+ Err(_) => {
+ tracing::warn!(
+ timeout_secs = SUMMARY_GENERATION_TIMEOUT.as_secs(),
+ "{LOG_PREFIX} summary generation timed out"
+ );
+ None
+ }
+ }
+}
+
/// Render a [`MeetingSummary`] as a markdown body for the thread message.
pub fn format_summary_markdown(summary: &MeetingSummary, label: &str) -> String {
let mut md = String::from("## Meeting summary\n\n");
diff --git a/src/openhuman/meet_agent/rpc.rs b/src/openhuman/meet_agent/rpc.rs
index efdb6b88d..319f7e609 100644
--- a/src/openhuman/meet_agent/rpc.rs
+++ b/src/openhuman/meet_agent/rpc.rs
@@ -21,8 +21,9 @@ use super::ops::VadEvent;
use super::session::{registry, CaptionOutcome};
use super::store::{self, MeetCallRecord};
use super::types::{
- ListCallsRequest, ListCallsResponse, PollSpeechRequest, PushCaptionRequest,
- PushListenPcmRequest, StartSessionRequest, StopSessionRequest,
+ GetCallDetailRequest, GetCallDetailResponse, ListCallsRequest, ListCallsResponse,
+ PollSpeechRequest, PushCaptionRequest, PushListenPcmRequest, StartSessionRequest,
+ StopSessionRequest,
};
/// Default `limit` for `handle_list_calls` when the caller omits one.
@@ -277,6 +278,21 @@ pub async fn handle_list_calls(params: Map) -> Result) -> Result {
+ let req: GetCallDetailRequest = serde_json::from_value(Value::Object(params))
+ .map_err(|e| format!("{LOG_PREFIX} invalid get_call_detail params: {e}"))?;
+ let detail = store::read_detail(&req.request_id).await?;
+ log::info!(
+ "{LOG_PREFIX} get_call_detail request_id={} found={}",
+ req.request_id,
+ detail.is_some()
+ );
+ let response = GetCallDetailResponse { ok: true, detail };
+ let value = serde_json::to_value(&response)
+ .map_err(|e| format!("{LOG_PREFIX} serialize get_call_detail response: {e}"))?;
+ RpcOutcome::new(value, vec![]).into_cli_compatible_json()
+}
+
/// Decode a base64 string of PCM16LE bytes into samples. Empty input is
/// a "heartbeat" push (no audio this tick) and yields an empty Vec.
fn decode_pcm16le_b64(b64: &str) -> Result, String> {
@@ -385,6 +401,29 @@ mod tests {
handle_stop_session(stop).await.unwrap();
}
+ #[tokio::test]
+ async fn get_call_detail_missing_returns_ok_with_null() {
+ // A call with no recorded detail (older call, or a best-effort write
+ // that failed) must resolve to `ok: true, detail: null` — the contract
+ // the recent-calls panel relies on to render its "nothing captured"
+ // state rather than surfacing an error.
+ let mut params = Map::new();
+ params.insert(
+ "request_id".into(),
+ json!("rpc-detail-never-recorded-3f9c1a"),
+ );
+ let out = handle_get_call_detail(params).await.unwrap();
+ assert_eq!(out.get("ok"), Some(&json!(true)));
+ assert_eq!(out.get("detail"), Some(&json!(null)));
+ }
+
+ #[tokio::test]
+ async fn get_call_detail_rejects_missing_request_id() {
+ // Empty params → deserialization error, surfaced as Err rather than a
+ // panic, so the RPC layer can return a clean error to the caller.
+ assert!(handle_get_call_detail(Map::new()).await.is_err());
+ }
+
#[test]
fn decode_pcm16le_b64_handles_empty() {
assert!(decode_pcm16le_b64("").unwrap().is_empty());
diff --git a/src/openhuman/meet_agent/schemas.rs b/src/openhuman/meet_agent/schemas.rs
index 87b9d0098..7a62ccfd1 100644
--- a/src/openhuman/meet_agent/schemas.rs
+++ b/src/openhuman/meet_agent/schemas.rs
@@ -45,6 +45,11 @@ const DEFS: &[Def] = &[
schema: schema_list_calls,
handler: handle_list_calls,
},
+ Def {
+ function: "get_call_detail",
+ schema: schema_get_call_detail,
+ handler: handle_get_call_detail,
+ },
];
pub fn all_controller_schemas() -> Vec {
@@ -344,6 +349,38 @@ fn schema_list_calls() -> ControllerSchema {
}
}
+fn schema_get_call_detail() -> ControllerSchema {
+ ControllerSchema {
+ namespace: "meet_agent",
+ function: "get_call_detail",
+ description:
+ "Return the persisted transcript + summary for one completed Meet call, keyed by \
+ request_id. Used by the recent-calls panel to lazy-load detail when a row is \
+ expanded. `detail` is null when no detail was recorded for the call.",
+ inputs: vec![FieldSchema {
+ name: "request_id",
+ ty: TypeSchema::String,
+ comment: "request_id of the call (matches a MeetCallRecord row from list_calls).",
+ required: true,
+ }],
+ outputs: vec![
+ FieldSchema {
+ name: "ok",
+ ty: TypeSchema::Bool,
+ comment: "True when the read succeeded (even if no detail exists yet).",
+ required: true,
+ },
+ FieldSchema {
+ name: "detail",
+ ty: TypeSchema::Json,
+ comment:
+ "MeetCallDetail object (transcript + summary), or null when none recorded.",
+ required: false,
+ },
+ ],
+ }
+}
+
fn schema_unknown() -> ControllerSchema {
ControllerSchema {
namespace: "meet_agent",
@@ -383,6 +420,10 @@ fn handle_list_calls(p: Map) -> ControllerFuture {
Box::pin(async move { super::rpc::handle_list_calls(p).await })
}
+fn handle_get_call_detail(p: Map) -> ControllerFuture {
+ Box::pin(async move { super::rpc::handle_get_call_detail(p).await })
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -407,6 +448,7 @@ mod tests {
"poll_speech",
"stop_session",
"list_calls",
+ "get_call_detail",
]
);
}
diff --git a/src/openhuman/meet_agent/store.rs b/src/openhuman/meet_agent/store.rs
index a1e9cfa04..600f9223f 100644
--- a/src/openhuman/meet_agent/store.rs
+++ b/src/openhuman/meet_agent/store.rs
@@ -168,6 +168,136 @@ async fn read_recent_from(path: &Path, limit: usize) -> Result,
+ #[serde(default)]
+ pub assignee: Option,
+}
+
+/// Structured post-call summary persisted alongside the transcript.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct MeetCallSummary {
+ pub headline: String,
+ #[serde(default)]
+ pub key_points: Vec,
+ #[serde(default)]
+ pub action_items: Vec,
+}
+
+/// Full detail for a single recorded call: the transcript and the best-effort
+/// generated summary. Persisted as one JSON file per call (keyed by
+/// `request_id`) so the recent-calls list stays lean and the transcript is
+/// only read when a row is expanded.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct MeetCallDetail {
+ pub request_id: String,
+ /// `None` when summarisation failed or timed out — the UI falls back to the
+ /// transcript alone. `#[serde(default)]` keeps older files parseable.
+ #[serde(default)]
+ pub summary: Option,
+ #[serde(default)]
+ pub transcript: Vec,
+}
+
+/// Directory holding per-call detail JSON files — a sibling of `calls.jsonl`.
+async fn meet_call_details_dir() -> Result {
+ let workspace = Config::load_or_init()
+ .await
+ .map(|c| c.workspace_dir)
+ .map_err(|e| format!("load config: {e}"))?;
+ Ok(workspace.join("meet_agent").join("call_details"))
+}
+
+/// Map a `request_id` to a filesystem-safe, **injective** file stem.
+///
+/// ASCII alphanumerics and `-`/`_` pass through unchanged so the common cases —
+/// UUID correlation ids and `backend-` — stay human-readable; every other
+/// byte, `%` included, is percent-encoded as `%XX`. Because `%` is itself
+/// escaped the mapping is reversible, and therefore collision-free: distinct
+/// ids like `a/b`, `a:b`, and `a_b` map to distinct stems (`a%2Fb`, `a%3Ab`,
+/// `a_b`) instead of all collapsing onto one file and overwriting each other's
+/// detail. It also can never escape the details directory — `/`, `\`, and the
+/// `.` in `..` are all encoded.
+fn sanitize_stem(request_id: &str) -> String {
+ if request_id.is_empty() {
+ return "unknown".to_string();
+ }
+ let mut out = String::with_capacity(request_id.len());
+ for &b in request_id.as_bytes() {
+ if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
+ out.push(b as char);
+ } else {
+ out.push('%');
+ out.push_str(&format!("{b:02X}"));
+ }
+ }
+ out
+}
+
+/// Persist the detail for one call. Creates the details directory on demand.
+/// Best-effort at the call site: callers log and swallow failures since the
+/// call is already over and the list row still renders without detail.
+pub async fn write_detail(detail: &MeetCallDetail) -> Result<(), String> {
+ let dir = meet_call_details_dir().await?;
+ write_detail_to(&dir, detail).await
+}
+
+async fn write_detail_to(dir: &Path, detail: &MeetCallDetail) -> Result<(), String> {
+ tokio::fs::create_dir_all(dir)
+ .await
+ .map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
+ let path = dir.join(format!("{}.json", sanitize_stem(&detail.request_id)));
+ let json = serde_json::to_string(detail).map_err(|e| format!("serialize detail: {e}"))?;
+ tokio::fs::write(&path, json.as_bytes())
+ .await
+ .map_err(|e| format!("write {}: {e}", path.display()))?;
+ Ok(())
+}
+
+/// Read the detail for one call. Missing file → `Ok(None)` (older calls
+/// recorded before this feature, or a call whose detail write failed).
+pub async fn read_detail(request_id: &str) -> Result