diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index a8d11d609..47b27a0ab 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3076,6 +3076,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'لم يتم تسجيل أي خطوات', 'conversations.agentTaskInsights.viewProcessSource': 'عرض مصدر عملية الوكيل الكامل', 'conversations.agentTaskInsights.processing': 'قيد المعالجة', + 'conversations.agentTaskInsights.done': 'تم', + 'conversations.agentTaskInsights.failed': 'فشل', + 'conversations.agentTaskInsights.running': 'قيد التشغيل', + 'conversations.agentTaskInsights.cancelled': 'ملغى', + 'conversations.agentTaskInsights.awaitingUser': 'في انتظار الإدخال', + 'conversations.agentTaskInsights.viewDetails': 'عرض التفاصيل', + 'conversations.agentTaskInsights.subagentsHeading': 'الوكلاء الفرعيون', 'daemon.serviceBlockingGate.body': 'المحتوى', 'daemon.serviceBlockingGate.downloadHint': 'تلميح التنزيل', 'daemon.serviceBlockingGate.downloadLatest': 'تنزيل أحدث إصدار', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index b837b6834..ee202ea39 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3141,6 +3141,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'কোনো ধাপ রেকর্ড করা হয়নি', 'conversations.agentTaskInsights.viewProcessSource': 'সম্পূর্ণ এজেন্ট প্রক্রিয়ার উৎস দেখুন', 'conversations.agentTaskInsights.processing': 'প্রসেসিং', + 'conversations.agentTaskInsights.done': 'সম্পন্ন', + 'conversations.agentTaskInsights.failed': 'ব্যর্থ', + 'conversations.agentTaskInsights.running': 'চলছে', + 'conversations.agentTaskInsights.cancelled': 'বাতিল', + 'conversations.agentTaskInsights.awaitingUser': 'ইনপুটের অপেক্ষায়', + 'conversations.agentTaskInsights.viewDetails': 'বিস্তারিত দেখুন', + 'conversations.agentTaskInsights.subagentsHeading': 'সাব-এজেন্ট', 'daemon.serviceBlockingGate.body': 'বডি', 'daemon.serviceBlockingGate.downloadHint': 'ডাউনলোড হিন্ট', 'daemon.serviceBlockingGate.downloadLatest': 'সর্বশেষ সংস্করণ ডাউনলোড করুন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 64682978f..9613362f9 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3216,6 +3216,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.viewProcessSource': 'Vollständige Agentenprozess-Quelle anzeigen', 'conversations.agentTaskInsights.processing': 'Wird verarbeitet', + 'conversations.agentTaskInsights.done': 'Fertig', + 'conversations.agentTaskInsights.failed': 'Fehlgeschlagen', + 'conversations.agentTaskInsights.running': 'Läuft', + 'conversations.agentTaskInsights.cancelled': 'Abgebrochen', + 'conversations.agentTaskInsights.awaitingUser': 'Wartet auf Eingabe', + 'conversations.agentTaskInsights.viewDetails': 'Details anzeigen', + 'conversations.agentTaskInsights.subagentsHeading': 'Unteragenten', 'daemon.serviceBlockingGate.body': 'Körper', 'daemon.serviceBlockingGate.downloadHint': 'Hinweis herunterladen', 'daemon.serviceBlockingGate.downloadLatest': 'Lade die neueste Version herunter', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 8997f8e7a..95fe8cd3a 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3726,6 +3726,13 @@ const en: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'No steps recorded', 'conversations.agentTaskInsights.viewProcessSource': 'View full agent process Source', 'conversations.agentTaskInsights.processing': 'Processing', + 'conversations.agentTaskInsights.done': 'Done', + 'conversations.agentTaskInsights.failed': 'Failed', + 'conversations.agentTaskInsights.running': 'Running', + 'conversations.agentTaskInsights.cancelled': 'Cancelled', + 'conversations.agentTaskInsights.awaitingUser': 'Awaiting input', + 'conversations.agentTaskInsights.viewDetails': 'View details', + 'conversations.agentTaskInsights.subagentsHeading': 'Sub-agents', 'daemon.serviceBlockingGate.body': 'Retrying in the background. This usually resolves in a few seconds.', 'daemon.serviceBlockingGate.downloadHint': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 953fb45f9..88cc3334f 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3193,6 +3193,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.viewProcessSource': 'Ver la fuente completa del proceso del agente', 'conversations.agentTaskInsights.processing': 'Procesando', + 'conversations.agentTaskInsights.done': 'Listo', + 'conversations.agentTaskInsights.failed': 'Fallido', + 'conversations.agentTaskInsights.running': 'En curso', + 'conversations.agentTaskInsights.cancelled': 'Cancelado', + 'conversations.agentTaskInsights.awaitingUser': 'Esperando entrada', + 'conversations.agentTaskInsights.viewDetails': 'Ver detalles', + 'conversations.agentTaskInsights.subagentsHeading': 'Subagentes', 'daemon.serviceBlockingGate.body': 'Cuerpo', 'daemon.serviceBlockingGate.downloadHint': 'Sugerencia de descarga', 'daemon.serviceBlockingGate.downloadLatest': 'Descargar la última versión', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 6858c1180..86e6f0aa8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3208,6 +3208,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.viewProcessSource': "Voir la source complète du processus de l'agent", 'conversations.agentTaskInsights.processing': 'Traitement en cours', + 'conversations.agentTaskInsights.done': 'Terminé', + 'conversations.agentTaskInsights.failed': 'Échec', + 'conversations.agentTaskInsights.running': 'En cours', + 'conversations.agentTaskInsights.cancelled': 'Annulé', + 'conversations.agentTaskInsights.awaitingUser': 'En attente de saisie', + 'conversations.agentTaskInsights.viewDetails': 'Voir les détails', + 'conversations.agentTaskInsights.subagentsHeading': 'Sous-agents', 'daemon.serviceBlockingGate.body': 'Corps', 'daemon.serviceBlockingGate.downloadHint': 'Indice de téléchargement', 'daemon.serviceBlockingGate.downloadLatest': 'Télécharger la dernière version', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 75d6b4416..af023c589 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3142,6 +3142,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'कोई चरण दर्ज नहीं किया गया', 'conversations.agentTaskInsights.viewProcessSource': 'पूर्ण एजेंट प्रक्रिया स्रोत देखें', 'conversations.agentTaskInsights.processing': 'प्रोसेसिंग', + 'conversations.agentTaskInsights.done': 'पूर्ण', + 'conversations.agentTaskInsights.failed': 'विफल', + 'conversations.agentTaskInsights.running': 'चल रहा है', + 'conversations.agentTaskInsights.cancelled': 'रद्द किया गया', + 'conversations.agentTaskInsights.awaitingUser': 'इनपुट की प्रतीक्षा', + 'conversations.agentTaskInsights.viewDetails': 'विवरण देखें', + 'conversations.agentTaskInsights.subagentsHeading': 'सब-एजेंट', 'daemon.serviceBlockingGate.body': 'विवरण', 'daemon.serviceBlockingGate.downloadHint': 'डाउनलोड संकेत', 'daemon.serviceBlockingGate.downloadLatest': 'नवीनतम संस्करण डाउनलोड करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 0ca03f3a6..ec73ad8b4 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3145,6 +3145,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'Tidak ada langkah yang tercatat', 'conversations.agentTaskInsights.viewProcessSource': 'Lihat sumber proses agen lengkap', 'conversations.agentTaskInsights.processing': 'Memproses', + 'conversations.agentTaskInsights.done': 'Selesai', + 'conversations.agentTaskInsights.failed': 'Gagal', + 'conversations.agentTaskInsights.running': 'Berjalan', + 'conversations.agentTaskInsights.cancelled': 'Dibatalkan', + 'conversations.agentTaskInsights.awaitingUser': 'Menunggu masukan', + 'conversations.agentTaskInsights.viewDetails': 'Lihat detail', + 'conversations.agentTaskInsights.subagentsHeading': 'Sub-agen', 'daemon.serviceBlockingGate.body': 'Isi', 'daemon.serviceBlockingGate.downloadHint': 'Petunjuk unduhan', 'daemon.serviceBlockingGate.downloadLatest': 'Unduh Versi Terbaru', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 9838c98b0..8dce6308e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3187,6 +3187,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.viewProcessSource': "Visualizza l'origine completa del processo dell'agente", 'conversations.agentTaskInsights.processing': 'Elaborazione', + 'conversations.agentTaskInsights.done': 'Completato', + 'conversations.agentTaskInsights.failed': 'Non riuscito', + 'conversations.agentTaskInsights.running': 'In corso', + 'conversations.agentTaskInsights.cancelled': 'Annullato', + 'conversations.agentTaskInsights.awaitingUser': 'In attesa di input', + 'conversations.agentTaskInsights.viewDetails': 'Vedi dettagli', + 'conversations.agentTaskInsights.subagentsHeading': 'Sotto-agenti', 'daemon.serviceBlockingGate.body': 'Corpo', 'daemon.serviceBlockingGate.downloadHint': 'Suggerimento di download', 'daemon.serviceBlockingGate.downloadLatest': "Scarica l'ultima versione", diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index addd0f2c0..61945e624 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3116,6 +3116,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': '기록된 단계 없음', 'conversations.agentTaskInsights.viewProcessSource': '전체 에이전트 프로세스 소스 보기', 'conversations.agentTaskInsights.processing': '처리 중', + 'conversations.agentTaskInsights.done': '완료', + 'conversations.agentTaskInsights.failed': '실패', + 'conversations.agentTaskInsights.running': '실행 중', + 'conversations.agentTaskInsights.cancelled': '취소됨', + 'conversations.agentTaskInsights.awaitingUser': '입력 대기 중', + 'conversations.agentTaskInsights.viewDetails': '세부 정보 보기', + 'conversations.agentTaskInsights.subagentsHeading': '서브 에이전트', 'daemon.serviceBlockingGate.body': '본문', 'daemon.serviceBlockingGate.downloadHint': '다운로드 안내', 'daemon.serviceBlockingGate.downloadLatest': '최신 버전 다운로드', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 6db02c7be..7bc0118ea 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3173,6 +3173,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'Brak zarejestrowanych kroków', 'conversations.agentTaskInsights.viewProcessSource': 'Zobacz pełne źródło procesu agenta', 'conversations.agentTaskInsights.processing': 'Przetwarzanie', + 'conversations.agentTaskInsights.done': 'Gotowe', + 'conversations.agentTaskInsights.failed': 'Niepowodzenie', + 'conversations.agentTaskInsights.running': 'W toku', + 'conversations.agentTaskInsights.cancelled': 'Anulowano', + 'conversations.agentTaskInsights.awaitingUser': 'Oczekiwanie na dane', + 'conversations.agentTaskInsights.viewDetails': 'Zobacz szczegóły', + 'conversations.agentTaskInsights.subagentsHeading': 'Podagenci', 'daemon.serviceBlockingGate.body': 'Rdzeń OpenHuman nie odpowiada. Spróbuj ponownie lub pobierz najnowszą wersję aplikacji.', 'daemon.serviceBlockingGate.downloadHint': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 8bd811ed1..5bfc0cc2d 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3191,6 +3191,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'Nenhuma etapa registrada', 'conversations.agentTaskInsights.viewProcessSource': 'Ver a fonte completa do processo do agente', 'conversations.agentTaskInsights.processing': 'Processando', + 'conversations.agentTaskInsights.done': 'Concluído', + 'conversations.agentTaskInsights.failed': 'Falhou', + 'conversations.agentTaskInsights.running': 'Em execução', + 'conversations.agentTaskInsights.cancelled': 'Cancelado', + 'conversations.agentTaskInsights.awaitingUser': 'Aguardando entrada', + 'conversations.agentTaskInsights.viewDetails': 'Ver detalhes', + 'conversations.agentTaskInsights.subagentsHeading': 'Subagentes', 'daemon.serviceBlockingGate.body': 'Corpo', 'daemon.serviceBlockingGate.downloadHint': 'Dica de download', 'daemon.serviceBlockingGate.downloadLatest': 'Baixar Versão Mais Recente', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 828469086..026632b55 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3164,6 +3164,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': 'Шаги не записаны', 'conversations.agentTaskInsights.viewProcessSource': 'Показать полный источник процесса агента', 'conversations.agentTaskInsights.processing': 'Обработка', + 'conversations.agentTaskInsights.done': 'Готово', + 'conversations.agentTaskInsights.failed': 'Ошибка', + 'conversations.agentTaskInsights.running': 'Выполняется', + 'conversations.agentTaskInsights.cancelled': 'Отменено', + 'conversations.agentTaskInsights.awaitingUser': 'Ожидание ввода', + 'conversations.agentTaskInsights.viewDetails': 'Подробнее', + 'conversations.agentTaskInsights.subagentsHeading': 'Субагенты', 'daemon.serviceBlockingGate.body': 'Текст', 'daemon.serviceBlockingGate.downloadHint': 'Подсказка по загрузке', 'daemon.serviceBlockingGate.downloadLatest': 'Скачать последнюю версию', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index ab09c01e8..f7bce2d0f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2993,6 +2993,13 @@ const messages: TranslationMap = { 'conversations.agentTaskInsights.noSteps': '未记录任何步骤', 'conversations.agentTaskInsights.viewProcessSource': '查看完整的智能体处理来源', 'conversations.agentTaskInsights.processing': '处理中', + 'conversations.agentTaskInsights.done': '完成', + 'conversations.agentTaskInsights.failed': '失败', + 'conversations.agentTaskInsights.running': '运行中', + 'conversations.agentTaskInsights.cancelled': '已取消', + 'conversations.agentTaskInsights.awaitingUser': '等待输入', + 'conversations.agentTaskInsights.viewDetails': '查看详情', + 'conversations.agentTaskInsights.subagentsHeading': '子代理', 'daemon.serviceBlockingGate.body': '核心服务不可用,请等待或下载最新版本。', 'daemon.serviceBlockingGate.downloadHint': '下载最新版本', 'daemon.serviceBlockingGate.downloadLatest': '下载最新版本', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index a215465d8..517487e34 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -1,6 +1,6 @@ import { convertFileSrc } from '@tauri-apps/api/core'; import debugFactory from 'debug'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { type ChatSendError, chatSendError } from '../chat/chatSendError'; @@ -55,6 +55,7 @@ import { registerParallelRequest, setTaskBoardForThread, setToolTimelineForThread, + type ToolTimelineEntry, } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { selectSocketStatus } from '../store/socketSelectors'; @@ -260,6 +261,10 @@ const Conversations = ({ // Whether the consolidated "Agent Process Source" panel is open (the full // agent-run timeline + visited sources for the current thread). const [showProcessSource, setShowProcessSource] = useState(false); + // When the user clicks a step's "View details →", the Agent Process Source + // panel is scoped to that single step. `null` = the whole-run overview + // (opened by the bottom "View full agent process Source" link). + const [scopedDetailEntryId, setScopedDetailEntryId] = useState(null); const [inputMode, setInputMode] = useState('text'); const [replyMode, setReplyMode] = useState('text'); const [isRecording, setIsRecording] = useState(false); @@ -307,6 +312,7 @@ const Conversations = ({ // behaviour stays intact. const uiLocale = useAppSelector(state => state.locale?.current ?? 'en'); const toolTimelineByThread = useAppSelector(state => state.chatRuntime.toolTimelineByThread); + const processingByThread = useAppSelector(state => state.chatRuntime.processingByThread); const taskBoardByThread = useAppSelector(state => state.chatRuntime.taskBoardByThread); const inferenceStatusByThread = useAppSelector( state => state.chatRuntime.inferenceStatusByThread @@ -1477,6 +1483,9 @@ const Conversations = ({ const selectedThreadToolTimeline = selectedThreadId ? (toolTimelineByThread[selectedThreadId] ?? []) : []; + const selectedThreadProcessing = selectedThreadId + ? (processingByThread[selectedThreadId] ?? []) + : []; // Detached background sub-agents (mode === 'async') spawned in this thread. const backgroundProcesses = useMemo( () => selectBackgroundProcesses(selectedThreadToolTimeline), @@ -1569,6 +1578,100 @@ const Conversations = ({ const shouldRenderTimelineBeforeLatestAgentMessage = selectedThreadToolTimeline.length > 0 && !isSending && Boolean(latestVisibleAgentMessage); + // Anchor the "Agentic task insights" panel right after the latest turn's user + // message — processing happens *before* the answer, so it reads above the + // result (for both the live streaming preview and the settled agent bubbles). + // Anchoring on the user message (not the first/last agent message) avoids the + // multi-agent-message split from issue #3717. + const lastUserMessageId = [...visibleMessages].reverse().find(m => m.sender === 'user')?.id; + + // The insights panel (timeline + "View full agent process Source" opener), + // built once and rendered inline above the latest answer. `null` when there + // are no recorded steps for the thread. + // Open the Agent Process Source panel scoped to one step, or to the whole run. + const openScopedDetail = (entry: ToolTimelineEntry) => { + setScopedDetailEntryId(entry.id); + setShowProcessSource(true); + }; + const openWholeRunSource = () => { + setScopedDetailEntryId(null); + setShowProcessSource(true); + }; + const scopedDetailEntry = + scopedDetailEntryId != null + ? selectedThreadToolTimeline.find(e => e.id === scopedDetailEntryId) + : undefined; + + const agentInsights = + // Render when there are tool steps OR a persisted reasoning/narration + // transcript. A tool-less turn (the agent only thinks/narrates, no tool + // calls) has an empty timeline but still persists thoughts — without the + // transcript guard those thoughts would be unreachable. + selectedThreadToolTimeline.length > 0 || selectedThreadProcessing.length > 0 ? ( + <> + {hideAgentInsights ? ( + // "Hide agent thinking" is ON: suppress the verbose step rows. + // While in flight, surface a compact blinking "Processing" link; once + // settled the "View full agent process Source" opener below takes + // over (so only render this fallback when that opener won't). + isSending ? ( + + ) : !shouldRenderTimelineBeforeLatestAgentMessage ? ( + + ) : null + ) : selectedThreadToolTimeline.length > 0 ? ( + + ) : ( + // Transcript-only turn: reasoning/narration was streamed but no tool + // calls were made, so the inline step timeline is empty. The thoughts + // are still persisted — surface a standalone opener (matching the + // settled insights header) so the full-run panel stays reachable. + + )} + {/* "View full agent process Source" — only needed in the hidden-insights + settled state; when the timeline is visible the link lives in its + header (ToolTimelineBlock onViewWholeRun). */} + {shouldRenderTimelineBeforeLatestAgentMessage && hideAgentInsights && ( + + )} + + ) : null; + const filteredThreads = useMemo(() => { return threads.filter(t => isThreadVisibleInTab(t, selectedLabel)); }, [threads, selectedLabel]); @@ -1918,264 +2021,270 @@ const Conversations = ({ // the copy-to-clipboard action. const parsedContent = parseMessageImages(msg.content ?? ''); return ( -
-
+ +
- {msg.sender === 'agent' ? ( -
-
- {agentMessageViewMode === 'text' ? ( - - ) : ( - splitAgentMessageIntoBubbles(msg.content).map( - (segment, index, parts) => { - const position: AgentBubblePosition = - parts.length === 1 - ? 'single' - : index === 0 - ? 'first' - : index === parts.length - 1 - ? 'last' - : 'middle'; + className={`group/msg flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}> +
+ {msg.sender === 'agent' ? ( +
+
+ {agentMessageViewMode === 'text' ? ( + + ) : ( + splitAgentMessageIntoBubbles(msg.content).map( + (segment, index, parts) => { + const position: AgentBubblePosition = + parts.length === 1 + ? 'single' + : index === 0 + ? 'first' + : index === parts.length - 1 + ? 'last' + : 'middle'; - return ( - - ); - } - ) - )} - {/* Reaction affordance — the closed "+", the open picker, + return ( + + ); + } + ) + )} + {/* Reaction affordance — the closed "+", the open picker, and the resulting reaction chips all live here, tucked onto the bubble's bottom-left corner so the control never jumps to a separate row below the timestamp. */} - {latestVisibleMessage?.id === msg.id && - (() => { - const myReactions = - (msg.extraMetadata?.myReactions as string[] | undefined) ?? []; - const pickerOpen = reactionPickerMsgId === msg.id; - return ( -
- {myReactions.map(emoji => ( - - ))} - {pickerOpen ? ( -
- {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( + {latestVisibleMessage?.id === msg.id && + (() => { + const myReactions = + (msg.extraMetadata?.myReactions as string[] | undefined) ?? []; + const pickerOpen = reactionPickerMsgId === msg.id; + return ( +
+ {myReactions.map(emoji => ( + + ))} + {pickerOpen ? ( +
+ {['👍', '❤️', '😂', '🔥', '👀', '🎯'].map(emoji => ( + + ))} - ))} +
+ ) : ( -
- ) : ( - - )} -
- ); - })()} + )} +
+ ); + })()} +
+ {(() => { + const raw = msg.extraMetadata?.citations; + if (!Array.isArray(raw)) return null; + const citations = raw.filter( + (item): item is MessageCitation => + typeof item === 'object' && + item !== null && + typeof (item as MessageCitation).id === 'string' && + typeof (item as MessageCitation).key === 'string' && + typeof (item as MessageCitation).snippet === 'string' && + typeof (item as MessageCitation).timestamp === 'string' + ); + if (citations.length === 0) return null; + return ; + })()} + {latestVisibleMessage?.id === msg.id && ( +

+ {formatRelativeTime(msg.createdAt)} +

+ )}
- {(() => { - const raw = msg.extraMetadata?.citations; - if (!Array.isArray(raw)) return null; - const citations = raw.filter( - (item): item is MessageCitation => - typeof item === 'object' && - item !== null && - typeof (item as MessageCitation).id === 'string' && - typeof (item as MessageCitation).key === 'string' && - typeof (item as MessageCitation).snippet === 'string' && - typeof (item as MessageCitation).timestamp === 'string' - ); - if (citations.length === 0) return null; - return ; - })()} - {latestVisibleMessage?.id === msg.id && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- ) : ( -
- {(() => { - const displayText = parsedContent.text; - const dataUris = Array.isArray(msg.extraMetadata?.attachmentDataUris) - ? (msg.extraMetadata.attachmentDataUris as string[]) - : parsedContent.dataUris; - const hasImages = dataUris.length > 0; - // Document attachments carry no image data-URI (only - // images do); surface them as filename chips from the - // persisted attachmentKinds/attachmentNames metadata. - const kinds = Array.isArray(msg.extraMetadata?.attachmentKinds) - ? (msg.extraMetadata.attachmentKinds as string[]) - : []; - const names = Array.isArray(msg.extraMetadata?.attachmentNames) - ? (msg.extraMetadata.attachmentNames as string[]) - : []; - const fileNames = kinds - .map((k, i) => (k === 'file' ? names[i] : null)) - .filter((n): n is string => Boolean(n)); - const showTime = latestVisibleMessage?.id === msg.id; - return ( - <> - {hasImages && ( -
- {dataUris.map((uri, i) => ( - - ))} -
- )} - {fileNames.length > 0 && ( -
- {fileNames.map((name, i) => ( -
- - - - - {name} -
- ))} -
- )} - {(displayText || showTime) && ( -
- {displayText && ( - - )} - {showTime && ( -

- {formatRelativeTime(msg.createdAt)} -

- )} -
- )} - - ); - })()} -
- )} - + +
-
+ {msg.id === lastUserMessageId ? agentInsights : null} +
); })} {isSending && @@ -2305,66 +2414,15 @@ const Conversations = ({
)} - {/* Agentic task insights — rendered exactly once AFTER the full - message list. A single logical assistant turn can be persisted - as multiple agent ThreadMessages; anchoring the panel before the - last agent message split the response into two disconnected - chunks (issue #3717, Bug 2). Hoisting it here keeps the panel - after the complete response regardless of how many agent - messages the turn produced — both for the settled/inline case - (shouldRenderTimelineBeforeLatestAgentMessage) and the live - in-flight fallback. */} - {selectedThreadToolTimeline.length > 0 && - (hideAgentInsights ? ( - // "Hide agent thinking" is ON: suppress the verbose step rows. - // While the turn is still in flight, surface a single compact - // blinking "Processing" link that opens the full run in the - // Agent Process Source side panel. Once settled, the - // "View full agent process Source" button below takes over. - isSending ? ( - - ) : !shouldRenderTimelineBeforeLatestAgentMessage ? ( - // Settled, but the hoisted "View full agent process Source" - // opener below won't render because no agent message exists - // for this turn (e.g. a cancelled first turn — onError skips - // the agent message for `error_type === 'cancelled'`). Without - // this fallback the recorded steps would be unreachable while - // hidden, so surface our own opener whenever entries remain. - - ) : null - ) : ( - setOpenSubagentTaskId(sub.taskId)} - liveResponse={selectedStreamingAssistant?.content} - /> - ))} - {/* "View full agent process" — only in the settled/inline state - (turn finished, an agent message exists). Hoisted out of the - per-message map alongside the panel above so it renders once - after the response, never interleaved between bubbles. */} - {shouldRenderTimelineBeforeLatestAgentMessage && ( - - )} + {/* The "Agentic task insights" panel is rendered inline *above* the + latest answer (right after the latest turn's user message) so + processing reads before the result. This fallback only fires for + the rare thread with no user message (e.g. a proactive-only + thread) so the recorded steps are never unreachable. The cancel + control + view-process-source opener now live in `agentInsights` + and the floating footer respectively (upstream relocated the + in-flow cancel button below the composer). */} + {!lastUserMessageId && agentInsights}
) : isNewWindow ? ( @@ -2947,7 +3005,12 @@ const Conversations = ({ setShowProcessSource(false)} + transcript={selectedThreadProcessing} + scopedEntry={scopedDetailEntry} + onClose={() => { + setShowProcessSource(false); + setScopedDetailEntryId(null); + }} />
); diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 2bc558986..2aadc8569 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -19,6 +19,7 @@ import { chatCancel, chatClearQueue, chatSend } from '../../services/chatService import { CoreRpcError } from '../../services/coreRpcClient'; import agentProfileReducer from '../../store/agentProfileSlice'; import chatRuntimeReducer, { + appendProcessingProse, beginInferenceTurn, setInferenceStatusForThread, setTaskBoardForThread, @@ -1627,12 +1628,12 @@ describe('Conversations — worker thread back-to-parent navigation (#1624)', () // #3717 (Bug 2) — A single logical assistant turn can be persisted as multiple // agent ThreadMessages. The "Agentic task insights" panel used to be anchored -// inside the per-message map, immediately before the LAST agent message, which -// dropped it BETWEEN the earlier agent content and the final message — splitting -// one response into two disconnected chunks. The panel (and the "View full agent -// process" button) are now hoisted out of the map so they render exactly once, -// AFTER the complete response, regardless of how many agent messages the turn -// produced. +// immediately before the LAST agent message, which dropped it BETWEEN the +// earlier agent content and the final message — splitting one response into two +// disconnected chunks. The panel (and the "View full agent process" button) now +// render exactly once, anchored after the latest turn's USER message so they +// sit ABOVE the whole answer (processing before result) — never split between +// agent bubbles, regardless of how many agent messages the turn produced. describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1653,7 +1654,7 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', }); }); - it('renders the insights panel exactly once, after the last agent message of a multi-message turn', async () => { + it('renders the insights panel exactly once, above the answer of a multi-message turn (not split between bubbles)', async () => { const thread = makeThread({ id: 'multi-agent-thread', title: 'Multi-message turn' }); // One logical assistant turn persisted as TWO agent ThreadMessages. const messages: ThreadMessage[] = [ @@ -1728,27 +1729,33 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', // The "View full agent process" button is hoisted alongside it — also once. expect(screen.getAllByTestId('view-process-source')).toHaveLength(1); - // DOM order: the panel must follow the LAST agent message's content, never - // sit between the two agent bubbles. - const lastAgentText = screen.getByText('Second part of the answer.'); + // DOM order: processing happens before the result, so the panel sits ABOVE + // the answer — after the latest turn's user message and before BOTH agent + // bubbles (never split between them, preserving the #3717 invariant). + const userText = screen.getByText('Plan and then summarize.'); const firstAgentText = screen.getByText('First part of the answer.'); - expect(lastAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( - Node.DOCUMENT_POSITION_FOLLOWING + const lastAgentText = screen.getByText('Second part of the answer.'); + // The panel precedes the first agent bubble (and therefore both). + expect(firstAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_PRECEDING).toBe( + Node.DOCUMENT_POSITION_PRECEDING ); - expect(firstAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( + expect(lastAgentText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_PRECEDING).toBe( + Node.DOCUMENT_POSITION_PRECEDING + ); + // …and follows the user message. + expect(userText.compareDocumentPosition(panel) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( Node.DOCUMENT_POSITION_FOLLOWING ); - // Exercise the hoisted button: opens the "Agent Process Source" panel. + // Inline rows are compact — each shows a "View details →" link instead of + // an inline expand. Clicking one opens the full-run Agent Process Source + // side panel (every row opens the same panel). + const viewDetails = screen.getAllByTestId('view-details'); + expect(viewDetails.length).toBeGreaterThan(0); await act(async () => { - fireEvent.click(screen.getByTestId('view-process-source')); - }); - - // Exercise onViewSubagent: clicking the subagent row's "view full - // processing" affordance opens the subagent drawer for that task. - await act(async () => { - fireEvent.click(screen.getByTestId('subagent-view-processing')); + fireEvent.click(viewDetails[0]); }); + expect(await screen.findByTestId('agent-process-source-panel')).toBeInTheDocument(); }); it('hides the verbose timeline when "hide agent thinking" is on, but still opens the source panel', async () => { @@ -1869,6 +1876,68 @@ describe('Conversations — agent task insights panel anchoring (#3717 Bug 2)', expect(await screen.findByTestId('agent-task-insights')).toBeInTheDocument(); }); + it('surfaces a process-source opener for a tool-less (transcript-only) turn', async () => { + // The agent only streamed reasoning/narration — no tool calls — so the + // inline step timeline is empty, but the persisted thoughts must stay + // reachable through a standalone opener into the full-run panel. + const thread = makeThread({ id: 'transcript-only-thread', title: 'Thinking only' }); + const messages: ThreadMessage[] = [ + { + id: 'm-user', + sender: 'user', + type: 'text', + content: 'Just think out loud.', + extraMetadata: {}, + createdAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'm-agent', + sender: 'agent', + type: 'text', + content: 'Here is my reasoning result.', + extraMetadata: {}, + createdAt: '2026-01-01T00:01:00.000Z', + }, + ]; + mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); + mockGetThreadMessages.mockResolvedValue({ messages, count: messages.length }); + + let store: ReturnType | undefined; + await act(async () => { + store = await renderConversations({ + thread: { + ...selectedThreadState(thread), + messagesByThreadId: { [thread.id]: messages }, + messages, + }, + socket: socketState('connected'), + }); + }); + + await screen.findByText('Here is my reasoning result.'); + // Seed a narration-only transcript (no tool timeline at all). + await act(async () => { + store!.dispatch( + appendProcessingProse({ + threadId: thread.id, + kind: 'narration', + round: 1, + delta: 'Let me reason about this carefully.', + }) + ); + }); + + // The verbose step timeline never renders (there are no tool steps)… + expect(screen.queryByTestId('agent-task-insights')).toBeNull(); + // …but the standalone opener appears and opens the full-run panel, which + // shows the persisted thoughts. + const opener = screen.getByTestId('view-process-source'); + await act(async () => { + fireEvent.click(opener); + }); + expect(await screen.findByTestId('agent-process-source-panel')).toBeInTheDocument(); + }); + it('keeps a settled source opener when hidden and no agent message exists (cancelled first turn)', async () => { // A cancelled first turn records timeline steps but never persists an agent // message, so the hoisted "View full agent process Source" button does not diff --git a/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx b/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx index 2c696cb3f..475fd19bd 100644 --- a/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx +++ b/app/src/pages/conversations/components/AgentProcessSourcePanel.tsx @@ -2,10 +2,15 @@ import { useEffect } from 'react'; import Button from '../../../components/ui/Button'; import { useT } from '../../../lib/i18n/I18nContext'; -import type { ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; -import { type AgentSource, extractAgentSources } from '../../../utils/toolTimelineFormatting'; +import type { ProcessingTranscriptItem, ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; +import { + type AgentSource, + extractAgentSources, + formatTimelineEntry, +} from '../../../utils/toolTimelineFormatting'; import { AgentSparkIcon } from './AgentTimelineRail'; -import { ToolTimelineBlock } from './ToolTimelineBlock'; +import { ProcessingTranscriptView } from './ProcessingTranscriptView'; +import { SubagentActivityBlock, ToolTimelineBlock } from './ToolTimelineBlock'; /** Compact globe glyph for a source row. Inherits `currentColor`. */ function GlobeIcon({ className }: { className?: string }) { @@ -65,10 +70,20 @@ function AgentSourceRow({ source }: { source: AgentSource }) { export function AgentProcessSourcePanel({ open, entries, + transcript = [], + scopedEntry, onClose, }: { open: boolean; entries: ToolTimelineEntry[]; + /** Ordered narration/thinking/tool transcript. When present, the panel + * renders the interleaved Hermes view; otherwise it falls back to the + * tool-only timeline. */ + transcript?: ProcessingTranscriptItem[]; + /** When set, the panel is scoped to a single step — its title becomes the + * step label and the body shows only that step's details (its sub-agent + * activity, or its tool detail). `undefined` → the whole-run overview. */ + scopedEntry?: ToolTimelineEntry; onClose: () => void; }) { const { t } = useT(); @@ -85,7 +100,14 @@ export function AgentProcessSourcePanel({ if (!open) return null; - const sources = extractAgentSources(entries); + // Sources/sub-agents are scoped to the single step when one is selected, + // else they cover the whole run. + const sources = extractAgentSources(scopedEntry ? [scopedEntry] : entries); + const subagentEntries = entries.filter(entry => entry.subagent); + // For a scoped *non*-sub-agent step, the detail (args / output) to show. + const scopedDetail = scopedEntry + ? (formatTimelineEntry(scopedEntry).detail ?? scopedEntry.argsBuffer) + : undefined; return (
@@ -103,7 +125,9 @@ export function AgentProcessSourcePanel({ - {t('conversations.agentTaskInsights.processSourceTitle')} + {scopedEntry + ? formatTimelineEntry(scopedEntry).title + : t('conversations.agentTaskInsights.processSourceTitle')} ) : null} @@ -324,6 +353,8 @@ const BODY_SURFACE = 'bg-stone-50 dark:bg-neutral-800/60'; export function ToolTimelineBlock({ entries, onViewSubagent, + onViewDetails, + onViewWholeRun, expandAllRows = false, liveResponse, }: { @@ -332,6 +363,15 @@ export function ToolTimelineBlock({ * subagent cards render without the "view full processing" affordance * (e.g. interrupted-snapshot rendering with no live driver). */ onViewSubagent?: (subagent: SubagentActivity) => void; + /** Compact chat mode: when set, a finished step renders as a single + * `label + "View details →"` line (no inline expand) and the link opens the + * side panel scoped to *that* step via this callback. The panel itself + * renders without `onViewDetails` to keep the full expanded view. */ + onViewDetails?: (entry: ToolTimelineEntry) => void; + /** Opens the whole-run "Agent Process Source" panel. When set, a compact + * "View full agent process Source →" link sits in the group header beside the + * "Agentic task insights" title (clicking it does NOT toggle the collapse). */ + onViewWholeRun?: () => void; /** Expand every row's details by default (used by the "Agent Process * Source" panel, where the whole run should be visible at a glance). * In the inline chat only the latest running row auto-expands. */ @@ -348,20 +388,36 @@ export function ToolTimelineBlock({ if (entries.length === 0) return null; - // The group header is a static section label — the live "working" state is - // conveyed by the pulsing agent-name rows (and the chat's own activity - // indicator), so the header does NOT repeat a "Working…" string. - return ( -
- - - {t('conversations.agentTaskInsights.title')} - - - ▶ - - -
+ const isRunning = latestRunningEntryId != null; + + const titleLabel = ( + + {t('conversations.agentTaskInsights.title')} + + ); + + // Whole-run "View full agent process Source →" link — sits in the header + // beside the title, in both the collapsible and the static layout. + const wholeRunLink = onViewWholeRun ? ( + + ) : null; + + // The rows + the parent's streaming response — shared by both the collapsible + // (in-flight) and static (settled) header layouts below. + const body = ( + <> +
{entries.map((entry, index) => { const formatted = formatTimelineEntry(entry); const detailContent = @@ -373,26 +429,48 @@ export function ToolTimelineBlock({ // detail to show. Mirrors the rule that a non-subagent row only // expands when it has detail content. const expandable = detailContent != null || subagent != null; - const shouldAutoExpand = - expandAllRows || (latestRunningEntryId != null && latestRunningEntryId === entry.id); + const isLatestRunning = latestRunningEntryId != null && latestRunningEntryId === entry.id; + const shouldAutoExpand = expandAllRows || isLatestRunning; const nameTone = agentNameTone(entry.status); + // Chat mode: the currently-running step stays expanded inline in the + // main UI; finished steps collapse to a compact "View details →" link + // (their full activity lives in the side panel). + const compact = onViewDetails != null && !isLatestRunning; return ( - {expandable ? ( + {compact ? ( + // Collapsed step: the whole label is the link — "Run Code →" + // opens the full-run panel scoped to this step. A collapsed row + // is backgrounded, so it never pulses — only the single active + // (expanded) step blinks. Strip `animate-pulse` from the tone. + + ) : expandable ? (
- {formatted.title} - + {formatted.title} + {workerRef ? (
+ className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-stone-600 dark:text-neutral-300 ${BODY_SURFACE}`}> {workerRef.before} ) : formatted.detail ? (
+ className={`mt-1 rounded-xl rounded-tl-md px-2.5 py-2 text-[13px] whitespace-pre-wrap break-words text-stone-600 dark:text-neutral-300 ${BODY_SURFACE}`}> {formatted.detail}
) : detailContent ? (
+                      className={`mt-1 max-h-24 overflow-y-auto rounded px-2 py-1 font-mono text-[12px] whitespace-pre-wrap break-all text-stone-600 dark:text-neutral-300 ${BODY_SURFACE}`}>
                       {detailContent}
                     
) : null} @@ -420,7 +498,7 @@ export function ToolTimelineBlock({
) : (
- {formatted.title} + {formatted.title}
)}
@@ -428,6 +506,53 @@ export function ToolTimelineBlock({ })}
{liveResponse ? : null} + + ); + + // The group header is a static section label — the live "working" state is + // conveyed by the pulsing agent-name rows, so it never repeats a "Working…" + // string. While the run is in flight the group is collapsible; once it + // settles the chevron/collapse is dropped and the header renders static — + // matching the finished sub-agent steps, which also drop their collapse when + // done. + if (!isRunning) { + return ( +
+
+ {onViewWholeRun ? ( + // Settled: the whole title is the link — "Agentic task insights →" + // opens the full-run panel (matches the collapsed step rows). + + ) : ( + titleLabel + )} +
+ {body} +
+ ); + } + + return ( +
+ + {titleLabel} + + ▶ + + {wholeRunLink} + + {body}
); } diff --git a/app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx b/app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx index f9885ece1..a561d0c74 100644 --- a/app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx +++ b/app/src/pages/conversations/components/__tests__/AgentProcessSourcePanel.test.tsx @@ -98,6 +98,98 @@ describe('AgentProcessSourcePanel', () => { expect(screen.queryByTestId('subagent-view-processing')).toBeNull(); }); + it('renders the interleaved transcript (thoughts + grouped human steps) when present', () => { + renderPanel( + {}} + /> + ); + // Narration prose renders (the thoughts the user wanted surfaced). + expect(screen.getByText('Let me check both docs first.')).toBeInTheDocument(); + expect(screen.getByText('Now I can see what is missing.')).toBeInTheDocument(); + // The two consecutive reads collapse into one human-summarized group. + expect(screen.getByText('Read 2 files')).toBeInTheDocument(); + expect(screen.getByTestId('processing-transcript')).toBeInTheDocument(); + }); + + it('shows each sub-agent’s full activity in a "Sub-agents" deep-dive alongside the transcript', () => { + renderPanel( + {}} + /> + ); + // The parent narration shows via the transcript view… + expect(screen.getByText('Delegating to a researcher.')).toBeInTheDocument(); + // …and the sub-agent's full activity (its thoughts) shows in the deep-dive, + // with no redundant "view full processing" button (no onView). + expect(screen.getByTestId('agent-source-subagent')).toBeInTheDocument(); + const activity = screen.getByTestId('subagent-activity'); + expect(activity.textContent).toContain('planning the search'); + expect(screen.queryByTestId('subagent-view-processing')).toBeNull(); + }); + + it('scopes to a single step when scopedEntry is set (only that step, with its name as title)', () => { + const scoped: ToolTimelineEntry = { + id: 'sa-scope', + name: 'subagent:researcher', + round: 1, + status: 'success', + subagent: { + taskId: 'task-9', + agentId: 'researcher', + toolCalls: [], + transcript: [{ kind: 'thinking', iteration: 1, text: 'scoped thought' }], + }, + }; + renderPanel( + {}} + /> + ); + // Header shows the step's label, not the generic title. + expect(screen.getByText('Researching')).toBeInTheDocument(); + // Only the scoped step's activity renders… + expect(screen.getByTestId('subagent-activity').textContent).toContain('scoped thought'); + // …and the whole-run transcript / other steps do NOT. + expect(screen.queryByTestId('processing-transcript')).toBeNull(); + expect(screen.queryByText('whole-run narration')).toBeNull(); + }); + it('renders no source rows when no web tools were used', () => { renderPanel( { ); const calls = screen.getAllByTestId('subagent-tool-call'); expect(calls).toHaveLength(3); + // Human labels + timing, with status as a tinted "Done" / "Failed" / + // "Running" tag instead of a bare ✓/✕ glyph or the raw lowercase word. expect(calls[0].textContent).toContain('Searching the web'); - expect(calls[0].textContent).toContain('success'); + expect(calls[0].textContent).toContain('Done'); expect(calls[0].textContent).toContain('312ms'); expect(calls[1].textContent).toContain('Composio Execute'); - expect(calls[1].textContent).toContain('running'); - expect(calls[1].textContent).toContain('·t2'); + expect(calls[1].textContent).toContain('Running'); + expect(calls[1].textContent).not.toContain('·t2'); expect(calls[2].textContent).toContain('Reading file'); - expect(calls[2].textContent).toContain('error'); + expect(calls[2].textContent).toContain('Failed'); + expect(calls[2].textContent).toContain('50ms'); }); - it('renders every thought inline as a labeled "Thoughts" block (reasoning + narration)', () => { + it('labels cancelled / awaiting-user calls distinctly (not the green "Done" pill)', () => { + renderInStore( + + ); + const calls = screen.getAllByTestId('subagent-tool-call'); + expect(calls).toHaveLength(2); + // A cancelled / awaiting-user call must NOT read as a successful "Done" step. + expect(calls[0].textContent).toContain('Cancelled'); + expect(calls[0].textContent).not.toContain('Done'); + expect(calls[1].textContent).toContain('Awaiting input'); + expect(calls[1].textContent).not.toContain('Done'); + }); + + it('prefers the server-supplied label + contextual detail for a child tool call', () => { + renderInStore( + + ); + const row = screen.getByTestId('subagent-tool-call'); + expect(row.textContent).toContain('Reading messages'); + expect(row.textContent).toContain('steven@gmail.com'); + // Never the raw snake_case slug. + expect(row.textContent).not.toContain('GMAIL_READ_MESSAGES'); + }); + + it('renders every thought inline as quoted prose (reasoning + narration)', () => { renderInStore( { /> ); const thoughts = screen.getAllByTestId('subagent-thought'); - // Both reasoning and visible narration surface as their own Thoughts block. + // Both reasoning and visible narration surface as their own prose block — + // shown directly, with no "Thoughts" heading. expect(thoughts).toHaveLength(2); - expect(thoughts[0].textContent).toContain('Thoughts'); expect(thoughts[0].textContent).toContain('pondering the request'); + expect(thoughts[0].textContent).not.toContain('Thoughts'); expect(thoughts[1].textContent).toContain('Here is what I found so far'); }); @@ -137,7 +188,7 @@ describe('SubagentActivityBlock', () => { expect(rows[2].textContent).toContain('Found three relevant results'); }); - it('renders each thought as a collapsible details (open by default) with a thought icon', () => { + it('shows a thought directly as prose — no heading, no collapse', () => { renderInStore( { /> ); const thought = screen.getByTestId('subagent-thought'); - expect(thought.tagName).toBe('DETAILS'); - expect(thought).toHaveAttribute('open'); - // The thought-balloon icon sits in the clickable summary alongside the label. - expect(thought.querySelector('summary')?.textContent).toContain('💭'); - expect(thought.querySelector('summary')?.textContent).toContain('Thoughts'); + // No collapsible
/ and no "Thoughts" heading — the text + // is shown directly. + expect(thought.tagName).not.toBe('DETAILS'); + expect(thought.querySelector('summary')).toBeNull(); + expect(thought.textContent).toContain('weighing the options'); + expect(thought.textContent).not.toContain('Thoughts'); + expect(thought.textContent).not.toContain('💭'); }); it('strips a leaked envelope from the thought text', () => { @@ -407,3 +460,74 @@ describe('ToolTimelineBlock — worker thread ref status propagation', () => { expect(screen.queryByTestId('worker-thread-status-badge')).toBeNull(); }); }); + +describe('ToolTimelineBlock — compact chat mode (onViewDetails)', () => { + const entries: ToolTimelineEntry[] = [ + // A finished step. + { id: 'tl-1', name: 'agent_prepare_context', round: 1, status: 'success', detail: 'fetch X' }, + // The currently-running sub-agent (latest running). + { + id: 'sa-1', + name: 'subagent:researcher', + round: 1, + status: 'running', + subagent: { + taskId: 'task-1', + agentId: 'researcher', + toolCalls: [], + transcript: [{ kind: 'thinking', iteration: 1, text: 'pondering' }], + }, + }, + ]; + + it('collapses finished steps to a "View details" link but keeps the running step expanded inline', () => { + const onViewDetails = vi.fn(); + renderInStore(); + + // Only the finished step collapses to a "View details →" link. + const links = screen.getAllByTestId('view-details'); + expect(links).toHaveLength(1); + + // The currently-running sub-agent stays expanded inline in the main UI + // (its activity is visible) — and shows no "View details" link itself. + const activity = screen.getByTestId('subagent-activity'); + expect(activity.textContent).toContain('pondering'); + + // Clicking the finished step's link opens the full-run panel. + fireEvent.click(links[0]); + expect(onViewDetails).toHaveBeenCalledTimes(1); + }); + + it('collapses an already-finished sub-agent (no longer running) to a "View details" link', () => { + const onViewDetails = vi.fn(); + renderInStore( + + ); + // No running step → the finished sub-agent collapses (no inline activity). + expect(screen.getByTestId('view-details')).toBeInTheDocument(); + expect(screen.queryByTestId('subagent-activity')).toBeNull(); + }); + + it('still expands inline (no compact link) when onViewDetails is omitted (panel mode)', () => { + renderInStore(); + // Panel/expandable path: sub-agent activity is shown, no "View details" link. + expect(screen.getByTestId('subagent-activity')).toBeInTheDocument(); + expect(screen.queryByTestId('view-details')).toBeNull(); + }); +}); diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index 325b152e6..07a3a4367 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -22,14 +22,17 @@ import { } from '../services/chatService'; import { store } from '../store'; import { + appendProcessingProse, appendSubagentStreamDelta, clearInferenceStatusForThread, clearParallelRequest, clearPendingApprovalForThread, + clearProcessingForThread, clearStreamingAssistantForThread, endInferenceTurn, markInferenceTurnStreaming, recordChatTurnUsage, + recordProcessingTool, recordSubagentTranscriptTool, resolveSubagentTranscriptTool, setInferenceStatusForThread, @@ -56,7 +59,11 @@ import { setSelectedThread, } from '../store/threadSlice'; import { IS_PROD } from '../utils/config'; -import { formatTimelineEntry, promptFromArgsBuffer } from '../utils/toolTimelineFormatting'; +import { + formatTimelineEntry, + isKnownClientTool, + promptFromArgsBuffer, +} from '../utils/toolTimelineFormatting'; const logChatRuntime = debug('openhuman:chat-runtime'); const USER_FACING_AGENT_ERROR_MESSAGE = @@ -340,7 +347,18 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { const decorateEntry = (entry: ToolTimelineEntry): ToolTimelineEntry => { const formatted = formatTimelineEntry(entry); - return { ...entry, displayName: formatted.title, detail: formatted.detail }; + // The server now attaches a human label/detail for dynamic + // Composio/MCP/integration tools the client can't know. Trust it for + // those; for the fixed set of built-ins the client formatter labels + // well (with args-aware detail), the client label stays authoritative. + if (entry.displayName && !isKnownClientTool(entry.name)) { + return { + ...entry, + displayName: entry.displayName, + detail: entry.detail ?? formatted.detail, + }; + } + return { ...entry, displayName: formatted.title, detail: formatted.detail ?? entry.detail }; }; // When a turn ends, any follow-ups the user queued behind it are about to be @@ -396,6 +414,9 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { const cleanup = subscribeChatEvents({ onInferenceStart: (event: ChatInferenceStartEvent) => { rtLog('inference_start', { thread: event.thread_id, request: event.request_id }); + // Fresh turn: drop the previous turn's live processing transcript so a + // new turn's narration/steps don't append onto the old one. + dispatch(clearProcessingForThread({ threadId: event.thread_id })); dispatch(markInferenceTurnStreaming({ threadId: event.thread_id })); dispatch( setInferenceStatusForThread({ @@ -446,6 +467,12 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { ? existing.findIndex(entry => entry.id === event.tool_call_id) : -1; + // Stable row id, shared with the processing-transcript tool pointer so + // the panel can resolve the row by `callId`. + const rowId = + event.tool_call_id ?? + `${event.thread_id}:${event.round}:${existing.length}:${event.tool_name}`; + let entries: ToolTimelineEntry[]; if (existingIdx >= 0) { entries = [...existing]; @@ -454,21 +481,26 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { name: event.tool_name, round: event.round, status: 'running', + displayName: event.tool_display_label ?? entries[existingIdx].displayName, + detail: event.tool_display_detail ?? entries[existingIdx].detail, }); } else { entries = [ ...existing, decorateEntry({ - id: - event.tool_call_id ?? - `${event.thread_id}:${event.round}:${existing.length}:${event.tool_name}`, + id: rowId, name: event.tool_name, round: event.round, status: 'running', + displayName: event.tool_display_label, + detail: event.tool_display_detail, }), ]; } dispatch(setToolTimelineForThread({ threadId: event.thread_id, entries })); + dispatch( + recordProcessingTool({ threadId: event.thread_id, round: event.round, callId: rowId }) + ); }, onToolResult: (event: ChatToolResultEvent) => { const eventKey = `tool_result:${event.thread_id}:${event.request_id ?? 'none'}:${event.round}:${event.tool_name}:${event.success}:${event.tool_call_id ?? ''}`; @@ -674,6 +706,8 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { status: 'running', iteration: event.subagent?.child_iteration, args: event.args, + displayName: event.tool_display_label, + detail: event.tool_display_detail, }, ], }, @@ -689,6 +723,8 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { toolName: event.tool_name, iteration: event.subagent?.child_iteration, args: event.args, + displayName: event.tool_display_label, + detail: event.tool_display_detail, }) ); }, @@ -803,6 +839,16 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { }; } dispatch(setStreamingAssistantForThread({ threadId: event.thread_id, streaming })); + // Build the live interleaved processing transcript so a mid-turn + // "View processing" isn't empty (the persisted one lands on settle). + dispatch( + appendProcessingProse({ + threadId: event.thread_id, + kind: 'narration', + round: event.round, + delta: event.delta, + }) + ); }, onThinkingDelta: event => { const cr = store.getState().chatRuntime; @@ -832,6 +878,14 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { }; } dispatch(setStreamingAssistantForThread({ threadId: event.thread_id, streaming })); + dispatch( + appendProcessingProse({ + threadId: event.thread_id, + kind: 'thinking', + round: event.round, + delta: event.delta, + }) + ); }, onToolArgsDelta: event => { const cr = store.getState().chatRuntime; diff --git a/app/src/services/chatService.ts b/app/src/services/chatService.ts index 0ae64a8f5..56c9d6e6d 100644 --- a/app/src/services/chatService.ts +++ b/app/src/services/chatService.ts @@ -28,6 +28,15 @@ export interface ChatToolCallEvent { * this id for end-to-end reconciliation. */ tool_call_id?: string; + /** + * Server-computed human label for this call (e.g. "Reading messages"), + * set by the Rust `Tool::display_label`. Present for dynamic + * Composio/MCP/integration tools the client can't label itself; absent + * for built-ins the client formatter already handles. + */ + tool_display_label?: string; + /** Server-computed contextual detail (e.g. "steven@gmail.com"). */ + tool_display_detail?: string; } export interface ChatToolResultEvent { @@ -307,6 +316,10 @@ export interface ChatSubagentToolCallEvent { * with no/`null` arguments. */ args?: unknown; + /** Server-computed human label for this child call (from `Tool::display_label`). */ + tool_display_label?: string; + /** Server-computed contextual detail (path / recipient / query). */ + tool_display_detail?: string; subagent?: SubagentProgressDetail; } diff --git a/app/src/store/chatRuntimeSlice.test.ts b/app/src/store/chatRuntimeSlice.test.ts index e56a4bb48..3674b912b 100644 --- a/app/src/store/chatRuntimeSlice.test.ts +++ b/app/src/store/chatRuntimeSlice.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { AgentRun, AgentRunStatus, PersistedTurnState } from '../types/turnState'; import chatRuntimeReducer, { + appendProcessingProse, clearAllChatRuntime, clearQueueStatusForThread, clearRuntimeForThread, @@ -10,6 +11,7 @@ import chatRuntimeReducer, { hydrateRuntimeFromSnapshot, type QueueStatus, setQueueStatusForThread, + setToolTimelineForThread, } from './chatRuntimeSlice'; function makeRun(id: string, status: AgentRunStatus): AgentRun { @@ -84,8 +86,15 @@ describe('chatRuntimeSlice queue status', () => { status: { active: true, steers: 0, followups: 1, collects: 0, total: 1 }, }) ); + // Also seed a processing transcript so the clear covers it too (a global + // reset must not leave stale "View processing" prose behind). + store.dispatch( + appendProcessingProse({ threadId: 't1', kind: 'narration', round: 1, delta: 'thinking…' }) + ); + expect(store.getState().chatRuntime.processingByThread.t1).toHaveLength(1); store.dispatch(clearAllChatRuntime()); expect(store.getState().chatRuntime.queueStatusByThread).toEqual({}); + expect(store.getState().chatRuntime.processingByThread).toEqual({}); }); it('updates queue status when set again', () => { @@ -232,3 +241,134 @@ describe('chatRuntimeSlice queue status', () => { expect(store.getState().chatRuntime.queueStatusByThread['t2']?.followups).toBe(2); }); }); + +describe('hydrateRuntimeFromSnapshot — sub-agent prose persistence', () => { + it('carries live sub-agent thoughts across rehydration (matched by taskId)', () => { + const store = makeStore(); + // Live in-memory row: sub-agent with streamed reasoning + a tool call. + // Live and persisted rows use different entry ids, so the merge matches + // on the sub-agent taskId. + store.dispatch( + setToolTimelineForThread({ + threadId: 't9', + entries: [ + { + id: 't9:subagent:task-x:spawn_subagent', + name: 'subagent:researcher', + round: 1, + status: 'running', + subagent: { + taskId: 'task-x', + agentId: 'researcher', + toolCalls: [], + transcript: [ + { kind: 'thinking', iteration: 1, text: 'let me search the inbox' }, + { + kind: 'tool', + iteration: 1, + callId: 'c1', + toolName: 'web_search', + status: 'success', + }, + ], + }, + }, + ], + }) + ); + + // Snapshot rebuilds the sub-agent transcript from tool calls only (no + // prose) and uses the persisted entry id `subagent:`. + const snapshot: PersistedTurnState = { + threadId: 't9', + requestId: 'req-1', + lifecycle: 'streaming', + iteration: 1, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [ + { + id: 'subagent:task-x', + name: 'subagent:researcher', + round: 1, + status: 'running', + subagent: { + taskId: 'task-x', + agentId: 'researcher', + toolCalls: [{ callId: 'c1', toolName: 'web_search', status: 'success' }], + }, + }, + ], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + + store.dispatch(hydrateRuntimeFromSnapshot({ snapshot })); + + const row = store + .getState() + .chatRuntime.toolTimelineByThread['t9'].find(e => e.subagent?.taskId === 'task-x'); + const transcript = row?.subagent?.transcript ?? []; + // The streamed thought survives the rehydration instead of being clobbered + // by the prose-less snapshot. + const thinking = transcript.find(i => i.kind === 'thinking'); + expect(thinking && 'text' in thinking ? thinking.text : undefined).toBe( + 'let me search the inbox' + ); + }); + + it('replays a persisted sub-agent transcript on a settled turn (no live data)', () => { + const store = makeStore(); + // No live entries seeded — this is the settled / reloaded case. The + // snapshot itself now carries the sub-agent prose transcript. + const snapshot: PersistedTurnState = { + threadId: 't10', + requestId: 'req-1', + lifecycle: 'completed', + iteration: 2, + maxIterations: 10, + streamingText: '', + thinking: '', + toolTimeline: [ + { + id: 'subagent:task-y', + name: 'subagent:researcher', + round: 1, + status: 'success', + subagent: { + taskId: 'task-y', + agentId: 'researcher', + toolCalls: [{ callId: 'c1', toolName: 'web_search', status: 'success' }], + transcript: [ + { kind: 'thinking', iteration: 1, text: 'planning the search' }, + { + kind: 'tool', + iteration: 1, + callId: 'c1', + toolName: 'web_search', + status: 'success', + }, + { kind: 'text', iteration: 1, text: 'here is the summary' }, + ], + }, + }, + ], + startedAt: '2026-06-23T00:00:00Z', + updatedAt: '2026-06-23T00:00:00Z', + }; + + store.dispatch(hydrateRuntimeFromSnapshot({ snapshot })); + + const row = store + .getState() + .chatRuntime.toolTimelineByThread['t10'].find(e => e.subagent?.taskId === 'task-y'); + const transcript = row?.subagent?.transcript ?? []; + // The persisted prose survives a reload with no in-memory live data. + expect(transcript.map(i => i.kind)).toEqual(['thinking', 'tool', 'text']); + const thinking = transcript[0]; + expect(thinking.kind === 'thinking' ? thinking.text : undefined).toBe('planning the search'); + const text = transcript[2]; + expect(text.kind === 'text' ? text.text : undefined).toBe('here is the summary'); + }); +}); diff --git a/app/src/store/chatRuntimeSlice.ts b/app/src/store/chatRuntimeSlice.ts index 99799134f..f69f8f287 100644 --- a/app/src/store/chatRuntimeSlice.ts +++ b/app/src/store/chatRuntimeSlice.ts @@ -7,7 +7,9 @@ import type { AgentRun, PersistedSubagentActivity, PersistedSubagentToolCall, + PersistedSubagentTranscriptItem, PersistedToolTimelineEntry, + PersistedTranscriptItem, PersistedTurnState, TaskBoard, } from '../types/turnState'; @@ -15,6 +17,13 @@ import { resetUserScopedState } from './resetActions'; const turnStateLog = debug('chatRuntime.turnState'); +/** + * Ordered item in the parent turn's processing transcript (narration / + * thinking / tool-call pointer). Same shape as the persisted wire type; the + * "View processing" panel renders these interleaved. + */ +export type ProcessingTranscriptItem = PersistedTranscriptItem; + export type ToolTimelineEntryStatus = | 'running' | 'success' @@ -138,6 +147,10 @@ export type SubagentTranscriptItem = args?: unknown; /** The tool's actual output text (set on completion). */ result?: string; + /** Server-computed human label (from `Tool::display_label`), if any. */ + displayName?: string; + /** Server-computed contextual detail (path / recipient / query). */ + detail?: string; }; /** One child tool call performed by a running sub-agent. */ @@ -157,6 +170,10 @@ export interface SubagentToolCallEntry { args?: unknown; /** The tool's actual output text (set on completion). */ result?: string; + /** Server-computed human label (from `Tool::display_label`), if any. */ + displayName?: string; + /** Server-computed contextual detail (path / recipient / query). */ + detail?: string; } export interface ToolTimelineEntry { @@ -298,6 +315,15 @@ interface ChatRuntimeState { */ parallelRequestThreads: Record; toolTimelineByThread: Record; + /** + * Ordered narration/thinking/tool transcript per thread for the + * "View processing" panel — the interleaved Hermes-style record. Hydrated + * from the persisted turn-state snapshot (which is now KEPT on completion), + * so a settled / reloaded turn replays its full reasoning. Tool items point + * into `toolTimelineByThread` by `callId`. Empty/absent → panel falls back + * to the tool-only view. + */ + processingByThread: Record; taskBoardByThread: Record; inferenceTurnLifecycleByThread: Record; pendingApprovalByThread: Record; @@ -352,6 +378,7 @@ const initialState: ChatRuntimeState = { parallelStreamsByThread: {}, parallelRequestThreads: {}, toolTimelineByThread: {}, + processingByThread: {}, taskBoardByThread: {}, inferenceTurnLifecycleByThread: {}, pendingApprovalByThread: {}, @@ -396,9 +423,61 @@ function subagentToolCallFromPersisted(call: PersistedSubagentToolCall): Subagen iteration: call.iteration, elapsedMs: call.elapsedMs, outputChars: call.outputChars, + displayName: call.displayName, + detail: call.detail, }; } +/** + * Carry the live sub-agent prose (reasoning/narration) across a snapshot + * rehydration. Sub-agent streamed text/thinking is live-only — the persisted + * snapshot rebuilds a sub-agent transcript from its tool calls *without* the + * prose. So when a thread re-hydrates mid-turn (e.g. the user switches tabs + * and comes back), the snapshot rows would otherwise lose the inline thoughts. + * Match by sub-agent `taskId` (live and persisted rows use different entry + * ids) and graft the richer in-memory prose transcript onto the new rows. + */ +function preserveLiveSubagentProse( + existing: ToolTimelineEntry[] | undefined, + next: ToolTimelineEntry[] +): ToolTimelineEntry[] { + if (!existing || existing.length === 0) return next; + const liveProse = new Map(); + for (const entry of existing) { + const tx = entry.subagent?.transcript; + if (entry.subagent && tx && tx.some(i => i.kind === 'text' || i.kind === 'thinking')) { + liveProse.set(entry.subagent.taskId, tx); + } + } + if (liveProse.size === 0) return next; + return next.map(entry => { + if (!entry.subagent) return entry; + const saved = liveProse.get(entry.subagent.taskId); + if (!saved) return entry; + // Clone the items so we don't reuse Immer drafts from the prior state. + return { ...entry, subagent: { ...entry.subagent, transcript: saved.map(i => ({ ...i })) } }; + }); +} + +function subagentTranscriptItemFromPersisted( + item: PersistedSubagentTranscriptItem +): SubagentTranscriptItem { + if (item.kind === 'tool') { + return { + kind: 'tool', + iteration: item.iteration, + callId: item.callId, + toolName: item.toolName, + status: item.status, + elapsedMs: item.elapsedMs, + outputChars: item.outputChars, + displayName: item.displayName, + detail: item.detail, + }; + } + return { kind: item.kind, iteration: item.iteration, text: item.text }; +} + function subagentActivityFromPersisted(activity: PersistedSubagentActivity): SubagentActivity { return { taskId: activity.taskId, @@ -413,19 +492,22 @@ function subagentActivityFromPersisted(activity: PersistedSubagentActivity): Sub elapsedMs: activity.elapsedMs, outputChars: activity.outputChars, toolCalls: activity.toolCalls.map(subagentToolCallFromPersisted), - // Streamed text/thinking is live-only and never persisted, so a - // rehydrated run can't replay the prose. Rebuild the transcript from - // the persisted tool calls (tool items only) so an interrupted run - // still shows its tool sequence in chronological order. - transcript: activity.toolCalls.map(call => ({ - kind: 'tool' as const, - iteration: call.iteration, - callId: call.callId, - toolName: call.toolName, - status: call.status, - elapsedMs: call.elapsedMs, - outputChars: call.outputChars, - })), + // Prefer the persisted prose transcript (reasoning/narration interleaved + // with tools) so a settled / reloaded run replays its thoughts. Fall back + // to a tool-only rebuild for snapshots written before sub-agent prose was + // persisted (the `transcript` field is absent there). + transcript: + activity.transcript && activity.transcript.length > 0 + ? activity.transcript.map(subagentTranscriptItemFromPersisted) + : activity.toolCalls.map(call => ({ + kind: 'tool' as const, + iteration: call.iteration, + callId: call.callId, + toolName: call.toolName, + status: call.status, + elapsedMs: call.elapsedMs, + outputChars: call.outputChars, + })), }; } @@ -601,6 +683,47 @@ const chatRuntimeSlice = createSlice({ }, clearToolTimelineForThread: (state, action: PayloadAction<{ threadId: string }>) => { delete state.toolTimelineByThread[action.payload.threadId]; + delete state.processingByThread[action.payload.threadId]; + }, + /** Reset the live processing transcript at the start of a fresh turn so a + * new turn's narration/steps don't append onto the previous turn's. */ + clearProcessingForThread: (state, action: PayloadAction<{ threadId: string }>) => { + delete state.processingByThread[action.payload.threadId]; + }, + /** + * Append a streamed narration/thinking delta to the live processing + * transcript, coalescing into the trailing same-kind, same-round block so + * a paragraph stays one item. Mirrors the Rust mirror's accumulation so + * the live "View processing" panel matches the persisted one. + */ + appendProcessingProse: ( + state, + action: PayloadAction<{ + threadId: string; + kind: 'narration' | 'thinking'; + round: number; + delta: string; + }> + ) => { + const { threadId, kind, round, delta } = action.payload; + if (!delta) return; + const list = (state.processingByThread[threadId] ??= []); + const last = list[list.length - 1]; + if (last && last.kind === kind && last.round === round) { + last.text += delta; + return; + } + list.push({ kind, round, seq: list.length, text: delta }); + }, + /** Record a tool call in the live processing transcript at its position. */ + recordProcessingTool: ( + state, + action: PayloadAction<{ threadId: string; round: number; callId: string }> + ) => { + const { threadId, round, callId } = action.payload; + const list = (state.processingByThread[threadId] ??= []); + if (list.some(i => i.kind === 'toolCall' && i.callId === callId)) return; + list.push({ kind: 'toolCall', round, seq: list.length, callId }); }, /** * Optimistically mark a detached background sub-agent as cancelled after the @@ -673,14 +796,26 @@ const chatRuntimeSlice = createSlice({ toolName: string; iteration?: number; args?: unknown; + displayName?: string; + detail?: string; }> ) => { - const { threadId, rowId, callId, toolName, iteration, args } = action.payload; + const { threadId, rowId, callId, toolName, iteration, args, displayName, detail } = + action.payload; const entry = state.toolTimelineByThread[threadId]?.find(e => e.id === rowId); if (!entry?.subagent) return; const transcript = (entry.subagent.transcript ??= []); if (transcript.some(i => i.kind === 'tool' && i.callId === callId)) return; - transcript.push({ kind: 'tool', iteration, callId, toolName, status: 'running', args }); + transcript.push({ + kind: 'tool', + iteration, + callId, + toolName, + status: 'running', + args, + displayName, + detail, + }); }, /** * Flip a transcript `tool` item to its terminal status when the child @@ -901,6 +1036,7 @@ const chatRuntimeSlice = createSlice({ delete state.parallelStreamsByThread[action.payload.threadId]; } delete state.toolTimelineByThread[action.payload.threadId]; + delete state.processingByThread[action.payload.threadId]; delete state.taskBoardByThread[action.payload.threadId]; delete state.inferenceTurnLifecycleByThread[action.payload.threadId]; delete state.pendingApprovalByThread[action.payload.threadId]; @@ -918,6 +1054,7 @@ const chatRuntimeSlice = createSlice({ state.parallelStreamsByThread = {}; state.parallelRequestThreads = {}; state.toolTimelineByThread = {}; + state.processingByThread = {}; state.taskBoardByThread = {}; state.inferenceTurnLifecycleByThread = {}; state.pendingApprovalByThread = {}; @@ -965,7 +1102,14 @@ const chatRuntimeSlice = createSlice({ const { snapshot } = action.payload; const threadId = snapshot.threadId; - state.inferenceTurnLifecycleByThread[threadId] = snapshot.lifecycle; + // `completed` is a settled turn, not an in-flight lifecycle — drop any + // stale in-flight marker rather than store it (the in-flight enum only + // covers started/streaming/interrupted). + if (snapshot.lifecycle === 'completed') { + delete state.inferenceTurnLifecycleByThread[threadId]; + } else { + state.inferenceTurnLifecycleByThread[threadId] = snapshot.lifecycle; + } // Snapshots don't carry pending-approval payloads; drop any stale in-memory // approval so the card reflects the rehydrated core truth, not pre-drift state. delete state.pendingApprovalByThread[threadId]; @@ -973,18 +1117,21 @@ const chatRuntimeSlice = createSlice({ state.taskBoardByThread[threadId] = snapshot.taskBoard; } - // Interrupted turns have no live driver — surface only the - // lifecycle so the UI renders a retry affordance instead of - // resurrecting a fake "live" inference status / streaming buffer - // from snapshot fields that may be stale. - if (snapshot.lifecycle === 'interrupted') { + // Terminal turns (interrupted = crashed mid-flight; completed = finished + // normally, snapshot kept for replay) have no live driver — surface only + // the lifecycle so the UI renders settled, not a fake "live" status / + // streaming buffer from stale snapshot fields. The processing transcript + // is still carried so "View processing" replays the full reasoning. + if (snapshot.lifecycle === 'interrupted' || snapshot.lifecycle === 'completed') { delete state.inferenceStatusByThread[threadId]; delete state.streamingAssistantByThread[threadId]; - // No live driver remains for this turn — settle any in-flight rows so - // their agent names stop pulsing instead of blinking forever. - state.toolTimelineByThread[threadId] = snapshot.toolTimeline - .map(toolTimelineFromPersisted) - .map(settleOrphanedTimelineEntry); + // Settle any in-flight rows so their agent names stop pulsing + // (no-op for an already-completed snapshot whose rows are terminal). + state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( + state.toolTimelineByThread[threadId], + snapshot.toolTimeline.map(toolTimelineFromPersisted).map(settleOrphanedTimelineEntry) + ); + state.processingByThread[threadId] = snapshot.transcript ?? []; return; } @@ -1010,7 +1157,11 @@ const chatRuntimeSlice = createSlice({ delete state.streamingAssistantByThread[threadId]; } - state.toolTimelineByThread[threadId] = snapshot.toolTimeline.map(toolTimelineFromPersisted); + state.toolTimelineByThread[threadId] = preserveLiveSubagentProse( + state.toolTimelineByThread[threadId], + snapshot.toolTimeline.map(toolTimelineFromPersisted) + ); + state.processingByThread[threadId] = snapshot.transcript ?? []; }, /** * Rebuild durable historical subagent rows from the run ledger. This is @@ -1048,6 +1199,9 @@ export const { clearParallelRequest, setToolTimelineForThread, clearToolTimelineForThread, + clearProcessingForThread, + appendProcessingProse, + recordProcessingTool, markSubagentCancelled, appendSubagentStreamDelta, recordSubagentTranscriptTool, diff --git a/app/src/types/turnState.ts b/app/src/types/turnState.ts index 35c36f97a..544a803fd 100644 --- a/app/src/types/turnState.ts +++ b/app/src/types/turnState.ts @@ -5,7 +5,7 @@ * to surface interrupted turns left behind by a previous core process. */ -export type PersistedTurnLifecycle = 'started' | 'streaming' | 'interrupted'; +export type PersistedTurnLifecycle = 'started' | 'streaming' | 'interrupted' | 'completed'; export type PersistedTurnPhase = 'thinking' | 'tool_use' | 'subagent'; @@ -59,8 +59,45 @@ export interface PersistedSubagentToolCall { iteration?: number; elapsedMs?: number; outputChars?: number; + /** Server-computed human label for this child call (from `Tool::display_label`). */ + displayName?: string; + /** Server-computed contextual detail (path / recipient / query). */ + detail?: string; } +/** + * One ordered item in the parent turn's processing transcript — the + * interleaved record of narration, reasoning, and tool calls used to render + * the "View processing" panel (mirrors the Rust `TranscriptItem`). `seq` is a + * monotonic per-turn ordering key; a `toolCall` item points at a row in + * {@link PersistedTurnState.toolTimeline} by `callId`. + */ +export type PersistedTranscriptItem = + | { kind: 'narration'; round: number; seq: number; text: string } + | { kind: 'thinking'; round: number; seq: number; text: string } + | { kind: 'toolCall'; round: number; seq: number; callId: string }; + +/** + * One ordered item in a sub-agent's processing transcript (mirrors the Rust + * `SubagentTranscriptItem`). Unlike the parent transcript there is no `seq` — + * order is the array order. Persisting these lets the inline sub-agent thoughts + * survive a settled turn / reload. + */ +export type PersistedSubagentTranscriptItem = + | { kind: 'thinking'; iteration?: number; text: string } + | { kind: 'text'; iteration?: number; text: string } + | { + kind: 'tool'; + iteration?: number; + callId: string; + toolName: string; + status: PersistedToolStatus; + elapsedMs?: number; + outputChars?: number; + displayName?: string; + detail?: string; + }; + export interface PersistedSubagentActivity { taskId: string; agentId: string; @@ -75,6 +112,10 @@ export interface PersistedSubagentActivity { /** Persistent worker sub-thread id backing this delegation (camelCase from core). */ workerThreadId?: string; toolCalls: PersistedSubagentToolCall[]; + /** Ordered reasoning/narration/tool transcript for this sub-agent — what the + * inline thoughts render from. Absent on snapshots written before this + * field; the UI then falls back to rebuilding tool-only items. */ + transcript?: PersistedSubagentTranscriptItem[]; } export interface PersistedToolTimelineEntry { @@ -101,6 +142,9 @@ export interface PersistedTurnState { streamingText: string; thinking: string; toolTimeline: PersistedToolTimelineEntry[]; + /** Ordered narration/thinking/tool transcript for the processing panel. + * Absent on snapshots written before this field. */ + transcript?: PersistedTranscriptItem[]; taskBoard?: TaskBoard | null; startedAt: string; updatedAt: string; diff --git a/app/src/utils/__tests__/toolTimelineFormatting.test.ts b/app/src/utils/__tests__/toolTimelineFormatting.test.ts index 045b18d8b..28a5b7376 100644 --- a/app/src/utils/__tests__/toolTimelineFormatting.test.ts +++ b/app/src/utils/__tests__/toolTimelineFormatting.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from 'vitest'; import type { ToolTimelineEntry } from '../../store/chatRuntimeSlice'; +import type { PersistedTranscriptItem } from '../../types/turnState'; import { + buildProcessingBlocks, + categorizeTool, formatTimelineEntry, formatToolName, + isKnownClientTool, stripToolCallEnvelopes, + summarizeToolGroup, } from '../toolTimelineFormatting'; function entry(overrides: Partial): ToolTimelineEntry { @@ -271,3 +276,100 @@ describe('stripToolCallEnvelopes', () => { expect(stripToolCallEnvelopes('just a normal sentence')).toBe('just a normal sentence'); }); }); + +describe('isKnownClientTool', () => { + it('recognizes built-ins and special agent rows', () => { + expect(isKnownClientTool('file_read')).toBe(true); + expect(isKnownClientTool('shell')).toBe(true); + expect(isKnownClientTool('subagent:researcher')).toBe(true); + expect(isKnownClientTool('delegate_to_integrations_agent')).toBe(true); + }); + + it('does not recognize dynamic Composio/MCP actions (server labels them)', () => { + expect(isKnownClientTool('GMAIL_SEND_EMAIL')).toBe(false); + expect(isKnownClientTool('composio_notion_create_page')).toBe(false); + expect(isKnownClientTool('some_random_mcp_tool')).toBe(false); + }); +}); + +describe('summarizeToolGroup', () => { + it('uses the specific label for a single row', () => { + expect(summarizeToolGroup([entry({ name: 'file_read', argsBuffer: '{"path":"a.ts"}' })])).toBe( + 'Reading file' + ); + }); + + it('counts a homogeneous group', () => { + expect( + summarizeToolGroup([ + entry({ id: 'a', name: 'file_read' }), + entry({ id: 'b', name: 'file_read' }), + ]) + ).toBe('Read 2 files'); + }); + + it('joins distinct category phrases for a mixed group', () => { + expect( + summarizeToolGroup([ + entry({ id: 'a', name: 'file_write' }), + entry({ id: 'b', name: 'shell' }), + entry({ id: 'c', name: 'shell' }), + ]) + ).toBe('Edited 1 file, ran 2 commands'); + }); +}); + +describe('categorizeTool', () => { + it('maps tools (incl. subagent-prefixed) to a category', () => { + expect(categorizeTool('grep')).toBe('search'); + expect(categorizeTool('subagent:web_fetch')).toBe('fetch'); + expect(categorizeTool('GMAIL_SEND_EMAIL')).toBe('other'); + }); +}); + +describe('buildProcessingBlocks', () => { + const tx = (over: Partial & { kind: PersistedTranscriptItem['kind'] }) => + ({ round: 1, seq: 0, ...over }) as PersistedTranscriptItem; + + it('interleaves prose with grouped consecutive tool rows in seq order', () => { + const entries = [ + entry({ id: 'c1', name: 'file_read' }), + entry({ id: 'c2', name: 'file_read' }), + ]; + const transcript: PersistedTranscriptItem[] = [ + tx({ kind: 'thinking', seq: 0, text: 'Let me look' }), + tx({ kind: 'narration', seq: 1, text: 'Reading the files' }), + tx({ kind: 'toolCall', seq: 2, callId: 'c1' }), + tx({ kind: 'toolCall', seq: 3, callId: 'c2' }), + tx({ kind: 'narration', seq: 4, text: 'Done' }), + ]; + const blocks = buildProcessingBlocks(transcript, entries); + expect(blocks.map(b => b.kind)).toEqual(['thinking', 'narration', 'toolGroup', 'narration']); + const group = blocks[2]; + if (group.kind !== 'toolGroup') throw new Error('expected toolGroup'); + expect(group.summary).toBe('Read 2 files'); + expect(group.entries).toHaveLength(2); + }); + + it('skips tool pointers with no matching entry', () => { + const blocks = buildProcessingBlocks([tx({ kind: 'toolCall', seq: 0, callId: 'missing' })], []); + expect(blocks).toHaveLength(0); + }); + + it('falls back to a single group when there is no transcript', () => { + const entries = [entry({ id: 'c1', name: 'shell' })]; + const blocks = buildProcessingBlocks([], entries); + expect(blocks).toHaveLength(1); + expect(blocks[0].kind).toBe('toolGroup'); + }); +}); + +describe('formatter null-safety (malformed / legacy snapshot guard)', () => { + it('does not throw on undefined input', () => { + // A snake_case (legacy) persisted transcript item yields undefined + // camelCase fields; the formatter must degrade, not crash the app. + expect(formatToolName(undefined)).toBe(''); + expect(stripToolCallEnvelopes(undefined)).toBe(''); + expect(stripToolCallEnvelopes(null)).toBe(''); + }); +}); diff --git a/app/src/utils/toolTimelineFormatting.ts b/app/src/utils/toolTimelineFormatting.ts index 93dd45de2..b12ccb363 100644 --- a/app/src/utils/toolTimelineFormatting.ts +++ b/app/src/utils/toolTimelineFormatting.ts @@ -1,4 +1,5 @@ import type { ToolTimelineEntry } from '../store/chatRuntimeSlice'; +import type { PersistedTranscriptItem } from '../types/turnState'; interface ParsedToolArgs { agent_id?: string; @@ -77,10 +78,74 @@ const TOOL_DISPLAY_NAMES: Record = { * Format a raw tool name into a short human-readable label. * Used for subagent child tool rows and sub-mascot activity text. */ -export function formatToolName(toolName: string): string { +export function formatToolName(toolName: string | undefined): string { + if (!toolName) return ''; return TOOL_DISPLAY_NAMES[toolName] ?? humanizeIdentifier(toolName); } +/** + * The fixed set of built-in / special tools this client formatter labels + * well on its own (with args-aware detail). For these, the client label is + * authoritative and a server-supplied `display_label` is ignored — the + * server label only wins for *dynamic* tools (Composio/MCP/integration + * actions) the client can't possibly know, which is where raw `snake_case` + * used to leak through. Keep in sync with {@link formatTimelineEntry} / + * {@link formatToolDetail}. + */ +const CLIENT_KNOWN_TOOLS = new Set([ + ...Object.keys(TOOL_DISPLAY_NAMES), + // args-aware built-ins handled by formatToolDetail() + 'shell', + 'node_exec', + 'npm_exec', + 'web_fetch', + 'http_request', + 'curl', + 'web_search', + 'gitbooks_search', + 'file_read', + 'file_write', + 'vault_write_markdown', + 'edit', + 'apply_patch', + 'grep', + 'glob', + 'list', + 'git_operations', + 'browser', + 'browser_open', + 'screenshot', + 'image_info', + 'install_tool', + 'lsp', + 'run_tests', + 'run_linter', + 'read_diff', + // special-cased agent / integration rows + 'spawn_subagent', + 'integrations_agent', + 'researcher', + 'agent_prepare_context', + 'context_scout', + 'composio_list_connections', + 'orchestrator', + 'critic', + 'tools_agent', + 'code_executor', +]); + +/** + * Whether the client formatter recognizes this tool (so its label should win + * over any server-supplied one). True for built-ins, the special agent rows, + * and the `subagent:` / `delegate_` families that {@link formatTimelineEntry} + * handles explicitly. + */ +export function isKnownClientTool(name: string): boolean { + return ( + name.startsWith('subagent:') || name.startsWith('delegate_') || CLIENT_KNOWN_TOOLS.has(name) + ); +} + /** * Strip `` envelopes that some models emit inline in * their visible / reasoning text. The structured call is already surfaced as @@ -89,12 +154,95 @@ export function formatToolName(toolName: string): string { * half-arrived delta never flashes raw markup. Whitespace is left intact — * callers that render single-line previews collapse it themselves. */ -export function stripToolCallEnvelopes(text: string): string { +export function stripToolCallEnvelopes(text: string | undefined | null): string { + if (!text) return ''; return text .replace(/]*>[\s\S]*?<\/tool_call>/gi, '') .replace(/]*>[\s\S]*$/i, ''); } +/** Broad activity category for a tool, used to group + icon timeline rows. */ +export type ToolCategory = 'read' | 'write' | 'search' | 'run' | 'fetch' | 'browse' | 'other'; + +const TOOL_CATEGORIES: Record = { + file_read: 'read', + list: 'read', + read_diff: 'read', + file_write: 'write', + vault_write_markdown: 'write', + edit: 'write', + apply_patch: 'write', + grep: 'search', + glob: 'search', + web_search: 'search', + gitbooks_search: 'search', + gitbooks_get_page: 'read', + shell: 'run', + node_exec: 'run', + npm_exec: 'run', + run_tests: 'run', + run_linter: 'run', + git_operations: 'run', + web_fetch: 'fetch', + http_request: 'fetch', + curl: 'fetch', + browser: 'browse', + browser_open: 'browse', + screenshot: 'browse', +}; + +/** Categorize a (possibly `subagent:`-prefixed) tool name for grouping/icons. */ +export function categorizeTool(name: string): ToolCategory { + const base = name.replace(/^subagent:/, ''); + return TOOL_CATEGORIES[base] ?? 'other'; +} + +/** Plural-aware verb phrase per category, e.g. `read` + 2 → "Read 2 files". */ +const CATEGORY_PHRASE: Record< + ToolCategory, + { verb: string; noun: [singular: string, plural: string] } +> = { + read: { verb: 'Read', noun: ['file', 'files'] }, + write: { verb: 'Edited', noun: ['file', 'files'] }, + search: { verb: 'Ran', noun: ['search', 'searches'] }, + run: { verb: 'Ran', noun: ['command', 'commands'] }, + fetch: { verb: 'Fetched', noun: ['page', 'pages'] }, + browse: { verb: 'Browsed', noun: ['page', 'pages'] }, + other: { verb: 'Ran', noun: ['step', 'steps'] }, +}; + +/** + * Summarize a group of consecutive tool rows into a single Hermes-style + * header — "Viewed 2 files", "Ran 3 commands", or, for a mixed group, the + * distinct category phrases joined ("Edited a file, read a file"). A + * single-row group defers to that row's specific label (more informative + * than a generic count). Pure + deterministic for unit testing. + */ +export function summarizeToolGroup(entries: ToolTimelineEntry[]): string { + if (entries.length === 0) return ''; + if (entries.length === 1) { + return formatTimelineEntry(entries[0]).title; + } + // Count per category, preserving first-seen order. + const order: ToolCategory[] = []; + const counts = new Map(); + for (const entry of entries) { + const cat = categorizeTool(entry.name); + if (!counts.has(cat)) order.push(cat); + counts.set(cat, (counts.get(cat) ?? 0) + 1); + } + const phrases = order.map((cat, i) => { + const n = counts.get(cat) ?? 0; + const { verb, noun } = CATEGORY_PHRASE[cat]; + const word = n === 1 ? noun[0] : noun[1]; + const phrase = `${verb} ${n} ${word}`; + // Lowercase the leading verb on all but the first phrase so the joined + // sentence reads naturally ("Edited a file, ran 2 commands"). + return i === 0 ? phrase : phrase.charAt(0).toLowerCase() + phrase.slice(1); + }); + return phrases.join(', '); +} + export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string; detail?: string } { const parsedArgs = parseToolArgs(entry.argsBuffer); @@ -178,6 +326,70 @@ export function formatTimelineEntry(entry: ToolTimelineEntry): { title: string; }; } +/** + * A render block for the "View processing" panel — either a prose block + * (the agent's narration or hidden reasoning) or a group of consecutive + * tool rows under a Hermes-style summary. {@link buildProcessingBlocks} + * derives an ordered list of these from the interleaved transcript. + */ +export type ProcessingBlock = + | { kind: 'narration'; key: string; text: string } + | { kind: 'thinking'; key: string; text: string } + | { kind: 'toolGroup'; key: string; summary: string; entries: ToolTimelineEntry[] }; + +/** + * Turn the ordered transcript (narration / thinking / tool-call pointers) + * plus the tool timeline into the interleaved Hermes render model: prose + * flows inline, and runs of consecutive tool calls collapse into one group + * with a summary header. Tool pointers are resolved against `entries` by id; + * unknown ids are skipped. Pure + deterministic for unit testing. + * + * When `transcript` is empty (legacy snapshot / pre-streaming row), returns a + * single tool group over all `entries` so the caller still renders the rows. + */ +export function buildProcessingBlocks( + transcript: PersistedTranscriptItem[], + entries: ToolTimelineEntry[] +): ProcessingBlock[] { + const byId = new Map(entries.map(e => [e.id, e])); + + if (transcript.length === 0) { + return entries.length > 0 + ? [{ kind: 'toolGroup', key: 'all', summary: summarizeToolGroup(entries), entries }] + : []; + } + + const ordered = [...transcript].sort((a, b) => a.seq - b.seq); + const blocks: ProcessingBlock[] = []; + let group: ToolTimelineEntry[] = []; + + const flush = () => { + if (group.length === 0) return; + blocks.push({ + kind: 'toolGroup', + key: `tg-${group[0].id}`, + summary: summarizeToolGroup(group), + entries: group, + }); + group = []; + }; + + for (const item of ordered) { + if (item.kind === 'toolCall') { + const entry = byId.get(item.callId); + if (entry) group.push(entry); + continue; + } + // A prose item ends the current tool group. + flush(); + const text = stripToolCallEnvelopes(item.text).trim(); + if (!text) continue; + blocks.push({ kind: item.kind, key: `${item.kind}-${item.seq}`, text }); + } + flush(); + return blocks; +} + export function promptFromArgsBuffer(argsBuffer?: string): string | undefined { return parseToolArgs(argsBuffer)?.prompt?.trim() || undefined; } @@ -435,7 +647,8 @@ function normalizeIntegrationName(value: string): string { } } -function humanizeIdentifier(value: string): string { +function humanizeIdentifier(value: string | undefined | null): string { + if (!value) return ''; return value .replace(/^subagent:/, '') .replace(/^delegate_/, '') diff --git a/src/bin/harness_subagent_audit.rs b/src/bin/harness_subagent_audit.rs index aa114437b..f1af72c3c 100644 --- a/src/bin/harness_subagent_audit.rs +++ b/src/bin/harness_subagent_audit.rs @@ -395,6 +395,7 @@ async fn drain_progress( tool_name, arguments, iteration, + .. } => { let argument_keys = argument_keys(&arguments); eprintln!( @@ -557,6 +558,7 @@ async fn drain_progress( tool_name, arguments: _, iteration, + .. } => { eprintln!( "[harness_subagent_audit] progress turn={} subagent_tool_started agent_id={} task_id={} tool={} call_id={} iteration={}", diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 1ca34c6ee..5373cb99a 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -199,6 +199,17 @@ pub struct WebChannelEvent { /// Per-thread task board snapshot carried by `task_board_updated`. #[serde(skip_serializing_if = "Option::is_none")] pub task_board: Option, + /// Server-computed human label for a tool call (on `tool_call` / + /// `subagent_tool_call`), e.g. "Reading messages". The frontend renders + /// this verbatim for dynamic Composio/MCP/integration tools it can't + /// label itself, falling back to its own formatter when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_display_label: Option, + /// Server-computed contextual detail for a tool call (on `tool_call` / + /// `subagent_tool_call`), e.g. "steven@gmail.com" — the bracketed target + /// shown after [`Self::tool_display_label`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_display_detail: Option, } /// Per-event subagent progress detail attached to `WebChannelEvent`. diff --git a/src/openhuman/agent/harness/engine/progress.rs b/src/openhuman/agent/harness/engine/progress.rs index aa0a67326..2cb5982a9 100644 --- a/src/openhuman/agent/harness/engine/progress.rs +++ b/src/openhuman/agent/harness/engine/progress.rs @@ -26,12 +26,18 @@ pub(crate) trait ProgressReporter: Send + Sync { async fn iteration_started(&self, _iteration: u32, _max_iterations: u32) {} async fn cost_updated(&self, _model: &str, _iteration: u32, _cost: &TurnCost) {} async fn turn_completed(&self, _iterations: u32) {} + /// `display_label` / `display_detail` carry the server-computed human + /// label (e.g. "Reading messages") and contextual detail (e.g. + /// "steven@gmail.com"); `None` lets the client formatter decide. + #[allow(clippy::too_many_arguments)] async fn tool_started( &self, _call_id: &str, _tool_name: &str, _arguments: &serde_json::Value, _iteration: u32, + _display_label: Option<&str>, + _display_detail: Option<&str>, ) { } #[allow(clippy::too_many_arguments)] @@ -133,12 +139,15 @@ impl ProgressReporter for TurnProgress { } } + #[allow(clippy::too_many_arguments)] async fn tool_started( &self, call_id: &str, tool_name: &str, arguments: &serde_json::Value, iteration: u32, + display_label: Option<&str>, + display_detail: Option<&str>, ) { if let Some(ref sink) = self.sink { emit( @@ -148,6 +157,8 @@ impl ProgressReporter for TurnProgress { tool_name: tool_name.to_string(), arguments: arguments.clone(), iteration, + display_label: display_label.map(str::to_string), + display_detail: display_detail.map(str::to_string), }, ); } @@ -213,12 +224,15 @@ impl ProgressReporter for SubagentProgress { } } + #[allow(clippy::too_many_arguments)] async fn tool_started( &self, call_id: &str, tool_name: &str, arguments: &serde_json::Value, iteration: u32, + display_label: Option<&str>, + display_detail: Option<&str>, ) { if let Some(ref sink) = self.sink { emit( @@ -230,6 +244,8 @@ impl ProgressReporter for SubagentProgress { tool_name: tool_name.to_string(), arguments: arguments.clone(), iteration, + display_label: display_label.map(str::to_string), + display_detail: display_detail.map(str::to_string), }, ); } diff --git a/src/openhuman/agent/harness/engine/tools.rs b/src/openhuman/agent/harness/engine/tools.rs index a0b40eec1..0e016d9fd 100644 --- a/src/openhuman/agent/harness/engine/tools.rs +++ b/src/openhuman/agent/harness/engine/tools.rs @@ -93,11 +93,30 @@ pub(crate) async fn run_one_tool( ) -> ToolRunResult { let iteration_u32 = (iteration + 1) as u32; + // Compute the human label + contextual detail once, server-side, from the + // resolved tool (covers dynamic Composio/MCP/integration tools the client + // can't know). `None` for an unknown/filtered-out tool → the client + // formatter falls back to its own labels. + let (display_label, display_detail) = match tool_opt { + Some(tool) => ( + tool.display_label(&call.arguments), + tool.display_detail(&call.arguments), + ), + None => (None, None), + }; + // Emit a "tool started" event for every parsed call, even ones that will be // rejected below (approval denied, CliRpcOnly, unknown) — the client-side // row was created from the streamed args and needs a terminal event. progress - .tool_started(progress_call_id, &call.name, &call.arguments, iteration_u32) + .tool_started( + progress_call_id, + &call.name, + &call.arguments, + iteration_u32, + display_label.as_deref(), + display_detail.as_deref(), + ) .await; // Helper: emit a failed "tool completed" event for an early-exit path diff --git a/src/openhuman/agent/harness/session/agent_tool_exec.rs b/src/openhuman/agent/harness/session/agent_tool_exec.rs index b8f7bc8d7..80d8bc2fa 100644 --- a/src/openhuman/agent/harness/session/agent_tool_exec.rs +++ b/src/openhuman/agent/harness/session/agent_tool_exec.rs @@ -67,12 +67,24 @@ pub(super) async fn run_agent_tool_call( uuid::Uuid::new_v4().simple() ) }); + // Resolve the tool (when visible) to compute the server-side human label + // + contextual detail once, so dynamic Composio/MCP tools never surface as + // raw snake_case. `None` when the tool isn't found → client formatter. + let (display_label, display_detail) = match ctx.tools.iter().find(|t| t.name() == call.name) { + Some(tool) => ( + tool.display_label(&call.arguments), + tool.display_detail(&call.arguments), + ), + None => (None, None), + }; progress .tool_started( &call_id, &call.name, &call.arguments, (iteration + 1) as u32, + display_label.as_deref(), + display_detail.as_deref(), ) .await; log::info!("[agent] executing tool: {}", call.name); diff --git a/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs b/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs index 97c265e0f..4006bdb65 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs @@ -70,8 +70,18 @@ impl super::super::super::engine::ToolSource for SubagentToolSource<'_> { "[subagent_runner] tool not in allowlist for this sub-agent" ); let iteration_u32 = (iteration + 1) as u32; + // Not-in-allowlist reject path: no resolved tool to label, so defer + // to the client formatter. (Allowed sub-agent calls run through the + // shared `run_one_tool`, which computes the label there.) progress - .tool_started(progress_call_id, &call.name, &call.arguments, iteration_u32) + .tool_started( + progress_call_id, + &call.name, + &call.arguments, + iteration_u32, + None, + None, + ) .await; let mut available: Vec<&str> = self.allowed_names.iter().map(|s| s.as_str()).collect(); if let Some(resolver) = self.lazy_resolver.as_ref() { diff --git a/src/openhuman/agent/progress.rs b/src/openhuman/agent/progress.rs index 1e1aee668..99e47aceb 100644 --- a/src/openhuman/agent/progress.rs +++ b/src/openhuman/agent/progress.rs @@ -37,6 +37,13 @@ pub enum AgentProgress { arguments: serde_json::Value, /// 1-based iteration index. iteration: u32, + /// Server-computed human label for the chat processing timeline + /// (e.g. "Reading messages"), or `None` to defer to the client + /// formatter. Set from [`crate::openhuman::tools::traits::Tool::display_label`]. + display_label: Option, + /// Server-computed contextual detail shown after the label + /// (e.g. "steven@gmail.com"), from `Tool::display_detail`. + display_detail: Option, }, /// A tool execution completed (success or failure). @@ -155,6 +162,12 @@ pub enum AgentProgress { arguments: serde_json::Value, /// 1-based child iteration index this call belongs to. iteration: u32, + /// Server-computed human label for the timeline (e.g. "Reading + /// messages"), or `None` to defer to the client formatter. Mirrors + /// the top-level `ToolCallStarted.display_label`. + display_label: Option, + /// Server-computed contextual detail (e.g. "steven@gmail.com"). + display_detail: Option, }, /// A sub-agent's tool execution finished. diff --git a/src/openhuman/agent/progress_tracing/tests.rs b/src/openhuman/agent/progress_tracing/tests.rs index 8d9a2b599..3d30a6b9f 100644 --- a/src/openhuman/agent/progress_tracing/tests.rs +++ b/src/openhuman/agent/progress_tracing/tests.rs @@ -69,6 +69,8 @@ fn tool_started(call_id: &str, tool: &str, iter: u32) -> AgentProgress { tool_name: tool.to_string(), arguments: serde_json::json!({"secret": "do-not-export"}), iteration: iter, + display_label: None, + display_detail: None, } } @@ -267,6 +269,8 @@ fn subagent_lifecycle_nests_under_the_turn() { tool_name: "read_file".to_string(), arguments: serde_json::Value::Null, iteration: 1, + display_label: None, + display_detail: None, }, 30, ), diff --git a/src/openhuman/channels/proactive.rs b/src/openhuman/channels/proactive.rs index 788dea086..d94d4481c 100644 --- a/src/openhuman/channels/proactive.rs +++ b/src/openhuman/channels/proactive.rs @@ -213,6 +213,8 @@ impl EventHandler for ProactiveMessageSubscriber { citations: None, subagent: None, task_board: None, + tool_display_label: None, + tool_display_detail: None, }); // 2. If an active external channel is configured, deliver there too. diff --git a/src/openhuman/channels/providers/presentation.rs b/src/openhuman/channels/providers/presentation.rs index 3d924bf26..a5d475095 100644 --- a/src/openhuman/channels/providers/presentation.rs +++ b/src/openhuman/channels/providers/presentation.rs @@ -71,6 +71,8 @@ pub async fn deliver_response( tool_call_id: None, subagent: None, task_board: None, + tool_display_label: None, + tool_display_detail: None, citations: if citations.is_empty() { None } else { @@ -117,6 +119,8 @@ pub async fn deliver_response( tool_call_id: None, subagent: None, task_board: None, + tool_display_label: None, + tool_display_detail: None, citations: if i == 0 && !citations.is_empty() { Some(serde_json::json!(citations)) } else { @@ -153,6 +157,8 @@ pub async fn deliver_response( tool_call_id: None, subagent: None, task_board: None, + tool_display_label: None, + tool_display_detail: None, citations: if citations.is_empty() { None } else { diff --git a/src/openhuman/channels/providers/telegram/channel_tests.rs b/src/openhuman/channels/providers/telegram/channel_tests.rs index 09200abe5..6e3f305d6 100644 --- a/src/openhuman/channels/providers/telegram/channel_tests.rs +++ b/src/openhuman/channels/providers/telegram/channel_tests.rs @@ -1918,6 +1918,8 @@ async fn test_thinking_placeholder_logic() { tool_name: "shell".into(), arguments: serde_json::json!({}), iteration: 1, + display_label: None, + display_detail: None, }) .await .unwrap(); diff --git a/src/openhuman/channels/providers/web/progress_bridge.rs b/src/openhuman/channels/providers/web/progress_bridge.rs index 4228bcb9b..429f72374 100644 --- a/src/openhuman/channels/providers/web/progress_bridge.rs +++ b/src/openhuman/channels/providers/web/progress_bridge.rs @@ -307,6 +307,8 @@ pub(crate) fn spawn_progress_bridge( tool_name, arguments, iteration, + display_label, + display_detail, } => { parent_tool_count += 1; ledger_append_event( @@ -339,6 +341,8 @@ pub(crate) fn spawn_progress_bridge( args: Some(arguments), round: Some(iteration), tool_call_id: Some(call_id), + tool_display_label: display_label, + tool_display_detail: display_detail, ..Default::default() }); } @@ -725,6 +729,8 @@ pub(crate) fn spawn_progress_bridge( tool_name, arguments, iteration, + display_label, + display_detail, } => { let count = child_tool_counts.entry(task_id.clone()).or_insert(0); *count += 1; @@ -766,6 +772,8 @@ pub(crate) fn spawn_progress_bridge( }, round: Some(round), tool_call_id: Some(call_id), + tool_display_label: display_label, + tool_display_detail: display_detail, subagent: Some(SubagentProgressDetail { child_iteration: Some(iteration), agent_id: Some(agent_id), diff --git a/src/openhuman/channels/runtime/test_support.rs b/src/openhuman/channels/runtime/test_support.rs index 41733292c..bd1113e86 100644 --- a/src/openhuman/channels/runtime/test_support.rs +++ b/src/openhuman/channels/runtime/test_support.rs @@ -378,6 +378,8 @@ pub async fn run_dispatch_harness(options: DispatchHarnessOptions) -> DispatchHa tool_name: "harness_tool".to_string(), arguments: serde_json::json!({}), iteration: 1, + display_label: None, + display_detail: None, }) .await; } diff --git a/src/openhuman/composio/action_tool.rs b/src/openhuman/composio/action_tool.rs index 9c89dae6b..867a4ae8b 100644 --- a/src/openhuman/composio/action_tool.rs +++ b/src/openhuman/composio/action_tool.rs @@ -97,6 +97,18 @@ impl ComposioActionTool { } } +/// Render a Composio action slug (`GMAIL_SEND_EMAIL`) as a sentence-cased +/// human phrase ("Gmail send email") for the chat processing timeline. +fn humanize_composio_action(slug: &str) -> String { + let lower = slug.trim().to_ascii_lowercase().replace('_', " "); + let lower = lower.split_whitespace().collect::>().join(" "); + let mut chars = lower.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => slug.to_string(), + } +} + #[async_trait] impl Tool for ComposioActionTool { fn name(&self) -> &str { @@ -133,6 +145,14 @@ impl Tool for ComposioActionTool { ToolCategory::Workflow } + fn display_label(&self, _args: &Value) -> Option { + // Composio slugs are UPPER_SNAKE (e.g. `GMAIL_SEND_EMAIL`). Render a + // sentence-cased phrase ("Gmail send email") instead of the shouty + // raw slug or a Title-Cased "GMAIL SEND EMAIL". The contextual target + // (recipient/query) is filled by the trait-default `display_detail`. + Some(humanize_composio_action(&self.action_name)) + } + async fn execute(&self, args: Value) -> anyhow::Result { // Agent-level sandbox gate (issue #685, CodeRabbit follow-up on // PR #904) — mirrors the check in @@ -363,6 +383,38 @@ mod tests { .join(" ") } + #[test] + fn humanize_composio_action_sentence_cases_slug() { + assert_eq!( + humanize_composio_action("GMAIL_SEND_EMAIL"), + "Gmail send email" + ); + assert_eq!( + humanize_composio_action("GOOGLECALENDAR_EVENTS_LIST"), + "Googlecalendar events list" + ); + assert_eq!(humanize_composio_action(""), ""); + } + + #[test] + fn display_label_is_human_and_detail_pulls_recipient() { + let tool = ComposioActionTool::new( + fake_config(), + "GMAIL_SEND_EMAIL".to_string(), + "Send an email via Gmail".to_string(), + None, + ); + assert_eq!( + tool.display_label(&serde_json::Value::Null).as_deref(), + Some("Gmail send email") + ); + assert_eq!( + tool.display_detail(&serde_json::json!({ "recipient_email": "steven@gmail.com" })) + .as_deref(), + Some("steven@gmail.com") + ); + } + #[tokio::test] async fn sandbox_read_only_blocks_per_action_write_call() { let t = ComposioActionTool::new( diff --git a/src/openhuman/threads/README.md b/src/openhuman/threads/README.md index a4f601641..f087c1dd1 100644 --- a/src/openhuman/threads/README.md +++ b/src/openhuman/threads/README.md @@ -26,7 +26,7 @@ Conversation thread and message management. Owns the RPC surface and controller | `src/openhuman/threads/turn_state/mod.rs` | Submodule export hub for in-flight turn snapshots. | | `src/openhuman/threads/turn_state/types.rs` | Wire/storage types: `TurnState`, `TurnLifecycle`, `TurnPhase`, `ToolTimelineEntry`/`Status`, `SubagentActivity`/`ToolCall`, and the get/list/clear request/response payloads. camelCase to mirror `chatRuntimeSlice.ts`. | | `src/openhuman/threads/turn_state/store.rs` | `TurnStateStore` — atomic per-thread JSON snapshot store (tempfile + persist + dir fsync), process-wide mutex; `put`/`get`/`delete`/`list`/`clear_all`/`mark_all_interrupted` plus free-fn wrappers. | -| `src/openhuman/threads/turn_state/mirror.rs` | `TurnStateMirror` — translates `agent::progress::AgentProgress` events into `TurnState` mutations, flushing at iteration/tool boundaries; deletes snapshot on completion, flags `Interrupted` otherwise. | +| `src/openhuman/threads/turn_state/mirror.rs` | `TurnStateMirror` — translates `agent::progress::AgentProgress` events into `TurnState` mutations (incl. the ordered narration/thinking/tool `transcript`), flushing at iteration/tool boundaries; on completion marks the snapshot `Completed` and **keeps** it (so the "View processing" panel can replay the finished turn), flags `Interrupted` if the bridge exits without `TurnCompleted`. | | `*_tests.rs` | Sibling test suites: `ops_tests.rs`, `schemas_tests.rs`, `turn_state/store_tests.rs`, `turn_state/mirror_tests.rs` (plus inline tests in `error.rs`, `title.rs`, `welcome_migration.rs`). | ## Public surface @@ -63,7 +63,7 @@ Wired into the registry from `src/core/all.rs` (controllers + schemas extended w ## Persistence - **Threads + messages**: delegated to `memory::conversations` (JSONL store under the workspace), not owned here. -- **Turn snapshots** (`turn_state/store.rs`): one JSON file per thread at `/memory/conversations/turn_states/.json`. Whole-file atomic overwrite (tempfile → fsync → persist → best-effort dir fsync), serialized through a process-wide `parking_lot::Mutex`. File presence ⇒ turn was non-terminal at last write; a surviving snapshot on cold boot is marked `Interrupted`. +- **Turn snapshots** (`turn_state/store.rs`): one JSON file per thread at `/memory/conversations/turn_states/.json`. Whole-file atomic overwrite (tempfile → fsync → persist → best-effort dir fsync), serialized through a process-wide `parking_lot::Mutex`. A non-terminal file surviving cold boot is marked `Interrupted`; a `Completed` snapshot is intentionally retained (for processing replay) and skipped by startup interrupted-marking. The next turn on the thread overwrites it. - **Task board**: persisted by `agent::task_board::TaskBoardStore` under the workspace (this module only proxies). - **Migration marker**: `state/migrations/welcome_to_orchestrator_v1.done` guards the welcome migration. diff --git a/src/openhuman/threads/turn_state/mirror.rs b/src/openhuman/threads/turn_state/mirror.rs index 78d584cdd..7431c6e50 100644 --- a/src/openhuman/threads/turn_state/mirror.rs +++ b/src/openhuman/threads/turn_state/mirror.rs @@ -18,8 +18,8 @@ use crate::openhuman::agent::progress::AgentProgress; use super::store::TurnStateStore; use super::types::{ - SubagentActivity, SubagentToolCall, ToolTimelineEntry, ToolTimelineStatus, TurnLifecycle, - TurnPhase, TurnState, + SubagentActivity, SubagentToolCall, SubagentTranscriptItem, ToolTimelineEntry, + ToolTimelineStatus, TranscriptItem, TurnLifecycle, TurnPhase, TurnState, }; const MIRROR_LOG_PREFIX: &str = "[threads:turn_state:mirror]"; @@ -32,6 +32,10 @@ pub struct TurnStateMirror { /// Set to `true` once we observe `TurnCompleted` so `finish` knows /// to delete the snapshot rather than mark it interrupted. turn_completed: bool, + /// Monotonic ordering key for [`TranscriptItem`]s. Round alone can't + /// order narration vs thinking vs tool calls *within* one iteration, so + /// every transcript push stamps and increments this. + next_seq: u32, } impl TurnStateMirror { @@ -49,6 +53,7 @@ impl TurnStateMirror { store, state, turn_completed: false, + next_seq: 0, }; mirror.flush(); mirror @@ -80,11 +85,17 @@ impl TurnStateMirror { call_id, tool_name, iteration, + display_label, + display_detail, .. } => { self.state.lifecycle = TurnLifecycle::Streaming; self.state.phase = Some(TurnPhase::ToolUse); self.state.active_tool = Some(tool_name.clone()); + // Record the tool row in the ordered transcript so the + // processing panel can interleave it between narration / + // thinking at the position it actually occurred. + self.push_transcript_tool(*iteration, call_id); // `ToolCallArgsDelta` may have already created a // synthetic placeholder for this `call_id` before the // start event arrived. Reuse it (filling in `name` / @@ -100,6 +111,14 @@ impl TurnStateMirror { existing.name = tool_name.clone(); existing.round = *iteration; existing.status = ToolTimelineStatus::Running; + // Only overwrite with a present server value so an + // args-delta placeholder's fields aren't clobbered to None. + if display_label.is_some() { + existing.display_name = display_label.clone(); + } + if display_detail.is_some() { + existing.detail = display_detail.clone(); + } } else { self.state.tool_timeline.push(ToolTimelineEntry { id: call_id.clone(), @@ -107,8 +126,8 @@ impl TurnStateMirror { round: *iteration, status: ToolTimelineStatus::Running, args_buffer: None, - display_name: None, - detail: None, + display_name: display_label.clone(), + detail: display_detail.clone(), source_tool_name: None, subagent: None, }); @@ -172,6 +191,7 @@ impl TurnStateMirror { output_chars: None, worker_thread_id: worker_thread_id.clone(), tool_calls: Vec::new(), + transcript: Vec::new(), }), }); self.flush(); @@ -234,6 +254,8 @@ impl TurnStateMirror { call_id, tool_name, iteration, + display_label, + display_detail, .. } => { if let Some(entry) = self.find_subagent_entry_mut(task_id) { @@ -245,10 +267,28 @@ impl TurnStateMirror { iteration: Some(*iteration), elapsed_ms: None, output_chars: None, + display_name: display_label.clone(), + detail: display_detail.clone(), + }); + // Mirror the call into the ordered transcript so the + // rehydrated thoughts interleave it at the right spot. + activity.transcript.push(SubagentTranscriptItem::Tool { + iteration: Some(*iteration), + call_id: call_id.clone(), + tool_name: tool_name.clone(), + status: ToolTimelineStatus::Running, + elapsed_ms: None, + output_chars: None, + display_name: display_label.clone(), + detail: display_detail.clone(), }); } } - false + // Flush at sub-agent tool boundaries so prose streamed since the + // last boundary reaches disk (the parent is blocked on the + // spawn tool, so its own flushes don't fire mid sub-agent run). + self.flush(); + true } AgentProgress::SubagentToolCallCompleted { task_id, @@ -260,33 +300,59 @@ impl TurnStateMirror { } => { if let Some(entry) = self.find_subagent_entry_mut(task_id) { if let Some(activity) = entry.subagent.as_mut() { + let status = if *success { + ToolTimelineStatus::Success + } else { + ToolTimelineStatus::Error + }; if let Some(call) = activity .tool_calls .iter_mut() .rev() .find(|c| c.call_id == *call_id) { - call.status = if *success { - ToolTimelineStatus::Success - } else { - ToolTimelineStatus::Error - }; + call.status = status; call.elapsed_ms = Some(*elapsed_ms); call.output_chars = Some(*output_chars); } + // Keep the transcript's Tool item in lockstep so the + // rehydrated row shows the terminal status + timing. + if let Some(SubagentTranscriptItem::Tool { + status: tx_status, + elapsed_ms: tx_elapsed, + output_chars: tx_output, + .. + }) = activity + .transcript + .iter_mut() + .rev() + .find(|item| matches!(item, SubagentTranscriptItem::Tool { call_id: c, .. } if c == call_id)) + { + *tx_status = status; + *tx_elapsed = Some(*elapsed_ms); + *tx_output = Some(*output_chars); + } } } + self.flush(); + true + } + AgentProgress::SubagentTextDelta { + task_id, + delta, + iteration, + .. + } => { + self.push_subagent_prose(task_id, *iteration, delta, false); false } - AgentProgress::SubagentTextDelta { .. } - | AgentProgress::SubagentThinkingDelta { .. } => { - // Sub-agent streaming text/thinking is display-only: it is - // rendered live in the parent thread's subagent transcript - // but intentionally **not** persisted to the turn-state - // snapshot. The child's final assistant text lands in the - // thread on completion, so replaying partial deltas after a - // reconnect would add weight without value. Acknowledge - // without mutating the snapshot or flushing. + AgentProgress::SubagentThinkingDelta { + task_id, + delta, + iteration, + .. + } => { + self.push_subagent_prose(task_id, *iteration, delta, true); false } AgentProgress::TaskBoardUpdated { board } => { @@ -294,12 +360,14 @@ impl TurnStateMirror { self.flush(); true } - AgentProgress::TextDelta { delta, .. } => { + AgentProgress::TextDelta { delta, iteration } => { self.state.streaming_text.push_str(delta); + self.push_transcript_narration(*iteration, delta); false } - AgentProgress::ThinkingDelta { delta, .. } => { + AgentProgress::ThinkingDelta { delta, iteration } => { self.state.thinking.push_str(delta); + self.push_transcript_thinking(*iteration, delta); false } AgentProgress::ToolCallArgsDelta { @@ -337,12 +405,16 @@ impl TurnStateMirror { } AgentProgress::TurnCompleted { .. } => { self.turn_completed = true; - if let Err(err) = self.store.delete(&self.state.thread_id) { - log::warn!( - "{MIRROR_LOG_PREFIX} failed to delete snapshot for thread={}: {err}", - self.state.thread_id - ); - } + // Keep the snapshot (don't delete) so a reloaded / cold-booted + // client can replay this turn's processing transcript via + // `getTurnState`. Mark it `Completed` and quiesce the live + // fields so the UI renders it settled (no spinner / retry), + // and startup interrupted-marking leaves it alone. + self.state.lifecycle = TurnLifecycle::Completed; + self.state.phase = None; + self.state.active_tool = None; + self.state.active_subagent = None; + self.flush(); true } AgentProgress::TurnCostUpdated { .. } => { @@ -379,6 +451,73 @@ impl TurnStateMirror { } } + /// Append a visible-narration delta to the transcript, coalescing into + /// the trailing [`TranscriptItem::Narration`] when it's the most recent + /// item and from the same round — so a streamed paragraph stays one item + /// instead of one-per-token. A new round (or any intervening thinking / + /// tool item) starts a fresh narration block. + fn push_transcript_narration(&mut self, round: u32, delta: &str) { + if let Some(TranscriptItem::Narration { round: r, text, .. }) = + self.state.transcript.last_mut() + { + if *r == round { + text.push_str(delta); + return; + } + } + let seq = self.next_seq(); + self.state.transcript.push(TranscriptItem::Narration { + round, + seq, + text: delta.to_string(), + }); + } + + /// Append a hidden-reasoning delta to the transcript, with the same + /// coalescing rule as [`Self::push_transcript_narration`]. + fn push_transcript_thinking(&mut self, round: u32, delta: &str) { + if let Some(TranscriptItem::Thinking { round: r, text, .. }) = + self.state.transcript.last_mut() + { + if *r == round { + text.push_str(delta); + return; + } + } + let seq = self.next_seq(); + self.state.transcript.push(TranscriptItem::Thinking { + round, + seq, + text: delta.to_string(), + }); + } + + /// Record a tool call in the transcript at the point it occurred, as a + /// pointer into [`TurnState::tool_timeline`] (the row's status/label live + /// there). Skips a duplicate if the same `call_id` was already recorded + /// (e.g. a start event after an args-delta placeholder). + fn push_transcript_tool(&mut self, round: u32, call_id: &str) { + let already = self.state.transcript.iter().any( + |item| matches!(item, TranscriptItem::ToolCall { call_id: c, .. } if c == call_id), + ); + if already { + return; + } + let seq = self.next_seq(); + self.state.transcript.push(TranscriptItem::ToolCall { + round, + seq, + call_id: call_id.to_string(), + }); + } + + /// Return the next monotonic transcript ordering key and advance it. + fn next_seq(&mut self) -> u32 { + let seq = self.next_seq; + self.next_seq = self.next_seq.saturating_add(1); + seq + } + fn find_subagent_entry_mut(&mut self, task_id: &str) -> Option<&mut ToolTimelineEntry> { let needle = format!("subagent:{task_id}"); self.state @@ -388,6 +527,55 @@ impl TurnStateMirror { .find(|entry| entry.id == needle) } + /// Append a sub-agent prose delta (narration when `is_thinking == false`, + /// reasoning otherwise) to that sub-agent's transcript, coalescing into the + /// trailing same-kind, same-iteration item so a streamed paragraph stays + /// one entry (mirrors the frontend `appendSubagentStreamDelta`). Mutate- + /// only (no flush) — high-frequency like the parent's `TextDelta`; the + /// accumulated prose is persisted at the next sub-agent tool boundary. + fn push_subagent_prose( + &mut self, + task_id: &str, + iteration: u32, + delta: &str, + is_thinking: bool, + ) { + let Some(entry) = self.find_subagent_entry_mut(task_id) else { + return; + }; + let Some(activity) = entry.subagent.as_mut() else { + return; + }; + match activity.transcript.last_mut() { + Some(SubagentTranscriptItem::Thinking { + iteration: it, + text, + }) if is_thinking && *it == Some(iteration) => { + text.push_str(delta); + return; + } + Some(SubagentTranscriptItem::Text { + iteration: it, + text, + }) if !is_thinking && *it == Some(iteration) => { + text.push_str(delta); + return; + } + _ => {} + } + activity.transcript.push(if is_thinking { + SubagentTranscriptItem::Thinking { + iteration: Some(iteration), + text: delta.to_string(), + } + } else { + SubagentTranscriptItem::Text { + iteration: Some(iteration), + text: delta.to_string(), + } + }); + } + #[cfg(test)] pub(crate) fn snapshot(&self) -> &TurnState { &self.state diff --git a/src/openhuman/threads/turn_state/mirror_tests.rs b/src/openhuman/threads/turn_state/mirror_tests.rs index 9aa9b7843..55bf22cbd 100644 --- a/src/openhuman/threads/turn_state/mirror_tests.rs +++ b/src/openhuman/threads/turn_state/mirror_tests.rs @@ -27,6 +27,81 @@ fn iteration_start_promotes_lifecycle_and_records_round() { assert_eq!(s.phase, Some(TurnPhase::Thinking)); } +#[test] +fn transcript_interleaves_narration_thinking_and_tools_in_order() { + let (_d, mut m) = fresh("t"); + // A turn that thinks, narrates, calls a tool, then narrates again. The + // transcript must preserve that exact streaming order via `seq`, coalesce + // consecutive same-kind deltas in the same round, and carry the server + // label through onto the tool row. + m.observe(&AgentProgress::ThinkingDelta { + delta: "Let me ".into(), + iteration: 1, + }); + m.observe(&AgentProgress::ThinkingDelta { + delta: "check.".into(), + iteration: 1, + }); + m.observe(&AgentProgress::TextDelta { + delta: "Reading your inbox".into(), + iteration: 1, + }); + m.observe(&AgentProgress::ToolCallStarted { + call_id: "tc-1".into(), + tool_name: "gmail_read".into(), + arguments: serde_json::json!({ "to": "x@y.com" }), + iteration: 1, + display_label: Some("Reading messages".into()), + display_detail: Some("x@y.com".into()), + }); + m.observe(&AgentProgress::TextDelta { + delta: "Done.".into(), + iteration: 1, + }); + + let s = m.snapshot(); + assert_eq!( + s.transcript.len(), + 4, + "thinking, narration, tool, narration" + ); + match &s.transcript[0] { + TranscriptItem::Thinking { text, seq, .. } => { + assert_eq!(text, "Let me check.", "coalesced same-round thinking"); + assert_eq!(*seq, 0); + } + other => panic!("expected thinking first, got {other:?}"), + } + match &s.transcript[1] { + TranscriptItem::Narration { text, .. } => assert_eq!(text, "Reading your inbox"), + other => panic!("expected narration, got {other:?}"), + } + match &s.transcript[2] { + TranscriptItem::ToolCall { call_id, .. } => assert_eq!(call_id, "tc-1"), + other => panic!("expected tool call, got {other:?}"), + } + match &s.transcript[3] { + TranscriptItem::Narration { text, .. } => assert_eq!(text, "Done."), + other => panic!("expected trailing narration, got {other:?}"), + } + // seq is strictly increasing in push order. + let seqs: Vec = s + .transcript + .iter() + .map(|i| match i { + TranscriptItem::Narration { seq, .. } + | TranscriptItem::Thinking { seq, .. } + | TranscriptItem::ToolCall { seq, .. } => *seq, + }) + .collect(); + assert_eq!(seqs, vec![0, 1, 2, 3]); + + // The server label/detail landed on the timeline row the transcript points to. + let row = s.tool_timeline.iter().find(|e| e.id == "tc-1").unwrap(); + assert_eq!(row.display_name.as_deref(), Some("Reading messages")); + assert_eq!(row.detail.as_deref(), Some("x@y.com")); +} + #[test] fn tool_call_start_and_complete_track_timeline() { let (_d, mut m) = fresh("t"); @@ -39,6 +114,8 @@ fn tool_call_start_and_complete_track_timeline() { tool_name: "shell".into(), arguments: serde_json::json!({}), iteration: 1, + display_label: None, + display_detail: None, }); let s = m.snapshot(); assert_eq!(s.tool_timeline.len(), 1); @@ -105,6 +182,8 @@ fn tool_call_started_reuses_args_delta_placeholder_for_same_call_id() { tool_name: "shell".into(), arguments: serde_json::json!({}), iteration: 1, + display_label: None, + display_detail: None, }); let timeline = &m.snapshot().tool_timeline; assert_eq!( @@ -184,16 +263,24 @@ fn task_board_update_is_stored_and_flushed() { } #[test] -fn turn_completed_deletes_snapshot_and_finish_is_noop() { +fn turn_completed_keeps_snapshot_as_completed_and_finish_is_noop() { let dir = tempdir().expect("tempdir"); let store = TurnStateStore::new(dir.path().to_path_buf()); let mut mirror = TurnStateMirror::new(store.clone(), "t", "req-1"); mirror.observe(&AgentProgress::TurnCompleted { iterations: 3 }); - assert!(store.get("t").expect("get").is_none()); + // The snapshot is kept (not deleted) so a reloaded client can replay the + // finished turn's processing transcript, marked terminal `Completed` with + // the live fields quiesced. + let loaded = store.get("t").expect("get").expect("snapshot kept"); + assert_eq!(loaded.lifecycle, TurnLifecycle::Completed); + assert!(loaded.active_tool.is_none()); + assert!(loaded.active_subagent.is_none()); + assert!(loaded.phase.is_none()); - // finish() must not resurrect the snapshot. + // finish() must not flip a completed snapshot back to interrupted. mirror.finish(); - assert!(store.get("t").expect("get").is_none()); + let after = store.get("t").expect("get").expect("snapshot still kept"); + assert_eq!(after.lifecycle, TurnLifecycle::Completed); } #[test] @@ -240,6 +327,8 @@ fn subagent_lifecycle_records_and_clears_active() { tool_name: "search".into(), arguments: serde_json::Value::Null, iteration: 1, + display_label: None, + display_detail: None, }); let activity = m.snapshot().tool_timeline[0] .subagent @@ -261,3 +350,104 @@ fn subagent_lifecycle_records_and_clears_active() { assert_eq!(s.tool_timeline[0].status, ToolTimelineStatus::Success); assert!(s.active_subagent.is_none()); } + +#[test] +fn subagent_transcript_persists_interleaved_prose_and_tools() { + let (_d, mut m) = fresh("t"); + m.observe(&AgentProgress::IterationStarted { + iteration: 1, + max_iterations: 25, + }); + m.observe(&AgentProgress::SubagentSpawned { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + mode: "typed".into(), + dedicated_thread: false, + prompt_chars: 10, + worker_thread_id: None, + display_name: Some("Researcher".into()), + }); + // Reasoning (two same-iteration deltas, must coalesce), then a tool, then + // visible narration — the order must be preserved in the transcript. + m.observe(&AgentProgress::SubagentThinkingDelta { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + delta: "let me ".into(), + iteration: 1, + }); + m.observe(&AgentProgress::SubagentThinkingDelta { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + delta: "search.".into(), + iteration: 1, + }); + // A sub-agent tool boundary must flush the accumulated prose to disk. + let flushed = m.observe(&AgentProgress::SubagentToolCallStarted { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + call_id: "c1".into(), + tool_name: "search".into(), + arguments: serde_json::Value::Null, + iteration: 1, + display_label: Some("Searching".into()), + display_detail: None, + }); + assert!(flushed, "sub-agent tool boundary must flush"); + m.observe(&AgentProgress::SubagentTextDelta { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + delta: "Found it.".into(), + iteration: 1, + }); + m.observe(&AgentProgress::SubagentToolCallCompleted { + agent_id: "researcher".into(), + task_id: "sub-1".into(), + call_id: "c1".into(), + tool_name: "search".into(), + success: true, + output_chars: 5, + output: String::new(), + elapsed_ms: 12, + iteration: 1, + }); + + let activity = m.snapshot().tool_timeline[0] + .subagent + .as_ref() + .expect("activity") + .clone(); + assert_eq!(activity.transcript.len(), 3, "thinking, tool, narration"); + match &activity.transcript[0] { + SubagentTranscriptItem::Thinking { text, .. } => { + assert_eq!(text, "let me search.", "coalesced same-iteration thinking"); + } + other => panic!("expected thinking, got {other:?}"), + } + match &activity.transcript[1] { + SubagentTranscriptItem::Tool { + call_id, status, .. + } => { + assert_eq!(call_id, "c1"); + // Completion flips the transcript tool item, not just `tool_calls`. + assert_eq!(*status, ToolTimelineStatus::Success); + } + other => panic!("expected tool, got {other:?}"), + } + match &activity.transcript[2] { + SubagentTranscriptItem::Text { text, .. } => assert_eq!(text, "Found it."), + other => panic!("expected narration, got {other:?}"), + } + + // The wire form MUST be camelCase — the FE reads `toolName`/`callId`, and + // snake_case leaking through caused a `replace`-on-undefined crash. + let json = serde_json::to_string(m.snapshot()).expect("serialize"); + assert!( + json.contains("\"toolName\""), + "tool item must serialize camelCase" + ); + assert!(json.contains("\"callId\"")); + assert!( + !json.contains("\"tool_name\""), + "no snake_case fields on the wire" + ); +} diff --git a/src/openhuman/threads/turn_state/store.rs b/src/openhuman/threads/turn_state/store.rs index c406f0744..865cd4c1a 100644 --- a/src/openhuman/threads/turn_state/store.rs +++ b/src/openhuman/threads/turn_state/store.rs @@ -171,7 +171,13 @@ impl TurnStateStore { let snapshots = self.list()?; let mut count = 0usize; for mut snapshot in snapshots { - if matches!(snapshot.lifecycle, TurnLifecycle::Interrupted) { + // Already-terminal snapshots are left as-is: `Interrupted` is + // idempotent, and `Completed` snapshots are intentionally kept so + // the processing panel can replay a finished turn after a reboot. + if matches!( + snapshot.lifecycle, + TurnLifecycle::Interrupted | TurnLifecycle::Completed + ) { continue; } snapshot.lifecycle = TurnLifecycle::Interrupted; diff --git a/src/openhuman/threads/turn_state/store_tests.rs b/src/openhuman/threads/turn_state/store_tests.rs index eb415feef..191edfe9b 100644 --- a/src/openhuman/threads/turn_state/store_tests.rs +++ b/src/openhuman/threads/turn_state/store_tests.rs @@ -104,6 +104,24 @@ fn mark_all_interrupted_promotes_lifecycle_and_clears_active_fields() { assert_eq!(count, 0); } +#[test] +fn mark_all_interrupted_leaves_completed_snapshots_untouched() { + let dir = tempdir().expect("tempdir"); + let store = TurnStateStore::new(dir.path().to_path_buf()); + let mut state = sample_state("t"); + // A finished turn is kept as `Completed` so its processing can be replayed; + // startup interrupted-marking must not flip it to `Interrupted`. + state.lifecycle = TurnLifecycle::Completed; + store.put(&state).expect("put"); + + let count = store + .mark_all_interrupted("2026-05-04T10:01:00Z") + .expect("mark"); + assert_eq!(count, 0); + let loaded = store.get("t").expect("get").expect("present"); + assert_eq!(loaded.lifecycle, TurnLifecycle::Completed); +} + #[test] fn clear_all_removes_corrupted_snapshots_too() { use std::io::Write as _; diff --git a/src/openhuman/threads/turn_state/types.rs b/src/openhuman/threads/turn_state/types.rs index c95f7840a..91741c0d3 100644 --- a/src/openhuman/threads/turn_state/types.rs +++ b/src/openhuman/threads/turn_state/types.rs @@ -23,6 +23,11 @@ pub enum TurnLifecycle { Started, Streaming, Interrupted, + /// The turn finished normally. The snapshot is **kept** (not deleted) so + /// the chat "View processing" panel can replay the full transcript + + /// tool timeline after a reload / cold boot — startup interrupted-marking + /// skips this state, and the next turn on the thread overwrites it. + Completed, } /// High-level phase the agent is in within an iteration. @@ -98,6 +103,12 @@ pub struct SubagentActivity { pub worker_thread_id: Option, #[serde(default)] pub tool_calls: Vec, + /// Ordered reasoning/narration/tool transcript for this sub-agent — what + /// the inline "Agentic task insights" thoughts render from. Persisted (not + /// live-only) so the thoughts survive a settled turn / reload. + /// `#[serde(default)]` so snapshots written before this field load empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transcript: Vec, } /// One child tool call performed by a running sub-agent. @@ -113,14 +124,104 @@ pub struct SubagentToolCall { pub elapsed_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_chars: Option, + /// Server-computed human label for this child call (e.g. "Reading file"), + /// or `None` to defer to the client formatter. Mirrors the parent + /// [`ToolTimelineEntry::display_name`] so the same reusable row renderer + /// reads the same field for both main-agent and sub-agent calls. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Server-computed contextual detail (e.g. the path / recipient). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, } -/// Persisted snapshot of an in-flight agent turn for one thread. +/// One ordered item in a sub-agent's processing transcript — its streamed +/// reasoning (`thinking`), visible narration (`text`), or a tool call, in the +/// exact order they occurred. Mirrors the frontend `SubagentTranscriptItem` +/// union 1:1 (order = push order; no `seq` is needed because each sub-agent's +/// transcript is built as a single ordered list). Persisting these lets the +/// inline "Agentic task insights" thoughts survive a settled turn / reload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +// `rename_all` renames the variant tags; `rename_all_fields` renames the +// fields *inside* the struct variants (call_id → callId, …) — without the +// latter the FE would read `undefined` for camelCase fields. +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "kind" +)] +pub enum SubagentTranscriptItem { + /// The sub-agent's hidden reasoning. + Thinking { + #[serde(default, skip_serializing_if = "Option::is_none")] + iteration: Option, + text: String, + }, + /// The sub-agent's visible narration. + Text { + #[serde(default, skip_serializing_if = "Option::is_none")] + iteration: Option, + text: String, + }, + /// A child tool call at the point it occurred (self-contained so a + /// rehydrated row renders without cross-referencing `tool_calls`). + Tool { + #[serde(default, skip_serializing_if = "Option::is_none")] + iteration: Option, + call_id: String, + tool_name: String, + status: ToolTimelineStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + elapsed_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + output_chars: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, +} + +/// One ordered item in the parent turn's processing transcript. +/// +/// Unlike [`ToolTimelineEntry`] (a flat list of tool rows), the transcript +/// preserves the **interleaving** of the agent's visible narration, its +/// hidden reasoning, and its tool calls in the exact order they streamed — +/// so the chat "View processing" panel can render prose between tool groups +/// the way Claude / Hermes does. `seq` is a monotonic per-turn ordering key +/// (round alone can't order narration vs thinking within one round). Tool +/// items hold only a `call_id` pointer into [`TurnState::tool_timeline`] so +/// the row's status/label live in exactly one place. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +// `rename_all_fields` is required so the `ToolCall.call_id` field serializes as +// `callId` (the FE reads camelCase) — `rename_all` alone only renames variants. +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "kind" +)] +pub enum TranscriptItem { + /// The agent's visible assistant text between tool calls. + Narration { round: u32, seq: u32, text: String }, + /// The agent's hidden reasoning (when the model emits it). + Thinking { round: u32, seq: u32, text: String }, + /// A pointer to a tool row in [`TurnState::tool_timeline`]. + ToolCall { + round: u32, + seq: u32, + call_id: String, + }, +} + +/// Persisted snapshot of an in-flight (or just-finished) agent turn for one +/// thread. /// /// Written to disk by the web-channel progress consumer at iteration -/// boundaries, tool start/complete, and on terminal events. Deleted -/// on successful turn completion. A surviving snapshot at startup -/// indicates an interrupted turn. +/// boundaries, tool start/complete, and on terminal events. On normal +/// completion it is marked [`TurnLifecycle::Completed`] and **kept** (so the +/// "View processing" panel can replay the finished turn's transcript); a +/// non-terminal snapshot surviving startup is marked +/// [`TurnLifecycle::Interrupted`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TurnState { @@ -141,6 +242,11 @@ pub struct TurnState { pub thinking: String, #[serde(default)] pub tool_timeline: Vec, + /// Ordered, interleaved record of the agent's narration, reasoning, and + /// tool calls for the "View processing" panel. `#[serde(default)]` so + /// snapshots written before this field still load (as empty). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub transcript: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub task_board: Option, pub started_at: String, @@ -206,6 +312,7 @@ impl TurnState { streaming_text: String::new(), thinking: String::new(), tool_timeline: Vec::new(), + transcript: Vec::new(), task_board: None, started_at: now.clone(), updated_at: now, diff --git a/src/openhuman/tools/generated.rs b/src/openhuman/tools/generated.rs index e8c824cc2..f6b531b26 100644 --- a/src/openhuman/tools/generated.rs +++ b/src/openhuman/tools/generated.rs @@ -11,7 +11,9 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope}; +use crate::openhuman::tools::traits::{ + humanize_tool_name, PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope, +}; #[derive(Debug, Clone)] pub struct GeneratedToolDefinition { @@ -19,6 +21,12 @@ pub struct GeneratedToolDefinition { pub name: String, /// Human-readable tool description. pub description: String, + /// Curated human verb-phrase for the chat processing timeline (e.g. + /// "Reading messages", "Sending email"). When set, this is shown + /// verbatim instead of a Title-Cased derivation of [`Self::name`], so a + /// dynamic Composio/MCP action never surfaces as raw `snake_case`. The + /// catalog/registry that builds these definitions populates it. + pub display_label: Option, /// JSON schema for tool arguments. pub parameters_schema: Value, /// Permission required to execute this tool. @@ -52,6 +60,7 @@ impl GeneratedToolDefinition { Self { name: name.into(), description: description.into(), + display_label: None, parameters_schema, permission_level: PermissionLevel::ReadOnly, category: ToolCategory::Workflow, @@ -218,6 +227,18 @@ impl Tool for GeneratedTool { .map(GeneratedToolRisk::is_external_effect) .unwrap_or(false) } + + fn display_label(&self, _args: &Value) -> Option { + Some( + self.definition + .display_label + .clone() + .unwrap_or_else(|| humanize_tool_name(&self.definition.name)), + ) + } + + // `display_detail` uses the trait default (`context_detail_from_args`), + // which already pulls the recipient/query/path from the dynamic args. } /// Convert generated definitions into boxed tool trait objects. @@ -288,6 +309,7 @@ pub fn admit_generated_tool_definitions( fn normalize_definition(definition: &mut GeneratedToolDefinition) { definition.name = definition.name.trim().to_string(); definition.description = definition.description.trim().to_string(); + definition.display_label = trim_option(definition.display_label.take()); definition.adapter_id = definition.adapter_id.trim().to_string(); definition.provider_id = normalize_optional_provider_id(definition.provider_id.take()); definition.capability_id = trim_option(definition.capability_id.take()); diff --git a/src/openhuman/tools/generated_tests.rs b/src/openhuman/tools/generated_tests.rs index dd65ab4f4..94c518821 100644 --- a/src/openhuman/tools/generated_tests.rs +++ b/src/openhuman/tools/generated_tests.rs @@ -266,3 +266,41 @@ async fn generated_tool_marks_execute_risk_as_external_effect() { assert!(tool.external_effect()); } + +#[tokio::test] +async fn generated_tool_uses_curated_display_label_when_set() { + let mut definition = sample_definition(); + definition.display_label = Some("Sending update".into()); + let tool = GeneratedTool::new(definition, Arc::new(EchoAdapter)).unwrap(); + + assert_eq!( + tool.display_label(&json!({})).as_deref(), + Some("Sending update") + ); +} + +#[tokio::test] +async fn generated_tool_falls_back_to_humanized_name_for_label() { + // No curated label → derive a Title-Cased phrase from the action name, + // never the raw snake_case. + let tool = GeneratedTool::new(sample_definition(), Arc::new(EchoAdapter)).unwrap(); + + assert_eq!( + tool.display_label(&json!({})).as_deref(), + Some("Send Update") + ); +} + +#[tokio::test] +async fn generated_tool_pulls_contextual_detail_from_args() { + let tool = GeneratedTool::new(sample_definition(), Arc::new(EchoAdapter)).unwrap(); + + // A recipient-style arg becomes the bracketed context, Claude-style. + assert_eq!( + tool.display_detail(&json!({ "to": "steven@gmail.com" })) + .as_deref(), + Some("steven@gmail.com") + ); + // Nothing recognizable → no detail (label-only row). + assert!(tool.display_detail(&json!({ "message": "hi" })).is_none()); +} diff --git a/src/openhuman/tools/traits.rs b/src/openhuman/tools/traits.rs index 6a987727b..d7b13ee42 100644 --- a/src/openhuman/tools/traits.rs +++ b/src/openhuman/tools/traits.rs @@ -138,6 +138,118 @@ pub struct ToolSpec { pub parameters: serde_json::Value, } +/// Derive a Title-Cased, human-readable label from a raw tool name. +/// +/// Strips common machine prefixes (`composio_`, `mcp_`) and turns +/// snake_case / kebab-case into spaced Title Case: +/// `gmail_read_message` → "Gmail Read Message". Used as the default for +/// [`Tool::display_label`] and as a safety net for any tool that doesn't +/// supply a curated phrase, so a timeline row never shows raw `snake_case`. +pub fn humanize_tool_name(name: &str) -> String { + let trimmed = name + .strip_prefix("composio_") + .or_else(|| name.strip_prefix("mcp_")) + .unwrap_or(name); + + let mut out = String::with_capacity(trimmed.len()); + let mut capitalize = true; + for ch in trimmed.chars() { + if ch == '_' || ch == '-' { + if !out.is_empty() && !out.ends_with(' ') { + out.push(' '); + } + capitalize = true; + } else if capitalize { + out.extend(ch.to_uppercase()); + capitalize = false; + } else { + out.push(ch); + } + } + + let label = out.trim(); + if label.is_empty() { + name.to_string() + } else { + label.to_string() + } +} + +/// Pull the single most-relevant contextual argument out of a tool-call's +/// args for the timeline "detail" (the bracketed context after the label). +/// +/// Scans a prioritized list of common argument keys (recipient, query, +/// path, command, …) and returns the first present, non-empty value as a +/// trimmed, length-capped string — so a row can read "reading messages from +/// steven@gmail.com" / `Read(src/openhuman/wallet/ops.rs)` without every tool +/// hand-writing a [`Tool::display_detail`] override. Returns `None` when no +/// recognized key carries a usable scalar. +pub fn context_detail_from_args(args: &serde_json::Value) -> Option { + /// Common "what is this acting on" keys, most-specific first. + const CONTEXT_KEYS: &[&str] = &[ + "to", + "recipient", + "recipient_email", + "to_email", + "email", + "query", + "q", + "search", + "search_query", + "url", + "file_path", + "path", + "command", + "cmd", + "subject", + "title", + "channel", + "channel_id", + "repo", + "repository", + "name", + "id", + ]; + + let obj = args.as_object()?; + for key in CONTEXT_KEYS { + let Some(value) = obj.get(*key) else { continue }; + if let Some(rendered) = render_context_value(value) { + return Some(rendered); + } + } + None +} + +/// Render a single arg value to a compact detail string, or `None` when it +/// carries nothing useful (empty string, object, null). +fn render_context_value(value: &serde_json::Value) -> Option { + /// Max characters for a timeline detail before it is elided. + const MAX_DETAIL: usize = 80; + + let raw = match value { + serde_json::Value::String(s) => s.trim().to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Array(items) => items + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", "), + _ => String::new(), + }; + let raw = raw.split_whitespace().collect::>().join(" "); + if raw.is_empty() { + return None; + } + if raw.chars().count() > MAX_DETAIL { + let truncated: String = raw.chars().take(MAX_DETAIL.saturating_sub(1)).collect(); + Some(format!("{truncated}…")) + } else { + Some(raw) + } +} + /// Core tool trait — implement for any capability (built-in or integration-based). #[async_trait] pub trait Tool: Send + Sync { @@ -329,6 +441,31 @@ pub trait Tool: Send + Sync { parameters: self.parameters_schema(), } } + + /// Short, human-readable verb phrase describing this call for the chat + /// "agent processing" timeline (e.g. "Reading messages", "Running + /// command", "Reading file"). The default derives a Title-Cased label + /// from [`Self::name`] via [`humanize_tool_name`]; dynamic / integration + /// tools (Composio, MCP, generated) and high-value built-ins override to + /// return a curated phrase so a row never reads as raw `snake_case`. + /// + /// Paired with [`Self::display_detail`] (the specific argument), the UI + /// renders `label (detail)` — Claude-style `Read(path)` / + /// "reading messages from steven@gmail.com". + fn display_label(&self, _args: &serde_json::Value) -> Option { + Some(humanize_tool_name(self.name())) + } + + /// The specific, contextual argument for this call — the file path, email + /// address, command, or query pulled from `args` — shown in brackets + /// after [`Self::display_label`]. The default pulls the most-relevant + /// common argument via [`context_detail_from_args`], so any tool reads + /// like "reading messages from steven@gmail.com" / `Read(path)` without a + /// hand-written override. Tools whose meaningful arg sits under an unusual + /// key override to surface the right value. + fn display_detail(&self, args: &serde_json::Value) -> Option { + context_detail_from_args(args) + } } #[cfg(test)] @@ -438,6 +575,84 @@ mod tests { assert!(!tool.external_effect()); } + // ── display_label / display_detail ───────────────────────────── + + #[test] + fn default_display_label_humanizes_tool_name() { + let tool = DummyTool; + assert_eq!( + tool.display_label(&serde_json::Value::Null).as_deref(), + Some("Dummy Tool") + ); + } + + #[test] + fn default_display_detail_pulls_context_arg() { + let tool = DummyTool; + // No object args → nothing to surface. + assert!(tool.display_detail(&serde_json::Value::Null).is_none()); + // A recognized key becomes the bracketed context. + assert_eq!( + tool.display_detail(&serde_json::json!({ "path": "src/main.rs" })) + .as_deref(), + Some("src/main.rs") + ); + } + + #[test] + fn context_detail_prefers_specific_keys_and_truncates() { + // `to` outranks `name` for a messaging-style call. + assert_eq!( + context_detail_from_args(&serde_json::json!({ + "name": "ignored", "to": "steven@gmail.com" + })) + .as_deref(), + Some("steven@gmail.com") + ); + // Long values are elided to keep the row compact. + let long = "x".repeat(200); + let detail = context_detail_from_args(&serde_json::json!({ "query": long })).unwrap(); + assert!(detail.chars().count() <= 80); + assert!(detail.ends_with('…')); + // Whitespace is collapsed. + assert_eq!( + context_detail_from_args(&serde_json::json!({ "command": " ls -la " })).as_deref(), + Some("ls -la") + ); + } + + #[test] + fn humanize_tool_name_title_cases_snake_case() { + assert_eq!( + humanize_tool_name("gmail_read_message"), + "Gmail Read Message" + ); + assert_eq!(humanize_tool_name("web_fetch"), "Web Fetch"); + assert_eq!(humanize_tool_name("shell"), "Shell"); + } + + #[test] + fn humanize_tool_name_strips_machine_prefixes() { + // Composio / MCP wrappers prefix the raw action name; the timeline + // label should read as the action, not the transport. + assert_eq!( + humanize_tool_name("composio_gmail_send_email"), + "Gmail Send Email" + ); + assert_eq!( + humanize_tool_name("mcp_notion_create_page"), + "Notion Create Page" + ); + } + + #[test] + fn humanize_tool_name_handles_kebab_and_empty() { + assert_eq!(humanize_tool_name("read-diff"), "Read Diff"); + // Degenerate input never panics and never yields an empty label. + assert_eq!(humanize_tool_name(""), ""); + assert_eq!(humanize_tool_name("___"), "___"); + } + // ── PermissionLevel ordering ─────────────────────────────────── #[test] diff --git a/src/openhuman/workflows/run_log.rs b/src/openhuman/workflows/run_log.rs index 90606a4e1..89d26d522 100644 --- a/src/openhuman/workflows/run_log.rs +++ b/src/openhuman/workflows/run_log.rs @@ -843,6 +843,8 @@ mod tests { tool_name: "codegraph_search".into(), arguments: serde_json::json!({"query": "x"}), iteration: 2, + display_label: None, + display_detail: None, }) .expect("tool call logged"); assert!(line.contains("codegraph_search")); diff --git a/tests/agent_harness_e2e.rs b/tests/agent_harness_e2e.rs index eb4159e02..67e2014fd 100644 --- a/tests/agent_harness_e2e.rs +++ b/tests/agent_harness_e2e.rs @@ -756,9 +756,11 @@ async fn subagent_delegation_happy_path_inner() { // request[1] = researcher subagent inner LLM call: canary text returned // request[2] = orchestrator synthesis: canary forwarded in final reply // - // NOTE: threads_turn_state_get returns None after a successful turn completion — - // TurnStateMirror deletes the snapshot on TurnCompleted (mirror.rs:338-341). - // Therefore we verify delegation via captured upstream requests, not turn state. + // NOTE: a completed turn's snapshot is now RETAINED (lifecycle `Completed`) + // so "View processing" can replay a finished turn; the snapshot is overwritten + // by the next turn, not deleted on completion. We verify delegation via the + // captured upstream requests rather than turn state, which keeps this test + // independent of the snapshot's retention/lifecycle details. let requests = with_captured(|c| c.clone()); assert!( requests.len() >= 3, diff --git a/tests/memory_core_threads_raw_coverage_e2e.rs b/tests/memory_core_threads_raw_coverage_e2e.rs index 1c1c0e979..7f9bc83f9 100644 --- a/tests/memory_core_threads_raw_coverage_e2e.rs +++ b/tests/memory_core_threads_raw_coverage_e2e.rs @@ -550,6 +550,8 @@ async fn thread_ops_welcome_migration_and_turn_state_cover_error_and_cleanup_pat tool_name: "memory.search".into(), arguments: json!({"q": "phoenix"}), iteration: 1, + display_label: None, + display_detail: None, })); assert!(mirror.observe(&AgentProgress::SubagentSpawned { agent_id: "researcher".into(), diff --git a/tests/memory_raw_coverage_e2e.rs b/tests/memory_raw_coverage_e2e.rs index c2480f0c7..95e9fa031 100644 --- a/tests/memory_raw_coverage_e2e.rs +++ b/tests/memory_raw_coverage_e2e.rs @@ -611,7 +611,10 @@ fn threads_turn_state_store_skips_corrupt_entries_and_marks_interrupted() { iteration: Some(1), elapsed_ms: Some(20), output_chars: Some(64), + display_name: None, + detail: None, }], + transcript: vec![], }), }); let second = TurnState::started("thread-b", "req-b", 2, "2026-05-29T12:01:00Z"); diff --git a/tests/memory_threads_raw_coverage_e2e.rs b/tests/memory_threads_raw_coverage_e2e.rs index 394a92138..7a44af300 100644 --- a/tests/memory_threads_raw_coverage_e2e.rs +++ b/tests/memory_threads_raw_coverage_e2e.rs @@ -3042,6 +3042,14 @@ impl ComposioProvider for EmptySlugProvider { #[tokio::test] async fn memory_sync_provider_trait_defaults_and_connection_hook_are_deterministic() { let tmp = TempDir::new().expect("tempdir"); + // Determinism (as this test's name promises): `identity_set` → + // `persist_provider_profile` writes through the PROCESS-GLOBAL memory client + // and returns 0 when it isn't ready. Other tests in this binary rebind that + // global, so under parallel execution this test could otherwise observe an + // unready client and see 0 instead of 1. Bind the global to this test's + // workspace up front so the assertion is independent of execution order. + openhuman_core::openhuman::memory::global::init(tmp.path().to_path_buf()) + .expect("init global memory client"); let ctx = ProviderContext { config: Arc::new(config_in(&tmp)), toolkit: "raw_coverage".into(), @@ -3140,6 +3148,8 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { tool_name: "memory.search".into(), arguments: json!({ "q": "coverage" }), iteration: 2, + display_label: None, + display_detail: None, })); assert!(mirror.observe(&AgentProgress::ToolCallCompleted { call_id: "call-1".into(), @@ -3174,15 +3184,22 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { max_iterations: 3, extended_policy: false, })); - assert!(!mirror.observe(&AgentProgress::SubagentToolCallStarted { + // Sub-agent tool boundaries now FLUSH (return `true`): while a sub-agent + // runs, the parent is blocked on its long-lived `spawn_subagent` tool, so + // the parent's own iteration/tool flushes don't fire. Flushing on the + // child's tool start/complete is what lets the sub-agent's streamed thoughts + // reach disk mid-run (survives tab-switch / reload). + assert!(mirror.observe(&AgentProgress::SubagentToolCallStarted { agent_id: "researcher".into(), task_id: "task-1".into(), call_id: "child-call".into(), tool_name: "memory.read".into(), arguments: serde_json::Value::Null, iteration: 1, + display_label: None, + display_detail: None, })); - assert!(!mirror.observe(&AgentProgress::SubagentToolCallCompleted { + assert!(mirror.observe(&AgentProgress::SubagentToolCallCompleted { agent_id: "researcher".into(), task_id: "task-1".into(), call_id: "child-call".into(), @@ -3259,10 +3276,14 @@ fn turn_state_mirror_persists_progress_edges_from_public_events() { let mut complete = TurnStateMirror::new(store.clone(), "thread/completed", "request-complete"); assert!(complete.observe(&AgentProgress::TurnCompleted { iterations: 2 })); complete.finish(); - assert!(store + // A completed turn's snapshot is now RETAINED (lifecycle `Completed`) rather + // than deleted, so the "View processing" panel can replay a finished turn + // after reload. `finish()`/mark-all-interrupted skips `Completed` snapshots. + let completed = store .get("thread/completed") .expect("completed snapshot lookup") - .is_none()); + .expect("completed snapshot retained for replay"); + assert_eq!(completed.lifecycle, TurnLifecycle::Completed); } #[test] @@ -3451,7 +3472,10 @@ fn turn_state_store_persists_lists_marks_and_clears_snapshots() { iteration: Some(1), elapsed_ms: Some(100), output_chars: Some(10), + display_name: None, + detail: None, }], + transcript: vec![], }), }); let second = TurnState::started("thread/b", "request-2", 2, "2026-05-29T12:01:00Z");