feat(agent): live streaming subagent view with reopenable sub-threads (#3007)

This commit is contained in:
Steven Enamakel
2026-05-30 01:04:23 -07:00
committed by GitHub
parent c10f6064af
commit 3556842337
43 changed files with 1963 additions and 67 deletions
+2 -2
View File
@@ -365,8 +365,8 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test.
- **Pre-merge** (code changes): Prettier, ESLint, `tsc --noEmit` in `app/`; `cargo fmt` + `cargo check` for changed Rust.
- **No dynamic imports** in production `app/src` code — static `import` / `import type` only. No `import()`, `React.lazy(() => import(...))`, `await import(...)`. For heavy optional paths, use a static import and guard the call site with `try/catch` or a runtime check. *Exceptions*: Vitest harness patterns in `*.test.ts` / `__tests__` / `test/setup.ts`; ambient `typeof import('…')` in `.d.ts`; config files (e.g. `tailwind.config.js` JSDoc).
- **Dual socket sync**: when changing the realtime protocol, keep `socketService` / MCP transport aligned with core socket behavior (see `gitbooks/developing/architecture.md` dual-socket section).
- **i18n for all UI text**: every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, error messages, dialog copy) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) in the same PR — other locales fall back to English. Exceptions: developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values).
- **i18n locale files — update ALL locales**: each locale is a **single flat file** at `app/src/lib/i18n/<locale>.ts` (`en.ts` is the source of truth; the chunked `chunks/<locale>-N.ts` layout was retired). When adding or changing keys in `en.ts`, you **must also** add the same key to every non-English locale file (use the English value as a placeholder — translators fill in later). CI enforces parity via `pnpm i18n:check`; a missing or extra key in any locale will fail the i18n coverage gate. Locales: `ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pl`, `pt`, `ru`, `zh-CN`. **`pnpm i18n:english:check`** ([`scripts/i18n-find-english.ts`](scripts/i18n-find-english.ts)) is a second gate that catches values still rendering English — including *stale* English (translated from an older en string that since changed), which `i18n:check` cannot see because it only compares against the current en value. It uses script-coverage for non-Latin locales and English-only function words for Latin locales, with a reviewed `INTENTIONAL_ENGLISH` allowlist for brand names / commands / paths / units / cognates. Add genuinely-English literals to that allowlist; never use it to silence an untranslated string.
- **i18n for all UI text**: every user-visible string in `app/src/**` (headings, labels, button text, placeholders, status chips, toasts, error messages, dialog copy) must go through `useT()` from `app/src/lib/i18n/I18nContext`. Hard-coded literals in JSX or `label=`/`placeholder=`/`aria-label=` props are not allowed. Add the key to [`app/src/lib/i18n/en.ts`](app/src/lib/i18n/en.ts) **and a real translation to every other locale file** in the same PR (see the next bullet — English-only fallback is the runtime safety net for *missing* keys, not an excuse to ship untranslated values). Exceptions: developer-only debug logs, code identifiers, and non-display data (URLs, slugs, technical sentinel values).
- **i18n locale files — update ALL locales with REAL translations**: each locale is a **single flat file** at `app/src/lib/i18n/<locale>.ts` (`en.ts` is the source of truth; the chunked `chunks/<locale>-N.ts` layout was retired). When adding or changing keys in `en.ts`, you **must also** add the same key to every non-English locale file **with an actual, correct translation in that language — NOT an English placeholder**. Do not copy the English string into `ar.ts`/`de.ts`/… and "leave it for translators"; translate it (the short UI strings here are well within reach — translate them, and only fall back to English for genuine brand names / commands / paths / units, which belong in the `INTENTIONAL_ENGLISH` allowlist). CI enforces this two ways: `pnpm i18n:check` fails on a missing or extra key in any locale (parity), and **`pnpm i18n:english:check`** ([`scripts/i18n-find-english.ts`](scripts/i18n-find-english.ts)) fails on any value still rendering English — including *stale* English (translated from an older en string that since changed). The English-detection gate uses script-coverage for non-Latin locales and English-only function words for Latin locales; the `INTENTIONAL_ENGLISH` allowlist is **only** for genuinely-English literals (brand names / commands / paths / units / cognates) — never use it to silence an untranslated string. Locales: `ar`, `bn`, `de`, `es`, `fr`, `hi`, `id`, `it`, `ko`, `pl`, `pt`, `ru`, `zh-CN`.
---
+11
View File
@@ -2310,6 +2310,17 @@ const messages: TranslationMap = {
'conversations.taskKanban.updateFailed': 'ولم يتمكن من استكمال المهمة؛ ولم يتم توفير التغييرات.',
'conversations.toolTimeline.turn': 'دور',
'conversations.toolTimeline.workerThread': 'محادثة عامل',
'conversations.subagent.viewProcessing': 'عرض المعالجة الكاملة',
'conversations.subagent.parent': 'الأصل',
'conversations.subagent.thinking': 'التفكير',
'conversations.subagent.response': 'الرد',
'conversations.subagent.toolCalls': 'استدعاءات الأدوات',
'conversations.subagent.working': 'يعمل…',
'conversations.subagent.noOutputYet': 'لا يوجد ناتج بعد',
'conversations.subagent.close': 'إغلاق',
'conversations.subagent.statusRunning': 'قيد التشغيل',
'conversations.subagent.statusCompleted': 'اكتمل',
'conversations.subagent.statusFailed': 'فشل',
'daemon.serviceBlockingGate.body': 'المحتوى',
'daemon.serviceBlockingGate.downloadHint': 'تلميح التنزيل',
'daemon.serviceBlockingGate.downloadLatest': 'تنزيل أحدث إصدار',
+11
View File
@@ -2355,6 +2355,17 @@ const messages: TranslationMap = {
'conversations.taskKanban.updateFailed': 'কাজটি সংরক্ষণ করা যায়নি।',
'conversations.toolTimeline.turn': 'টার্ন',
'conversations.toolTimeline.workerThread': 'ওয়ার্কার থ্রেড',
'conversations.subagent.viewProcessing': 'সম্পূর্ণ প্রক্রিয়া দেখুন',
'conversations.subagent.parent': 'মূল',
'conversations.subagent.thinking': 'ভাবছে',
'conversations.subagent.response': 'প্রতিক্রিয়া',
'conversations.subagent.toolCalls': 'টুল কল',
'conversations.subagent.working': 'কাজ করছে…',
'conversations.subagent.noOutputYet': 'এখনও কোনো আউটপুট নেই',
'conversations.subagent.close': 'বন্ধ করুন',
'conversations.subagent.statusRunning': 'চলছে',
'conversations.subagent.statusCompleted': 'সম্পন্ন',
'conversations.subagent.statusFailed': 'ব্যর্থ',
'daemon.serviceBlockingGate.body': 'বডি',
'daemon.serviceBlockingGate.downloadHint': 'ডাউনলোড হিন্ট',
'daemon.serviceBlockingGate.downloadLatest': 'সর্বশেষ সংস্করণ ডাউনলোড করুন',
+11
View File
@@ -2417,6 +2417,17 @@ const messages: TranslationMap = {
'Aufgabe konnte nicht aktualisiert werden; Änderungen wurden nicht gespeichert.',
'conversations.toolTimeline.turn': 'drehen',
'conversations.toolTimeline.workerThread': 'Worker-Thread',
'conversations.subagent.viewProcessing': 'Vollständige Verarbeitung anzeigen',
'conversations.subagent.parent': 'Übergeordnet',
'conversations.subagent.thinking': 'Denkt nach',
'conversations.subagent.response': 'Antwort',
'conversations.subagent.toolCalls': 'Tool-Aufrufe',
'conversations.subagent.working': 'Arbeitet…',
'conversations.subagent.noOutputYet': 'Noch keine Ausgabe',
'conversations.subagent.close': 'Schließen',
'conversations.subagent.statusRunning': 'läuft',
'conversations.subagent.statusCompleted': 'abgeschlossen',
'conversations.subagent.statusFailed': 'fehlgeschlagen',
'daemon.serviceBlockingGate.body': 'Körper',
'daemon.serviceBlockingGate.downloadHint': 'Hinweis herunterladen',
'daemon.serviceBlockingGate.downloadLatest': 'Lade die neueste Version herunter',
+11
View File
@@ -2561,6 +2561,17 @@ const en: TranslationMap = {
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
'conversations.toolTimeline.turn': 'turn',
'conversations.toolTimeline.workerThread': 'worker thread',
'conversations.subagent.viewProcessing': 'View full processing',
'conversations.subagent.parent': 'Parent',
'conversations.subagent.thinking': 'Thinking',
'conversations.subagent.response': 'Response',
'conversations.subagent.toolCalls': 'Tool calls',
'conversations.subagent.working': 'Working…',
'conversations.subagent.noOutputYet': 'No output yet',
'conversations.subagent.close': 'Close',
'conversations.subagent.statusRunning': 'running',
'conversations.subagent.statusCompleted': 'completed',
'conversations.subagent.statusFailed': 'failed',
'daemon.serviceBlockingGate.body':
'Retrying in the background. This usually resolves in a few seconds.',
'daemon.serviceBlockingGate.downloadHint':
+11
View File
@@ -2399,6 +2399,17 @@ const messages: TranslationMap = {
'No se pudo actualizar la tarea; los cambios no se guardaron.',
'conversations.toolTimeline.turn': 'turno',
'conversations.toolTimeline.workerThread': 'hilo de worker',
'conversations.subagent.viewProcessing': 'Ver procesamiento completo',
'conversations.subagent.parent': 'Principal',
'conversations.subagent.thinking': 'Pensando',
'conversations.subagent.response': 'Respuesta',
'conversations.subagent.toolCalls': 'Llamadas a herramientas',
'conversations.subagent.working': 'Trabajando…',
'conversations.subagent.noOutputYet': 'Aún no hay resultados',
'conversations.subagent.close': 'Cerrar',
'conversations.subagent.statusRunning': 'en ejecución',
'conversations.subagent.statusCompleted': 'completado',
'conversations.subagent.statusFailed': 'fallido',
'daemon.serviceBlockingGate.body': 'Cuerpo',
'daemon.serviceBlockingGate.downloadHint': 'Sugerencia de descarga',
'daemon.serviceBlockingGate.downloadLatest': 'Descargar la última versión',
+11
View File
@@ -2409,6 +2409,17 @@ const messages: TranslationMap = {
"Impossible de mettre à jour la tâche; les modifications n'ont pas été enregistrées.",
'conversations.toolTimeline.turn': 'tour',
'conversations.toolTimeline.workerThread': 'fil worker',
'conversations.subagent.viewProcessing': 'Voir le traitement complet',
'conversations.subagent.parent': 'Parent',
'conversations.subagent.thinking': 'Réflexion',
'conversations.subagent.response': 'Réponse',
'conversations.subagent.toolCalls': 'Appels doutils',
'conversations.subagent.working': 'En cours…',
'conversations.subagent.noOutputYet': 'Aucun résultat pour linstant',
'conversations.subagent.close': 'Fermer',
'conversations.subagent.statusRunning': 'en cours',
'conversations.subagent.statusCompleted': 'terminé',
'conversations.subagent.statusFailed': 'échoué',
'daemon.serviceBlockingGate.body': 'Corps',
'daemon.serviceBlockingGate.downloadHint': 'Indice de téléchargement',
'daemon.serviceBlockingGate.downloadLatest': 'Télécharger la dernière version',
+11
View File
@@ -2359,6 +2359,17 @@ const messages: TranslationMap = {
'conversations.taskKanban.updateFailed': 'कार्य अद्यतन नहीं कर सका; परिवर्तन बचाया नहीं गया।',
'conversations.toolTimeline.turn': 'टर्न',
'conversations.toolTimeline.workerThread': 'वर्कर थ्रेड',
'conversations.subagent.viewProcessing': 'पूरी प्रोसेसिंग देखें',
'conversations.subagent.parent': 'मूल',
'conversations.subagent.thinking': 'सोच रहा है',
'conversations.subagent.response': 'प्रतिक्रिया',
'conversations.subagent.toolCalls': 'टूल कॉल',
'conversations.subagent.working': 'काम कर रहा है…',
'conversations.subagent.noOutputYet': 'अभी तक कोई आउटपुट नहीं',
'conversations.subagent.close': 'बंद करें',
'conversations.subagent.statusRunning': 'चल रहा है',
'conversations.subagent.statusCompleted': 'पूर्ण',
'conversations.subagent.statusFailed': 'विफल',
'daemon.serviceBlockingGate.body': 'विवरण',
'daemon.serviceBlockingGate.downloadHint': 'डाउनलोड संकेत',
'daemon.serviceBlockingGate.downloadLatest': 'नवीनतम संस्करण डाउनलोड करें',
+11
View File
@@ -2362,6 +2362,17 @@ const messages: TranslationMap = {
'conversations.taskKanban.updateFailed': 'Tak bisa memutakhirkan tugas; perubahan tak disimpan.',
'conversations.toolTimeline.turn': 'giliran',
'conversations.toolTimeline.workerThread': 'thread worker',
'conversations.subagent.viewProcessing': 'Lihat proses lengkap',
'conversations.subagent.parent': 'Induk',
'conversations.subagent.thinking': 'Berpikir',
'conversations.subagent.response': 'Respons',
'conversations.subagent.toolCalls': 'Panggilan alat',
'conversations.subagent.working': 'Bekerja…',
'conversations.subagent.noOutputYet': 'Belum ada keluaran',
'conversations.subagent.close': 'Tutup',
'conversations.subagent.statusRunning': 'berjalan',
'conversations.subagent.statusCompleted': 'selesai',
'conversations.subagent.statusFailed': 'gagal',
'daemon.serviceBlockingGate.body': 'Isi',
'daemon.serviceBlockingGate.downloadHint': 'Petunjuk unduhan',
'daemon.serviceBlockingGate.downloadLatest': 'Unduh Versi Terbaru',
+11
View File
@@ -2392,6 +2392,17 @@ const messages: TranslationMap = {
"Impossibile aggiornare l'attività; le modifiche non sono state salvate.",
'conversations.toolTimeline.turn': 'turno',
'conversations.toolTimeline.workerThread': 'thread worker',
'conversations.subagent.viewProcessing': 'Visualizza elaborazione completa',
'conversations.subagent.parent': 'Principale',
'conversations.subagent.thinking': 'Ragionamento',
'conversations.subagent.response': 'Risposta',
'conversations.subagent.toolCalls': 'Chiamate agli strumenti',
'conversations.subagent.working': 'In corso…',
'conversations.subagent.noOutputYet': 'Ancora nessun output',
'conversations.subagent.close': 'Chiudi',
'conversations.subagent.statusRunning': 'in esecuzione',
'conversations.subagent.statusCompleted': 'completato',
'conversations.subagent.statusFailed': 'non riuscito',
'daemon.serviceBlockingGate.body': 'Corpo',
'daemon.serviceBlockingGate.downloadHint': 'Suggerimento di download',
'daemon.serviceBlockingGate.downloadLatest': "Scarica l'ultima versione",
+11
View File
@@ -2337,6 +2337,17 @@ const messages: TranslationMap = {
'작업을 업데이트할 수 없어 변경 사항이 저장되지 않았습니다.',
'conversations.toolTimeline.turn': '턴',
'conversations.toolTimeline.workerThread': '워커 스레드',
'conversations.subagent.viewProcessing': '전체 처리 과정 보기',
'conversations.subagent.parent': '상위',
'conversations.subagent.thinking': '생각 중',
'conversations.subagent.response': '응답',
'conversations.subagent.toolCalls': '도구 호출',
'conversations.subagent.working': '작업 중…',
'conversations.subagent.noOutputYet': '아직 출력이 없습니다',
'conversations.subagent.close': '닫기',
'conversations.subagent.statusRunning': '실행 중',
'conversations.subagent.statusCompleted': '완료됨',
'conversations.subagent.statusFailed': '실패',
'daemon.serviceBlockingGate.body': '본문',
'daemon.serviceBlockingGate.downloadHint': '다운로드 안내',
'daemon.serviceBlockingGate.downloadLatest': '최신 버전 다운로드',
+11
View File
@@ -2387,6 +2387,17 @@ const messages: TranslationMap = {
'Nie udało się zaktualizować zadania; zmian nie zapisano.',
'conversations.toolTimeline.turn': 'tura',
'conversations.toolTimeline.workerThread': 'wątek workera',
'conversations.subagent.viewProcessing': 'Zobacz pełne przetwarzanie',
'conversations.subagent.parent': 'Nadrzędny',
'conversations.subagent.thinking': 'Myślenie',
'conversations.subagent.response': 'Odpowiedź',
'conversations.subagent.toolCalls': 'Wywołania narzędzi',
'conversations.subagent.working': 'Pracuje…',
'conversations.subagent.noOutputYet': 'Brak wyników',
'conversations.subagent.close': 'Zamknij',
'conversations.subagent.statusRunning': 'w toku',
'conversations.subagent.statusCompleted': 'zakończono',
'conversations.subagent.statusFailed': 'niepowodzenie',
'daemon.serviceBlockingGate.body':
'Rdzeń OpenHuman nie odpowiada. Spróbuj ponownie lub pobierz najnowszą wersję aplikacji.',
'daemon.serviceBlockingGate.downloadHint':
+11
View File
@@ -2399,6 +2399,17 @@ const messages: TranslationMap = {
'Não foi possível atualizar a tarefa; as alterações não foram salvas.',
'conversations.toolTimeline.turn': 'turno',
'conversations.toolTimeline.workerThread': 'thread de worker',
'conversations.subagent.viewProcessing': 'Ver processamento completo',
'conversations.subagent.parent': 'Principal',
'conversations.subagent.thinking': 'Pensando',
'conversations.subagent.response': 'Resposta',
'conversations.subagent.toolCalls': 'Chamadas de ferramentas',
'conversations.subagent.working': 'Trabalhando…',
'conversations.subagent.noOutputYet': 'Ainda sem resultado',
'conversations.subagent.close': 'Fechar',
'conversations.subagent.statusRunning': 'em execução',
'conversations.subagent.statusCompleted': 'concluído',
'conversations.subagent.statusFailed': 'falhou',
'daemon.serviceBlockingGate.body': 'Corpo',
'daemon.serviceBlockingGate.downloadHint': 'Dica de download',
'daemon.serviceBlockingGate.downloadLatest': 'Baixar Versão Mais Recente',
+11
View File
@@ -2373,6 +2373,17 @@ const messages: TranslationMap = {
'conversations.taskKanban.updateFailed': 'Не удалось обновить задачу; изменения не сохранились.',
'conversations.toolTimeline.turn': 'ход',
'conversations.toolTimeline.workerThread': 'чат воркера',
'conversations.subagent.viewProcessing': 'Посмотреть весь процесс',
'conversations.subagent.parent': 'Родительский',
'conversations.subagent.thinking': 'Думает',
'conversations.subagent.response': 'Ответ',
'conversations.subagent.toolCalls': 'Вызовы инструментов',
'conversations.subagent.working': 'Выполняется…',
'conversations.subagent.noOutputYet': 'Пока нет результата',
'conversations.subagent.close': 'Закрыть',
'conversations.subagent.statusRunning': 'выполняется',
'conversations.subagent.statusCompleted': 'завершено',
'conversations.subagent.statusFailed': 'ошибка',
'daemon.serviceBlockingGate.body': 'Текст',
'daemon.serviceBlockingGate.downloadHint': 'Подсказка по загрузке',
'daemon.serviceBlockingGate.downloadLatest': 'Скачать последнюю версию',
+11
View File
@@ -2241,6 +2241,17 @@ const messages: TranslationMap = {
'conversations.taskKanban.updateFailed': '无法更新任务;更改未保存。',
'conversations.toolTimeline.turn': '轮次',
'conversations.toolTimeline.workerThread': '工作线程',
'conversations.subagent.viewProcessing': '查看完整处理过程',
'conversations.subagent.parent': '父级',
'conversations.subagent.thinking': '思考中',
'conversations.subagent.response': '回复',
'conversations.subagent.toolCalls': '工具调用',
'conversations.subagent.working': '处理中…',
'conversations.subagent.noOutputYet': '暂无输出',
'conversations.subagent.close': '关闭',
'conversations.subagent.statusRunning': '运行中',
'conversations.subagent.statusCompleted': '已完成',
'conversations.subagent.statusFailed': '失败',
'daemon.serviceBlockingGate.body': '核心服务不可用,请等待或下载最新版本。',
'daemon.serviceBlockingGate.downloadHint': '下载最新版本',
'daemon.serviceBlockingGate.downloadLatest': '下载最新版本',
+23 -2
View File
@@ -78,6 +78,7 @@ import { formatTimelineEntry } from '../utils/toolTimelineFormatting';
import { AgentMessageBubble, BubbleMarkdown } from './conversations/components/AgentMessageBubble';
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
import { LimitPill } from './conversations/components/LimitPill';
import { SubagentDrawer } from './conversations/components/SubagentDrawer';
import { TaskKanbanBoard } from './conversations/components/TaskKanbanBoard';
import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock';
import {
@@ -201,6 +202,9 @@ const Conversations = ({
const [attachments, setAttachments] = useState<Attachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
// Sub-agent whose full live transcript is open in the drawer, keyed by the
// owning timeline row's spawn `taskId`. Null when the drawer is closed.
const [openSubagentTaskId, setOpenSubagentTaskId] = useState<string | null>(null);
const [inputMode, setInputMode] = useState<InputMode>('text');
const [replyMode, setReplyMode] = useState<ReplyMode>('text');
const [isRecording, setIsRecording] = useState(false);
@@ -1069,6 +1073,12 @@ const Conversations = ({
const selectedThreadToolTimeline = selectedThreadId
? (toolTimelineByThread[selectedThreadId] ?? [])
: [];
// Re-derive the open subagent's live activity (and its row status) from the
// timeline on every render so the drawer streams token-by-token as
// subagent_text_delta / subagent_thinking_delta events land in Redux.
const openSubagentEntry = openSubagentTaskId
? selectedThreadToolTimeline.find(entry => entry.subagent?.taskId === openSubagentTaskId)
: undefined;
const selectedTaskBoard = selectedThreadId ? (taskBoardByThread[selectedThreadId] ?? null) : null;
const hasTaskBoard = Boolean(selectedTaskBoard?.cards.length);
const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden);
@@ -1644,7 +1654,10 @@ const Conversations = ({
<div key={msg.id}>
{shouldRenderTimelineBeforeLatestAgentMessage &&
latestVisibleAgentMessage?.id === msg.id && (
<ToolTimelineBlock entries={selectedThreadToolTimeline} />
<ToolTimelineBlock
entries={selectedThreadToolTimeline}
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
/>
)}
<div
className={`group/msg flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
@@ -1929,7 +1942,10 @@ const Conversations = ({
{/* Tool call timeline */}
{selectedThreadToolTimeline.length > 0 &&
!shouldRenderTimelineBeforeLatestAgentMessage && (
<ToolTimelineBlock entries={selectedThreadToolTimeline} />
<ToolTimelineBlock
entries={selectedThreadToolTimeline}
onViewSubagent={sub => setOpenSubagentTaskId(sub.taskId)}
/>
)}
{isSending && rustChat && (
<div className="flex justify-start px-1">
@@ -2311,6 +2327,11 @@ const Conversations = ({
modal={deleteModal}
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
/>
<SubagentDrawer
subagent={openSubagentEntry?.subagent ?? null}
status={openSubagentEntry?.status}
onClose={() => setOpenSubagentTaskId(null)}
/>
</div>
);
};
@@ -0,0 +1,347 @@
import { type ReactNode, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { threadApi } from '../../../services/api/threadApi';
import type {
SubagentActivity,
SubagentTranscriptItem,
ToolTimelineEntryStatus,
} from '../../../store/chatRuntimeSlice';
import type { ThreadMessage } from '../../../types/thread';
import { BubbleMarkdown } from './AgentMessageBubble';
/**
* Rebuild a renderable transcript from a worker sub-thread's persisted
* messages so a delegation can be reopened from memory after its live
* stream is gone (navigation / cold boot). The first `user` message is the
* parent's delegation prompt; `agent` messages with a `tool_name` in their
* metadata are tool calls, the rest are the sub-agent's visible text.
* Streamed reasoning isn't persisted, so reopened transcripts omit it.
*/
function transcriptFromMessages(messages: ThreadMessage[]): {
prompt?: string;
items: SubagentTranscriptItem[];
} {
let prompt: string | undefined;
const items: SubagentTranscriptItem[] = [];
for (const m of messages) {
const meta = m.extraMetadata ?? {};
const iteration = typeof meta.iteration === 'number' ? meta.iteration : undefined;
if (m.sender === 'user') {
if (prompt === undefined) prompt = m.content;
continue;
}
const toolName = typeof meta.tool_name === 'string' ? meta.tool_name : undefined;
if (toolName) {
items.push({ kind: 'tool', iteration, callId: m.id, toolName, status: 'success' });
} else if (m.content.trim().length > 0) {
items.push({ kind: 'text', iteration, text: m.content });
}
}
return { prompt, items };
}
/**
* Map a subagent row's terminal/running status to the visual tone used
* across the drawer (header dot, status pill). Mirrors the colour
* language of `ToolTimelineBlock` so the inline card and the drawer read
* as the same surface.
*/
function statusTone(status: ToolTimelineEntryStatus | undefined): {
dot: string;
pill: string;
label: 'statusRunning' | 'statusCompleted' | 'statusFailed';
} {
if (status === 'success') {
return {
dot: 'bg-sage-500',
pill: 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300',
label: 'statusCompleted',
};
}
if (status === 'error') {
return {
dot: 'bg-coral-500',
pill: 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300',
label: 'statusFailed',
};
}
return {
dot: 'bg-amber-500 animate-pulse',
pill: 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300',
label: 'statusRunning',
};
}
function formatElapsed(ms: number): string {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
}
/**
* Full live-transcript view for one sub-agent, slid in from the right.
*
* Driven entirely off the live [`SubagentActivity`] the caller passes
* because the caller re-derives that object from Redux on every render,
* the drawer updates token-by-token as `subagent_text_delta` /
* `subagent_thinking_delta` events stream in. Shows the streamed
* reasoning (collapsible), the streamed visible output (rendered as
* Markdown), and the chronological list of child tool calls with their
* status and timings.
*
* Rendered as `null` when no subagent is selected, so the parent can
* mount it unconditionally and just flip `subagent`.
*/
export function SubagentDrawer({
subagent,
status,
onClose,
}: {
subagent: SubagentActivity | null;
/** Lifecycle status of the owning timeline row (running/success/error). */
status?: ToolTimelineEntryStatus;
onClose: () => void;
}) {
const { t } = useT();
// Close on Escape for keyboard parity with the backdrop click.
useEffect(() => {
if (!subagent) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [subagent, onClose]);
// Reopen-from-memory: when there's no live transcript (the row was
// restored from a snapshot, or the user navigated back after the turn
// ended) but a worker sub-thread backs it, load that thread's persisted
// messages and render them as the conversation. Failures fall back to the
// empty/working placeholder rather than blocking the drawer.
// Tagged with the worker thread it was fetched for, so a pending request
// for a previous thread can't paint the wrong conversation after the user
// switches subagents.
const [fetched, setFetched] = useState<{
workerThreadId: string;
prompt?: string;
items: SubagentTranscriptItem[];
} | null>(null);
const liveTranscript = subagent?.transcript ?? [];
const workerThreadId = subagent?.workerThreadId;
const needsFetch = Boolean(subagent && workerThreadId && liveTranscript.length === 0);
useEffect(() => {
if (!needsFetch || !workerThreadId) {
setFetched(null);
return;
}
// Clear any prior thread's transcript up front so it can't linger while
// the new request is in flight.
setFetched(null);
let cancelled = false;
void threadApi
.getThreadMessages(workerThreadId)
.then(data => {
if (!cancelled) setFetched({ workerThreadId, ...transcriptFromMessages(data.messages) });
})
.catch(() => {
if (!cancelled) setFetched(null);
});
return () => {
cancelled = true;
};
}, [needsFetch, workerThreadId]);
if (!subagent) return null;
const tone = statusTone(status);
const isRunning = status !== 'success' && status !== 'error';
// Only trust the fetched transcript when it belongs to the current worker.
const fetchedForCurrent =
fetched && workerThreadId && fetched.workerThreadId === workerThreadId ? fetched : null;
const transcript = liveTranscript.length > 0 ? liveTranscript : (fetchedForCurrent?.items ?? []);
const promptText = subagent.prompt ?? fetchedForCurrent?.prompt;
// The last visible-text item gets the live cursor while the run is in
// flight (the model is mid-sentence on its final/visible output).
let lastTextIdx = -1;
for (let i = transcript.length - 1; i >= 0; i -= 1) {
if (transcript[i].kind === 'text') {
lastTextIdx = i;
break;
}
}
return (
<div className="fixed inset-0 z-50 flex justify-end" data-testid="subagent-drawer">
{/* Backdrop */}
<button
type="button"
aria-label={t('conversations.subagent.close')}
className="absolute inset-0 bg-stone-900/30 dark:bg-black/50"
onClick={onClose}
/>
<aside className="relative flex h-full w-full max-w-md flex-col bg-white dark:bg-neutral-900 shadow-xl">
{/* Header */}
<header className="flex items-center gap-2.5 border-b border-stone-200 dark:border-neutral-800 px-4 py-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-50 dark:bg-primary-500/15 text-base">
🤖
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-semibold text-stone-800 dark:text-neutral-100">
{subagent.agentId}
</span>
<span className={`h-2 w-2 shrink-0 rounded-full ${tone.dot}`} />
</div>
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-stone-500 dark:text-neutral-400">
<span className={`rounded-full px-1.5 py-0.5 ${tone.pill}`}>
{t(`conversations.subagent.${tone.label}`)}
</span>
{subagent.childIteration != null && subagent.childMaxIterations != null ? (
<span>
{t('conversations.toolTimeline.turn')} {subagent.childIteration}/
{subagent.childMaxIterations}
</span>
) : subagent.iterations != null ? (
<span>
{subagent.iterations} {t('conversations.toolTimeline.turn')}
</span>
) : null}
{subagent.elapsedMs != null ? <span>{formatElapsed(subagent.elapsedMs)}</span> : null}
{subagent.mode ? <span>{subagent.mode}</span> : null}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('conversations.subagent.close')}
className="shrink-0 rounded-full p-1.5 text-stone-400 hover:bg-stone-100 dark:hover:bg-neutral-800 hover:text-stone-600 dark:hover:text-neutral-200">
</button>
</header>
{/* Body a parentsubagent conversation: the parent's delegation
prompt opens it, then the sub-agent replies as one chronological
transcript (thinking, the text it produced, the tool calls that
text triggered, the next turn exactly as it was emitted). */}
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{/* Parent → sub-agent: the delegation prompt (the "input"). */}
{promptText ? (
<div className="flex justify-end" data-testid="subagent-parent-prompt">
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-primary-500 px-3 py-2 text-sm text-white">
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wide text-white/70">
{t('conversations.subagent.parent')}
</div>
<div className="whitespace-pre-wrap break-words">{promptText}</div>
</div>
</div>
) : null}
{/* Sub-agent side: avatar label + its turns. */}
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-stone-400 dark:text-neutral-500">
<span>🤖</span>
{subagent.agentId}
</div>
{transcript.length === 0 ? (
<p className="text-xs italic text-stone-400 dark:text-neutral-500">
{isRunning
? t('conversations.subagent.working')
: t('conversations.subagent.noOutputYet')}
</p>
) : (
<ol className="space-y-2">
{transcript.map((item, idx) => {
// Insert a "Turn N" divider when the iteration advances.
const prevIteration = idx > 0 ? transcript[idx - 1].iteration : undefined;
const showTurn = item.iteration != null && item.iteration !== prevIteration;
const turnDivider = showTurn ? (
<li
aria-hidden
className="flex items-center gap-2 pt-1 text-[10px] font-medium uppercase tracking-wide text-stone-400 dark:text-neutral-500"
data-testid="subagent-turn-divider">
<span className="h-px flex-1 bg-stone-200 dark:bg-neutral-800" />
{t('conversations.toolTimeline.turn')} {item.iteration}
<span className="h-px flex-1 bg-stone-200 dark:bg-neutral-800" />
</li>
) : null;
if (item.kind === 'thinking') {
return (
<ItemWrapper key={`th-${idx}`} divider={turnDivider}>
<div
className="rounded-lg bg-stone-50 dark:bg-neutral-800/60 px-3 py-2"
data-testid="subagent-transcript-thinking">
<div className="mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-stone-500 dark:text-neutral-400">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary-400" />
{t('conversations.subagent.thinking')}
</div>
<pre className="whitespace-pre-wrap break-words font-sans text-[12px] leading-relaxed text-stone-600 dark:text-neutral-300">
{item.text}
</pre>
</div>
</ItemWrapper>
);
}
if (item.kind === 'text') {
return (
<ItemWrapper key={`tx-${idx}`} divider={turnDivider}>
<div data-testid="subagent-transcript-text">
<BubbleMarkdown content={item.text} />
{isRunning && idx === lastTextIdx ? (
<span className="ml-0.5 inline-block h-3 w-1 animate-pulse bg-primary-400 align-middle" />
) : null}
</div>
</ItemWrapper>
);
}
const callTone =
item.status === 'running'
? 'text-amber-700 dark:text-amber-300'
: item.status === 'success'
? 'text-sage-700 dark:text-sage-300'
: 'text-coral-700 dark:text-coral-300';
const statusLabel =
item.status === 'running'
? t('conversations.subagent.statusRunning')
: item.status === 'success'
? t('conversations.subagent.statusCompleted')
: t('conversations.subagent.statusFailed');
return (
<ItemWrapper key={`tl-${item.callId}`} divider={turnDivider}>
<div
className="flex items-center gap-2 rounded-md border border-stone-200 bg-stone-50 px-2.5 py-1.5 text-xs dark:border-neutral-800 dark:bg-neutral-800/60"
data-testid="subagent-drawer-tool-call">
<span className={callTone}>🔧</span>
<span className="font-mono text-stone-700 dark:text-neutral-200">
{item.toolName}
</span>
<span className={`ml-auto ${callTone}`}>{statusLabel}</span>
{item.elapsedMs != null && item.status !== 'running' ? (
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{formatElapsed(item.elapsedMs)}
</span>
) : null}
</div>
</ItemWrapper>
);
})}
</ol>
)}
</div>
</aside>
</div>
);
}
/** Render a transcript row, prefixed by an optional "Turn N" divider. */
function ItemWrapper({ divider, children }: { divider: ReactNode; children: ReactNode }) {
return (
<>
{divider}
<li>{children}</li>
</>
);
}
@@ -35,7 +35,18 @@ function workerStatusFromEntry(
* present on the entry, which is true for any row produced by the
* `subagent_*` socket events from a current core.
*/
export function SubagentActivityBlock({ subagent }: { subagent: SubagentActivity }) {
/** Chars of streamed subagent text/thinking shown in the inline card tail. */
const SUBAGENT_PREVIEW_CHARS = 140;
export function SubagentActivityBlock({
subagent,
onView,
}: {
subagent: SubagentActivity;
/** Opens the full-transcript drawer for this subagent. Omitted in
* read-only contexts (e.g. a completed snapshot with no live driver). */
onView?: () => void;
}) {
const { t } = useT();
const headerBits: string[] = [];
if (subagent.mode) headerBits.push(subagent.mode);
@@ -58,6 +69,22 @@ export function SubagentActivityBlock({ subagent }: { subagent: SubagentActivity
: `${subagent.elapsedMs}ms`
);
}
// Live one-line preview of the subagent's streamed processing, derived
// from the ordered transcript: prefer the latest visible-output tail, then
// fall back to the latest reasoning tail while the child is still thinking
// and hasn't emitted visible text yet. Drives the at-a-glance "what is it
// doing right now" affordance on the card.
const transcript = subagent.transcript ?? [];
const lastTextItem = [...transcript].reverse().find(i => i.kind === 'text');
const lastThinkingItem = [...transcript].reverse().find(i => i.kind === 'thinking');
const previewItem = lastTextItem ?? lastThinkingItem;
const previewIcon = previewItem?.kind === 'text' ? '📝' : '💭';
const preview =
previewItem && 'text' in previewItem
? previewItem.text.replace(/\s+/g, ' ').trim().slice(-SUBAGENT_PREVIEW_CHARS)
: '';
return (
<div
className="mt-1 space-y-0.5 text-[10px] text-stone-500 dark:text-neutral-400"
@@ -109,11 +136,37 @@ export function SubagentActivityBlock({ subagent }: { subagent: SubagentActivity
})}
</ul>
) : null}
{preview ? (
<div
className="flex items-start gap-1 text-[10px] text-stone-500 dark:text-neutral-400"
data-testid="subagent-preview">
<span aria-hidden>{previewIcon}</span>
<span className="line-clamp-2 break-words italic">{preview}</span>
</div>
) : null}
{onView ? (
<button
type="button"
onClick={onView}
data-testid="subagent-view-processing"
className="mt-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-medium text-primary-600 hover:bg-primary-50 dark:text-primary-300 dark:hover:bg-primary-500/15">
{t('conversations.subagent.viewProcessing')}
</button>
) : null}
</div>
);
}
export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] }) {
export function ToolTimelineBlock({
entries,
onViewSubagent,
}: {
entries: ToolTimelineEntry[];
/** Opens the full-transcript drawer for a subagent row. When omitted,
* subagent cards render without the "view full processing" affordance
* (e.g. interrupted-snapshot rendering with no live driver). */
onViewSubagent?: (subagent: SubagentActivity) => void;
}) {
const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id;
const normalizeToolBody = (value?: string): string | undefined => {
@@ -199,7 +252,12 @@ export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] })
{detailContent}
</pre>
) : null}
{subagent ? <SubagentActivityBlock subagent={subagent} /> : null}
{subagent ? (
<SubagentActivityBlock
subagent={subagent}
onView={onViewSubagent ? () => onViewSubagent(subagent) : undefined}
/>
) : null}
</details>
) : (
<div className="ml-1 flex items-center gap-2">
@@ -0,0 +1,176 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { threadApi } from '../../../../services/api/threadApi';
import type { SubagentActivity, SubagentTranscriptItem } from '../../../../store/chatRuntimeSlice';
import { SubagentDrawer } from '../SubagentDrawer';
vi.mock('../../../../services/api/threadApi', () => ({
threadApi: { getThreadMessages: vi.fn() },
}));
function activity(overrides: Partial<SubagentActivity> = {}): SubagentActivity {
return { taskId: 'sub-1', agentId: 'researcher', toolCalls: [], transcript: [], ...overrides };
}
const INTERLEAVED: SubagentTranscriptItem[] = [
{ kind: 'thinking', iteration: 1, text: 'comparing the two sources' },
{ kind: 'text', iteration: 1, text: 'Let me search for that.' },
{
kind: 'tool',
iteration: 1,
callId: 'c1',
toolName: 'web_search',
status: 'success',
elapsedMs: 1200,
},
{ kind: 'text', iteration: 2, text: 'The answer is **42**.' },
];
describe('SubagentDrawer', () => {
it('renders nothing when no subagent is selected', () => {
const { container } = render(<SubagentDrawer subagent={null} onClose={() => {}} />);
expect(container.firstChild).toBeNull();
});
it('renders the transcript in chronological order (text where it occurred)', () => {
render(
<SubagentDrawer
subagent={activity({ transcript: INTERLEAVED })}
status="running"
onClose={() => {}}
/>
);
const drawer = screen.getByTestId('subagent-drawer');
expect(drawer.textContent).toContain('researcher');
// Walk the rendered transcript items and assert their on-screen order:
// thinking → text → tool → text — i.e. the tool sits between the two
// text blocks, not in a separate section.
const thinking = screen.getByTestId('subagent-transcript-thinking');
const tool = screen.getByTestId('subagent-drawer-tool-call');
const texts = screen.getAllByTestId('subagent-transcript-text');
expect(texts).toHaveLength(2);
const order = (el: Element) =>
Array.prototype.indexOf.call(drawer.querySelectorAll('[data-testid]'), el);
expect(order(thinking)).toBeLessThan(order(texts[0]));
expect(order(texts[0])).toBeLessThan(order(tool));
expect(order(tool)).toBeLessThan(order(texts[1]));
expect(thinking.textContent).toContain('comparing the two sources');
expect(tool.textContent).toContain('web_search');
expect(tool.textContent).toContain('1.2s');
expect(texts[1].textContent).toContain('The answer is');
});
it('opens with the parent delegation prompt as a chat bubble', () => {
render(
<SubagentDrawer
subagent={activity({
prompt: 'Research Q3 revenue drivers and summarise.',
transcript: [{ kind: 'text', iteration: 1, text: 'On it.' }],
})}
status="running"
onClose={() => {}}
/>
);
const parent = screen.getByTestId('subagent-parent-prompt');
expect(parent.textContent).toContain('Research Q3 revenue drivers');
// The parent bubble renders before the sub-agent's reply.
const drawer = screen.getByTestId('subagent-drawer');
const text = screen.getByTestId('subagent-transcript-text');
const order = (el: Element) =>
Array.prototype.indexOf.call(drawer.querySelectorAll('[data-testid]'), el);
expect(order(parent)).toBeLessThan(order(text));
});
it('inserts a turn divider when the iteration advances', () => {
render(
<SubagentDrawer
subagent={activity({ transcript: INTERLEAVED })}
status="running"
onClose={() => {}}
/>
);
// Two distinct iterations (1 and 2) → two turn dividers.
expect(screen.getAllByTestId('subagent-turn-divider')).toHaveLength(2);
});
it('shows a working placeholder while running with an empty transcript', () => {
render(<SubagentDrawer subagent={activity()} status="running" onClose={() => {}} />);
expect(screen.getByTestId('subagent-drawer').textContent).toContain('Working');
});
it('reopens from memory: fetches the worker thread when there is no live transcript', async () => {
vi.mocked(threadApi.getThreadMessages).mockResolvedValue({
count: 3,
messages: [
{
id: 'm0',
content: 'Research Q3 revenue.',
type: 'text',
sender: 'user',
createdAt: 't0',
extraMetadata: { scope: 'worker_thread' },
},
{
id: 'm1',
content: 'Searched the web.',
type: 'text',
sender: 'agent',
createdAt: 't1',
extraMetadata: { tool_name: 'web_search', iteration: 1 },
},
{
id: 'm2',
content: 'Revenue grew 18%.',
type: 'text',
sender: 'agent',
createdAt: 't2',
extraMetadata: { iteration: 2, final: true },
},
],
});
render(
<SubagentDrawer
subagent={activity({ workerThreadId: 'worker-abc', transcript: [] })}
status="success"
onClose={() => {}}
/>
);
await waitFor(() => expect(threadApi.getThreadMessages).toHaveBeenCalledWith('worker-abc'));
// The persisted conversation renders: parent prompt + a tool call + the text.
await waitFor(() =>
expect(screen.getByTestId('subagent-parent-prompt').textContent).toContain('Research Q3')
);
expect(screen.getByTestId('subagent-drawer-tool-call').textContent).toContain('web_search');
expect(screen.getByTestId('subagent-transcript-text').textContent).toContain(
'Revenue grew 18%'
);
});
it('does not fetch when a live transcript is present', () => {
render(
<SubagentDrawer
subagent={activity({
workerThreadId: 'worker-abc',
transcript: [{ kind: 'text', iteration: 1, text: 'live' }],
})}
status="running"
onClose={() => {}}
/>
);
expect(threadApi.getThreadMessages).not.toHaveBeenCalled();
});
it('invokes onClose from the close button', async () => {
const onClose = vi.fn();
render(<SubagentDrawer subagent={activity()} status="success" onClose={onClose} />);
await userEvent.click(screen.getByText('✕'));
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { store } from '../../../../store';
import type { ToolTimelineEntry } from '../../../../store/chatRuntimeSlice';
@@ -75,6 +76,62 @@ describe('SubagentActivityBlock', () => {
expect(calls[1].textContent).toContain('·t2');
expect(calls[2].textContent).toContain('error');
});
it('shows a live preview of streamed visible text (preferred over thinking)', () => {
renderInStore(
<SubagentActivityBlock
subagent={{
taskId: 't',
agentId: 'researcher',
toolCalls: [],
transcript: [
{ kind: 'thinking', iteration: 1, text: 'pondering the request' },
{ kind: 'text', iteration: 1, text: 'Here is what I found so far about the topic' },
],
}}
/>
);
const preview = screen.getByTestId('subagent-preview');
expect(preview.textContent).toContain('Here is what I found so far');
// Visible text takes precedence, so the thinking tail is not shown.
expect(preview.textContent).not.toContain('pondering');
});
it('falls back to the thinking tail while only reasoning has streamed', () => {
renderInStore(
<SubagentActivityBlock
subagent={{
taskId: 't',
agentId: 'researcher',
toolCalls: [],
transcript: [{ kind: 'thinking', iteration: 1, text: 'I should search the web first' }],
}}
/>
);
expect(screen.getByTestId('subagent-preview').textContent).toContain(
'I should search the web first'
);
});
it('renders the view-processing button only when onView is provided', async () => {
const onView = vi.fn();
const { rerender } = renderInStore(
<SubagentActivityBlock subagent={{ taskId: 't', agentId: 'researcher', toolCalls: [] }} />
);
expect(screen.queryByTestId('subagent-view-processing')).toBeNull();
rerender(
<Provider store={store}>
<SubagentActivityBlock
subagent={{ taskId: 't', agentId: 'researcher', toolCalls: [] }}
onView={onView}
/>
</Provider>
);
const btn = screen.getByTestId('subagent-view-processing');
await userEvent.click(btn);
expect(onView).toHaveBeenCalledTimes(1);
});
});
describe('ToolTimelineBlock — subagent rendering', () => {
+68 -2
View File
@@ -10,6 +10,8 @@ import {
type ChatIterationStartEvent,
type ChatSegmentEvent,
type ChatSubagentDoneEvent,
type ChatSubagentTextDeltaEvent,
type ChatSubagentThinkingDeltaEvent,
type ChatTaskBoardUpdatedEvent,
type ChatToolCallEvent,
type ChatToolResultEvent,
@@ -19,12 +21,15 @@ import {
} from '../services/chatService';
import { store } from '../store';
import {
appendSubagentStreamDelta,
clearInferenceStatusForThread,
clearPendingApprovalForThread,
clearStreamingAssistantForThread,
endInferenceTurn,
markInferenceTurnStreaming,
recordChatTurnUsage,
recordSubagentTranscriptTool,
resolveSubagentTranscriptTool,
setInferenceStatusForThread,
setPendingApprovalForThread,
setStreamingAssistantForThread,
@@ -297,7 +302,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const findPendingDelegationContext = (
entries: ToolTimelineEntry[],
round: number
): { sourceToolName?: string; prompt?: string } => {
): { sourceToolName?: string; prompt?: string; spawnEntryId?: string } => {
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry.status !== 'running' || entry.round !== round) continue;
@@ -305,6 +310,7 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
return {
sourceToolName: entry.name,
prompt: entry.detail ?? promptFromArgsBuffer(entry.argsBuffer),
spawnEntryId: entry.id,
};
}
}
@@ -456,11 +462,19 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const existing = store.getState().chatRuntime.toolTimelineByThread[event.thread_id] ?? [];
const pendingContext = findPendingDelegationContext(existing, event.round);
// Collapse the parent's `spawn_subagent`/`delegate_*` tool-call row into
// the subagent row so the timeline shows ONE entry per delegation
// instead of "Research" (the tool call) + "Researching" (the child).
// The tool call's prompt is carried onto the subagent as the parent's
// delegation message, which the drawer renders as the opening turn.
const base = pendingContext.spawnEntryId
? existing.filter(e => e.id !== pendingContext.spawnEntryId)
: existing;
dispatch(
setToolTimelineForThread({
threadId: event.thread_id,
entries: [
...existing,
...base,
decorateEntry({
id: `${event.thread_id}:subagent:${event.skill_id}:${event.tool_name}`,
name: `subagent:${event.tool_name}`,
@@ -471,9 +485,12 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
subagent: {
taskId: event.skill_id,
agentId: event.tool_name,
workerThreadId: event.subagent?.worker_thread_id,
mode: event.subagent?.mode,
dedicatedThread: event.subagent?.dedicated_thread,
prompt: pendingContext.prompt,
toolCalls: [],
transcript: [],
},
}),
],
@@ -562,6 +579,17 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
},
};
dispatch(setToolTimelineForThread({ threadId: event.thread_id, entries: next }));
// Mirror the call into the ordered transcript so the drawer renders
// it right after the text that triggered it (chronological view).
dispatch(
recordSubagentTranscriptTool({
threadId: event.thread_id,
rowId,
callId: event.tool_call_id,
toolName: event.tool_name,
iteration: event.subagent?.child_iteration,
})
);
},
onSubagentToolResult: event => {
const taskId = event.subagent?.task_id ?? event.skill_id;
@@ -585,6 +613,44 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => {
const next = [...existing];
next[idx] = { ...entry, subagent: { ...entry.subagent, toolCalls: updatedCalls } };
dispatch(setToolTimelineForThread({ threadId: event.thread_id, entries: next }));
dispatch(
resolveSubagentTranscriptTool({
threadId: event.thread_id,
rowId,
callId: event.tool_call_id,
success: event.success,
elapsedMs: event.subagent?.elapsed_ms,
outputChars: event.subagent?.output_chars,
})
);
},
onSubagentTextDelta: (event: ChatSubagentTextDeltaEvent) => {
const taskId = event.subagent?.task_id;
const agentId = event.subagent?.agent_id;
if (!taskId || !agentId || !event.delta) return;
dispatch(
appendSubagentStreamDelta({
threadId: event.thread_id,
rowId: `${event.thread_id}:subagent:${taskId}:${agentId}`,
kind: 'text',
delta: event.delta,
iteration: event.subagent?.child_iteration,
})
);
},
onSubagentThinkingDelta: (event: ChatSubagentThinkingDeltaEvent) => {
const taskId = event.subagent?.task_id;
const agentId = event.subagent?.agent_id;
if (!taskId || !agentId || !event.delta) return;
dispatch(
appendSubagentStreamDelta({
threadId: event.thread_id,
rowId: `${event.thread_id}:subagent:${taskId}:${agentId}`,
kind: 'thinking',
delta: event.delta,
iteration: event.subagent?.child_iteration,
})
);
},
onSegment: (event: ChatSegmentEvent) => {
const eventKey = `segment:${event.thread_id}:${event.request_id}:${event.segment_index}`;
@@ -125,6 +125,114 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria
expect(timeline[0]?.status).toBe('running');
});
it('collapses a spawn_subagent tool-call row into the subagent row', () => {
const listeners = renderProvider();
act(() => {
// Parent invokes the delegation tool — creates a "spawn_subagent" row.
listeners.onToolCall?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_name: 'spawn_subagent',
skill_id: 'orchestration',
args: {},
tool_call_id: 'call-spawn',
});
// The delegation prompt streams in as the tool's args JSON.
listeners.onToolArgsDelta?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_call_id: 'call-spawn',
tool_name: 'spawn_subagent',
delta: '{"prompt":"Research Q3 revenue."}',
});
// The child spawns — should REPLACE the tool-call row, not add a second.
listeners.onSubagentSpawned?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_name: 'researcher',
skill_id: 'sub-1',
message: 'spawned',
subagent: { mode: 'typed' },
});
});
const timeline = store.getState().chatRuntime.toolTimelineByThread['t1'] ?? [];
expect(timeline).toHaveLength(1);
expect(timeline[0]?.name).toBe('subagent:researcher');
// The parent's delegation prompt is carried onto the subagent so the
// drawer can open the conversation with it.
expect(timeline[0]?.subagent?.prompt).toContain('Research Q3 revenue');
});
it('appends streamed subagent text & thinking deltas to the subagent transcript', () => {
const listeners = renderProvider();
act(() => {
listeners.onSubagentSpawned?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_name: 'researcher',
skill_id: 'sub-1',
message: 'spawned',
subagent: { mode: 'typed' },
});
listeners.onSubagentThinkingDelta?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
delta: 'let me think',
subagent: { task_id: 'sub-1', agent_id: 'researcher', child_iteration: 1 },
});
listeners.onSubagentTextDelta?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
delta: 'the answer',
subagent: { task_id: 'sub-1', agent_id: 'researcher', child_iteration: 1 },
});
});
const row = (store.getState().chatRuntime.toolTimelineByThread['t1'] ?? []).find(
e => e.subagent?.taskId === 'sub-1'
);
expect(row?.subagent?.transcript).toEqual([
{ kind: 'thinking', iteration: 1, text: 'let me think' },
{ kind: 'text', iteration: 1, text: 'the answer' },
]);
});
it('ignores subagent deltas missing task/agent/delta', () => {
const listeners = renderProvider();
act(() => {
listeners.onSubagentSpawned?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
tool_name: 'researcher',
skill_id: 'sub-1',
message: 'spawned',
subagent: { mode: 'typed' },
});
// No agent_id → dropped.
listeners.onSubagentTextDelta?.({
thread_id: 't1',
request_id: 'r1',
round: 0,
delta: 'x',
subagent: { task_id: 'sub-1' },
});
});
const row = (store.getState().chatRuntime.toolTimelineByThread['t1'] ?? []).find(
e => e.subagent?.taskId === 'sub-1'
);
expect(row?.subagent?.transcript).toEqual([]);
});
it('drops duplicate chat_done events with the same thread/request', async () => {
const listeners = renderProvider();
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
type ChatSubagentTextDeltaEvent,
type ChatSubagentThinkingDeltaEvent,
subscribeChatEvents,
} from '../chatService';
import { socketService } from '../socketService';
/**
* A minimal fake socket.io client capturing `.on` registrations so a test can
* fire a given event by name and assert the wired callback runs.
*/
function fakeSocket() {
const handlers = new Map<string, (payload: unknown) => void>();
return {
on: vi.fn((event: string, cb: (payload: unknown) => void) => {
handlers.set(event, cb);
}),
off: vi.fn((event: string) => {
handlers.delete(event);
}),
emit: (event: string, payload: unknown) => handlers.get(event)?.(payload),
has: (event: string) => handlers.has(event),
};
}
describe('subscribeChatEvents — subagent delta events', () => {
let socket: ReturnType<typeof fakeSocket>;
beforeEach(() => {
socket = fakeSocket();
vi.spyOn(socketService, 'getSocket').mockReturnValue(
socket as unknown as ReturnType<typeof socketService.getSocket>
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('registers and dispatches subagent_text_delta / subagent_thinking_delta', () => {
const onSubagentTextDelta = vi.fn();
const onSubagentThinkingDelta = vi.fn();
const unsubscribe = subscribeChatEvents({ onSubagentTextDelta, onSubagentThinkingDelta });
expect(socket.has('subagent_text_delta')).toBe(true);
expect(socket.has('subagent_thinking_delta')).toBe(true);
const textEvent: ChatSubagentTextDeltaEvent = {
thread_id: 't1',
request_id: 'r1',
round: 1,
delta: 'hello',
subagent: { task_id: 'sub-1', agent_id: 'researcher', child_iteration: 1 },
};
const thinkingEvent: ChatSubagentThinkingDeltaEvent = {
thread_id: 't1',
request_id: 'r1',
round: 1,
delta: 'pondering',
subagent: { task_id: 'sub-1', agent_id: 'researcher', child_iteration: 1 },
};
socket.emit('subagent_text_delta', textEvent);
socket.emit('subagent_thinking_delta', thinkingEvent);
expect(onSubagentTextDelta).toHaveBeenCalledWith(textEvent);
expect(onSubagentThinkingDelta).toHaveBeenCalledWith(thinkingEvent);
unsubscribe();
});
it('does not register subagent delta handlers when listeners are omitted', () => {
subscribeChatEvents({});
expect(socket.has('subagent_text_delta')).toBe(false);
expect(socket.has('subagent_thinking_delta')).toBe(false);
});
it('returns a no-op unsubscribe when there is no socket', () => {
vi.spyOn(socketService, 'getSocket').mockReturnValue(
null as unknown as ReturnType<typeof socketService.getSocket>
);
expect(() => subscribeChatEvents({ onSubagentTextDelta: vi.fn() })()).not.toThrow();
});
});
+71
View File
@@ -196,6 +196,8 @@ export interface SubagentProgressDetail {
elapsed_ms?: number;
iterations?: number;
output_chars?: number;
/** Persistent worker sub-thread id backing the delegation (on `subagent_spawned`). */
worker_thread_id?: string;
}
/** Extended payload for `subagent_spawned`. */
@@ -250,6 +252,37 @@ export interface ChatSubagentToolResultEvent {
subagent?: SubagentProgressDetail;
}
/**
* Emitted for each chunk of a sub-agent's streamed assistant text while
* the child iteration is in flight. Distinct from `text_delta` (which is
* the parent's own output) so the UI attributes the token to the running
* subagent row via `subagent.task_id` / `subagent.agent_id` and renders
* it in that row's live transcript. Concatenating `delta`s in order
* yields the child's visible text for the iteration.
*/
export interface ChatSubagentTextDeltaEvent {
thread_id: string;
request_id: string;
/** Parent iteration index (inherited from the parent context). */
round: number;
/** Text fragment from the sub-agent. */
delta: string;
subagent?: SubagentProgressDetail;
}
/**
* Emitted for each chunk of a sub-agent's streamed reasoning / thinking
* output. Counterpart to `thinking_delta` scoped to a child run only
* sent by models that expose `reasoning_content`.
*/
export interface ChatSubagentThinkingDeltaEvent {
thread_id: string;
request_id: string;
round: number;
delta: string;
subagent?: SubagentProgressDetail;
}
/**
* Emitted for each chunk of streamed assistant text that arrives from the
* provider during an iteration. Concatenating `delta` values in order yields
@@ -309,6 +342,8 @@ export interface ChatEventListeners {
onSubagentIterationStart?: (event: ChatSubagentIterationStartEvent) => void;
onSubagentToolCall?: (event: ChatSubagentToolCallEvent) => void;
onSubagentToolResult?: (event: ChatSubagentToolResultEvent) => void;
onSubagentTextDelta?: (event: ChatSubagentTextDeltaEvent) => void;
onSubagentThinkingDelta?: (event: ChatSubagentThinkingDeltaEvent) => void;
onSegment?: (event: ChatSegmentEvent) => void;
onTextDelta?: (event: ChatTextDeltaEvent) => void;
onThinkingDelta?: (event: ChatThinkingDeltaEvent) => void;
@@ -339,6 +374,8 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
subagentIterationStart: 'subagent_iteration_start',
subagentToolCall: 'subagent_tool_call',
subagentToolResult: 'subagent_tool_result',
subagentTextDelta: 'subagent_text_delta',
subagentThinkingDelta: 'subagent_thinking_delta',
segment: 'chat_segment',
textDelta: 'text_delta',
thinkingDelta: 'thinking_delta',
@@ -513,6 +550,40 @@ export function subscribeChatEvents(listeners: ChatEventListeners): () => void {
handlers.push([EVENTS.subagentToolResult, cb]);
}
if (listeners.onSubagentTextDelta) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentTextDeltaEvent;
chatLog(
'%s thread_id=%s task=%s child_round=%s chars=%d',
EVENTS.subagentTextDelta,
e.thread_id,
e.subagent?.task_id,
e.subagent?.child_iteration,
e.delta?.length ?? 0
);
listeners.onSubagentTextDelta?.(e);
};
socket.on(EVENTS.subagentTextDelta, cb);
handlers.push([EVENTS.subagentTextDelta, cb]);
}
if (listeners.onSubagentThinkingDelta) {
const cb = (payload: unknown) => {
const e = payload as ChatSubagentThinkingDeltaEvent;
chatLog(
'%s thread_id=%s task=%s child_round=%s chars=%d',
EVENTS.subagentThinkingDelta,
e.thread_id,
e.subagent?.task_id,
e.subagent?.child_iteration,
e.delta?.length ?? 0
);
listeners.onSubagentThinkingDelta?.(e);
};
socket.on(EVENTS.subagentThinkingDelta, cb);
handlers.push([EVENTS.subagentThinkingDelta, cb]);
}
if (listeners.onSegment) {
const cb = (payload: unknown) => {
const e = payload as ChatSegmentEvent;
@@ -0,0 +1,192 @@
import { describe, expect, it } from 'vitest';
import reducer, {
appendSubagentStreamDelta,
recordSubagentTranscriptTool,
resolveSubagentTranscriptTool,
setToolTimelineForThread,
type SubagentTranscriptItem,
type ToolTimelineEntry,
} from '../chatRuntimeSlice';
const THREAD = 'thread-1';
const ROW_ID = `${THREAD}:subagent:sub-1:researcher`;
function withSubagentRow(): ReturnType<typeof reducer> {
const entry: ToolTimelineEntry = {
id: ROW_ID,
name: 'subagent:researcher',
round: 1,
status: 'running',
subagent: { taskId: 'sub-1', agentId: 'researcher', toolCalls: [], transcript: [] },
};
return reducer(undefined, setToolTimelineForThread({ threadId: THREAD, entries: [entry] }));
}
function transcriptOf(state: ReturnType<typeof reducer>): SubagentTranscriptItem[] {
return state.toolTimelineByThread[THREAD][0].subagent?.transcript ?? [];
}
describe('subagent transcript reducers', () => {
it('accumulates consecutive same-kind deltas into one transcript item', () => {
let state = withSubagentRow();
state = reducer(
state,
appendSubagentStreamDelta({
threadId: THREAD,
rowId: ROW_ID,
kind: 'text',
delta: 'Hello ',
iteration: 1,
})
);
state = reducer(
state,
appendSubagentStreamDelta({
threadId: THREAD,
rowId: ROW_ID,
kind: 'text',
delta: 'world',
iteration: 1,
})
);
const t = transcriptOf(state);
expect(t).toHaveLength(1);
expect(t[0]).toMatchObject({ kind: 'text', text: 'Hello world', iteration: 1 });
});
it('does not merge same-kind deltas from different iterations', () => {
let state = withSubagentRow();
state = reducer(
state,
appendSubagentStreamDelta({
threadId: THREAD,
rowId: ROW_ID,
kind: 'text',
delta: 'turn one',
iteration: 1,
})
);
state = reducer(
state,
appendSubagentStreamDelta({
threadId: THREAD,
rowId: ROW_ID,
kind: 'text',
delta: 'turn two',
iteration: 2,
})
);
const t = transcriptOf(state);
expect(t).toHaveLength(2);
expect(t.map(i => (i.kind === 'text' ? i.iteration : null))).toEqual([1, 2]);
});
it('interleaves thinking, text, and tool calls in arrival order', () => {
let state = withSubagentRow();
// turn 1: think → text → tool call
state = reducer(
state,
appendSubagentStreamDelta({
threadId: THREAD,
rowId: ROW_ID,
kind: 'thinking',
delta: 'I should search',
iteration: 1,
})
);
state = reducer(
state,
appendSubagentStreamDelta({
threadId: THREAD,
rowId: ROW_ID,
kind: 'text',
delta: 'Let me look that up.',
iteration: 1,
})
);
state = reducer(
state,
recordSubagentTranscriptTool({
threadId: THREAD,
rowId: ROW_ID,
callId: 'c1',
toolName: 'web_search',
iteration: 1,
})
);
// turn 2: text only (final answer)
state = reducer(
state,
appendSubagentStreamDelta({
threadId: THREAD,
rowId: ROW_ID,
kind: 'text',
delta: 'Found it.',
iteration: 2,
})
);
const t = transcriptOf(state);
expect(t.map(i => i.kind)).toEqual(['thinking', 'text', 'tool', 'text']);
// The tool sits between the turn-1 text and the turn-2 text — i.e. where
// it actually occurred, not bucketed into a separate section.
expect(t[2]).toMatchObject({ kind: 'tool', callId: 'c1', status: 'running' });
expect(t[3]).toMatchObject({ kind: 'text', text: 'Found it.', iteration: 2 });
});
it('resolves a transcript tool item to its terminal status with timing', () => {
let state = withSubagentRow();
state = reducer(
state,
recordSubagentTranscriptTool({
threadId: THREAD,
rowId: ROW_ID,
callId: 'c1',
toolName: 'web_search',
iteration: 1,
})
);
state = reducer(
state,
resolveSubagentTranscriptTool({
threadId: THREAD,
rowId: ROW_ID,
callId: 'c1',
success: true,
elapsedMs: 1200,
outputChars: 480,
})
);
const tool = transcriptOf(state).find(i => i.kind === 'tool');
expect(tool).toMatchObject({ status: 'success', elapsedMs: 1200, outputChars: 480 });
});
it('de-dupes a redelivered tool-call event by callId', () => {
let state = withSubagentRow();
const record = recordSubagentTranscriptTool({
threadId: THREAD,
rowId: ROW_ID,
callId: 'c1',
toolName: 'web_search',
});
state = reducer(state, record);
state = reducer(state, record);
expect(transcriptOf(state).filter(i => i.kind === 'tool')).toHaveLength(1);
});
it('is a no-op when the thread or row is unknown', () => {
const state = withSubagentRow();
expect(
reducer(
state,
appendSubagentStreamDelta({ threadId: 'nope', rowId: ROW_ID, kind: 'text', delta: 'x' })
)
).toEqual(state);
const unknownRow = reducer(
state,
appendSubagentStreamDelta({ threadId: THREAD, rowId: 'missing', kind: 'text', delta: 'x' })
);
expect(transcriptOf(unknownRow)).toHaveLength(0);
});
});
+157
View File
@@ -38,10 +38,24 @@ export interface SubagentActivity {
taskId: string;
/** Sub-agent definition id (e.g. `researcher`). */
agentId: string;
/**
* Persistent worker sub-thread id (`worker-<uuid>`) backing this
* delegation, when one was created. Lets the drawer reopen the full
* parentsubagent conversation from memory (via `threadApi.getThreadMessages`)
* after the live transcript is gone navigation, cold boot, etc.
*/
workerThreadId?: string;
/** Resolved spawn mode — `"typed"` or `"fork"`. */
mode?: string;
/** `true` when the spawn requested a dedicated worker thread. */
dedicatedThread?: boolean;
/**
* The parent's delegation prompt what the parent agent asked this
* sub-agent to do. Rendered as the opening (parent) turn in the drawer's
* parentsubagent chat. Captured from the originating `spawn_subagent` /
* `delegate_*` tool call when the row is created.
*/
prompt?: string;
/** Sub-agent's current 1-based iteration index (live). */
childIteration?: number;
/** Sub-agent's iteration cap. */
@@ -54,8 +68,44 @@ export interface SubagentActivity {
outputChars?: number;
/** Child tool calls executed inside the sub-agent, in arrival order. */
toolCalls: SubagentToolCallEntry[];
/**
* Ordered, interleaved record of everything the sub-agent did, in the
* exact sequence it happened: a run of streamed thinking, then streamed
* visible text, then the tool calls that text triggered, then the next
* iteration's thinking/text, and so on. This is what the full-processing
* drawer renders so reasoning, output, and tool calls appear *where they
* occurred* instead of being split into three flat sections.
*
* Built incrementally from the `subagent_text_delta` /
* `subagent_thinking_delta` / `subagent_tool_call` / `subagent_tool_result`
* socket events in arrival order (the core flushes a child's text/thinking
* deltas before its tool-call events within an iteration, so arrival order
* is chronological order). Text is **not** persisted to the turn-state
* snapshot on rehydration the transcript is rebuilt from the persisted
* `toolCalls` (tool items only), so an interrupted run still shows its
* tool sequence. Absent on legacy/test rows that predate streaming.
*/
transcript?: SubagentTranscriptItem[];
}
/**
* One entry in a sub-agent's ordered {@link SubagentActivity.transcript}.
* A `thinking`/`text` item accumulates streamed deltas; a `tool` item is a
* child tool call whose `status` flips on its result event.
*/
export type SubagentTranscriptItem =
| { kind: 'thinking'; iteration?: number; text: string }
| { kind: 'text'; iteration?: number; text: string }
| {
kind: 'tool';
iteration?: number;
callId: string;
toolName: string;
status: ToolTimelineEntryStatus;
elapsedMs?: number;
outputChars?: number;
};
/** One child tool call performed by a running sub-agent. */
export interface SubagentToolCallEntry {
/** Provider-assigned tool call id. */
@@ -174,6 +224,7 @@ function subagentActivityFromPersisted(activity: PersistedSubagentActivity): Sub
return {
taskId: activity.taskId,
agentId: activity.agentId,
workerThreadId: activity.workerThreadId,
mode: activity.mode,
dedicatedThread: activity.dedicatedThread,
childIteration: activity.childIteration,
@@ -182,6 +233,19 @@ 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,
})),
};
}
@@ -230,6 +294,96 @@ const chatRuntimeSlice = createSlice({
clearToolTimelineForThread: (state, action: PayloadAction<{ threadId: string }>) => {
delete state.toolTimelineByThread[action.payload.threadId];
},
/**
* Append a streamed `subagent_text_delta` / `subagent_thinking_delta`
* chunk to the ordered transcript of the matching subagent row. The row
* is located by its synthetic id (`<thread>:subagent:<taskId>:<agentId>`)
* built from the event's subagent detail the same id the
* `subagent_spawned` handler created.
*
* Consecutive deltas of the same kind extend the trailing transcript
* item; a kind switch (or an intervening tool call) starts a new item.
* That keeps reasoning, output, and tool calls in the exact order they
* occurred. No-ops if the row isn't present yet (a delta racing ahead of
* its spawn event is dropped rather than resurrecting a context-less row).
*/
appendSubagentStreamDelta: (
state,
action: PayloadAction<{
threadId: string;
rowId: string;
kind: 'text' | 'thinking';
delta: string;
iteration?: number;
}>
) => {
const { threadId, rowId, kind, delta, iteration } = action.payload;
const entry = state.toolTimelineByThread[threadId]?.find(e => e.id === rowId);
if (!entry?.subagent) return;
const transcript = (entry.subagent.transcript ??= []);
const last = transcript[transcript.length - 1];
// Extend the trailing item only when it's the same kind AND the same
// iteration — otherwise two same-kind chunks from different turns (with
// no tool call between them) would fuse into one transcript entry.
if (
last &&
(last.kind === 'text' || last.kind === 'thinking') &&
last.kind === kind &&
last.iteration === iteration
) {
last.text += delta;
} else {
transcript.push({ kind, iteration, text: delta });
}
},
/**
* Record the start of a child tool call as a `tool` item at the current
* tail of the subagent transcript i.e. right after the text that
* triggered it. De-duped by `callId` so a socket redelivery doesn't
* append twice. Complements the flat `toolCalls` list (kept for the
* compact card + persistence).
*/
recordSubagentTranscriptTool: (
state,
action: PayloadAction<{
threadId: string;
rowId: string;
callId: string;
toolName: string;
iteration?: number;
}>
) => {
const { threadId, rowId, callId, toolName, iteration } = 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' });
},
/**
* Flip a transcript `tool` item to its terminal status when the child
* tool result arrives, recording timing/size. No-op if the matching
* item isn't present.
*/
resolveSubagentTranscriptTool: (
state,
action: PayloadAction<{
threadId: string;
rowId: string;
callId: string;
success: boolean;
elapsedMs?: number;
outputChars?: number;
}>
) => {
const { threadId, rowId, callId, success, elapsedMs, outputChars } = action.payload;
const entry = state.toolTimelineByThread[threadId]?.find(e => e.id === rowId);
const item = entry?.subagent?.transcript?.find(i => i.kind === 'tool' && i.callId === callId);
if (!item || item.kind !== 'tool') return;
item.status = success ? 'success' : 'error';
if (elapsedMs != null) item.elapsedMs = elapsedMs;
if (outputChars != null) item.outputChars = outputChars;
},
setTaskBoardForThread: (
state,
action: PayloadAction<{ threadId: string; board: TaskBoard }>
@@ -356,6 +510,9 @@ export const {
clearStreamingAssistantForThread,
setToolTimelineForThread,
clearToolTimelineForThread,
appendSubagentStreamDelta,
recordSubagentTranscriptTool,
resolveSubagentTranscriptTool,
setTaskBoardForThread,
clearTaskBoardForThread,
setPendingApprovalForThread,
+2
View File
@@ -67,6 +67,8 @@ export interface PersistedSubagentActivity {
iterations?: number;
elapsedMs?: number;
outputChars?: number;
/** Persistent worker sub-thread id backing this delegation (camelCase from core). */
workerThreadId?: string;
toolCalls: PersistedSubagentToolCall[];
}
+5
View File
@@ -251,6 +251,11 @@ pub struct SubagentProgressDetail {
/// (on `subagent_tool_result`).
#[serde(skip_serializing_if = "Option::is_none")]
pub output_chars: Option<u64>,
/// Persistent worker sub-thread id backing the delegation (on
/// `subagent_spawned`). The frontend stores it on the subagent row and
/// uses it to reopen the full parent↔subagent conversation from memory.
#[serde(skip_serializing_if = "Option::is_none")]
pub worker_thread_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -31,7 +31,9 @@ use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::context::prompt::{
render_subagent_system_prompt, PromptContext, PromptTool, SubagentRenderOptions,
};
use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, Provider, ToolCall};
use crate::openhuman::inference::provider::{
ChatMessage, ChatRequest, Provider, ProviderDelta, ToolCall,
};
use crate::openhuman::memory_conversations::ConversationMessage;
use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec};
use crate::openhuman::util::truncate_with_ellipsis;
@@ -1449,17 +1451,77 @@ async fn run_inner_loop(
.await;
}
let resp = provider
// Stream the child's tokens to the parent's progress sink so the
// UI can render the sub-agent's thinking/output live, attributed
// to this row via `task_id`. Mirrors the main turn loop
// (`session/turn.rs`): only set up the SSE sink when a listener
// exists, otherwise the channel buffer would back-pressure the
// provider and we'd lose the non-streaming HTTP fast path for
// providers that don't implement streaming.
let child_iteration_for_stream = (iteration + 1) as u32;
let (delta_tx_opt, delta_forwarder) = if let Some(ref sink) = progress_sink {
let (tx, mut rx) = tokio::sync::mpsc::channel::<ProviderDelta>(128);
let sink = sink.clone();
let agent_id_for_stream = agent_id.to_string();
let task_id_for_stream = task_id.to_string();
let forwarder = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
// Only visible text and reasoning deltas attribute to
// the subagent transcript; tool-call arg fragments are
// already surfaced via SubagentToolCall* lifecycle
// events, so they're dropped here to avoid double-render.
let mapped = match event {
ProviderDelta::TextDelta { delta } => AgentProgress::SubagentTextDelta {
agent_id: agent_id_for_stream.clone(),
task_id: task_id_for_stream.clone(),
delta,
iteration: child_iteration_for_stream,
},
ProviderDelta::ThinkingDelta { delta } => {
AgentProgress::SubagentThinkingDelta {
agent_id: agent_id_for_stream.clone(),
task_id: task_id_for_stream.clone(),
delta,
iteration: child_iteration_for_stream,
}
}
ProviderDelta::ToolCallStart { .. }
| ProviderDelta::ToolCallArgsDelta { .. } => continue,
};
// Await backpressure so streamed deltas arrive in order
// and aren't silently dropped when the downstream
// progress bridge is slow.
if sink.send(mapped).await.is_err() {
break;
}
}
});
(Some(tx), Some(forwarder))
} else {
(None, None)
};
let chat_result = provider
.chat(
ChatRequest {
messages: history.as_slice(),
tools: request_tools,
stream: None,
stream: delta_tx_opt.as_ref(),
},
model,
temperature,
)
.await?;
.await;
// Drop the sender so the forwarder task observes channel close and
// terminates instead of leaking. This must run on BOTH the success
// and error paths — propagating the provider error with `?` before
// joining the forwarder would orphan the task and leak the sender.
drop(delta_tx_opt);
if let Some(forwarder) = delta_forwarder {
let _ = forwarder.await;
}
let resp = chat_result?;
if let Some(ref u) = resp.usage {
usage.input_tokens += u.input_tokens;
@@ -174,7 +174,7 @@ fn append_subagent_role_contract_is_idempotent() {
use crate::openhuman::agent::harness::fork_context::with_parent_context;
use crate::openhuman::inference::provider::{
ChatRequest as PChatRequest, ChatResponse, Provider, ToolCall,
ChatRequest as PChatRequest, ChatResponse, Provider, ProviderDelta, ToolCall,
};
use parking_lot::Mutex;
use std::sync::Arc;
@@ -225,16 +225,45 @@ impl Provider for ScriptedProvider {
tool_count: request.tools.map_or(0, |tools| tools.len()),
model: model.to_string(),
});
let mut q = self.responses.lock();
if q.is_empty() {
return Ok(ChatResponse {
text: Some(String::new()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
});
let response = {
let mut q = self.responses.lock();
if q.is_empty() {
ChatResponse {
text: Some(String::new()),
tool_calls: vec![],
usage: None,
reasoning_content: None,
}
} else {
q.remove(0)
}
};
// Mirror a real streaming provider: when the caller attached a
// `stream` sink, forward this response's reasoning then visible
// text as `ProviderDelta`s before returning the aggregate. Lets
// the subagent runner's per-iteration sink exercise the
// `SubagentThinkingDelta` / `SubagentTextDelta` forwarding path.
if let Some(sink) = request.stream {
if let Some(reasoning) = response.reasoning_content.as_deref() {
if !reasoning.is_empty() {
let _ = sink
.send(ProviderDelta::ThinkingDelta {
delta: reasoning.to_string(),
})
.await;
}
}
if let Some(text) = response.text.as_deref() {
if !text.is_empty() {
let _ = sink
.send(ProviderDelta::TextDelta {
delta: text.to_string(),
})
.await;
}
}
}
Ok(q.remove(0))
Ok(response)
}
fn supports_native_tools(&self) -> bool {
@@ -251,6 +280,15 @@ fn text_response(text: &str) -> ChatResponse {
}
}
fn text_response_with_reasoning(text: &str, reasoning: &str) -> ChatResponse {
ChatResponse {
text: Some(text.into()),
tool_calls: vec![],
usage: None,
reasoning_content: Some(reasoning.into()),
}
}
fn tool_response(name: &str, args: &str) -> ChatResponse {
ChatResponse {
text: Some(String::new()),
@@ -859,6 +897,91 @@ async fn typed_mode_emits_child_progress_events_when_sink_attached() {
assert_eq!(tool_done[0].2, 1);
}
/// A sub-agent's streamed visible text and reasoning are forwarded to the
/// parent's progress sink as `SubagentTextDelta` / `SubagentThinkingDelta`
/// events tagged with the child's `agent_id` / `task_id`, in order, and
/// the concatenated text deltas reconstruct the final assistant text. The
/// web-channel bridge turns these into `subagent_text_delta` /
/// `subagent_thinking_delta` socket events the parent thread renders live.
#[tokio::test]
async fn typed_mode_forwards_child_text_and_thinking_deltas() {
use crate::openhuman::agent::progress::AgentProgress;
let provider = ScriptedProvider::new(vec![text_response_with_reasoning(
"the final answer",
"let me reason about this",
)]);
let mut parent = make_parent(provider, vec![]);
let (tx, mut rx) = tokio::sync::mpsc::channel::<AgentProgress>(64);
parent.on_progress = Some(tx);
let def = make_def_named_tools(&[]);
let outcome = with_parent_context(parent, async {
run_subagent(&def, "answer me", SubagentRunOptions::default()).await
})
.await
.expect("runner should succeed");
assert_eq!(outcome.output, "the final answer");
let mut events = Vec::new();
while let Some(ev) = rx.recv().await {
events.push(ev);
}
let thinking: Vec<_> = events
.iter()
.filter_map(|e| match e {
AgentProgress::SubagentThinkingDelta {
agent_id,
task_id,
delta,
iteration,
} => Some((agent_id.clone(), task_id.clone(), delta.clone(), *iteration)),
_ => None,
})
.collect();
assert_eq!(thinking.len(), 1, "one thinking delta forwarded");
assert_eq!(thinking[0].2, "let me reason about this");
assert_eq!(thinking[0].3, 1, "tagged with the child iteration");
assert!(!thinking[0].1.is_empty(), "carries the child task id");
let text: Vec<_> = events
.iter()
.filter_map(|e| match e {
AgentProgress::SubagentTextDelta {
agent_id,
task_id,
delta,
iteration,
} => Some((agent_id.clone(), task_id.clone(), delta.clone(), *iteration)),
_ => None,
})
.collect();
assert_eq!(text.len(), 1, "one text delta forwarded");
assert_eq!(text[0].2, "the final answer");
assert_eq!(text[0].3, 1);
// Same child identity on both delta kinds so the UI attributes them to
// one subagent row.
assert_eq!(text[0].0, thinking[0].0, "same agent_id");
assert_eq!(text[0].1, thinking[0].1, "same task_id");
// Ordering: the thinking delta precedes the text delta within the
// iteration, matching the provider's emission order.
let thinking_pos = events
.iter()
.position(|e| matches!(e, AgentProgress::SubagentThinkingDelta { .. }))
.unwrap();
let text_pos = events
.iter()
.position(|e| matches!(e, AgentProgress::SubagentTextDelta { .. }))
.unwrap();
assert!(
thinking_pos < text_pos,
"thinking streams before visible text"
);
}
/// Runs without an attached sink must remain backwards compatible — the
/// runner is a no-op for child progress and the outcome is unchanged.
#[tokio::test]
+33
View File
@@ -68,6 +68,11 @@ pub enum AgentProgress {
/// whether to render the prompt detail inline or behind a
/// "show more" affordance.
prompt_chars: usize,
/// Persistent worker sub-thread id backing this delegation, when
/// one was created (`worker-<uuid>`). The UI uses it to reopen the
/// full parent↔subagent conversation from memory after the live
/// turn ends. `None` for live-only runs (no parent context).
worker_thread_id: Option<String>,
},
/// A sub-agent completed successfully.
@@ -132,6 +137,34 @@ pub enum AgentProgress {
iteration: u32,
},
/// A chunk of a sub-agent's visible assistant text arrived from the
/// provider while the child iteration is still in flight. Distinct
/// from [`Self::TextDelta`] so the parent thread can attribute the
/// streamed token to a specific live subagent row (via `task_id`)
/// and render it inside that row's transcript instead of merging it
/// into the parent's own streaming buffer. Emitted **only from
/// inside [`crate::openhuman::agent::harness::subagent_runner`]** when
/// the parent context carries an `on_progress` sink.
SubagentTextDelta {
agent_id: String,
task_id: String,
delta: String,
/// 1-based child iteration index this delta belongs to.
iteration: u32,
},
/// A chunk of a sub-agent's model reasoning / thinking output
/// arrived (for models that emit `reasoning_content`). Counterpart
/// to [`Self::ThinkingDelta`] scoped to a child run — see
/// [`Self::SubagentTextDelta`] for the attribution rationale.
SubagentThinkingDelta {
agent_id: String,
task_id: String,
delta: String,
/// 1-based child iteration index.
iteration: u32,
},
/// The agent rewrote the per-thread task board. Emitted by the
/// `todo` tool (or `openhuman.todos_*` RPC) after the board has been persisted.
TaskBoardUpdated {
+1
View File
@@ -421,6 +421,7 @@ impl AgentOrchestrationSession {
mode: "typed".to_string(),
dedicated_thread: false,
prompt_chars: prompt.chars().count(),
worker_thread_id: None,
})
.await;
}
@@ -13,6 +13,8 @@ pub mod spawn_worker_thread;
#[cfg(test)]
#[path = "tools/tools_e2e_tests.rs"]
mod tools_e2e_tests;
#[path = "tools/worker_thread.rs"]
mod worker_thread;
pub(crate) use dispatch::dispatch_subagent;
@@ -57,6 +57,7 @@ pub(crate) async fn dispatch_subagent(
mode: "typed".to_string(),
dedicated_thread: false,
prompt_chars: prompt.chars().count(),
worker_thread_id: None,
})
.await;
}
@@ -265,6 +265,7 @@ impl Tool for SpawnParallelAgentsTool {
mode: "typed".to_string(),
dedicated_thread: false,
prompt_chars: prompt.chars().count(),
worker_thread_id: None,
})
.await
{
@@ -392,6 +392,25 @@ impl Tool for SpawnSubagentTool {
.unwrap_or_else(|| "standalone".into());
let task_id = format!("sub-{}", uuid::Uuid::new_v4());
// Persist this delegation as a reopenable worker sub-thread, seeded
// with the prompt, so the parent↔subagent conversation survives
// navigation and restarts — the same machinery `spawn_worker_thread`
// uses. Best-effort: with no parent context or thread store the run
// still proceeds live-only (`worker_thread_id: None`).
let worker_thread_id = current_parent().and_then(|p| {
let parent_thread_id =
crate::openhuman::inference::provider::thread_context::current_thread_id()?;
let title: String = prompt.chars().take(60).collect();
super::worker_thread::create_worker_thread(
p.workspace_dir.clone(),
&parent_thread_id,
&definition.id,
&title,
&prompt,
)
.ok()
});
publish_global(DomainEvent::SubagentSpawned {
parent_session: parent_session.clone(),
agent_id: definition.id.clone(),
@@ -413,6 +432,7 @@ impl Tool for SpawnSubagentTool {
mode: "typed".to_string(),
dedicated_thread,
prompt_chars: prompt.chars().count(),
worker_thread_id: worker_thread_id.clone(),
})
.await;
}
@@ -424,7 +444,7 @@ impl Tool for SpawnSubagentTool {
context,
model_override,
task_id: Some(task_id.clone()),
worker_thread_id: None,
worker_thread_id: worker_thread_id.clone(),
};
let progress_sink = current_parent().and_then(|p| p.on_progress.clone());
@@ -12,9 +12,7 @@
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::agent::harness::fork_context::current_parent;
use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions};
use crate::openhuman::memory_conversations::{
self as conversations, ConversationMessage, CreateConversationThread,
};
use crate::openhuman::memory_conversations::{self as conversations};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
use async_trait::async_trait;
use serde_json::json;
@@ -183,46 +181,14 @@ impl Tool for SpawnWorkerThreadTool {
.ok_or_else(|| anyhow::anyhow!("agent_id '{}' not found", agent_id))?;
// ── Create Worker Thread ───────────────────────────────────────
let worker_thread_id = format!("worker-{}", uuid::Uuid::new_v4());
let now = chrono::Utc::now().to_rfc3339();
conversations::ensure_thread(
// Shared with `spawn_subagent` so both delegation paths persist an
// identical, reopenable sub-thread seeded with the prompt.
let worker_thread_id = super::worker_thread::create_worker_thread(
parent.workspace_dir.clone(),
CreateConversationThread {
id: worker_thread_id.clone(),
title: task_title.clone(),
created_at: now.clone(),
parent_thread_id: Some(current_thread_id.clone()),
labels: Some(vec!["worker".to_string()]),
personality_id: None,
},
)
.map_err(|e| anyhow::anyhow!(e))?;
tracing::info!(
agent_id = %agent_id,
worker_thread_id = %worker_thread_id,
parent_thread_id = %current_thread_id,
task_title = %task_title,
created_at = %now,
"[spawn_worker_thread] created worker thread"
);
// Append initial user message to the worker thread
conversations::append_message(
parent.workspace_dir.clone(),
&worker_thread_id,
ConversationMessage {
id: format!("user:{}", uuid::Uuid::new_v4()),
content: prompt.clone(),
message_type: "text".to_string(),
extra_metadata: json!({
"scope": "worker_thread",
"agent_id": agent_id,
}),
sender: "user".to_string(),
created_at: now,
},
&current_thread_id,
&agent_id,
&task_title,
&prompt,
)
.map_err(|e| anyhow::anyhow!(e))?;
@@ -294,6 +260,7 @@ mod tests {
};
use crate::openhuman::agent::harness::fork_context::with_parent_context;
use crate::openhuman::agent::harness::ParentExecutionContext;
use crate::openhuman::memory_conversations::{ConversationMessage, CreateConversationThread};
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::TempDir;
@@ -0,0 +1,114 @@
//! Shared helper for materialising a sub-agent's work as a persistent
//! conversation sub-thread.
//!
//! Both `spawn_worker_thread` (explicit) and `spawn_subagent` (inline)
//! back their run with a `worker-<uuid>` thread linked to the parent so the
//! delegation is reopenable from memory and rendered as a parent↔subagent
//! chat. The thread is created with `parent_thread_id` set (which hides it
//! from the main sidebar — it surfaces in the "Workers" tab and the
//! subagent drawer instead) and seeded with the delegation prompt as the
//! opening `user` message. The sub-agent runner then appends each turn and
//! tool result to the same thread via its `worker_thread_id` sink.
use std::path::PathBuf;
use serde_json::json;
use crate::openhuman::memory_conversations::{
self as conversations, ConversationMessage, CreateConversationThread,
};
/// Create a worker sub-thread linked to `parent_thread_id` and seed it with
/// the delegation `prompt` as the opening user message. Returns the new
/// thread id, or an `Err` string if the thread store rejected the create.
///
/// The seed-message append is best-effort: a failure there is logged but
/// does not fail the call (the thread still exists and the runner will
/// append the sub-agent's turns).
pub(crate) fn create_worker_thread(
workspace_dir: PathBuf,
parent_thread_id: &str,
agent_id: &str,
title: &str,
prompt: &str,
) -> Result<String, String> {
let worker_thread_id = format!("worker-{}", uuid::Uuid::new_v4());
let now = chrono::Utc::now().to_rfc3339();
conversations::ensure_thread(
workspace_dir.clone(),
CreateConversationThread {
id: worker_thread_id.clone(),
title: title.to_string(),
created_at: now.clone(),
parent_thread_id: Some(parent_thread_id.to_string()),
labels: Some(vec!["worker".to_string()]),
personality_id: None,
},
)?;
tracing::info!(
agent_id = %agent_id,
worker_thread_id = %worker_thread_id,
parent_thread_id = %parent_thread_id,
"[worker_thread] created sub-thread for delegation"
);
if let Err(err) = conversations::append_message(
workspace_dir,
&worker_thread_id,
ConversationMessage {
id: format!("user:{}", uuid::Uuid::new_v4()),
content: prompt.to_string(),
message_type: "text".to_string(),
extra_metadata: json!({ "scope": "worker_thread", "agent_id": agent_id }),
sender: "user".to_string(),
created_at: now,
},
) {
tracing::warn!(
worker_thread_id = %worker_thread_id,
error = %err,
"[worker_thread] failed to seed delegation prompt (continuing)"
);
}
Ok(worker_thread_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_child_thread_linked_to_parent_and_seeds_prompt() {
let dir = std::env::temp_dir().join(format!("wt-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let id = create_worker_thread(
dir.clone(),
"parent-thread-1",
"researcher",
"Q3",
"Find Q3",
)
.expect("thread should be created");
// The new thread is labelled `worker` and linked to the parent — that
// link is what hides it from the main sidebar (Workers tab only).
let threads = conversations::list_threads(dir.clone()).unwrap();
let thread = threads
.iter()
.find(|t| t.id == id)
.expect("thread persisted");
assert_eq!(thread.parent_thread_id.as_deref(), Some("parent-thread-1"));
assert!(thread.labels.contains(&"worker".to_string()));
// It opens with the delegation prompt as the user message, so the
// drawer can render the parent↔subagent chat from memory on reopen.
let messages = conversations::get_messages(dir, &id).unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender, "user");
assert_eq!(messages[0].content, "Find Q3");
}
}
+50
View File
@@ -1102,6 +1102,7 @@ fn spawn_progress_bridge(
mode,
dedicated_thread,
prompt_chars,
worker_thread_id,
} => {
publish_web_channel_event(WebChannelEvent {
event: "subagent_spawned".to_string(),
@@ -1116,6 +1117,7 @@ fn spawn_progress_bridge(
mode: Some(mode),
dedicated_thread: Some(dedicated_thread),
prompt_chars: Some(prompt_chars as u64),
worker_thread_id,
..Default::default()
}),
..Default::default()
@@ -1252,6 +1254,54 @@ fn spawn_progress_bridge(
..Default::default()
});
}
AgentProgress::SubagentTextDelta {
agent_id,
task_id,
delta,
iteration,
} => {
publish_web_channel_event(WebChannelEvent {
event: "subagent_text_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(round),
delta: Some(delta),
delta_kind: Some("text".to_string()),
skill_id: Some(task_id.clone()),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
..Default::default()
}),
..Default::default()
});
}
AgentProgress::SubagentThinkingDelta {
agent_id,
task_id,
delta,
iteration,
} => {
publish_web_channel_event(WebChannelEvent {
event: "subagent_thinking_delta".to_string(),
client_id: client_id.clone(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
round: Some(round),
delta: Some(delta),
delta_kind: Some("thinking".to_string()),
skill_id: Some(task_id.clone()),
subagent: Some(SubagentProgressDetail {
child_iteration: Some(iteration),
agent_id: Some(agent_id),
task_id: Some(task_id),
..Default::default()
}),
..Default::default()
});
}
AgentProgress::TaskBoardUpdated { board } => {
log::debug!(
"[web_channel][bridge] task_board_updated client_id={} thread_id={} request_id={} cards={}",
+2
View File
@@ -168,6 +168,8 @@ pub fn format_event(ev: &AgentProgress) -> Option<String> {
| AgentProgress::ToolCallArgsDelta { .. }
| AgentProgress::TurnCostUpdated { .. }
| AgentProgress::TaskBoardUpdated { .. }
| AgentProgress::SubagentTextDelta { .. }
| AgentProgress::SubagentThinkingDelta { .. }
| AgentProgress::SubagentIterationStarted { .. } => return None,
};
Some(format!(
@@ -144,6 +144,7 @@ impl TurnStateMirror {
task_id,
mode,
dedicated_thread,
worker_thread_id,
..
} => {
self.state.phase = Some(TurnPhase::Subagent);
@@ -167,6 +168,7 @@ impl TurnStateMirror {
iterations: None,
elapsed_ms: None,
output_chars: None,
worker_thread_id: worker_thread_id.clone(),
tool_calls: Vec::new(),
}),
});
@@ -265,6 +267,17 @@ impl TurnStateMirror {
}
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.
false
}
AgentProgress::TaskBoardUpdated { board } => {
self.state.task_board = Some(board.clone());
self.flush();
@@ -224,6 +224,7 @@ fn subagent_lifecycle_records_and_clears_active() {
mode: "typed".into(),
dedicated_thread: false,
prompt_chars: 42,
worker_thread_id: None,
});
let s = m.snapshot();
assert_eq!(s.active_subagent.as_deref(), Some("researcher"));
@@ -87,6 +87,11 @@ pub struct SubagentActivity {
pub elapsed_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_chars: Option<usize>,
/// Persistent worker sub-thread backing this delegation, when one was
/// created. Lets the UI reopen the full parent↔subagent conversation
/// from memory after a cold boot / interrupted turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_thread_id: Option<String>,
#[serde(default)]
pub tool_calls: Vec<SubagentToolCall>,
}