Repository navigation
feat(agent): live streaming subagent view with reopenable sub-threads - #3007
Conversation
Sub-agents previously ran non-streaming (provider.chat with stream: None) and only emitted lifecycle counters, so their "processing" surfaced as static colored pills that filled in after the run. The final message just popped in at the end. This streams a sub-agent's reasoning and visible output token-by-token and gives it two UI surfaces: a richer live inline card and a full-transcript drawer. Core (Rust): - progress.rs: new SubagentTextDelta / SubagentThinkingDelta variants (agent_id, task_id, delta, iteration). - subagent_runner/ops.rs: the per-iteration provider.chat now builds a ProviderDelta streaming sink (mirroring session/turn.rs), maps child text/reasoning deltas onto the new variants, and forwards them to the parent's on_progress. Gated on a listener being attached so CLI / triage / tests keep the non-streaming fast path. - channels/providers/web.rs: bridge arms emitting subagent_text_delta / subagent_thinking_delta socket events with delta + subagent detail. - turn_state/mirror.rs + skills/run_log.rs: handle the new variants as live-only / non-persisted. Frontend: - chatService.ts: event types, listeners, EVENTS entries, registrations. - chatRuntimeSlice.ts: SubagentActivity gains live-only streamingText / streamingThinking; new appendSubagentStreamDelta reducer. - ChatRuntimeProvider.tsx: wires the two delta handlers to the matching subagent row. - ToolTimelineBlock.tsx: the old pills become a richer card with a live one-line preview and a "View full processing" button. - SubagentDrawer.tsx (new): slide-in panel with the full live transcript (streamed reasoning, Markdown output with cursor, child tool calls), re-derived from Redux so it streams live. Wired into Conversations.tsx. - i18n: 10 new keys across en.ts + all 14 locale chunk files. Tests: subagent_runner delta ordering/attribution test; turn_state mirror unchanged-green; Vitest for the reducer, the card preview/button, and the drawer.
…t chat
Two follow-ups to the subagent streaming work:
1. Collapse the duplicate timeline rows. A delegation previously showed
TWO entries — the parent's `spawn_subagent`/`delegate_*` tool call
("Research") and the child `subagent:*` row ("Researching"). The spawn
tool-call row is now removed when the subagent row is created
(ChatRuntimeProvider.onSubagentSpawned), and its delegation prompt is
carried onto the subagent as `prompt` for the drawer's opening turn.
2. Render the drawer as a parent↔subagent conversation. The delegation
prompt opens it as a right-aligned "Parent" bubble (the input); the
sub-agent replies below as its interleaved transcript (thinking, the
text it produced, the tool calls that text triggered, the next turn).
Adds `prompt` to SubagentActivity and a `conversations.subagent.parent`
i18n key (en + all locale chunks).
Also fixes the colour tokens: the new card/drawer used non-existent
`ocean-*` classes (no-ops) — switched to the real brand `primary-*`.
Tests: provider dedupe+prompt-capture test; drawer parent-prompt + order
tests; existing suites green.
Make ordinary `spawn_subagent` delegations persist like dedicated worker threads do, so the parent↔subagent conversation survives navigation and restarts and can be reopened from memory. Backend: - New shared helper `agent_orchestration/tools/worker_thread.rs::create_worker_thread` — creates a `worker-<uuid>` thread linked to the parent (`parent_thread_id`, which hides it from the main sidebar; it surfaces in the Workers tab) and seeds it with the delegation prompt as the opening user message. `spawn_worker_thread` now reuses it; `spawn_subagent` calls it and passes the resulting `worker_thread_id` to the runner so each turn + tool result is appended to the same thread. - `AgentProgress::SubagentSpawned` + `SubagentProgressDetail` carry `worker_thread_id`; web.rs bridges it on the `subagent_spawned` event; turn-state mirror persists it so a cold-boot snapshot can reopen too. - Other emitters (dispatch / orchestration ops / parallel) pass None for now. Frontend: - SubagentActivity + PersistedSubagentActivity gain `workerThreadId`; set on spawn and carried through snapshot rehydration. - SubagentDrawer reopens from memory: when there's no live transcript but a worker thread backs the row, it fetches that thread's messages (threadApi.getThreadMessages) and rebuilds the conversation (prompt + tool calls + text). Live transcript still wins while streaming. Tests: worker_thread helper (thread linked + prompt seeded); drawer reopen-from-memory + no-fetch-when-live; existing Rust + Vitest suites green.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (4)
📝 WalkthroughWalkthroughStreams subagent thinking/text deltas from providers into AgentProgress, emits web-channel events, subscribes in frontend chat service, updates Redux transcript state, and surfaces live or persisted subagent transcripts in a new SubagentDrawer UI; worker threads are persisted when possible and i18n keys added across locales. ChangesSubagent Transcript Streaming & Display
Estimated code review effort 🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
Resolves the i18n conflict from upstream tinyhumansai#3004 (chunked chunks/<locale>-N.ts layout retired → single flat <locale>.ts files): - Accept upstream's deletion of all chunks/* files. - Re-home the conversations.subagent.* keys into en.ts (already auto-merged) and add them to every flat locale file with REAL translations (not English placeholders) — satisfies both i18n:check (parity) and i18n:english:check (no untranslated English). - CLAUDE.md i18n rule updated: locale files must carry actual translations, never English placeholders.
The diff-coverage gate (≥80% on changed lines) flagged the new subagent streaming wiring as under-covered: - chatService.ts subagent_text_delta / subagent_thinking_delta socket registration (was 12.5%) — new chatService.subagentDelta test registers both via a fake socket, fires each event, and asserts the listener fires (+ the no-listener and no-socket paths). - ChatRuntimeProvider onSubagentTextDelta / onSubagentThinkingDelta handlers (was 28.6%) — new tests append a streamed text+thinking delta to the subagent transcript, plus the guard path that drops deltas missing task/agent/delta.
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/providers/ChatRuntimeProvider.tsx (1)
470-497:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDeduplicate subagent rows when
subagent_spawnedis re-delivered.Line 470 builds a new subagent row every time, but only removes
spawnEntryId. If the same spawn event is delivered twice, you end up with duplicateids in the timeline.💡 Proposed fix
- const base = pendingContext.spawnEntryId - ? existing.filter(e => e.id !== pendingContext.spawnEntryId) - : existing; + const subagentRowId = `${event.thread_id}:subagent:${event.skill_id}:${event.tool_name}`; + const base = existing.filter( + e => e.id !== pendingContext.spawnEntryId && e.id !== subagentRowId + ); dispatch( setToolTimelineForThread({ threadId: event.thread_id, entries: [ ...base, decorateEntry({ - id: `${event.thread_id}:subagent:${event.skill_id}:${event.tool_name}`, + id: subagentRowId, name: `subagent:${event.tool_name}`,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/providers/ChatRuntimeProvider.tsx` around lines 470 - 497, The code always appends a new subagent entry (built via decorateEntry with id `${event.thread_id}:subagent:${event.skill_id}:${event.tool_name}`) but only removes pendingContext.spawnEntryId, which allows duplicate entries when the same spawn event is re-delivered; update the logic in the setToolTimelineForThread dispatch to compute the newSubagentId (the same template above) and filter existing entries to remove both pendingContext.spawnEntryId (if present) and any entry whose id equals newSubagentId before appending the decorated entry, or alternatively deduplicate by id after building the new entries array so the timeline contains at most one entry per id.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/pages/conversations/components/SubagentDrawer.tsx`:
- Around line 121-124: When workerThreadId changes while needsFetch is true we
must clear stale transcript state so the drawer doesn't show the previous
thread's data; update the effect or fetch logic that depends on
workerThreadId/needsFetch (the state variables fetched and setFetched and the
hook that triggers the fetch) to setFetched(null) (or a dedicated "loading"
state) immediately when workerThreadId changes or when a new fetch is initiated,
then populate fetched only after the new request resolves; ensure the
cleanup/cancel path also doesn't repopulate fetched for an old request (use a
request id or abort controller tied to workerThreadId) so only the current
thread's response updates fetched.
- Line 305: SubagentDrawer renders raw enum values via item.status; change it to
use localized labels by passing item.status through the translation hook useT()
(or a small mapping helper) and render the translated string instead of the raw
enum. Specifically, import/use useT() in SubagentDrawer.tsx, create a mapping or
helper like getStatusLabel(status) that calls t('subagent.status.running'),
t('subagent.status.success'), t('subagent.status.error') (or similar keys), and
replace <span className={`ml-auto ${callTone}`}>{item.status}</span> with the
translated label; ensure all enum branches are covered and keys are added to the
i18n resource bundle.
In `@app/src/store/chatRuntimeSlice.ts`:
- Around line 325-329: The transcript merging logic currently only checks
last.kind and will append delta across different iterations; update the merge
condition in the block handling transcript updates (the variables last,
transcript, kind, iteration) to require both last.kind === kind AND
last.iteration === iteration (or equivalent) before concatenating last.text +=
delta, otherwise always push a new { kind, iteration, text: delta } entry to
preserve turn boundaries.
In `@CLAUDE.md`:
- Line 369: Unify the contradictory i18n guidance by making the policy
consistent: update the paragraph that references en.ts, INTENTIONAL_ENGLISH, and
the checks (pnpm i18n:check and pnpm i18n:english:check /
scripts/i18n-find-english.ts) so it clearly states that all non-English locale
files must contain real translations (no English fallbacks), and keep the
INTENTIONAL_ENGLISH allowlist description as the only exception for genuine
brand/command/path/unit literals; remove or rewrite the sentence that implies
other locales may fall back to English so the document consistently enforces
translation parity against en.ts.
In `@src/openhuman/agent/harness/subagent_runner/ops.rs`:
- Around line 1503-1520: The provider.chat(...).await? can return early and skip
the cleanup of the streaming forwarder; change the flow to capture the Result
from provider.chat (call provider.chat in provider.chat(...).await and store
e.g. resp_res), then always perform the cleanup: drop(delta_tx_opt) and if let
Some(forwarder) = delta_forwarder { let _ = forwarder.await; } before
propagating the error (i.e. return resp_res? or map_err), ensuring symbols
provider.chat, delta_tx_opt, delta_forwarder, and forwarder are used exactly as
in the diff so the forwarder task is awaited even when chat fails.
---
Outside diff comments:
In `@app/src/providers/ChatRuntimeProvider.tsx`:
- Around line 470-497: The code always appends a new subagent entry (built via
decorateEntry with id
`${event.thread_id}:subagent:${event.skill_id}:${event.tool_name}`) but only
removes pendingContext.spawnEntryId, which allows duplicate entries when the
same spawn event is re-delivered; update the logic in the
setToolTimelineForThread dispatch to compute the newSubagentId (the same
template above) and filter existing entries to remove both
pendingContext.spawnEntryId (if present) and any entry whose id equals
newSubagentId before appending the decorated entry, or alternatively deduplicate
by id after building the new entries array so the timeline contains at most one
entry per id.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1396d558-b0ee-4a86-a74b-f5a436727d57
📒 Files selected for processing (43)
CLAUDE.mdapp/src/lib/i18n/ar.tsapp/src/lib/i18n/bn.tsapp/src/lib/i18n/de.tsapp/src/lib/i18n/en.tsapp/src/lib/i18n/es.tsapp/src/lib/i18n/fr.tsapp/src/lib/i18n/hi.tsapp/src/lib/i18n/id.tsapp/src/lib/i18n/it.tsapp/src/lib/i18n/ko.tsapp/src/lib/i18n/pl.tsapp/src/lib/i18n/pt.tsapp/src/lib/i18n/ru.tsapp/src/lib/i18n/zh-CN.tsapp/src/pages/Conversations.tsxapp/src/pages/conversations/components/SubagentDrawer.tsxapp/src/pages/conversations/components/ToolTimelineBlock.tsxapp/src/pages/conversations/components/__tests__/SubagentDrawer.test.tsxapp/src/pages/conversations/components/__tests__/ToolTimelineBlock.test.tsxapp/src/providers/ChatRuntimeProvider.tsxapp/src/providers/__tests__/ChatRuntimeProvider.test.tsxapp/src/services/__tests__/chatService.subagentDelta.test.tsapp/src/services/chatService.tsapp/src/store/__tests__/chatRuntimeSlice.subagentStream.test.tsapp/src/store/chatRuntimeSlice.tsapp/src/types/turnState.tssrc/core/socketio.rssrc/openhuman/agent/harness/subagent_runner/ops.rssrc/openhuman/agent/harness/subagent_runner/ops_tests.rssrc/openhuman/agent/progress.rssrc/openhuman/agent_orchestration/ops.rssrc/openhuman/agent_orchestration/tools.rssrc/openhuman/agent_orchestration/tools/dispatch.rssrc/openhuman/agent_orchestration/tools/spawn_parallel_agents.rssrc/openhuman/agent_orchestration/tools/spawn_subagent.rssrc/openhuman/agent_orchestration/tools/spawn_worker_thread.rssrc/openhuman/agent_orchestration/tools/worker_thread.rssrc/openhuman/channels/providers/web.rssrc/openhuman/skills/run_log.rssrc/openhuman/threads/turn_state/mirror.rssrc/openhuman/threads/turn_state/mirror_tests.rssrc/openhuman/threads/turn_state/types.rs
- SubagentDrawer: tag the reopen-fetched transcript with its workerThreadId and only render it when it matches the current row, so switching subagents can't briefly show a previous thread's conversation; clear stale state when a new fetch starts. - SubagentDrawer: localize the transcript tool-call status label via useT() instead of rendering the raw running/success/error enum. - chatRuntimeSlice: only merge adjacent same-kind transcript deltas when they share the same iteration, so two same-kind chunks from different turns (no tool call between) don't fuse into one entry. - subagent_runner: drop the stream sender + join the forwarder on BOTH the success and error paths (was leaking the task when provider.chat errored via early `?`). - CLAUDE.md: reconcile the i18n guidance — English fallback is the runtime safety net for missing keys, not a license to ship untranslated values; real translations required in every locale.
Summary
spawn_subagenttool call and the subagent row).spawn_subagentnow persists a reopenable worker sub-thread, so the conversation survives navigation/restart and the drawer can rebuild it from memory.Problem
Subagent activity was opaque: the UI showed static colored pills in the tool timeline and only surfaced the final message when the turn completed — no live view of what the child was thinking or doing. It also rendered the delegation as two confusing rows ("Research" tool call + "Researching" subagent), split text and tool calls into separate sections (losing the chronology of a real LLM turn), and ordinary
spawn_subagentruns were ephemeral — once the live stream ended there was no way to reopen the loop.Solution
AgentProgress::Subagent{Text,Thinking}Deltavariants; the subagent runner builds aProviderDeltastreaming sink (mirroring the main turn loop) and forwards child deltas to the parent'son_progress, gated on a listener so CLI/triage/tests keep the non-streaming fast path. Bridged tosubagent_text_delta/subagent_thinking_deltasocket events.SubagentActivitygains an orderedtranscript(thinking/text/tool items); consecutive same-kind deltas extend the trailing item, tool calls slot in where they fired. The drawer renders this chronologically with per-turn dividers and a live cursor.onSubagentSpawnedremoves the originatingspawn_subagent/delegate_*tool-call row and carries its prompt onto the subagent.create_worker_threadhelper (reused byspawn_worker_thread) creates aworker-<uuid>thread linked to the parent (hidden from the sidebar, surfaced in the Workers tab) seeded with the prompt;worker_thread_idflows through the spawn event + turn-state snapshot. The drawer fetches that thread viathreadApi.getThreadMessageswhen no live transcript is present (reopen-from-memory); the live transcript wins while streaming.Design notes/tradeoffs: streamed text/thinking is display-only and not persisted to the turn-state snapshot (the child's final answer already lands in the thread); reopened transcripts therefore omit streamed reasoning. The
dispatch/ orchestration-ops/spawn_parallel_agentsspawn paths still passworker_thread_id: None(follow-up) — the commonspawn_subagentpath is fully wired.Submission Checklist
diff-coverenforces the gate.Impact
worker_thread_idfield (all additive/optional — older clients/snapshots stay parseable).spawn_subagentnow writes a small worker sub-thread (parent-linked, so excluded from the main thread list). Streaming sink is only set up when a progress listener exists, so non-interactive runs are unaffected.Related
worker_thread_idpersistence into thedispatch/ orchestration-ops/spawn_parallel_agentsspawn paths.AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
Validation Run
pnpm --filter openhuman-app format:check(prettier --write on changed files + cargo fmt)pnpm typecheck— cleancargo fmt+cargo check --bin openhuman-corecleanapp/src-tauri) not modified.Validation Blocked
command:N/Aerror:N/Aimpact:N/ABehavior Changes
Parity Contract
Duplicate / Superseded PR Handling
Summary by CodeRabbit
New Features
Localization