mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(agent): live streaming subagent view with reopenable sub-threads (#3007)
This commit is contained in:
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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': 'تنزيل أحدث إصدار',
|
||||
|
||||
@@ -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': 'সর্বশেষ সংস্করণ ডাউনলোড করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 d’outils',
|
||||
'conversations.subagent.working': 'En cours…',
|
||||
'conversations.subagent.noOutputYet': 'Aucun résultat pour l’instant',
|
||||
'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',
|
||||
|
||||
@@ -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': 'नवीनतम संस्करण डाउनलोड करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '최신 버전 다운로드',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Скачать последнюю версию',
|
||||
|
||||
@@ -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': '下载最新版本',
|
||||
|
||||
@@ -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 parent↔subagent 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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* parent↔subagent 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
|
||||
* parent↔subagent 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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
¤t_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");
|
||||
}
|
||||
}
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user