diff --git a/index.js b/index.js index 07ade0ae..7ffc3c0e 100644 --- a/index.js +++ b/index.js @@ -226,12 +226,35 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, messages: [new HumanMessage(message)], }; - for await (const [_namespace, mode, payload] of await agent.stream(input, { + for await (const [namespace, mode, payload] of await agent.stream(input, { ...config, ...options, streamMode: ["messages", "tools"], subgraphs: true, })) { + // With `subgraphs: true`, the root graph yields a namespace like + // `["model_request:"]` and each subagent subgraph yields a + // namespace containing a `tools:` segment (e.g. + // `["tools:call_abc123", "model_request:"]`). The orchestrator's + // own output has NO `tools:` segment, so a `tools:` prefix check is the + // correct discriminator (mirrors the deepagents library's own + // `isSubagentNamespace`). Subagent events are emitted with a + // `subagent_` prefix and carry the namespace so the TUI can route them + // to a dedicated SUBAGENTS store instead of folding them into the + // orchestrator's assistant bubble. + // + // However, the orchestrator's OWN tool calls are also emitted under a + // `tools:` namespace (e.g. `["tools:"]` — length 1). A subagent + // subgraph always adds a second segment (a nested `model_request:` or + // `tools:`), so a namespace is subagent-scoped only when it has a + // `tools:` segment AND more than one segment. This keeps the + // orchestrator's tool calls in the conversation view while routing + // subagent activity to the SUBAGENTS store. + const isSubagent = + Array.isArray(namespace) && + namespace.length > 1 && + namespace.some((part) => part.startsWith("tools:")); + if (mode === "messages") { const [msg] = payload; const msgType = msg?._getType ? msg._getType() : msg?.type; @@ -244,16 +267,27 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, // is false, tool text is skipped entirely. if (msgType === "tool") { if (showToolResults !== false && streamingCallback) { - streamingCallback({ type: "tool", text, name: msg?.name }); + streamingCallback({ + type: isSubagent ? "subagent_tool" : "tool", + text, + name: msg?.name, + namespace, + }); } continue; } - collectedContent += text; + if (!isSubagent) { + collectedContent += text; + } if (streamingCallback) { - streamingCallback({ type: "message", text }); + streamingCallback({ + type: isSubagent ? "subagent_message" : "message", + text, + namespace, + }); } } @@ -261,9 +295,15 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, const reasoningContent = msg?.additional_kwargs?.reasoning_content ?? msg?.additional_kwargs?.reasoning; if (reasoningContent) { - collectedReasoning += reasoningContent; + if (!isSubagent) { + collectedReasoning += reasoningContent; + } if (streamingCallback) { - streamingCallback({ type: "reasoning", text: reasoningContent }); + streamingCallback({ + type: isSubagent ? "subagent_reasoning" : "reasoning", + text: reasoningContent, + namespace, + }); } } @@ -271,9 +311,15 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, if (Array.isArray(msg?.content)) { for (const block of msg.content) { if (block?.type === "reasoning" && block.reasoning) { - collectedReasoning += block.reasoning; + if (!isSubagent) { + collectedReasoning += block.reasoning; + } if (streamingCallback) { - streamingCallback({ type: "reasoning", text: block.reasoning }); + streamingCallback({ + type: isSubagent ? "subagent_reasoning" : "reasoning", + text: block.reasoning, + namespace, + }); } } } @@ -282,16 +328,18 @@ async function callProvider(_name, _providerConfig, message, streamingCallback, // payload is StreamToolsOutput with event, name, etc. if (payload?.event === "on_tool_start") { streamingCallback({ - type: "on_tool_start", + type: isSubagent ? "subagent_tool_start" : "on_tool_start", name: payload.name, data: { input: payload.input }, + namespace, }); } if (payload?.event === "on_tool_end") { streamingCallback({ - type: "on_tool_end", + type: isSubagent ? "subagent_tool_end" : "on_tool_end", name: payload.name, data: { output: payload.output }, + namespace, }); } } diff --git a/openspec/changes/archive/2026-10-11-tui-subagents-panel/.openspec.yaml b/openspec/changes/archive/2026-10-11-tui-subagents-panel/.openspec.yaml new file mode 100644 index 00000000..a3692196 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-tui-subagents-panel/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/archive/2026-10-11-tui-subagents-panel/design.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/design.md new file mode 100644 index 00000000..4d0cf618 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-tui-subagents-panel/design.md @@ -0,0 +1,41 @@ +## Context + +The TUI currently renders all stream events into a single assistant bubble. When the orchestrator delegates via the `task` tool, the subagent's reasoning and message segments are flattened into the same bubble as the orchestrator's output because the stream namespace is discarded in `callProvider` (`index.js`). With `subgraphs: true`, `agent.stream()` yields `[namespace, mode, payload]` where the root graph has an empty namespace and each subagent subgraph has a populated namespace (e.g. `["task:"]`). + +## Goals / Non-Goals + +**Goals:** +- Capture the stream namespace in `callProvider` so subagent activity is distinguishable from the root graph. +- Emit dedicated `subagent_*` event types carrying the namespace for subagent reasoning, message, and tool activity. +- Route `subagent_*` events to a dedicated subagent store in the TUI, not the orchestrator's assistant bubble. +- Add a `SUBAGENTS` panel that renders each subagent's reasoning/message segments and tool calls. +- Wire the panel into the App render switch. + +**Non-Goals:** +- Not changing the orchestrator's conversation bubble behavior for root-graph events. +- Not adding new npm packages or dependencies. +- Not altering subagent delegation logic in `src/agent/deepAgents.js`. + +## Decisions + +### Decision 1: Emit subagent event types at the source (`callProvider`) +The namespace is only available at the `agent.stream()` iteration site in `callProvider`. By the time events reach the TUI handler, the namespace is lost. Therefore, the capture must happen in `callProvider`. When `namespace.length > 0`, emit `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end` carrying the namespace. The root graph (empty namespace) continues to emit the existing `message`/`reasoning`/`on_tool_*` event types. + +**Alternatives considered:** Routing the namespace through the existing event types (e.g. adding a `namespace` field to `message`/`reasoning`). Rejected because it would require the TUI handler to branch on a field for every event and would not cleanly separate subagent tool activity from orchestrator tool activity. + +### Decision 2: Route subagent events to a dedicated store in the TUI +In `createStreamingHandler` (`src/tui/conversationArea.js`), handle the new `subagent_*` event types by routing them to a dedicated subagent store keyed by namespace, not to `streamingMsgIdRef.current`. This keeps the orchestrator's assistant bubble clean while capturing subagent activity separately. + +**Alternatives considered:** Folding subagent events into the same bubble with a namespace prefix. Rejected because it defeats the purpose of separating subagent activity from the orchestrator's conversation. + +### Decision 3: Dedicated `SUBAGENTS` panel +Add `SUBAGENTS` to `PANELS` and register it in `getPanelOrder()`. Create `src/tui/subagentsPanel.js` rendering each subagent's reasoning/message segments and tool calls, reusing `MessageBubble` or a variant. This is consistent with the existing panel system (SKILLS/MEMORIES/SETTINGS/SESSIONS/PROJECTS). + +**Alternatives considered:** Inline collapsible sections in the conversation (busier, harder to scan); a second always-visible message list (more complex layout). The dedicated panel provides clean separation and is consistent with the existing panel architecture. + +## Risks / Trade-offs + +- [Subagent events emitted before the subagent store is initialized] → The store is created lazily on first subagent event; events for unknown namespaces create a new entry. +- [Multiple subagents in one turn] → The store is keyed by namespace, so each subagent gets its own entry. +- [Nested subagent delegation] → Each subgraph has a distinct namespace, so nested subagents are captured separately. +- [Subagent tool calls with errors] → `subagent_tool_end` carries the namespace; the panel renders tool activity per subagent. diff --git a/openspec/changes/archive/2026-10-11-tui-subagents-panel/proposal.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/proposal.md new file mode 100644 index 00000000..555885ad --- /dev/null +++ b/openspec/changes/archive/2026-10-11-tui-subagents-panel/proposal.md @@ -0,0 +1,35 @@ +## Why + +When the orchestrator delegates work via the `task` tool, the subagent's reasoning and message segments are currently flattened into the same assistant bubble as the orchestrator's own output. The stream namespace that identifies which subgraph produced each event is discarded in `callProvider`, so there is no way to distinguish what the orchestrator said from what each subagent did. This makes multi-agent runs hard to follow and audit. + +## What Changes + +- Capture the stream namespace in `callProvider` (`index.js`) so subagent subgraphs can be distinguished from the root graph. +- When the namespace is populated (subagent activity), emit new event types (`subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end`) carrying the namespace, instead of the flat `message`/`reasoning`/`on_tool_*` events. +- Keep the root graph (empty namespace) emitting the existing event types unchanged. +- Route `subagent_*` events in the TUI streaming handler to a dedicated subagent store keyed by namespace, not to the orchestrator's assistant bubble. +- Add a `SUBAGENTS` panel to the TUI that renders each subagent's reasoning/message segments and tool calls. +- Wire the `SUBAGENTS` panel into the App render switch. + +## Capabilities + +### New Capabilities +- `tui-subagents-panel`: A dedicated TUI panel that captures and renders each subagent's reasoning and message segments separately from the orchestrator's conversation, including subagent tool calls. + +### Modified Capabilities +- `tui-streaming`: The streaming handler must route subagent events to a dedicated subagent store instead of the orchestrator's assistant bubble. + +## Impact + +- `index.js` — `callProvider` captures the stream namespace and emits subagent event types. +- `src/tui/conversationArea.js` — `createStreamingHandler` routes `subagent_*` events to a subagent store. +- `src/tui/panels.js` — `PANELS` enum gains `SUBAGENTS`; `getPanelOrder()` registers it. +- `src/tui/subagentsPanel.js` — new panel rendering subagent activity. +- `src/tui/app.js` — render switch gains a `SUBAGENTS` branch. +- `tests/unit/tui/subagentsPanel.test.js` — new unit tests. + +## Non-goals + +- Not changing the orchestrator's conversation bubble behavior for root-graph events. +- Not adding new npm packages or dependencies. +- Not altering subagent delegation logic in `src/agent/deepAgents.js`. diff --git a/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-streaming/spec.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-streaming/spec.md new file mode 100644 index 00000000..6b5aac2c --- /dev/null +++ b/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-streaming/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Streaming handler SHALL route subagent events to a subagent store +The `createStreamingHandler` function SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, instead of updating `streamingMsgIdRef.current`. The orchestrator's assistant bubble SHALL continue to receive the existing `message`, `reasoning`, `on_tool_start`, `on_tool_end`, `tool`, and `on_tool_error` event types. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Root graph events continue to update assistant bubble +- **WHEN** a `message` or `reasoning` event is received without a subagent namespace +- **THEN** the event continues to update `streamingMsgIdRef.current` diff --git a/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-subagents-panel/spec.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-subagents-panel/spec.md new file mode 100644 index 00000000..75a2ede3 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-tui-subagents-panel/specs/tui-subagents-panel/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: callProvider SHALL capture the stream namespace +`callProvider` in `index.js` SHALL capture the `_namespace` from `agent.stream()` when iterating with `subgraphs: true`. When `namespace.length > 0`, it SHALL emit subagent event types carrying the namespace instead of the flat `message`/`reasoning`/`on_tool_*` events. The root graph (empty namespace) SHALL continue emitting the existing event types. + +#### Scenario: Root graph emits existing event types +- **WHEN** `callProvider` iterates a stream event with an empty namespace (`[]`) +- **THEN** it emits the existing `message`, `reasoning`, `on_tool_start`, or `on_tool_end` event types + +#### Scenario: Subagent emits subagent_message +- **WHEN** `callProvider` iterates a stream event with a populated namespace (e.g. `["task:"]`) and mode `messages` with message text +- **THEN** it emits a `subagent_message` event carrying the namespace and text + +#### Scenario: Subagent emits subagent_reasoning +- **WHEN** `callProvider` iterates a stream event with a populated namespace and reasoning content +- **THEN** it emits a `subagent_reasoning` event carrying the namespace and reasoning text + +#### Scenario: Subagent emits subagent_tool_start +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_start` +- **THEN** it emits a `subagent_tool_start` event carrying the namespace and tool name + +#### Scenario: Subagent emits subagent_tool_end +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_end` +- **THEN** it emits a `subagent_tool_end` event carrying the namespace and tool name + +### Requirement: Subagent events SHALL carry the namespace +Each `subagent_*` event emitted by `callProvider` SHALL include the namespace that identifies the subgraph that produced it. + +#### Scenario: Event carries namespace +- **WHEN** a `subagent_message` event is emitted +- **THEN** the event includes a `namespace` field equal to the populated namespace array + +### Requirement: TUI streaming handler SHALL route subagent events to a subagent store +`createStreamingHandler` in `src/tui/conversationArea.js` SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, not to `streamingMsgIdRef.current`. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +### Requirement: SUBAGENTS panel SHALL be registered in PANELS +`PANELS` in `src/tui/panels.js` SHALL include a `SUBAGENTS` entry, and `getPanelOrder()` SHALL include it in the tab navigation order. + +#### Scenario: PANELS includes SUBAGENTS +- **WHEN** `PANELS` is inspected +- **THEN** it includes a `SUBAGENTS` entry + +#### Scenario: getPanelOrder includes SUBAGENTS +- **WHEN** `getPanelOrder()` is called +- **THEN** the returned order array includes `PANELS.SUBAGENTS` + +### Requirement: SUBAGENTS panel SHALL render subagent activity +`src/tui/subagentsPanel.js` SHALL render each subagent's reasoning/message segments and tool calls, keyed by namespace. + +#### Scenario: Panel renders subagent message segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's message segments +- **THEN** the panel renders the subagent's message content + +#### Scenario: Panel renders subagent reasoning segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's reasoning segments +- **THEN** the panel renders the subagent's reasoning content + +#### Scenario: Panel renders subagent tool calls +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's tool calls +- **THEN** the panel renders the subagent's tool call activity + +### Requirement: App SHALL render the SUBAGENTS panel +The App render switch in `src/tui/app.js` SHALL include a `currentView === PANELS.SUBAGENTS` branch that renders the subagents panel, passing the subagent store. + +#### Scenario: App renders SUBAGENTS panel +- **WHEN** `currentView === PANELS.SUBAGENTS` +- **THEN** the App renders the subagents panel component diff --git a/openspec/changes/archive/2026-10-11-tui-subagents-panel/tasks.md b/openspec/changes/archive/2026-10-11-tui-subagents-panel/tasks.md new file mode 100644 index 00000000..123c58e6 --- /dev/null +++ b/openspec/changes/archive/2026-10-11-tui-subagents-panel/tasks.md @@ -0,0 +1,25 @@ +## 1. Capture the namespace in callProvider + +- [x] 1.1 In `index.js` `callProvider`, capture `_namespace` from the `agent.stream()` iteration and emit `subagent_message`/`subagent_reasoning`/`subagent_tool_start`/`subagent_tool_end` events carrying the namespace when `namespace.length > 0` +- [x] 1.2 Keep the root graph (empty namespace) emitting the existing `message`/`reasoning`/`on_tool_start`/`on_tool_end` event types unchanged + +## 2. Route subagent events in the TUI + +- [x] 2.1 In `src/tui/conversationArea.js` `createStreamingHandler`, handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, `subagent_tool_end` by routing them to a dedicated subagent store keyed by namespace, not `streamingMsgIdRef.current` + +## 3. Add the SUBAGENTS panel + +- [x] 3.1 Add `SUBAGENTS` to `PANELS` in `src/tui/panels.js` and register it in `getPanelOrder()` +- [x] 3.2 Create `src/tui/subagentsPanel.js` rendering each subagent's reasoning/message segments and tool calls, reusing `MessageBubble` or a variant + +## 4. Wire the panel into App + +- [x] 4.1 Add a `currentView === PANELS.SUBAGENTS` branch in the App render switch (`src/tui/app.js`) rendering the new panel, passing the subagent store + +## 5. Write unit tests + +- [x] 5.1 Add `tests/unit/tui/subagentsPanel.test.js` covering namespace discrimination, event routing, and tool-call redirection + +## 6. Verify + +- [x] 6.1 Run `npm run test`, `npm run lint`, and `npm run coverage` to confirm everything passes diff --git a/openspec/specs/tui-streaming/spec.md b/openspec/specs/tui-streaming/spec.md index c8892a1c..d543c4fb 100644 --- a/openspec/specs/tui-streaming/spec.md +++ b/openspec/specs/tui-streaming/spec.md @@ -170,3 +170,26 @@ After the assistant response is persisted to session state, `updateContextSize` - **WHEN** `updateContextSize` is called after persistence - **THEN** the system prompt token count is included in the final context size +### Requirement: Streaming handler SHALL route subagent events to a subagent store +The `createStreamingHandler` function SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, instead of updating `streamingMsgIdRef.current`. The orchestrator's assistant bubble SHALL continue to receive the existing `message`, `reasoning`, `on_tool_start`, `on_tool_end`, `tool`, and `on_tool_error` event types. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received with a namespace +- **THEN** the event is routed to the subagent store for that namespace, not to `streamingMsgIdRef.current` + +#### Scenario: Root graph events continue to update assistant bubble +- **WHEN** a `message` or `reasoning` event is received without a subagent namespace +- **THEN** the event continues to update `streamingMsgIdRef.current` + diff --git a/openspec/specs/tui-subagents-panel/spec.md b/openspec/specs/tui-subagents-panel/spec.md new file mode 100644 index 00000000..6e6d4fb6 --- /dev/null +++ b/openspec/specs/tui-subagents-panel/spec.md @@ -0,0 +1,87 @@ +# tui-subagents-panel Specification + +## Purpose +TBD - created by archiving change tui-subagents-panel. Update Purpose after archive. +## Requirements +### Requirement: callProvider SHALL capture the stream namespace +`callProvider` in `index.js` SHALL capture the `_namespace` from `agent.stream()` when iterating with `subgraphs: true`. When `namespace.length > 0`, it SHALL emit subagent event types carrying the namespace instead of the flat `message`/`reasoning`/`on_tool_*` events. The root graph (empty namespace) SHALL continue emitting the existing event types. + +#### Scenario: Root graph emits existing event types +- **WHEN** `callProvider` iterates a stream event with an empty namespace (`[]`) +- **THEN** it emits the existing `message`, `reasoning`, `on_tool_start`, or `on_tool_end` event types + +#### Scenario: Subagent emits subagent_message +- **WHEN** `callProvider` iterates a stream event with a populated namespace (e.g. `["task:"]`) and mode `messages` with message text +- **THEN** it emits a `subagent_message` event carrying the namespace and text + +#### Scenario: Subagent emits subagent_reasoning +- **WHEN** `callProvider` iterates a stream event with a populated namespace and reasoning content +- **THEN** it emits a `subagent_reasoning` event carrying the namespace and reasoning text + +#### Scenario: Subagent emits subagent_tool_start +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_start` +- **THEN** it emits a `subagent_tool_start` event carrying the namespace and tool name + +#### Scenario: Subagent emits subagent_tool_end +- **WHEN** `callProvider` iterates a stream event with a populated namespace and mode `tools` with `on_tool_end` +- **THEN** it emits a `subagent_tool_end` event carrying the namespace and tool name + +### Requirement: Subagent events SHALL carry the namespace +Each `subagent_*` event emitted by `callProvider` SHALL include the namespace that identifies the subgraph that produced it. + +#### Scenario: Event carries namespace +- **WHEN** a `subagent_message` event is emitted +- **THEN** the event includes a `namespace` field equal to the populated namespace array + +### Requirement: TUI streaming handler SHALL route subagent events to a subagent store +`createStreamingHandler` in `src/tui/conversationArea.js` SHALL handle `subagent_message`, `subagent_reasoning`, `subagent_tool_start`, and `subagent_tool_end` event types by routing them to a dedicated subagent store keyed by namespace, not to `streamingMsgIdRef.current`. + +#### Scenario: Subagent message routed to subagent store +- **WHEN** a `subagent_message` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent reasoning routed to subagent store +- **WHEN** a `subagent_reasoning` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool start routed to subagent store +- **WHEN** a `subagent_tool_start` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +#### Scenario: Subagent tool end routed to subagent store +- **WHEN** a `subagent_tool_end` event is received +- **THEN** the event is routed to the subagent store for its namespace, not to the orchestrator's assistant bubble + +### Requirement: SUBAGENTS panel SHALL be registered in PANELS +`PANELS` in `src/tui/panels.js` SHALL include a `SUBAGENTS` entry, and `getPanelOrder()` SHALL include it in the tab navigation order. + +#### Scenario: PANELS includes SUBAGENTS +- **WHEN** `PANELS` is inspected +- **THEN** it includes a `SUBAGENTS` entry + +#### Scenario: getPanelOrder includes SUBAGENTS +- **WHEN** `getPanelOrder()` is called +- **THEN** the returned order array includes `PANELS.SUBAGENTS` + +### Requirement: SUBAGENTS panel SHALL render subagent activity +`src/tui/subagentsPanel.js` SHALL render each subagent's reasoning/message segments and tool calls, keyed by namespace. + +#### Scenario: Panel renders subagent message segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's message segments +- **THEN** the panel renders the subagent's message content + +#### Scenario: Panel renders subagent reasoning segments +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's reasoning segments +- **THEN** the panel renders the subagent's reasoning content + +#### Scenario: Panel renders subagent tool calls +- **WHEN** the SUBAGENTS panel is rendered with a subagent store containing a subagent's tool calls +- **THEN** the panel renders the subagent's tool call activity + +### Requirement: App SHALL render the SUBAGENTS panel +The App render switch in `src/tui/app.js` SHALL include a `currentView === PANELS.SUBAGENTS` branch that renders the subagents panel, passing the subagent store. + +#### Scenario: App renders SUBAGENTS panel +- **WHEN** `currentView === PANELS.SUBAGENTS` +- **THEN** the App renders the subagents panel component + diff --git a/src/tui/app.js b/src/tui/app.js index e6fe458f..41d64a93 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -11,6 +11,8 @@ import { MemoryPanel } from "./memoryPanel.js"; import { SettingsPanel } from "./settingsPanel.js"; import { SessionsPanel } from "./sessionsPanel.js"; import { ProjectsPanel } from "./projectsPanel.js"; +import { SubagentsPanel } from "./subagentsPanel.js"; +import { updateSubagentStore } from "./subagentStore.js"; import { getActiveProviderConfig } from "../provider/index.js"; import { useMouseScroll, scaleScrollDelta } from "./useMouseScroll.js"; import { buildLayout, extractSelection, mapCoordToChar } from "./selectionLayout.js"; @@ -63,6 +65,11 @@ function App({ // and jump-to-next. Search mode takes precedence over normal input focus. const [searchMode, setSearchMode] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + // Subagent store — accumulated subagent activity keyed by namespace. Lifted + // to App so the SUBAGENTS panel re-renders live as subagents stream. The + // ConversationArea's streaming handler reports each subagent event here via + // onSubagentUpdate, which updates this state and re-renders the panel. + const [subagentStore, setSubagentStore] = useState({}); const lastInterruptTimeRef = useRef(0); const { exit } = useApp(); const exitRef = useRef(exit); @@ -70,6 +77,7 @@ function App({ const conversationAreaRef = useRef(null); const inputAreaRef = useRef(null); + const subagentsPanelRef = useRef(null); const messageCountRef = useRef(0); // Pending silent message to dispatch once the conversation area is mounted. // The conversation area unmounts during panel views, so the ref is null when @@ -111,6 +119,13 @@ function App({ const onContextChange = useCallback((size) => inputAreaRef.current?.setContextSize(size), []); const onCompactingChange = useCallback((val) => inputAreaRef.current?.setIsCompacting(val), []); const onInterruptInput = useCallback(() => inputAreaRef.current?.clearInput(), []); + // Fold a subagent event into the App-level subagent store. Called by the + // ConversationArea's streaming handler for each subagent_* event. Updating + // this state re-renders App, which passes the fresh store to the SUBAGENTS + // panel so it updates live. + const onSubagentUpdate = useCallback((event) => { + setSubagentStore((prev) => updateSubagentStore(prev, event)); + }, []); /** * onViewChange — switch between conversation and panel views. @@ -405,6 +420,26 @@ function App({ setShowBanner(false); } + // Ctrl+r / Ctrl+t toggle the global collapse state for reasoning and + // tool-call blocks. These apply only to the conversation view — the + // SUBAGENTS panel is a read-only view that always renders expanded, so + // it does not participate in these toggles. Return early so the key + // never reaches the input panel (which would render the letter). + if (key.ctrl && input === "r") { + setReasoningCollapsed((prev) => !prev); + setTimeout(() => { + conversationAreaRef.current?.scrollToBottom?.(); + }, 50); + return; + } + if (key.ctrl && input === "t") { + setToolCallCollapsed((prev) => !prev); + setTimeout(() => { + conversationAreaRef.current?.scrollToBottom?.(); + }, 50); + return; + } + // Panel view — defer all input to the active panel via its own useInput({ isActive }) // Each panel handles Escape to return to conversation when appropriate. if (currentView !== PANELS.CONVERSATION) { @@ -456,22 +491,6 @@ function App({ return; } - // Ctrl+r / Ctrl+t toggle the global collapse state for reasoning and - // tool-call blocks. Return early so the key never reaches the input - // panel (which would render the letter). Scroll to the bottom after the - // redraw so the user isn't left far from the content when blocks expand - // or collapse. - if (key.ctrl && input === "r") { - setReasoningCollapsed((prev) => !prev); - setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); - return; - } - if (key.ctrl && input === "t") { - setToolCallCollapsed((prev) => !prev); - setTimeout(() => conversationAreaRef.current?.scrollToBottom?.(), 50); - return; - } - // In search mode, Enter jumps to the next match; Shift+Enter jumps to // the previous match. The search input's ink-text-input onSubmit is a // no-op, so these keys are handled here. @@ -494,16 +513,15 @@ function App({ inputAreaRef.current?.navigateHistory("down"); } } else { - if (key.upArrow) conversationAreaRef.current?.scrollBy(-1); - if (key.downArrow) conversationAreaRef.current?.scrollBy(1); - if (key.pageUp) - conversationAreaRef.current?.scrollBy( - -(conversationAreaRef.current?.getViewportHeight?.() || 1), - ); - if (key.pageDown) - conversationAreaRef.current?.scrollBy( - conversationAreaRef.current?.getViewportHeight?.() || 1, - ); + // In the SUBAGENTS view, route scroll keys to the subagents panel + // (which has its own virtualized scroll). Otherwise route to the + // conversation area. + const scrollTarget = + currentView === PANELS.SUBAGENTS ? subagentsPanelRef.current : conversationAreaRef.current; + if (key.upArrow) scrollTarget?.scrollBy(-1); + if (key.downArrow) scrollTarget?.scrollBy(1); + if (key.pageUp) scrollTarget?.scrollBy(-(scrollTarget?.getViewportHeight?.() || 1)); + if (key.pageDown) scrollTarget?.scrollBy(scrollTarget?.getViewportHeight?.() || 1); } }); @@ -599,6 +617,13 @@ function App({ onClearProject: handleClearProject, activeView: currentView, }); + } else if (currentView === PANELS.SUBAGENTS) { + panelComponent = React.createElement(SubagentsPanel, { + ref: subagentsPanelRef, + subagentStore, + onViewChange: handleViewChange, + activeView: currentView, + }); } return React.createElement( @@ -647,6 +672,7 @@ function App({ onContextChange, onCompactingChange, onInterruptInput, + onSubagentUpdate, onQuit: handleQuit, onNewSession: handleNewSession, onViewChange: handleViewChange, diff --git a/src/tui/commandHelp.js b/src/tui/commandHelp.js index f98e6c19..29cf6292 100644 --- a/src/tui/commandHelp.js +++ b/src/tui/commandHelp.js @@ -34,6 +34,7 @@ export const COMMAND_GROUPS = [ "/sessions - view sessions panel", "/settings - view settings panel", "/skills - view skills panel", + "/subagents - view subagents panel", ], }, ]; diff --git a/src/tui/commandParser.js b/src/tui/commandParser.js index af7040cb..090159d0 100644 --- a/src/tui/commandParser.js +++ b/src/tui/commandParser.js @@ -113,6 +113,7 @@ export class CommandParser { ["memories", "memories"], ["skills", "skills"], ["settings", "settings"], + ["subagents", "subagents"], ]) { this.#register(cmd, (_args, _ctx) => { return { action: "view", value: view, message: `Switching to ${view} view.` }; diff --git a/src/tui/conversationArea.js b/src/tui/conversationArea.js index 24f8a926..d69a4982 100644 --- a/src/tui/conversationArea.js +++ b/src/tui/conversationArea.js @@ -71,6 +71,7 @@ const ConversationArea = forwardRef(function ConversationArea( onContextChange, onCompactingChange, onInterruptInput, + onSubagentUpdate, onQuit, onNewSession, onViewChange, @@ -107,6 +108,10 @@ const ConversationArea = forwardRef(function ConversationArea( const toolMessageTokensRef = useRef(0); const contextUpdateTimerRef = useRef(null); const pendingContextRef = useRef({ content: "" }); + // Subagent store is lifted to App state. The streaming handler reports each + // subagent_* event via onSubagentUpdate, which updates App's state and + // re-renders the SUBAGENTS panel live. This component no longer owns the + // store — it just forwards events upward. const skillList = registry ? registry.list() : []; const parser = new CommandParser(); @@ -649,6 +654,21 @@ const ConversationArea = forwardRef(function ConversationArea( return async (event) => { if (shouldAbort()) return; try { + // Subagent events are routed to the dedicated subagent store + // keyed by namespace, never to the orchestrator's assistant + // bubble. This keeps each subagent's reasoning/message + // segments and tool calls separate from the main conversation. + if ( + event.type === "subagent_message" || + event.type === "subagent_reasoning" || + event.type === "subagent_tool" || + event.type === "subagent_tool_start" || + event.type === "subagent_tool_end" + ) { + onSubagentUpdate?.(event); + return; + } + const currentEvents = messageListRef.current?.getMessageData(streamingMsgIdRef.current)?.events || []; messageListRef.current?.updateMessage(streamingMsgIdRef.current, { diff --git a/src/tui/panels.js b/src/tui/panels.js index b8101ba6..358d9f2c 100644 --- a/src/tui/panels.js +++ b/src/tui/panels.js @@ -8,6 +8,7 @@ export const PANELS = Object.freeze({ SETTINGS: "settings", SESSIONS: "sessions", PROJECTS: "projects", + SUBAGENTS: "subagents", }); /** @@ -22,6 +23,7 @@ export function getPanelOrder() { PANELS.SETTINGS, PANELS.SESSIONS, PANELS.PROJECTS, + PANELS.SUBAGENTS, ]; } diff --git a/src/tui/subagentStore.js b/src/tui/subagentStore.js new file mode 100644 index 00000000..74643624 --- /dev/null +++ b/src/tui/subagentStore.js @@ -0,0 +1,178 @@ +/** + * Subagent store — accumulates subagent reasoning/message segments and tool + * calls keyed by stream namespace. + * + * The orchestrator's `callProvider` emits `subagent_*` events carrying a + * namespace (e.g. `["task:"]`) for each subagent subgraph. This module + * provides pure functions to fold those events into a store that the + * SUBAGENTS panel renders. Keeping the logic pure makes it trivially testable + * without a React render. + */ + +/** + * Create an empty subagent store. + * @returns {Object} An empty store keyed by namespace string. + */ +export function createSubagentStore() { + return {}; +} + +/** + * Get the namespace key for a subagent event. + * @param {string[]} namespace - The stream namespace array (e.g. `["task:"]`) + * @returns {string} The namespace joined into a stable key. + */ +export function namespaceKey(namespace) { + return Array.isArray(namespace) ? namespace.join("/") : ""; +} + +/** + * Ensure a subagent entry exists for the given namespace. + * @param {Object} store - The subagent store. + * @param {string} key - The namespace key. + * @returns {Object} The subagent entry (created if absent). + */ +function ensureEntry(store, key) { + if (!store[key]) { + store[key] = { + namespace: key, + segments: [], + toolCalls: [], + }; + } + return store[key]; +} + +/** + * Coalesce a new segment into an existing segment list, mirroring the + * `MessageList.updateMessage` coalescing rules so subagent streaming output + * renders as continuous blocks rather than a fragment per chunk. + * + * - `message` segments append to the last segment when the type matches, + * otherwise they start a new block. + * - `reasoning` segments coalesce with the last reasoning segment (searching + * backwards, since a message may interleave), respecting a grammatical + * sentence boundary (period/question/exclamation followed by a capital). + * - `tool` segments always start a new block (each tool result is its own + * block with its own name header). + * + * @param {Array<{type: string, content: string, name?: string}>} segments - Existing segments. + * @param {{type: string, content: string, name?: string}} newSeg - The new segment to fold in. + * @returns {Array<{type: string, content: string, name?: string}>} The coalesced segments. + */ +function coalesceSegments(segments, newSeg) { + const merged = segments.map((s) => ({ ...s })); + + if (newSeg.type === "reasoning") { + // Search backwards for the last reasoning segment — continuous reasoning + // should coalesce even if a message interleaved between chunks. + let lastReasoning = null; + for (let i = merged.length - 1; i >= 0; i--) { + if (merged[i].type === "reasoning") { + lastReasoning = merged[i]; + break; + } + } + // Grammatical sentence boundary: a period followed by a capital letter + // starts a new block; otherwise coalesce. + if ( + lastReasoning && + (lastReasoning.content.endsWith(".") || + lastReasoning.content.endsWith("?") || + lastReasoning.content.endsWith("!")) && + /^[A-Z]/.test(newSeg.content) + ) { + merged.push({ ...newSeg }); + } else if (lastReasoning) { + lastReasoning.content += newSeg.content; + } else { + merged.push({ ...newSeg }); + } + } else if (newSeg.type === "tool") { + // Each tool message gets its own segment so it renders as its own block. + merged.push({ ...newSeg }); + } else { + // Message segments require a type match to append; a mismatch (e.g., + // message after reasoning) forces a new block. + const lastSeg = merged[merged.length - 1]; + if (lastSeg && lastSeg.type === newSeg.type) { + lastSeg.content += newSeg.content; + } else { + merged.push({ ...newSeg }); + } + } + + return merged; +} + +/** + * Fold a single subagent event into the store. Returns a new store object so + * React state updates are immutable. + * @param {Object} store - The current subagent store. + * @param {Object} event - A `subagent_*` event from `callProvider`. + * @returns {Object} The updated store. + */ +export function updateSubagentStore(store, event) { + const key = namespaceKey(event.namespace); + if (!key) return store; + + const next = { ...store }; + const entry = ensureEntry(next, key); + let segments = [...entry.segments]; + const toolCalls = [...entry.toolCalls]; + + switch (event.type) { + case "subagent_message": { + const text = event.data?.text || event.text || ""; + if (text) { + segments = coalesceSegments(segments, { type: "message", content: text }); + } + break; + } + case "subagent_reasoning": { + const text = event.data?.text || event.text || ""; + if (text) { + segments = coalesceSegments(segments, { type: "reasoning", content: text }); + } + break; + } + case "subagent_tool": { + const text = event.data?.text || event.text || ""; + const name = event.data?.name || event.name || ""; + if (text) { + segments = coalesceSegments(segments, { type: "tool", content: text, name }); + } + break; + } + case "subagent_tool_start": { + toolCalls.push({ + name: event.name, + input: event.data?.input, + status: "running", + }); + break; + } + case "subagent_tool_end": { + const last = toolCalls[toolCalls.length - 1]; + if (last && last.status === "running") { + toolCalls[toolCalls.length - 1] = { + ...last, + output: event.data?.output, + status: "done", + }; + } else { + toolCalls.push({ + name: event.name, + output: event.data?.output, + status: "done", + }); + } + break; + } + default: + break; + } + + next[key] = { ...entry, segments, toolCalls }; + return next; +} diff --git a/src/tui/subagentsPanel.js b/src/tui/subagentsPanel.js new file mode 100644 index 00000000..321b35ae --- /dev/null +++ b/src/tui/subagentsPanel.js @@ -0,0 +1,172 @@ +import React, { useMemo, useRef, useCallback, forwardRef, useImperativeHandle } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import { MessageBubble } from "./messageBubble.js"; +import { VirtualScrollView } from "./scrollView.js"; + +/** + * Subagents panel — renders each subagent's reasoning/message segments and + * tool calls captured during a multi-agent turn. + * + * The orchestrator's `callProvider` emits `subagent_*` events carrying a + * stream namespace (e.g. `["task:"]`). `createStreamingHandler` routes + * those events into a subagent store keyed by namespace. This panel reads that + * store and renders each subagent as a distinct block, reusing `MessageBubble` + * to render the reasoning/message segments and a compact tool-call list. + * + * The panel virtualizes the subagent list via `VirtualScrollView` and + * auto-scrolls to the bottom as new subagent output arrives, so the user can + * track live subagent activity. Manual scroll-up suppresses auto-scroll until + * the user returns to the bottom. + * + * Props: + * subagentStore - Object keyed by namespace string, each value being + * `{ namespace, segments, toolCalls }`. + * activeView - The current active view name (from PANELS). + * onViewChange - Callback to switch back to conversation view. + * + * This is a read-only view: it always renders the message bubbles expanded + * (reasoning and tool content visible). It does not participate in the global + * ctrl+r / ctrl+t collapse toggles, which apply only to the conversation view. + */ +export const SubagentsPanel = forwardRef(function SubagentsPanel( + { subagentStore = {}, activeView, onViewChange }, + ref, +) { + const isActive = activeView === "subagents"; + const { rows } = useWindowSize(); + const scrollRef = useRef(null); + // Track manual scroll-up: when the user scrolls away from the bottom, + // suppress auto-scroll until they return to the bottom. + const isUserScrolledUpRef = useRef(false); + + // VirtualScrollView keys items by `id` (used for the height map and React + // keys). Map each store entry to an object carrying its namespace as the id. + const entries = useMemo(() => { + return Object.values(subagentStore || {}).map((entry) => ({ + id: entry.namespace, + ...entry, + })); + }, [subagentStore]); + + // Escape returns to conversation view + useInput( + (_input, key) => { + if (key.escape) { + onViewChange?.("conversation"); + } + }, + { isActive }, + ); + + // Auto-scroll to the bottom when content grows, unless the user has scrolled + // up to read earlier output. + const handleContentHeightChange = useCallback((height, previousHeight) => { + if (!scrollRef.current || height <= previousHeight) return; + if (isUserScrolledUpRef.current) return; + scrollRef.current.scrollToBottom?.(); + }, []); + + // Track manual scroll position; resume auto-scroll when the user returns + // to the bottom. + const handleScroll = useCallback((offset) => { + const bottom = scrollRef.current?.getBottomOffset?.() || 0; + isUserScrolledUpRef.current = offset < bottom; + }, []); + + // Expose imperative scroll API so the app-level scroll keys (up/down/page) + // and the ctrl+r/ctrl+t scroll-to-bottom can route to this panel. + useImperativeHandle( + ref, + () => ({ + scrollToBottom: () => scrollRef.current?.scrollToBottom?.(), + scrollBy: (delta) => scrollRef.current?.scrollBy?.(delta), + scrollToTop: () => scrollRef.current?.scrollToTop?.(), + getViewportHeight: () => scrollRef.current?.getViewportHeight?.(), + getBottomOffset: () => scrollRef.current?.getBottomOffset?.(), + }), + [], + ); + + if (!isActive) { + return React.createElement(Box, null); + } + + if (entries.length === 0) { + return React.createElement( + Box, + { flexDirection: "column", padding: 1 }, + React.createElement(Text, { color: "gray" }, "No subagent activity yet."), + ); + } + + const viewportHeight = Math.max(1, rows - 4); + + return React.createElement( + Box, + { flexDirection: "column", padding: 1 }, + React.createElement(VirtualScrollView, { + ref: scrollRef, + items: entries, + height: viewportHeight, + overscan: 2, + estimateHeight: (entry) => estimateSubagentHeight(entry), + renderItem: (entry) => + React.createElement( + Box, + { flexDirection: "column", marginBottom: 1 }, + React.createElement(MessageBubble, { + role: "assistant", + content: (entry.segments || []).map((s) => s.content).join(""), + segments: entry.segments, + streaming: false, + assistantName: "subagent", + // Read-only view — always render expanded. The subagents + // panel does not participate in the global collapse toggles. + reasoningCollapsed: false, + toolCallCollapsed: false, + }), + renderToolCalls(entry.toolCalls), + ), + onContentHeightChange: handleContentHeightChange, + onScroll: handleScroll, + }), + ); +}); + +/** + * Estimate the rendered height of a subagent entry in rows, used by the + * virtualized scroll view for off-window items. The measured height corrects + * lazily as items scroll into view. + * @param {{segments?: Array<{type: string, content: string}>, toolCalls?: Array}} entry + * @returns {number} Estimated height in rows. + */ +export function estimateSubagentHeight(entry) { + // Header row + message content lines + tool-call rows. + let height = 1; + const text = (entry.segments || []).map((s) => s.content).join(""); + if (text) height += Math.ceil(text.length / 80) || 1; + // Each tool call renders as a row in the compact list. + height += (entry.toolCalls || []).length; + return Math.max(1, height); +} + +/** + * Render a subagent's tool calls as a compact list. + * @param {Array} toolCalls - The subagent's tool call entries. + * @returns {React.ReactElement|null} The rendered tool-call list, or null. + */ +function renderToolCalls(toolCalls) { + if (!toolCalls || toolCalls.length === 0) return null; + + return React.createElement( + Box, + { flexDirection: "column", marginTop: 1 }, + ...toolCalls.map((call, idx) => + React.createElement( + Text, + { key: idx, color: call.status === "error" ? "red" : "gray" }, + `🔧 ${call.name}${call.status === "running" ? " (running)" : ""}`, + ), + ), + ); +} diff --git a/tests/fixtures/mcp-server.mjs b/tests/fixtures/mcp-server.mjs index 95be5adc..80fcb5ad 100644 --- a/tests/fixtures/mcp-server.mjs +++ b/tests/fixtures/mcp-server.mjs @@ -52,6 +52,9 @@ async function main() { } main().catch((err) => { + // Standalone MCP server process entry point — stderr is the only channel + // for surfacing a fatal startup error to the parent process. + // eslint-disable-next-line no-console console.error(err); process.exit(1); }); diff --git a/tests/unit/tui.test.js b/tests/unit/tui.test.js index 3e2b1f49..72cc3030 100644 --- a/tests/unit/tui.test.js +++ b/tests/unit/tui.test.js @@ -303,6 +303,7 @@ describe("TUI - panel navigation", () => { "settings", "sessions", "projects", + "subagents", ]); }); @@ -312,21 +313,24 @@ describe("TUI - panel navigation", () => { assert.strictEqual(nextPanel("memories"), "settings"); assert.strictEqual(nextPanel("settings"), "sessions"); assert.strictEqual(nextPanel("sessions"), "projects"); - assert.strictEqual(nextPanel("projects"), "conversation"); + assert.strictEqual(nextPanel("projects"), "subagents"); + assert.strictEqual(nextPanel("subagents"), "conversation"); }); it("cycles to prev panel", () => { - assert.strictEqual(prevPanel("conversation"), "projects"); + assert.strictEqual(prevPanel("conversation"), "subagents"); assert.strictEqual(prevPanel("skills"), "conversation"); assert.strictEqual(prevPanel("memories"), "skills"); assert.strictEqual(prevPanel("settings"), "memories"); assert.strictEqual(prevPanel("sessions"), "settings"); assert.strictEqual(prevPanel("projects"), "sessions"); + assert.strictEqual(prevPanel("subagents"), "projects"); }); it("constants are frozen objects", () => { assert.ok(typeof PANELS === "object"); assert.strictEqual(PANELS.CONVERSATION, "conversation"); + assert.strictEqual(PANELS.SUBAGENTS, "subagents"); }); }); diff --git a/tests/unit/tui/commandParser.test.js b/tests/unit/tui/commandParser.test.js index b3ab1f6b..299b544e 100644 --- a/tests/unit/tui/commandParser.test.js +++ b/tests/unit/tui/commandParser.test.js @@ -337,6 +337,7 @@ describe("CommandParser", () => { describe("isViewCommand()", () => { it("returns true for view-switching commands", () => { assert.strictEqual(parser.isViewCommand("/skills"), true); + assert.strictEqual(parser.isViewCommand("/subagents"), true); assert.strictEqual(parser.isViewCommand("/memories"), true); assert.strictEqual(parser.isViewCommand("/sessions"), true); assert.strictEqual(parser.isViewCommand("/settings"), true); diff --git a/tests/unit/tui/panels.test.js b/tests/unit/tui/panels.test.js index 216eb808..f398ef5a 100644 --- a/tests/unit/tui/panels.test.js +++ b/tests/unit/tui/panels.test.js @@ -6,8 +6,9 @@ describe("getPanelOrder", () => { it("returns the panel order array", () => { const order = getPanelOrder(); assert.ok(Array.isArray(order)); - assert.strictEqual(order.length, 6); + assert.strictEqual(order.length, 7); assert.strictEqual(order[0], PANELS.CONVERSATION); + assert.strictEqual(order[order.length - 1], PANELS.SUBAGENTS); }); }); @@ -18,10 +19,11 @@ describe("nextPanel", () => { assert.strictEqual(nextPanel(PANELS.MEMORIES), PANELS.SETTINGS); assert.strictEqual(nextPanel(PANELS.SETTINGS), PANELS.SESSIONS); assert.strictEqual(nextPanel(PANELS.SESSIONS), PANELS.PROJECTS); + assert.strictEqual(nextPanel(PANELS.PROJECTS), PANELS.SUBAGENTS); }); it("wraps around from last to first", () => { - assert.strictEqual(nextPanel(PANELS.PROJECTS), PANELS.CONVERSATION); + assert.strictEqual(nextPanel(PANELS.SUBAGENTS), PANELS.CONVERSATION); }); it("handles unknown panel by wrapping to first", () => { @@ -32,6 +34,7 @@ describe("nextPanel", () => { describe("prevPanel", () => { it("cycles to the previous panel", () => { + assert.strictEqual(prevPanel(PANELS.SUBAGENTS), PANELS.PROJECTS); assert.strictEqual(prevPanel(PANELS.PROJECTS), PANELS.SESSIONS); assert.strictEqual(prevPanel(PANELS.SESSIONS), PANELS.SETTINGS); assert.strictEqual(prevPanel(PANELS.SETTINGS), PANELS.MEMORIES); @@ -40,11 +43,11 @@ describe("prevPanel", () => { }); it("wraps around from first to last", () => { - assert.strictEqual(prevPanel(PANELS.CONVERSATION), PANELS.PROJECTS); + assert.strictEqual(prevPanel(PANELS.CONVERSATION), PANELS.SUBAGENTS); }); it("handles unknown panel by wrapping to last", () => { const result = prevPanel("unknown"); - assert.strictEqual(result, PANELS.SESSIONS); + assert.strictEqual(result, PANELS.PROJECTS); }); }); diff --git a/tests/unit/tui/repro-subagents-escape.test.js b/tests/unit/tui/repro-subagents-escape.test.js new file mode 100644 index 00000000..97f56260 --- /dev/null +++ b/tests/unit/tui/repro-subagents-escape.test.js @@ -0,0 +1,59 @@ +import React, { createRef } from "react"; +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert"; +import { render } from "ink"; +import ConversationArea from "../../../src/tui/conversationArea.js"; + +let unmount; +afterEach(() => { + if (unmount) unmount(); +}); + +function renderConversationArea(ref, hidden, messageCountRef) { + return React.createElement(ConversationArea, { + ref, + config: { tui: { name: "Assistant" } }, + registry: null, + sessionState: null, + dispatchProvider: null, + scheduleManager: null, + onSaveSession: () => {}, + gcManager: null, + gcTrigger: null, + compactContext: null, + getContextMessages: () => [], + model: null, + systemPrompt: "", + messageCountRef, + hidden, + }); +} + +describe("Repro: ConversationArea hidden round-trip preserves message state", () => { + it("keeps messages when toggling hidden (panel view) and back", async () => { + const ref = createRef(); + const messageCountRef = { current: 0 }; + const { unmount: um, rerender } = render(renderConversationArea(ref, false, messageCountRef)); + unmount = um; + + // Add a message while visible + ref.current.addMessage({ role: "user", content: "hello world" }); + assert.strictEqual(messageCountRef.current, 1, "message added while visible"); + assert.strictEqual(ref.current.getSelectionMessages().length, 1, "message list has 1 message"); + + // Toggle hidden (panel view active) — the regression path + rerender(renderConversationArea(ref, true, messageCountRef)); + + // Toggle back to visible (Escape returns to conversation) + rerender(renderConversationArea(ref, false, messageCountRef)); + + // The message must survive the round-trip — getSelectionMessages reads + // from the MessageList's internal dataRef/idsRef, which is destroyed if + // the subtree unmounts. + assert.strictEqual( + ref.current.getSelectionMessages().length, + 1, + "message survives hidden round-trip", + ); + }); +}); diff --git a/tests/unit/tui/subagentsPanel.test.js b/tests/unit/tui/subagentsPanel.test.js new file mode 100644 index 00000000..f71dd619 --- /dev/null +++ b/tests/unit/tui/subagentsPanel.test.js @@ -0,0 +1,372 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import React from "react"; +import { renderToString } from "ink"; +import { + createSubagentStore, + updateSubagentStore, + namespaceKey, +} from "../../../src/tui/subagentStore.js"; +import { SubagentsPanel, estimateSubagentHeight } from "../../../src/tui/subagentsPanel.js"; + +describe("namespaceKey", () => { + it("joins a populated namespace into a stable key", () => { + assert.strictEqual(namespaceKey(["task:abc"]), "task:abc"); + assert.strictEqual(namespaceKey(["task:abc", "task:def"]), "task:abc/task:def"); + }); + + it("returns empty string for empty or non-array namespace", () => { + assert.strictEqual(namespaceKey([]), ""); + assert.strictEqual(namespaceKey(undefined), ""); + assert.strictEqual(namespaceKey(null), ""); + }); +}); + +describe("createSubagentStore", () => { + it("creates an empty store", () => { + assert.deepStrictEqual(createSubagentStore(), {}); + }); +}); + +describe("updateSubagentStore — namespace discrimination", () => { + it("ignores events with an empty namespace (root graph)", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_message", + namespace: [], + text: "root message", + }); + assert.deepStrictEqual(next, {}); + }); + + it('ignores events with a blank-component namespace (root graph yields [""])', () => { + // The store keys on the joined namespace; a blank component produces an + // empty key and is ignored. This guards against the root graph's + // `[""]` namespace leaking into the store. + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_message", + namespace: [""], + text: "root message", + }); + assert.deepStrictEqual(next, {}); + }); + + it("routes a subagent message to the namespace entry", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "subagent says hi", + }); + assert.ok(next["task:abc"]); + assert.deepStrictEqual(next["task:abc"].segments, [ + { type: "message", content: "subagent says hi" }, + ]); + }); + + it("routes a subagent reasoning segment to the namespace entry", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "thinking...", + }); + assert.deepStrictEqual(next["task:abc"].segments, [ + { type: "reasoning", content: "thinking..." }, + ]); + }); + + it("keeps distinct namespaces separate", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "first", + }); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:def"], + text: "second", + }); + assert.deepStrictEqual(store["task:abc"].segments, [{ type: "message", content: "first" }]); + assert.deepStrictEqual(store["task:def"].segments, [{ type: "message", content: "second" }]); + }); +}); + +describe("updateSubagentStore — segment coalescing", () => { + it("coalesces consecutive message chunks into one segment", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "Hello ", + }); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "world", + }); + assert.deepStrictEqual(store["task:abc"].segments, [ + { type: "message", content: "Hello world" }, + ]); + }); + + it("coalesces consecutive reasoning chunks into one segment", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "thinking ", + }); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "hard", + }); + assert.deepStrictEqual(store["task:abc"].segments, [ + { type: "reasoning", content: "thinking hard" }, + ]); + }); + + it("starts a new message block when a reasoning segment interleaves", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "The answer is ", + }); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "calculating...", + }); + store = updateSubagentStore(store, { + type: "subagent_message", + namespace: ["task:abc"], + text: "42", + }); + // The second message chunk does not match the last segment (reasoning), + // so it starts a new message block rather than appending to the first. + const segs = store["task:abc"].segments; + assert.strictEqual(segs.length, 3); + assert.deepStrictEqual(segs[0], { type: "message", content: "The answer is " }); + assert.deepStrictEqual(segs[1], { type: "reasoning", content: "calculating..." }); + assert.deepStrictEqual(segs[2], { type: "message", content: "42" }); + }); + + it("starts a new reasoning block at a grammatical sentence boundary", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "First thought.", + }); + store = updateSubagentStore(store, { + type: "subagent_reasoning", + namespace: ["task:abc"], + text: "Second thought", + }); + // A period followed by a capital letter starts a new block. + assert.strictEqual(store["task:abc"].segments.length, 2); + }); + + it("keeps each tool segment as its own block", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_tool", + namespace: ["task:abc"], + text: "output one", + name: "read_file", + }); + store = updateSubagentStore(store, { + type: "subagent_tool", + namespace: ["task:abc"], + text: "output two", + name: "read_file", + }); + assert.strictEqual(store["task:abc"].segments.length, 2); + }); +}); + +describe("updateSubagentStore — tool-call redirection", () => { + it("records a subagent tool start", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_tool_start", + namespace: ["task:abc"], + name: "read_file", + data: { input: { path: "/tmp/x" } }, + }); + assert.deepStrictEqual(next["task:abc"].toolCalls, [ + { name: "read_file", input: { path: "/tmp/x" }, status: "running" }, + ]); + }); + + it("marks a running tool as done on tool end", () => { + let store = createSubagentStore(); + store = updateSubagentStore(store, { + type: "subagent_tool_start", + namespace: ["task:abc"], + name: "read_file", + data: { input: { path: "/tmp/x" } }, + }); + store = updateSubagentStore(store, { + type: "subagent_tool_end", + namespace: ["task:abc"], + name: "read_file", + data: { output: "file contents" }, + }); + assert.strictEqual(store["task:abc"].toolCalls.length, 1); + assert.strictEqual(store["task:abc"].toolCalls[0].status, "done"); + assert.strictEqual(store["task:abc"].toolCalls[0].output, "file contents"); + }); + + it("routes a subagent tool text segment", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_tool", + namespace: ["task:abc"], + text: "tool output", + name: "read_file", + }); + assert.deepStrictEqual(next["task:abc"].segments, [ + { type: "tool", content: "tool output", name: "read_file" }, + ]); + }); + + it("records a tool end when no tool is running", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_tool_end", + namespace: ["task:abc"], + name: "read_file", + data: { output: "file contents" }, + }); + assert.deepStrictEqual(next["task:abc"].toolCalls, [ + { name: "read_file", output: "file contents", status: "done" }, + ]); + }); + + it("ignores unknown event types", () => { + const store = createSubagentStore(); + const next = updateSubagentStore(store, { + type: "subagent_unknown", + namespace: ["task:abc"], + text: "ignored", + }); + assert.deepStrictEqual(next["task:abc"].segments, []); + assert.deepStrictEqual(next["task:abc"].toolCalls, []); + }); +}); + +describe("SubagentsPanel", () => { + it("renders nothing when not active", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { "task:abc": { namespace: "task:abc", segments: [], toolCalls: [] } }, + activeView: "conversation", + }), + ); + assert.strictEqual(result, ""); + }); + + it("renders an empty state when there are no subagents", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: {}, + activeView: "subagents", + }), + ); + assert.ok(result.includes("No subagent activity yet.")); + }); + + it("renders each subagent's message segments", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [{ type: "message", content: "subagent says hi" }], + toolCalls: [], + }, + }, + activeView: "subagents", + }), + ); + assert.ok(result.includes("subagent says hi")); + }); + + it("renders subagent tool calls", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [], + toolCalls: [{ name: "read_file", status: "done" }], + }, + }, + activeView: "subagents", + }), + ); + assert.ok(result.includes("read_file")); + }); + + it("always renders reasoning expanded (read-only view)", () => { + // The subagents panel is read-only and does not participate in the global + // collapse toggles — reasoning content is always visible. + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [{ type: "reasoning", content: "secret thinking" }], + toolCalls: [], + }, + }, + activeView: "subagents", + }), + ); + assert.ok(result.includes("secret thinking")); + }); + + it("always renders tool content expanded (read-only view)", () => { + const result = renderToString( + React.createElement(SubagentsPanel, { + subagentStore: { + "task:abc": { + namespace: "task:abc", + segments: [{ type: "tool", content: "tool output", name: "read_file" }], + toolCalls: [], + }, + }, + activeView: "subagents", + }), + ); + assert.ok(result.includes("tool output")); + }); +}); + +describe("estimateSubagentHeight", () => { + it("returns at least 1 row for an empty entry", () => { + assert.ok(estimateSubagentHeight({ segments: [], toolCalls: [] }) >= 1); + }); + + it("accounts for message content and tool calls", () => { + const height = estimateSubagentHeight({ + segments: [{ type: "message", content: "hello world" }], + toolCalls: [{ name: "read_file", status: "done" }], + }); + // Header (1) + content lines + tool-call rows. + assert.ok(height >= 2); + }); + + it("grows with longer content", () => { + const short = estimateSubagentHeight({ segments: [{ type: "message", content: "hi" }] }); + const long = estimateSubagentHeight({ + segments: [{ type: "message", content: "x".repeat(200) }], + }); + assert.ok(long >= short); + }); +});