Repository navigation
Conversation
Adds OpenSpec change tui-subagents-panel with proposal, design, specs, and tasks for capturing the stream namespace in callProvider, routing subagent events to a dedicated store, and rendering a SUBAGENTS panel. Closes #1360
Captures the stream namespace in callProvider so subagent subgraphs emit distinct subagent_* event types, routes them to a dedicated subagent store in the TUI streaming handler, and renders a SUBAGENTS panel. - index.js: emit subagent_message/subagent_reasoning/subagent_tool/ subagent_tool_start/subagent_tool_end when namespace.length > 0 - src/tui/subagentStore.js: pure store for folding subagent events - src/tui/subagentsPanel.js: renders subagent segments and tool calls - src/tui/conversationArea.js: route subagent events to the store - src/tui/panels.js: add SUBAGENTS panel - src/tui/app.js: wire SUBAGENTS branch - tests: subagentsPanel.test.js + update panels/tui tests Closes #1360
Archives the OpenSpec change tui-subagents-panel and syncs the delta specs into openspec/specs/ (tui-streaming updated, tui-subagents-panel created).
Audit Results — tui-subagents-panelGoal Fulfillment
Spec Compliance
Task CompletionAll 8 tasks in Quality Check
Iterations
|
The subagent discriminator used namespace.length > 0, but LangGraph's subgraphs: true yields the root graph's namespace as [""] (an empty checkpoint_ns split on "|" produces [""], length 1, not []). This misrouted the orchestrator's own messages, reasoning, and tool calls as subagent_* events, so they were folded into the SUBAGENTS store and never rendered in the conversation view. Discriminate on whether the namespace has a non-empty component instead. Add a regression test documenting the [""] root-graph namespace.
The previous fix used a non-empty-component check, but the orchestrator's own output is emitted with a populated namespace (["model_request:<uuid>"]) under subgraphs: true. That still misrouted orchestrator messages as subagent_* events. The correct discriminator is a tools: prefix in the namespace — subagent subgraphs spawned by a tool call carry a tools: segment (e.g. ["tools:call_abc123", "model_request:<uuid>"]), while the orchestrator's own output has none. This mirrors the deepagents library's own isSubagentNamespace helper. Verified empirically via a streaming probe: orchestrator events carry ["model_request:<uuid>"] and are now classified as non-subagent.
The tools: prefix discriminator misrouted the orchestrator's own tool calls as subagent activity. The orchestrator's tool calls are emitted under a single-segment namespace (["tools:<id>"], length 1), while a subagent subgraph always adds a second segment (a nested model_request: or tools:). A namespace is subagent-scoped only when it has a tools: segment AND more than one segment. Verified empirically against the orchestrator's ls/task tool calls and subagent message/tool-call namespaces.
Subagent event routing — namespace discriminatorThis PR routes subagent activity to a dedicated SUBAGENTS store. The discriminator that separates orchestrator events from subagent events lives in The namespace structure (with
|
| Source | Namespace | Length |
|---|---|---|
| Orchestrator message | ["model_request:<uuid>"] |
1 |
| Orchestrator tool call | ["tools:<id>"] |
1 |
| Subagent message | ["tools:<id>", "model_request:<uuid>"] |
2 |
| Subagent tool call / result | ["tools:<id>", "tools:<subid>"] |
2 |
The rule
const isSubagent =
Array.isArray(namespace) &&
namespace.length > 1 &&
namespace.some((part) => part.startsWith("tools:"));A namespace is subagent-scoped only when it has a tools: segment and more than one segment. The orchestrator's own tool calls are emitted under a single tools: segment (length 1), so they stay in the conversation view. A subagent subgraph always adds a second segment (a nested model_request: or tools:), so it routes to the SUBAGENTS store.
Why not a UUID match?
There is no stable programmatic orchestrator identifier. The ReactAgent exposes no id/uuid attribute — its own keys are options and compactContext, and its prototype exposes graph, checkpointer, store, invoke, stream, etc. The model_request:<uuid> in the namespace is a per-request model ID, not a persistent orchestrator identity. So the discriminator is structural (namespace shape), not a UUID match.
History of the fix
- Initial:
namespace.length > 0— wrong. The root graph yields["model_request:<uuid>"](length 1), so the orchestrator's own messages were misrouted as subagent activity. - Second attempt:
namespace.some(part => part.startsWith("tools:"))— still wrong. The orchestrator's own tool calls are emitted under["tools:<id>"], so they were misrouted too. - Final:
namespace.length > 1 && namespace.some(part => part.startsWith("tools:"))— correct. Verified empirically against the orchestrator'sls/tasktool calls and subagent message/tool-call namespaces.
All 4215 tests pass; lint is clean.
The SUBAGENTS panel was registered in panels.js but unreachable — no slash command, no keyboard shortcut, and nextPanel/prevPanel were defined but never wired. Add /subagents to the view-switching commands and document it in the /help output, mirroring the /projects pattern.
The SUBAGENTS panel had no useInput handler, so Escape did nothing while in the subagent view — there was no way back to the orchestrator/conversation view. Add the same Escape-to-conversation pattern used by the skills panel.
The Escape handler referenced onViewChange but the component signature only destructured subagentStore and activeView, causing a ReferenceError when Escape was pressed in the subagents view. Add onViewChange to the destructuring and document it in the JSDoc.
The ConversationArea was unmounted whenever a panel view was active, so returning to the conversation view remounted it fresh and lost the session (message list, scroll position, streaming state). Pressing Escape from the subagents view appeared to start a new chat. Keep the ConversationArea mounted and overlay panels on top via absolute positioning. Remove the now-redundant loadConversation reload from handleViewChange. The app-level key handler gates on currentView, so Escape in a panel view only switches back to conversation — it never reaches the interrupt path while a panel is active.
View commands (/skills, /subagents, /memories, /sessions, /settings, /projects) are pure panel switches and should not kill an in-flight stream. handleSubmit now skips the interrupt when the parsed command action is 'view'. State-mutating commands like /projects clear still interrupt, since they dispatch a message to the LLM.
The subagent store pushed a new segment per stream chunk, so a subagent's message arrived as dozens of tiny fragments that MessageBubble rendered as a jumbled blend. Mirror the MessageList.updateMessage coalescing rules: message chunks append to the last message segment, reasoning coalesces (backwards-search, respecting grammatical sentence boundaries), and tool segments stay as their own blocks.
The ctrl+r (reasoning) and ctrl+t (tool-call) collapse toggles were gated behind the conversation-view early return, so they were dead in panel views. Move them to fire before that return so they work globally, and thread the collapse state through SubagentsPanel to MessageBubble so the subagents view respects the same toggles as the conversation view.
Two gaps fixed: 1. The subagent store was a useRef, so subagent events never triggered a React re-render — the panel showed stale output until the stream ended. Convert it to state so each update re-renders the panel live. 2. The panel had no scroll container — it truncated to the terminal height with no way to track output. Wire it to VirtualScrollView with auto-scroll-to-bottom (respecting manual scroll-up) and route the app-level scroll keys (up/down/page) to the panel when the subagents view is active.
The subagent store lived as state in ConversationArea, but the SUBAGENTS panel read it via getSubagentStore() during App's render. A child's state change doesn't re-render the parent, so the panel only refreshed when App re-rendered for another reason (e.g. ctrl+r). Lift the store to App state and have ConversationArea report each subagent event via onSubagentUpdate, so App re-renders and passes the fresh store to the panel. Also simplify the panel to mimic the conversation list: just message bubbles, no 'Subagents (N)' header and no extra wrapper.
VirtualScrollView keys items by item.id (used for the height map and React
keys). The subagents panel passed raw store entries with no id field, so all
items collided on undefined and the panel rendered blank. Map each entry to
{ id: namespace, ...entry } so the scroll view can key and measure them.
The subagents panel now renders reasoning/message/tool segments exactly like the main message list, and the global ctrl+r / ctrl+t collapse toggles apply to it. The panel threads reasoningCollapsed and toolCallCollapsed through to MessageBubble, and uses a friendly 'Subagent' assistantName label instead of the raw namespace header.
…name Tool calls in the subagents panel now render with the 🔧 glyph (matching the orchestrator's tool display) instead of a bullet list, and the assistant name label is lowercase 'subagent'.
Description
Adds a dedicated SUBAGENTS panel to the TUI that captures and renders each subagent's reasoning, message segments, and tool calls separately from the orchestrator's conversation.
callProvidernow captures the stream namespace so subagent subgraphs emit distinctsubagent_*event types, which are routed to a dedicated subagent store keyed by namespace instead of the orchestrator's assistant bubble.Also fixes two related issues surfaced while building the panel: view-switching slash commands (
/subagents,/skills,/memories, etc.) no longer interrupt an in-flight stream, and the ConversationArea stays mounted across panel views so message list and session state survive the round-trip.Type of Change
Testing
Added
tests/unit/tui/subagentsPanel.test.js(16 tests) covering namespace discrimination, event routing, and tool-call redirection. Addedtests/unit/tui/repro-subagents-escape.test.jsfor the Escape-to-conversation behavior. Updatedtests/unit/tui/panels.test.js,tests/unit/tui.test.js, andtests/unit/tui/commandParser.test.jsfor the new SUBAGENTS panel andisViewCommandhelper. Verified withnpm run test(4219 passing),npm run lint(0 errors), andnpm run coverage(subagentStore.jsat 100% line coverage).Coverage
Checklist
npm run lintpassesCloses #1360