diff --git a/package.json b/package.json index 773afb11b..ef2cd8854 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dev": "bun run src/cli.ts", "build": "bun build src/cli.ts --compile --outfile atomic", "test": "bun test", + "test:contracts": "bun test src/ui/utils/background-agent-provider-parity.test.ts src/ui/utils/background-agent-runtime-parity.test.ts src/ui/utils/background-agent-acceptance.test.ts src/ui/utils/background-agent-termination-integration.test.ts src/ui/utils/background-agent-keybinding-nonconflict.test.ts src/ui/utils/background-agent-parent-callback.test.ts", "typecheck": "tsc --noEmit", "lint": "oxlint --config=oxlint.json src", "lint:fix": "oxlint --config=oxlint.json --fix src", diff --git a/research/branch-tasks.md b/research/branch-tasks.md new file mode 100644 index 000000000..aa955c48f --- /dev/null +++ b/research/branch-tasks.md @@ -0,0 +1,26 @@ +# Branch: fix/tui-streaming-rendering + +## Overview + +This branch addresses TUI bugs related to **streaming content rendering and visual output** across all agents (Claude Code, OpenCode, GitHub Copilot). + +## Issues + +### #259 — Streaming text blocks clumped together +Thinking traces are not rendered as separate parts during streaming. Text blocks get clumped together instead of being visually distinct. + +### #258 — Background agents UI +Footer status bar, Ctrl+F termination flow, and tree view hints are not implemented for background agents. Affects Dev & Production. + +### #254 — Subagent output is final state instead of returning control +When a subagent completes, its output is shown as a final state rather than returning control back to the main agent's output stream. + +### #248 — Occasional formatting issues from reviewer agent +Sub-agent output (particularly the reviewer agent) occasionally has formatting/rendering issues in the TUI. + +### #231 — Reasoning indicator and timer continue after 100% +The reasoning indicator and elapsed timer keep running even after task progress has reached 100%. + +## Grouping Rationale + +All issues in this branch relate to how the TUI **renders and displays streamed content** — text blocks, agent output, progress indicators, and background agent status. Fixing these together ensures a consistent and correct rendering pipeline. diff --git a/research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md b/research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md new file mode 100644 index 000000000..5937f8948 --- /dev/null +++ b/research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md @@ -0,0 +1,491 @@ +--- +date: 2026-02-23 04:03:35 UTC +researcher: Copilot +git_commit: 938f157b0b6c9135ff9d010b698d80e09f9c7db9 +branch: fix/tui-streaming-rendering +repository: fix-tui-streaming-rendering +topic: "Background agents SDK event pipeline — why OpenCode/Copilot show no UI and Claude layout is incorrect" +tags: [research, codebase, background-agents, issue-258, sdk, event-pipeline, parallel-agents, opencode, copilot, claude, ui-layout] +status: complete +last_updated: 2026-02-23 +last_updated_by: Copilot +--- + +# Research: Background Agents SDK Event Pipeline (#258) + +## Research Question + +Investigate why background agents fail to render UI for OpenCode SDK and Copilot SDK (no UI at all), and why Claude Agent SDK's background agent UI has incorrect layout in the chatbox and tree view. For each SDK, trace the event pipeline from agent creation → stream events → message parts → UI rendering to identify where the data flow breaks or diverges. + +## Summary + +The background agent UI rendering depends on a multi-stage pipeline: **SDK events → UI integration state → ChatApp parallelAgents state → synthetic `parallel-agents` stream events → message parts → ParallelAgentsTree component**. Each SDK has different issues: + +1. **Claude Agent SDK**: The pipeline works end-to-end. Background agents render. The layout issue is in how `ParallelAgentsTree` is embedded within `MessageBubbleParts` (inside the scrollbox) while `BackgroundAgentFooter` sits outside as a sibling — both render but the tree placement within message parts and the chatbox layout interaction needs investigation at the rendering level. + +2. **OpenCode SDK**: The SDK properly emits `subagent.start` and `subagent.complete` events. The UI integration layer at `src/ui/index.ts` handles these events identically to Claude. The pipeline architecture is the same — the issue likely lies in whether OpenCode's native SDK actually fires these events during background agent execution, or whether the event data format differs in practice. + +3. **Copilot SDK**: **Root cause identified.** Copilot uses native `customAgents` config instead of a `Task` tool. The UI integration layer's eager agent creation (`src/ui/index.ts:641-676`) depends on detecting `tool.start` events with `toolName === "Task"`, which Copilot never emits. Without eager agent creation, the `parallelAgentHandler` callback is never invoked with initial agent data, and no `parallel-agents` events are generated. The `subagent.start` events DO fire, but the correlation logic at `src/ui/index.ts:1053-1058` depends on `pendingTaskEntries` which are only populated by Task tool starts. + +## Detailed Findings + +### 1. Shared UI Architecture: Event Pipeline End-to-End + +The pipeline has 6 stages that must all succeed for background agents to render: + +``` +Stage 1: SDK Client emits tool.start / subagent.start / subagent.complete events + ↓ +Stage 2: UI Integration (src/ui/index.ts) creates/updates ParallelAgent objects in state.parallelAgents + ↓ +Stage 3: UI Integration calls state.parallelAgentHandler(state.parallelAgents) callback + ↓ +Stage 4: ChatApp (src/ui/chat.tsx) receives agents, applies synthetic parallel-agents event + ↓ +Stage 5: Stream pipeline (src/ui/parts/stream-pipeline.ts) merges agents into AgentPart objects + ↓ +Stage 6: ParallelAgentsTree component renders the agent tree +``` + +#### Stage 1: SDK Event Emission + +Each SDK emits unified events via the `EventEmitter` base class (`src/sdk/base-client.ts:32`): + +| Event | Claude Source | OpenCode Source | Copilot Source | +|-------|-------------|----------------|----------------| +| `tool.start` | `PreToolUse` hook (`claude.ts:1421`) | `message.part.updated` SSE with `part.type === "tool"` (`opencode.ts:679`) | `tool.execution_start` (`copilot.ts:595`) | +| `tool.complete` | `PostToolUse` hook (`claude.ts:1428`) | `message.part.updated` SSE with status `"completed"` (`opencode.ts:686`) | `tool.execution_complete` (`copilot.ts:613`) | +| `subagent.start` | `SubagentStart` hook (`claude.ts:1441`) | `message.part.updated` SSE with `part.type === "agent"/"subtask"` (`opencode.ts:710-733`) | `subagent.started` native event (`copilot.ts:627`) | +| `subagent.complete` | `SubagentStop` hook (`claude.ts:1447`) | `message.part.updated` SSE with `part.type === "step-finish"` (`opencode.ts:734`) | `subagent.completed` native event (`copilot.ts:639`) | + +#### Stage 2: UI Integration — Agent Creation (`src/ui/index.ts`) + +**Two-path agent creation:** + +**Path A — Eager (Task tool detection, lines 638-730):** +- Triggered by `tool.start` events where `toolName === "Task"` or `"task"` (line 588) +- Extracts `run_in_background` from tool input (line 644) +- Creates `ParallelAgent` immediately with `status: "background"` or `"running"` (line 672) +- Stores in `pendingTaskEntries` queue for later correlation (line 645) +- **This path provides immediate UI feedback before `subagent.start` fires** + +**Path B — Subagent.start correlation (lines 1028-1164):** +- Triggered by `subagent.start` events +- Attempts to correlate with pending Task entry from Path A (lines 1053-1058) +- If eager agent exists: merges real `subagentId` into it (lines 1102-1115) +- If no eager agent: creates new `ParallelAgent` from scratch (lines 1135-1150) +- Background flag extracted from pending entry or fallback input (line 1091-1092) + +#### Stage 3: Handler Callback + +- Registered at `src/ui/index.ts:1814-1816` +- ChatApp registers via `setParallelAgentsHandler` prop +- Invoked with full `state.parallelAgents` array after every mutation + +#### Stage 4: ChatApp State Update (`src/ui/chat.tsx`) + +- `parallelAgents` state at line 1856 +- `parallelAgentsRef` sync ref at line 1935 +- During streaming: applies `parallel-agents` event to active message (lines 2791-2806) +- After streaming: applies to background message via `backgroundAgentMessageIdRef` (lines 2809-2832) + +#### Stage 5: Stream Pipeline (`src/ui/parts/stream-pipeline.ts`) + +- `applyStreamPartEvent()` at line 812 handles `parallel-agents` case (lines 863-877) +- `mergeParallelAgentsIntoParts()` at line 637 creates `AgentPart` objects +- Groups agents by `taskToolCallId` or consolidates into single tree + +#### Stage 6: Rendering + +- `PART_REGISTRY["agent"]` maps to `AgentPartDisplay` (`src/ui/components/parts/registry.tsx:26`) +- `AgentPartDisplay` wraps `ParallelAgentsTree` (`src/ui/components/parts/agent-part-display.tsx:24`) +- `BackgroundAgentFooter` renders at bottom of chat layout (`src/ui/chat.tsx:5996`) + +--- + +### 2. Claude Agent SDK — Working Pipeline, Layout Issues + +#### Event Flow (Working) + +1. Model calls Task tool → Claude SDK fires `PreToolUse` hook → `tool.start` event emitted +2. UI integration detects `toolName === "Task"` → creates eager `ParallelAgent` with `background: true` +3. Claude SDK fires `SubagentStart` hook → `subagent.start` event → merges with eager agent +4. `parallelAgentHandler` callback → ChatApp updates state → `parallel-agents` synthetic event applied +5. Stream pipeline creates `AgentPart` → `ParallelAgentsTree` renders + +**Key Claude-specific details:** +- Hook callbacks provide `agent_id`, `agent_type`, `toolUseID` for correlation (`claude.ts:1441-1450`) +- Session ID resolution via `resolveHookSessionId()` handles SDK → wrapped ID mapping (`claude.ts:1257-1285`) +- Post-stream background agent continuation works via guard at `src/ui/index.ts:1186-1191` + +#### Layout Architecture (Current State) + +The chat app layout hierarchy: + +``` +Root Box (100% height, column) — src/ui/chat.tsx:5779 +├── AtomicHeader (flexShrink={0}) +├── [Chat Mode]: +│ └── Box (flexGrow={1}, column) +│ └── Scrollbox (flexGrow={1}, stickyScroll, paddingLeft/Right={1}) +│ ├── Messages Array +│ │ └── MessageBubble (paddingLeft/Right={1}, marginBottom={1}) +│ │ └── MessageBubbleParts (column, gap={1}) +│ │ └── AgentPartDisplay (column) +│ │ └── ParallelAgentsTree (paddingLeft={1}) +│ ├── Input Area (textarea + hints) +│ ├── Ctrl+F warning (line 5985) +│ └── ... +└── BackgroundAgentFooter (flexShrink={0}) — line 5996 +``` + +**Layout observations:** +- `ParallelAgentsTree` is embedded inside message parts within the scrollbox +- `BackgroundAgentFooter` is a sibling to the scrollbox wrapper, at the bottom +- The input area is INSIDE the scrollbox (lines 5876+), not below it +- Ctrl+F warning text is rendered inside scrollbox (line 5985) +- Tree uses `paddingLeft={SPACING.CONTAINER_PAD}` (1 cell) and conditional `marginTop` +- `AgentPartDisplay` passes `noTopMargin` to tree, relying on parent `gap` for spacing + +**Chatbox layout context:** +- The footer is at line 5996, AFTER the scrollbox closing tag +- Footer uses `flexShrink={0}` to maintain its single-line height +- Scrollbox uses `flexGrow={1}` to take remaining space +- `FooterStatus` component exists (`src/ui/components/footer-status.tsx:99`) but is NOT mounted — only `BackgroundAgentFooter` is used + +--- + +### 3. OpenCode SDK — Event Pipeline Analysis + +#### Event Emission (Documented) + +OpenCode client maps SDK events to unified events via `handleSdkEvent()` (`opencode.ts:599-750`): + +- `part.type === "agent"` → `subagent.start` with `{ subagentId: part.id, subagentType: part.name }` (lines 710-716) +- `part.type === "subtask"` → `subagent.start` with `{ subagentId: part.id, subagentType: part.agent, task: part.description }` (lines 717-733) +- `part.type === "step-finish"` → `subagent.complete` with `{ subagentId: part.id, success, result }` (lines 734-742) +- `part.type === "tool"` with `status === "pending"/"running"` → `tool.start` (line 679) +- `part.type === "tool"` with `status === "completed"` → `tool.complete` (line 686) + +**Task tool detection path:** +- The UI integration at `src/ui/index.ts:588` checks `data.toolName === "Task"` or `"task"` +- OpenCode SDK's `tool.start` events include `toolName` from `part.name` field +- If OpenCode's model invokes a tool named "Task", the eager agent creation path (lines 641-676) is triggered +- The `run_in_background` flag is read from `toolInput` (line 644) + +**Two OpenCode part variants for sub-agents:** + +| Variant | Part Type | Fields | Used When | +|---------|-----------|--------|-----------| +| AgentPart | `"agent"` | `id`, `name`, `sessionID`, `messageID` | Agent-style dispatch | +| SubtaskPart | `"subtask"` | `id`, `prompt`, `description`, `agent` | Task-style dispatch | + +**Critical observation:** OpenCode emits `tool.start` for both `pending` AND `running` status updates for the same tool (line 679). The SDK sends richer `input` data in the `running` update. The UI integration at `src/ui/index.ts:685-721` handles this update path, re-checking the background flag at line 704. + +#### Where the Pipeline Could Break + +The code path is architecturally identical to Claude's for the shared UI integration layer. Potential failure points: + +1. **OpenCode SDK may not emit `tool.start` with `toolName: "Task"`** — If OpenCode's native tool naming differs, the eager agent creation at `src/ui/index.ts:641` would be bypassed +2. **`subagent.start` events may not correlate** — If `toolCallId` or `toolUseID` fields aren't populated in the OpenCode event data, the correlation at `src/ui/index.ts:1053-1058` would fail +3. **SSE event stream may not fire sub-agent events** — OpenCode's `part.type === "agent"` and `"subtask"` events depend on the SDK server emitting these part types +4. **Session ID mismatch** — OpenCode session IDs come from `properties.info.id` (line 606), which differs from Claude's hook-based session resolution +5. **Tool ID correlation** — OpenCode uses `part.callID` for tool correlation, while Claude uses `toolUseID` from hooks + +#### OpenCode Event Test Gaps + +From `src/sdk/clients/opencode.events.test.ts`: +- Tests `subtask` → `subagent.start` mapping (lines 108-156) +- Does NOT test `agent` part type mapping +- Does NOT test `step-finish` → `subagent.complete` mapping +- Does NOT test Task tool detection for background agents + +--- + +### 4. Copilot SDK — Root Cause: No Task Tool + +#### The Fundamental Problem + +Copilot's custom agent system does NOT use a `Task` tool. The UI integration's eager agent creation depends entirely on detecting Task tool starts: + +```typescript +// src/ui/index.ts:588 +const isTaskToolName = data.toolName === "Task" || data.toolName === "task"; + +// src/ui/index.ts:641-648 — Only triggered when isTaskToolName is true +if (isTaskToolName && data.toolInput && !isUpdate) { + const input = typeof data.toolInput === "string" ? JSON.parse(data.toolInput) : data.toolInput; + const isBackground = input.run_in_background === true; + pendingTaskEntries.push({ toolId, prompt, isBackground, runId: activeRunId }); + // ... creates eager ParallelAgent +} +``` + +**Copilot never triggers this path** because: +- Copilot loads agents via `customAgents` SDK config (`copilot.ts:883`) +- The SDK internally dispatches to agents without emitting `tool.start` with `toolName: "Task"` +- Copilot emits `subagent.started` directly → mapped to `subagent.start` at `copilot.ts:627-632` + +#### What Copilot's `subagent.start` Event Contains + +```typescript +// src/sdk/clients/copilot.ts:627-632 +{ + subagentId: data.toolCallId, // ✅ Present + subagentType: data.agentName, // ✅ Present + task: undefined, // ❌ Not extracted from SDK event + toolUseID: undefined, // ❌ Claude-specific field + toolCallId: data.toolCallId, // ✅ Present +} +``` + +#### Why `subagent.start` Handler Fails to Create Agent + +At `src/ui/index.ts:1027-1164`, the `subagent.start` handler: + +1. Tries to find pending Task entry: `pendingTaskEntries.find(...)` (line 1053) → **empty queue, finds nothing** +2. Tries to find eager agent by `toolUseID`: `toolCallToAgentMap.get(toolUseID)` (line 1041) → **undefined (no Claude hook ID)** +3. Tries to find eager agent by `toolCallId`: searches `state.parallelAgents` (line 1045-1071) → **empty array, finds nothing** +4. Falls through to fresh agent creation at line 1135-1150 + +**BUT** the fresh creation path has its own guard: + +```typescript +// src/ui/index.ts:1039-1042 +if (!state.isStreaming && !state.activeRunId) return; +if (!state.parallelAgentHandler || !data.subagentId) return; +``` + +If streaming state or `parallelAgentHandler` is not set up when the `subagent.start` event fires, the handler returns early. + +#### Comparison Table + +| Pipeline Stage | Claude | OpenCode | Copilot | +|---------------|--------|----------|---------| +| **Tool invocation model** | Model calls `Task` tool | Model calls `Task` tool | SDK dispatches to `customAgents` | +| **`tool.start` with Task name** | ✅ Via PreToolUse hook | ✅ Via SSE `tool` part | ❌ No Task tool exists | +| **Eager agent creation** | ✅ Triggered at line 641 | ✅ Triggered at line 641 | ❌ Never triggered | +| **`pendingTaskEntries` populated** | ✅ At line 645 | ✅ At line 645 | ❌ Empty queue | +| **`subagent.start` emitted** | ✅ Via SubagentStart hook | ✅ Via `agent`/`subtask` part | ✅ Via `subagent.started` | +| **Agent correlation succeeds** | ✅ Via `toolUseID` | ✅ Via `toolCallId`/queue | ❌ No entries to correlate | +| **Background flag available** | ✅ From `run_in_background` | ✅ From `run_in_background` | ❌ Not in event data | +| **`parallelAgentHandler` called** | ✅ At line 1151 | ✅ At line 1151 | ⚠️ Only if fresh creation succeeds | +| **`parallel-agents` event created** | ✅ In ChatApp | ✅ In ChatApp | ❌ No agents to create event for | +| **UI renders tree** | ✅ | ⚠️ (reported broken) | ❌ No rendering | + +--- + +### 5. Background Agent Termination Flow (Ctrl+F) + +The termination flow exists and is implemented for all SDKs: + +1. **Key detection**: `isBackgroundTerminationKey(event)` at `src/ui/utils/background-agent-termination.ts:22` +2. **Decision logic**: `getBackgroundTerminationDecision()` at line 29 — returns `warn` on first press, `terminate` on second +3. **Chat handler**: `src/ui/chat.tsx:4440-4520` — Ctrl+F branch in `useKeyboard` +4. **First press**: Sets `ctrlFPressed` state, shows warning at line 5985, starts 1s timeout (line 4514-4520) +5. **Second press**: Calls `interruptActiveBackgroundAgents()`, updates message parts, clears state, invokes parent callback, appends "All background agents killed" message (lines 4465-4511) +6. **Parent callback**: `src/ui/index.ts:1809-1823` — resets stream/run state, aborts session + +--- + +### 6. Background Agent Footer + +**Component**: `BackgroundAgentFooter` at `src/ui/components/background-agent-footer.tsx:12-36` + +**Resolution logic**: `resolveBackgroundAgentsForFooter()` at `src/ui/utils/background-agent-footer.ts:24-33`: +- Prefers live `parallelAgents` state (line 28) +- Falls back to most recent message with background agents (line 33) +- Returns empty if no background agents active + +**Mount location**: `src/ui/chat.tsx:5996` — outside scrollbox, at bottom of root column +- Uses `flexShrink={0}` — maintains height +- Returns `null` if no label (no agents) at `background-agent-footer.tsx:19-21` + +**Contract**: `BACKGROUND_FOOTER_CONTRACT` at `src/ui/utils/background-agent-contracts.ts:49-54`: +- Shows when ≥1 agent active +- Includes `"ctrl+f terminate"` hint +- Uses `"agents"` count format + +--- + +### 7. Tree Hint Generation + +**Builder**: `buildParallelAgentsHeaderHint()` at `src/ui/utils/background-agent-tree-hints.ts:22` + +**Hint values** (from `BACKGROUND_TREE_HINT_CONTRACT` at `background-agent-contracts.ts:70-74`): +- Running: `"background running · ctrl+f terminate"` (line 29) +- Complete: `"background complete · ctrl+o to expand"` (line 33) +- Default: `"ctrl+o to expand"` (line 37) + +**Used at**: `src/ui/components/parallel-agents-tree.tsx:550` — rendered in header alongside agent count text + +--- + +## Code References + +### SDK Clients +- `src/sdk/clients/claude.ts:1421-1450` — Claude hook callbacks for tool/subagent events +- `src/sdk/clients/opencode.ts:670-742` — OpenCode SSE event mapping for tools/subagents +- `src/sdk/clients/copilot.ts:527-661` — Copilot SDK event transformation +- `src/sdk/clients/copilot.ts:627-632` — Copilot subagent.started event data extraction +- `src/sdk/clients/copilot.ts:153-171` — Copilot event type mapping table +- `src/sdk/base-client.ts:32-87` — EventEmitter base class + +### UI Integration +- `src/ui/index.ts:588` — Task tool name detection (`isTaskToolName`) +- `src/ui/index.ts:638-730` — Eager agent creation on Task tool.start +- `src/ui/index.ts:1028-1164` — subagent.start event handler and correlation logic +- `src/ui/index.ts:1166-1226` — subagent.complete event handler +- `src/ui/index.ts:1809-1823` — Background agent termination parent callback + +### Chat App +- `src/ui/chat.tsx:1856` — `parallelAgents` state declaration +- `src/ui/chat.tsx:2774-2833` — Parallel agent effect (state → message parts) +- `src/ui/chat.tsx:2920-2938` — Post-stream background agent preservation +- `src/ui/chat.tsx:4440-4520` — Ctrl+F termination handler +- `src/ui/chat.tsx:5701-5704` — Footer agent resolution +- `src/ui/chat.tsx:5996` — BackgroundAgentFooter mount + +### Stream Pipeline +- `src/ui/parts/stream-pipeline.ts:637-765` — `mergeParallelAgentsIntoParts()` +- `src/ui/parts/stream-pipeline.ts:863-877` — `parallel-agents` event case handler +- `src/ui/parts/guards.ts:19-23` — `shouldFinalizeOnToolComplete()` background guard +- `src/ui/parts/types.ts:86-90` — `AgentPart` interface + +### Components +- `src/ui/components/parallel-agents-tree.tsx:480-587` — Main tree component +- `src/ui/components/parallel-agents-tree.tsx:280-454` — `AgentRow` component +- `src/ui/components/background-agent-footer.tsx:12-36` — Footer component +- `src/ui/components/parts/agent-part-display.tsx:17-31` — Parts bridge to tree +- `src/ui/components/parts/registry.tsx:26` — Part registry entry for "agent" +- `src/ui/components/footer-status.tsx:99` — FooterStatus (exists but NOT mounted) + +### Utilities +- `src/ui/utils/background-agent-footer.ts:24-33` — Footer agent resolver +- `src/ui/utils/background-agent-termination.ts:22-57` — Termination key/decision/interrupt +- `src/ui/utils/background-agent-tree-hints.ts:22-37` — Tree header hint builder +- `src/ui/utils/background-agent-contracts.ts:37-74` — Contract definitions + +### Tests +- `src/sdk/unified-event-parity.test.ts` — Verifies all SDKs register same event types (NOT that they emit them) +- `src/sdk/clients/opencode.events.test.ts` — OpenCode event mapping tests (missing `agent` and `step-finish`) +- `src/sdk/clients/copilot.test.ts` — Copilot tests (NO subagent event tests) +- `src/ui/parallel-agent-background-lifecycle.test.ts` — Background agent lifecycle +- `src/ui/parts/background-agent-e2e.test.ts` — Background agent E2E +- `src/ui/utils/background-agent-provider-parity.test.ts` — Provider parity tests + +## Architecture Documentation + +### Event Pipeline Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SDK Clients │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Claude │ │ OpenCode │ │ Copilot │ │ +│ │ Hooks │ │ SSE │ │ Events │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ emitEvent() emitEvent() emitEvent() │ +│ (tool.start, (tool.start, (subagent.started │ +│ subagent. subagent. → subagent.start) │ +│ start, etc) start, etc) ❌ NO tool.start │ +│ for "Task" │ +└──────────┬──────────┬──────────────┬───────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ UI Integration (src/ui/index.ts) │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ tool.start handler (line 638) │ │ +│ │ if toolName === "Task": │ │ +│ │ → create eager ParallelAgent │ │ +│ │ → populate pendingTaskEntries │ ← COPILOT │ +│ │ → call parallelAgentHandler() │ SKIPPED │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ subagent.start handler (line 1028) │ │ +│ │ → correlate with pendingTaskEntries │ ← COPILOT │ +│ │ → merge with eager agent OR create fresh │ NO ENTRY │ +│ │ → call parallelAgentHandler() │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ subagent.complete handler (line 1166) │ │ +│ │ → update agent status │ │ +│ │ → call parallelAgentHandler() │ │ +│ └──────────────────────────────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ChatApp (src/ui/chat.tsx) │ +│ │ +│ parallelAgents state ← parallelAgentHandler callback │ +│ │ │ +│ ▼ │ +│ useEffect: apply parallel-agents synthetic event │ +│ │ │ +│ ▼ │ +│ applyStreamPartEvent({ type: "parallel-agents", agents }) │ +│ │ │ +│ ▼ │ +│ mergeParallelAgentsIntoParts() → AgentPart in message.parts │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ ┌──────────────────────┐ │ +│ │ Scrollbox │ │ BackgroundAgentFooter │ │ +│ │ └─ MessageParts │ │ (outside scrollbox) │ │ +│ │ └─ AgentPart │ │ flexShrink={0} │ │ +│ │ └─ Tree │ └──────────────────────┘ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Per-SDK Pipeline Status + +| Stage | Claude | OpenCode | Copilot | +|-------|--------|----------|---------| +| 1. SDK emits tool.start for Task | ✅ | ✅ | ❌ No Task tool | +| 2. Eager agent created | ✅ | ✅ | ❌ Bypassed | +| 3. pendingTaskEntries populated | ✅ | ✅ | ❌ Empty | +| 4. SDK emits subagent.start | ✅ | ✅ | ✅ | +| 5. Correlation with pending entry | ✅ | ✅ | ❌ Nothing to correlate | +| 6. Background flag extracted | ✅ | ✅ | ❌ Not in event data | +| 7. parallelAgentHandler called | ✅ | ✅* | ⚠️ Only if fresh creation succeeds | +| 8. parallel-agents event applied | ✅ | ✅* | ❌ | +| 9. AgentPart in message.parts | ✅ | ✅* | ❌ | +| 10. ParallelAgentsTree renders | ✅ | ✅* | ❌ | + +*\* OpenCode is architecturally identical to Claude in the UI layer, but the user reports no UI. The issue may be in whether the native OpenCode SDK actually fires the expected events during sub-agent execution.* + +## Historical Context (from research/) + +- `research/tickets/2026-02-23-0258-background-agents-ui.md` — Prior ticket research documenting component locations and existing implementations +- `research/docs/2026-02-23-gh-issue-258-background-agents-ui.md` — GitHub issue extraction with screenshot URLs +- `research/docs/2026-02-23-sdk-subagent-api-research.md` — External SDK documentation research via DeepWiki +- `research/docs/2026-02-15-sub-agent-tree-status-lifecycle-sdk-parity.md` — Prior lifecycle/status research +- `research/docs/2026-02-15-subagent-event-flow-diagram.md` — Prior event flow documentation +- `research/docs/2026-02-16-sub-agent-tree-inline-state-lifecycle-research.md` — Prior inline state lifecycle research +- `research/docs/2026-02-12-sub-agent-sdk-integration-analysis.md` — Prior SDK integration analysis + +## Related Research + +- `research/docs/2026-02-23-sdk-subagent-api-research.md` — DeepWiki-sourced SDK API research (companion document) +- `research/docs/2026-02-16-sub-agent-tree-inline-state-lifecycle-research.md` +- `research/docs/2026-02-12-sdk-ui-standardization-comprehensive.md` +- `research/docs/2026-02-15-subagent-premature-completion-investigation.md` + +## Open Questions + +1. **OpenCode runtime behavior**: The code architecture supports background agents for OpenCode (same UI integration path as Claude), but the user reports no UI. Need to verify whether the OpenCode SDK server actually emits `part.type === "subtask"` or `"agent"` events during sub-agent execution, or whether the tool naming differs from "Task". + +2. **Copilot fresh agent creation path**: At `src/ui/index.ts:1135-1150`, a fresh agent creation path exists for `subagent.start` events without prior Task entries. If `state.isStreaming` and `state.parallelAgentHandler` are properly set, this path SHOULD create agents for Copilot. Need to verify whether streaming state guards at line 1039-1042 are blocking Copilot's events. + +3. **Claude layout specifics**: The tree renders inside message parts within the scrollbox, while the footer is outside. The exact nature of the "incorrect layout" for Claude needs visual verification — whether it's spacing, positioning, or content ordering. + +4. **Background flag for Copilot**: Even if the fresh creation path works, Copilot's `subagent.started` events don't carry `run_in_background`. All Copilot agents would render as foreground. Need to determine if Copilot's custom agent system supports background execution. diff --git a/research/docs/2026-02-23-gh-issue-258-background-agents-ui.md b/research/docs/2026-02-23-gh-issue-258-background-agents-ui.md new file mode 100644 index 000000000..7ab54154c --- /dev/null +++ b/research/docs/2026-02-23-gh-issue-258-background-agents-ui.md @@ -0,0 +1,86 @@ +--- +date: 2026-02-23 02:06:44 UTC +researcher: OpenCode +git_commit: f674c962807d5926f0f19633e66192e7d8ce1039 +branch: fix/tui-streaming-rendering +repository: fix-tui-streaming-rendering +topic: "GitHub issue extraction: #258 Background agents UI" +tags: [research, github, issue-258, tui, background-agents] +status: complete +last_updated: 2026-02-23 +last_updated_by: OpenCode +--- + +# Research + +## Research Question +Extract all available information and image assets from GitHub issue #258 using `gh`. + +## Summary +Issue `#258` is open and labeled `bug`, authored by `lavaman131`, with no issue comments currently present. The issue body includes five screenshot assets hosted on `raw.githubusercontent.com` and a detailed problem statement covering footer status behavior, Ctrl+F termination, and agent tree hint behavior. + +## GitHub Extraction (via gh) + +### Issue metadata +- Issue: `#258` +- Title: `[BUG] TUI: Background agents UI — footer status bar, Ctrl+F termination flow, and tree view hints not implemented — Claude Code, OpenCode, Copilot — Dev & Production` +- State: `OPEN` +- Author: `lavaman131` +- Labels: `bug` +- Created: `2026-02-22T20:31:58Z` +- Updated: `2026-02-22T20:34:37Z` +- URL: `https://github.com/flora131/atomic/issues/258` + +Source command output: `gh issue view 258 --json number,title,state,author,createdAt,updatedAt,url,labels,body` + +### Timeline events +- Labeled `bug` at `2026-02-22T20:31:59Z` +- Referenced by commit `92b9badb08a9b3d0a3243cd4ec8c140c190d2744` at `2026-02-23T00:07:57Z` + +Source command output: `gh api repos/flora131/atomic/issues/258/events` + +### Referenced commit from timeline +- Commit: `92b9badb08a9b3d0a3243cd4ec8c140c190d2744` +- Message: `docs(research): add branch task breakdown for TUI streaming rendering` +- Changed file includes `research/branch-tasks.md` +- Commit URL: `https://github.com/flora131/atomic/commit/92b9badb08a9b3d0a3243cd4ec8c140c190d2744` + +Source command output: `gh api repos/flora131/atomic/commits/92b9badb08a9b3d0a3243cd4ec8c140c190d2744` + +### Issue comments +- No issue comments returned. + +Source command output: `gh api repos/flora131/atomic/issues/258/comments` + +## Extracted Image Assets + +All screenshot URLs were extracted from the issue body using `gh issue view 258 --json body -q .body | rg -o 'https://[^)\\s]+'`. + +1. Background Agent Tree UI (Running state) + - URL: `https://raw.githubusercontent.com/flora131/atomic/lavaman131/hotfix/ralph-workflow/tmux-screenshots/background-task-subagent/background-agent-tree-ui.png` + - Caption text in issue: three task agents running, expanded tree view. + +2. Background Agent Tree UI (Initializing state) + - URL: `https://raw.githubusercontent.com/flora131/atomic/lavaman131/hotfix/ralph-workflow/tmux-screenshots/background-task-subagent/background-agent-tree-ui0.png` + - Caption text in issue: two task agents initializing with `0 tool uses`. + +3. Background Agent Chatbox UI (footer hint) + - URL: `https://raw.githubusercontent.com/flora131/atomic/lavaman131/hotfix/ralph-workflow/tmux-screenshots/background-task-subagent/background-agent-chatbox-ui.png` + - Caption text in issue: footer status bar with agent count and `ctrl+f` termination hint. + +4. Confirmation Background Agents + - URL: `https://raw.githubusercontent.com/flora131/atomic/lavaman131/hotfix/ralph-workflow/tmux-screenshots/background-task-subagent/confirmation-background-agents.png` + - Caption text in issue: first Ctrl+F press confirmation prompt. + +5. Chat Message Background Sub Agent Terminated + - URL: `https://raw.githubusercontent.com/flora131/atomic/lavaman131/hotfix/ralph-workflow/tmux-screenshots/background-task-subagent/chat-message-background-sub-agent-kill.png` + - Caption text in issue: chat confirmation `All background agents killed` after second Ctrl+F. + +## Historical Context Linkage +- `research/branch-tasks.md` includes issue grouping text for `#258` and explicitly states `Affects Dev & Production`. + +## Related Research +- `research/tickets/2026-02-23-0258-background-agents-ui.md` + +## Open Questions +- The issue body lists explicit "missing" behaviors; codebase state at this research timestamp is documented separately in the ticket research artifact. diff --git a/research/docs/2026-02-23-sdk-subagent-api-research.md b/research/docs/2026-02-23-sdk-subagent-api-research.md new file mode 100644 index 000000000..526e03891 --- /dev/null +++ b/research/docs/2026-02-23-sdk-subagent-api-research.md @@ -0,0 +1,980 @@ +--- +title: Sub-Agent/Background Agent API Research +date: 2026-02-23 +author: Research Team +tags: [sdk, sub-agent, streaming, api, events] +status: completed +--- + +# Sub-Agent/Background Agent API Research + +## Executive Summary + +This document analyzes how three major coding agent SDKs handle sub-agent/background task tool calls and event streaming: + +1. **OpenCode** (anomalyco/opencode) - Uses a dedicated `Task` tool with `message.part.updated` events +2. **GitHub Copilot SDK** (github/copilot-sdk) - Emits granular tool execution lifecycle events +3. **Claude Agent SDK** - Uses `parent_tool_use_id` for nesting with hook-based events + +Key findings: +- All three SDKs support nested/hierarchical tool execution +- Event streaming approaches vary: OpenCode uses SSE with part updates, Copilot uses lifecycle events, Claude uses hooks +- OpenTUI provides Flexbox-like layout with Yoga for dynamic agent tree visualization + +--- + +## 1. OpenCode SDK (anomalyco/opencode) + +**DeepWiki Search**: https://deepwiki.com/search/how-does-opencode-handle-subag_4598ffc8-50f0-4ce5-8563-069c60e03e68 + +### Sub-Agent/Background Task Handling + +OpenCode uses a dedicated **`Task` tool** to orchestrate sub-agent execution and background tasks. + +#### Key Components: + +**Task Tool Configuration**: +```typescript +interface TaskToolInput { + prompt: string; + subagent_type: string; // e.g., "General", "Explore" +} +``` + +**Execution Flow**: +1. When `SessionPrompt.loop()` encounters a task of `type === "subtask"`, a `TaskTool` is initialized +2. An `assistant` message and a `tool` part are created to represent the running task +3. The `tool` part's state is set to `"running"` with details about the subtask's prompt, description, and agent type +4. The `TaskTool.execute()` method is called with task arguments and context + +**Available Subagent Types**: +- **"General"**: For researching complex questions and executing multi-step tasks autonomously +- **"Explore"**: For fast, read-only exploration of codebases + +### Events Emitted During Tool Execution + +#### Primary Event: `message.part.updated` + +OpenCode streams tool results and status updates through the `message.part.updated` event. + +**Event Trigger Points**: +1. **Tool Start**: When `Session.updatePart()` updates the `ToolPart` state to `"running"` +2. **During Execution**: When the `metadata()` function within `Tool.Context` updates the `ToolPart` with new metadata +3. **Tool Completion**: When the `ToolPart` state is updated to `"completed"` (includes output, metadata, attachments) +4. **Tool Error**: When the `ToolPart` state is updated to `"error"` (includes error message) + +#### Additional Plugin Events: +- `tool.execute.before` - Triggered before tool execution +- `tool.execute.after` - Triggered after tool execution + +### Event Format for Streaming Tool Results + +The `message.part.updated` event carries a `MessageV2.ToolPart` object: + +```typescript +interface ToolPart { + id: string; // Unique identifier for the part + messageID: string; // ID of the assistant message this part belongs to + sessionID: string; // ID of the session + type: "tool"; // Always "tool" for tool calls + callID: string; // Unique identifier for the tool call + tool: string; // Name of the tool being called (e.g., "task") + state: { + status: "running" | "completed" | "error"; + input: unknown; // Arguments provided to the tool + output?: unknown; // Result of the tool execution (if completed) + error?: string; // Error message (if error) + metadata?: unknown; // Additional metadata from the tool provider + attachments?: Array; // Files or media attached to the tool result + time: { + start: string; + end?: string; + }; + }; +} +``` + +**Model Message Transformation**: +The `toModelMessages` function transforms `ToolPart` objects into `assistantMessage.parts` with specific types: +- `type`: `"tool-TOOLNAME"` (e.g., `"tool-task"`) +- State values: `"output-available"` or `"output-error"` + +### SSE/Streaming API for Tool Calls + +**Server-Sent Events (SSE) Architecture**: +```typescript +// Client subscribes via the /event endpoint +const events = sdk.event.subscribe(); + +// Iterate over the event stream +for await (const event of events.stream) { + if (event.type === 'message.part.updated') { + // Handle tool execution update + const toolPart = event.part; + console.log(`Tool: ${toolPart.tool}, Status: ${toolPart.state.status}`); + } +} +``` + +**Granular Tool Input Events** (from OpenAICompatibleChatLanguageModel): +- `tool-input-start` - Tool input begins +- `tool-input-delta` - Incremental tool input updates +- `tool-input-end` - Tool input complete +- `tool-call` - Overall tool call status + +**Direct MCP Tool Invocation**: +```bash +opencode mcp call +``` +Useful for testing and debugging tool calls directly. + +### References + +- **Architecture**: [OpenCode Architecture Wiki](https://deepwiki.com/wiki/anomalyco/opencode#2) +- **MCP Integration**: [MCP (Model Context Protocol) Wiki](https://deepwiki.com/wiki/anomalyco/opencode#13) +- Key Files: + - `SessionPrompt.loop()` - Main execution loop for subtask handling + - `TaskTool.execute()` - Task tool execution method + - `Session.updatePart()` - Updates ToolPart state and emits events + - `openai-compatible-chat-language-model.ts` - Granular tool input events + +--- + +## 2. GitHub Copilot SDK (github/copilot-sdk) + +**DeepWiki Search**: https://deepwiki.com/search/how-does-the-copilot-sdk-handl_35f77c7a-128d-436b-b281-dc7fc72b32a4 + +### Tool Call Handling During Streaming + +The Copilot SDK uses an **event-driven architecture** with granular lifecycle events for tool execution tracking. + +### Events Emitted for Tool Execution + +#### Tool Execution Lifecycle Events: + +1. **`tool.execution_start`** - Emitted when tool execution begins +2. **`tool.execution_progress`** - Ongoing progress updates during execution +3. **`tool.execution_partial_result`** - Partial results from a tool still executing +4. **`tool.execution_complete`** - Tool execution finished (success or failure) + +#### Event Subscription: + +```typescript +// TypeScript example +session.on('ToolExecutionStartEvent', (event) => { + console.log(`Tool ${event.data.toolName} started`); +}); + +session.on('ToolExecutionCompleteEvent', (event) => { + console.log(`Tool ${event.data.toolCallId} completed: ${event.data.success}`); +}); +``` + +```csharp +// C# example +session.On((evt) => { + Console.WriteLine($"Tool {evt.Data.ToolName} started"); +}); + +session.On((evt) => { + Console.WriteLine($"Tool completed: {evt.Data.Success}"); +}); +``` + +### Event Format for tool_start and tool_result + +#### `ToolExecutionStartEvent` Format: + +```typescript +{ + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "tool.execution_start"; + data: { + toolCallId: string; + toolName: string; + arguments?: unknown; + mcpServerName?: string; // For MCP tools + mcpToolName?: string; // Original MCP tool name + parentToolCallId?: string; // For nested tool calls + }; +} +``` + +**Key Fields**: +- `toolCallId`: Unique identifier for this tool call +- `toolName`: Name of the tool being executed +- `arguments`: Input arguments passed to the tool +- `parentToolCallId`: Links to parent tool for nested/sub-agent calls + +#### `ToolExecutionCompleteEvent` Format: + +```typescript +{ + id: string; + timestamp: string; + parentId: string | null; + ephemeral?: boolean; + type: "tool.execution_complete"; + data: { + toolCallId: string; + success: boolean; + isUserRequested?: boolean; + result?: { + content: string; + detailedContent?: string; + contents?: Array< + | { type: "text"; text: string; } + | { type: "terminal"; text: string; exitCode?: number; cwd?: string; } + | { type: "image"; data: string; mimeType: string; } + | { type: "audio"; data: string; mimeType: string; } + | { + type: "resource_link"; + uri: string; + name: string; + title?: string; + description?: string; + mimeType?: string; + size?: number; + icons?: Array<{ + src: string; + mimeType?: string; + sizes?: string[]; + theme?: "light" | "dark"; + }>; + } + | { + type: "resource"; + resource: { + uri: string; + mimeType?: string; + } & ({ text: string } | { blob: string }); + } + >; + }; + error?: { + message: string; + code?: string; + }; + toolTelemetry?: { + [k: string]: unknown; + }; + parentToolCallId?: string; // For nested tool calls + }; +} +``` + +**Key Fields**: +- `success`: Boolean indicating successful execution +- `result`: Rich content result with multiple content types supported +- `error`: Error details if `success: false` +- `parentToolCallId`: Links to parent tool for nested calls + +**Supported Content Types**: +- `text` - Plain text output +- `terminal` - Terminal command output with exit code +- `image` - Base64-encoded image with MIME type +- `audio` - Base64-encoded audio +- `resource_link` - Link to external resources with metadata +- `resource` - Embedded resource with text or binary blob + +### Sub-Agent and Background Tasks + +#### Sub-Agent Lifecycle Events: + +```typescript +// Sub-agent started +{ + type: "subagent.started"; + data: { + // Sub-agent details + }; +} + +// Sub-agent completed +{ + type: "subagent.completed"; + data: { + // Sub-agent results + }; +} +``` + +### Tool Execution Hooks + +The SDK provides hooks for intercepting tool calls: + +**`onPreToolUse`**: +- Intercept tool calls before execution +- Use cases: Permission control, argument modification + +**`onPostToolUse`**: +- Process tool results after execution +- Use cases: Result transformation, logging + +### Streaming Configuration + +```typescript +const sessionConfig: SessionConfig = { + streaming: true // Enable incremental assistant.message_delta events +}; +``` + +### References + +- **Event-Driven Architecture**: [Copilot SDK Events Wiki](https://deepwiki.com/wiki/github/copilot-sdk#3.3) +- **TypeScript SDK**: [Node.js/TypeScript SDK Wiki](https://deepwiki.com/wiki/github/copilot-sdk#6.1) +- Key Components: + - `CopilotClient` - Manages CLI server connection and session management + - `CopilotSession` - Represents single conversation context, handles event streams and tool execution + - `SessionConfig.streaming` - Enables incremental message streaming + +--- + +## 3. Claude Agent SDK (TypeScript) + +**Source**: `docs/claude-agent-sdk/typescript-sdk.md` + +### Sub-Agent/Background Task Handling + +Claude Agent SDK uses a **hierarchical parent-child relationship** model with `parent_tool_use_id` for nested tool execution. + +### Agent Definition and Configuration + +#### Programmatic Agent Definition: + +```typescript +import { createClient } from '@claude/agent-sdk'; + +const client = createClient({ + agents: { + "research_agent": { + description: "Specialized agent for researching complex technical topics", + tools: ["ReadFile", "SearchFiles", "WebSearch"], + prompt: "You are a research specialist. Focus on gathering and synthesizing information.", + model: "sonnet" + }, + "code_agent": { + description: "Agent specialized in writing and reviewing code", + tools: ["EditFile", "CreateFile", "RunCommand"], + prompt: "You are a coding specialist. Focus on implementing clean, well-tested code.", + model: "opus" + } + }, + includePartialMessages: true // Enable streaming events +}); +``` + +**AgentDefinition Type**: +```typescript +type AgentDefinition = { + description: string; // Natural language description of when to use this agent + tools?: string[]; // Array of allowed tool names (inherits all if omitted) + prompt: string; // The agent's system prompt + model?: "sonnet" | "opus" | "haiku" | "inherit"; // Model override +} +``` + +### Task Tool for Sub-Agent Execution + +#### Task Tool Input: + +```typescript +interface TaskInput { + /** + * Description of what needs to be done (required) + */ + prompt: string; + + /** + * The type of specialized agent to use for this task (required) + */ + subagent_type: string; +} +``` + +#### Task Tool Output: + +```typescript +interface TaskOutput { + /** + * Final result message from the subagent + */ + result: string; + + /** + * Token usage statistics + */ + usage?: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + + /** + * Total cost in USD + */ + total_cost_usd?: number; + + /** + * Execution duration in milliseconds + */ + duration_ms?: number; +} +``` + +### Parent-Child Relationship Model + +All messages and events include `parent_tool_use_id` to track nesting: + +#### SDKAssistantMessage: +```typescript +type SDKAssistantMessage = { + type: "assistant"; + uuid: UUID; + session_id: string; + message: APIAssistantMessage; // From Anthropic SDK + parent_tool_use_id: string | null; // Links to parent tool +} +``` + +#### SDKUserMessage: +```typescript +type SDKUserMessage = { + type: "user"; + uuid?: UUID; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; // Links to parent tool +} +``` + +### Streaming Events with Partial Messages + +When `includePartialMessages: true`: + +#### SDKPartialAssistantMessage: +```typescript +type SDKPartialAssistantMessage = { + type: "stream_event"; + event: RawMessageStreamEvent; // From Anthropic SDK + parent_tool_use_id: string | null; // Links to parent tool + uuid: UUID; + session_id: string; +} +``` + +**Key Points**: +- Wraps Anthropic SDK's `RawMessageStreamEvent` +- Includes `parent_tool_use_id` to maintain nesting context +- Only emitted when `includePartialMessages: true` + +### Hook Events for Sub-Agent Lifecycle + +#### SubagentStart Hook: + +```typescript +type SubagentStartHookInput = BaseHookInput & { + hook_event_name: "SubagentStart"; + agent_id: string; // Unique identifier for the sub-agent instance + agent_type: string; // Type of agent being started +} +``` + +**Use Cases**: +- Track sub-agent initialization +- Set up agent-specific monitoring +- Log agent hierarchy + +#### SubagentStop Hook: + +```typescript +type SubagentStopHookInput = BaseHookInput & { + hook_event_name: "SubagentStop"; + stop_hook_active: boolean; // Whether stop hook is active +} +``` + +**Use Cases**: +- Cleanup after sub-agent completion +- Aggregate sub-agent metrics +- Handle errors or timeouts + +### Available Hook Events + +```typescript +type HookEvent = + | "PreToolUse" + | "PostToolUse" + | "PostToolUseFailure" + | "Notification" + | "UserPromptSubmit" + | "SessionStart" + | "SessionEnd" + | "Stop" + | "SubagentStart" // ← Sub-agent lifecycle + | "SubagentStop" // ← Sub-agent lifecycle + | "PreCompact" + | "PermissionRequest"; +``` + +### Hook Configuration + +```typescript +const client = createClient({ + hooks: { + "SubagentStart": [ + { + match: (input) => input.agent_type === "research_agent", + callback: async (input, toolUseID, options) => { + console.log(`Research agent starting: ${input.agent_id}`); + return {}; + } + } + ], + "SubagentStop": [ + { + callback: async (input, toolUseID, options) => { + console.log(`Sub-agent stopped`); + return {}; + } + } + ], + "PreToolUse": [ + { + match: (input) => input.tool_name === "Task", + callback: async (input, toolUseID, options) => { + console.log(`Delegating to sub-agent: ${input.input.subagent_type}`); + return {}; + } + } + ], + "PostToolUse": [ + { + match: (input) => input.tool_name === "Task", + callback: async (input, toolUseID, options) => { + console.log(`Sub-agent completed with result`); + return {}; + } + } + ] + } +}); +``` + +### Base Hook Input + +All hook inputs extend: +```typescript +type BaseHookInput = { + session_id: string; + transcript_path: string; + cwd: string; + permission_mode?: string; +} +``` + +### Tool Result Type + +```typescript +type CallToolResult = { + content: Array<{ + type: "text" | "image" | "resource"; + // Additional fields vary by type + }>; + isError?: boolean; +} +``` + +From `@modelcontextprotocol/sdk/types.js` + +### References + +- **Source File**: `docs/claude-agent-sdk/typescript-sdk.md` (2177 lines) +- **Key Sections**: + - Lines 98: `agents` configuration parameter + - Lines 166-184: `AgentDefinition` type + - Lines 424-458: Message types with `parent_tool_use_id` + - Lines 530-540: `SDKPartialAssistantMessage` for streaming + - Lines 575-593: `HookEvent` types + - Lines 730-747: `SubagentStartHookInput` and `SubagentStopHookInput` + - Lines 1310-1339: `TaskOutput` type + +--- + +## 4. OpenTUI Layout System (anomalyco/opentui) + +**DeepWiki Search**: https://deepwiki.com/search/how-does-opentui-handle-layout_4cc79fb4-6938-4825-af47-0701e34b8a89 + +### Layout Engine + +OpenTUI uses the **Yoga layout engine** which provides CSS Flexbox-like capabilities for terminal layouts. + +### Available Layout Components + +#### Core Components: + +1. **`Box`**: Versatile container component + - Supports borders, backgrounds + - All Flexbox layout properties + - Groups other renderables and defines layout relationships + +2. **`Scrollbox`**: Scrollable container + - Manages content larger than visible area + - Horizontal and vertical scrolling + - Sticky scroll behavior (for logs/chat interfaces) + - Keyboard navigation support + +3. **`Text`**: Styled text display + - Nested text modifiers: ``, ``, ``, ``, ``, ``, `
` + - Rich text display capabilities + +#### Additional Components: +- `Input` - User input field +- `Textarea` - Multi-line text input +- `Select` - Selection dropdown +- `Code` - Code block with syntax highlighting +- `LineNumber` - Line number display +- `Diff` - Diff viewer +- `ASCIIFont` - ASCII art text +- `FrameBuffer` - Direct pixel manipulation +- `Markdown` - Markdown renderer +- `Slider` - Slider control + +### Dynamic Content and Agent Trees + +**Flexbox Properties for Dynamic Content**: +```typescript +// Example: Dynamic agent tree layout + + + {/* Header - fixed size */} + + + + {/* Dynamic agent tree - grows to fill space */} + {agents.map(agent => ( + + {agent.name}: {agent.status} + {agent.output} + + ))} + + + + {/* Footer - fixed size */} + + +``` + +**Dynamic Sizing Properties**: +- `flexGrow`: Component expands to fill available space +- `flexShrink`: Component contracts when space is limited +- `flexDirection`: `"row"` or `"column"` + +**Reconciler Pattern**: +- React and SolidJS integrations use reconciler pattern +- Translates framework virtual DOM operations into OpenTUI `Renderable` instances +- Enables declarative UI with dynamic updates + +### Footer/Fixed Position Elements + +#### Absolute Positioning: + +```typescript + + {/* Main content */} + + {/* Scrollable content */} + + + {/* Fixed footer */} + + Footer content + + +``` + +**Positioning Properties**: +- `position: "absolute"` - Remove from normal flow +- `left`, `top`, `right`, `bottom` - Position relative to parent + +#### Flexbox Layout for Headers/Footers: + +```typescript + + {/* Header - maintains size */} + + Header + + + {/* Main content - takes remaining space */} + + {/* Scrollable agent tree */} + + + {/* Footer - maintains size */} + + Footer + + +``` + +**Pattern Benefits**: +- Header/footer maintain size with `flexShrink: 0` +- Main content uses `flexGrow: 1` to fill remaining space +- Scrollbox handles its own scrolling independently + +### Framework Integration + +**Available Integrations**: +- React (`@opentui/react`) +- SolidJS (`@opentui/solid`) + +**Example React Usage**: +```typescript +import { Box, Scrollbox, Text } from '@opentui/react'; + +function AgentTree({ agents }) { + return ( + + + {agents.map(agent => ( + + ))} + + + ); +} + +function AgentNode({ agent, depth }) { + return ( + + {agent.name} + {agent.children?.map(child => ( + + ))} + + ); +} +``` + +### References + +- **Framework Integration**: [OpenTUI Framework Integration Wiki](https://deepwiki.com/wiki/anomalyco/opentui#7) +- Key Types: + - `Renderable` - Base type for all UI elements + - `BoxRenderable` - Container with Flexbox properties + - `ScrollBoxRenderable` - Scrollable container + - `TextRenderable` - Text display + +--- + +## Comparison Matrix + +| Feature | OpenCode | Copilot SDK | Claude SDK | +|---------|----------|-------------|------------| +| **Sub-Agent Mechanism** | `Task` tool with `subagent_type` | Tool hierarchy with `parentToolCallId` | `Task` tool + `parent_tool_use_id` | +| **Event Model** | SSE with `message.part.updated` | Lifecycle events (`tool.execution_*`) | Hooks + streaming events | +| **Nesting Support** | ✅ Via Task tool | ✅ Via `parentToolCallId` | ✅ Via `parent_tool_use_id` | +| **Streaming** | SSE endpoint (`/event`) | `streaming: true` in config | `includePartialMessages: true` | +| **Tool State** | `running`, `completed`, `error` | `success` boolean + result/error | Hook-based state tracking | +| **Progress Updates** | `message.part.updated` + metadata | `tool.execution_progress` | Partial messages via Anthropic SDK | +| **Sub-Agent Events** | Implicit via `message.part.updated` | `subagent.started`, `subagent.completed` | `SubagentStart`, `SubagentStop` hooks | +| **Content Types** | Attachments array | Text, terminal, image, audio, resource | Text, image, resource (MCP types) | +| **Hooks/Interceptors** | Plugin system (`tool.execute.before/after`) | `onPreToolUse`, `onPostToolUse` | Comprehensive hook system (12 events) | + +--- + +## Key Insights + +### 1. Event Granularity + +- **OpenCode**: Single event type (`message.part.updated`) with state transitions + - Simple but requires parsing state changes + - Metadata updates enable progress tracking + +- **Copilot SDK**: Dedicated events for each lifecycle stage + - More events to handle but clearer intent + - Separate progress events for long-running tools + +- **Claude SDK**: Hook-based with optional streaming + - Most flexible - can intercept at multiple points + - Streaming is opt-in via `includePartialMessages` + +### 2. Parent-Child Relationships + +All three SDKs track nesting, but with different mechanisms: + +- **OpenCode**: Implicit via message structure and tool hierarchy +- **Copilot SDK**: Explicit `parentToolCallId` field +- **Claude SDK**: Explicit `parent_tool_use_id` on all messages + +**Recommendation**: Explicit parent IDs (Copilot/Claude approach) make tree reconstruction easier for UI. + +### 3. Result Content Types + +- **Copilot SDK** has the richest content type support: + - Terminal output with exit codes + - Images and audio + - Resource links with metadata + +- **OpenCode** uses generic attachments array +- **Claude SDK** follows MCP protocol types + +### 4. OpenTUI Layout for Agent Trees + +Key patterns for displaying agent hierarchies: + +```typescript +// Fixed header/footer with scrollable agent tree + + {/* Header */} + + {renderAgentTree(agents)} + + {/* Footer */} + + +// Indent nested agents +function renderAgentTree(agents, depth = 0) { + return agents.map(agent => ( + + {agent.name} + {agent.children && renderAgentTree(agent.children, depth + 1)} + + )); +} +``` + +--- + +## Implementation Recommendations + +### For Building Sub-Agent UIs: + +1. **Use explicit parent IDs**: Track `parent_tool_use_id` / `parentToolCallId` to build agent trees +2. **Stream events**: Enable streaming for real-time updates (`includePartialMessages` or `streaming: true`) +3. **Handle all states**: Support running, completed, error states with appropriate UI feedback +4. **Flexbox layout**: Use `flexGrow` for dynamic content, `flexShrink: 0` for fixed headers/footers +5. **Scrollable containers**: Wrap dynamic agent lists in Scrollbox with `stickyScroll` for logs + +### Event Handling Pattern: + +```typescript +// Pseudo-code for tracking agent tree +const agentTree = new Map(); + +function handleToolStart(event) { + const node = { + id: event.toolCallId, + parentId: event.parentToolCallId || event.parent_tool_use_id, + status: 'running', + name: event.toolName, + children: [] + }; + + agentTree.set(node.id, node); + + if (node.parentId) { + const parent = agentTree.get(node.parentId); + parent?.children.push(node); + } +} + +function handleToolComplete(event) { + const node = agentTree.get(event.toolCallId); + if (node) { + node.status = event.success ? 'completed' : 'error'; + node.result = event.result || event.output; + node.error = event.error; + } +} +``` + +### OpenTUI Layout Pattern: + +```typescript +import { Box, Scrollbox, Text } from '@opentui/react'; + +function SubAgentDashboard({ rootAgent }) { + return ( + + {/* Fixed header */} + + Agent Execution Tree + + + {/* Scrollable agent tree */} + + + + + {/* Fixed footer */} + + Status: {rootAgent.status} + + + ); +} + +function AgentTreeNode({ agent, depth }) { + const statusIcon = { + running: '⏳', + completed: '✅', + error: '❌' + }[agent.status]; + + return ( + + + {statusIcon} {agent.name} + + {agent.children.map(child => ( + + ))} + + ); +} +``` + +--- + +## Related Resources + +### DeepWiki Searches: +- [OpenCode Sub-Agent Handling](https://deepwiki.com/search/how-does-opencode-handle-subag_4598ffc8-50f0-4ce5-8563-069c60e03e68) +- [Copilot SDK Tool Calls](https://deepwiki.com/search/how-does-the-copilot-sdk-handl_35f77c7a-128d-436b-b281-dc7fc72b32a4) +- [OpenTUI Layout System](https://deepwiki.com/search/how-does-opentui-handle-layout_4cc79fb4-6938-4825-af47-0701e34b8a89) + +### Wiki Pages: +- [OpenCode Architecture](https://deepwiki.com/wiki/anomalyco/opencode#2) +- [OpenCode MCP Integration](https://deepwiki.com/wiki/anomalyco/opencode#13) +- [Copilot SDK Event-Driven Architecture](https://deepwiki.com/wiki/github/copilot-sdk#3.3) +- [Copilot SDK TypeScript API](https://deepwiki.com/wiki/github/copilot-sdk#6.1) +- [OpenTUI Framework Integration](https://deepwiki.com/wiki/anomalyco/opentui#7) + +### Local Documentation: +- `docs/claude-agent-sdk/typescript-sdk.md` - Claude Agent SDK TypeScript API +- Claude SDK GitHub: https://github.com/anthropics/anthropic-sdk-typescript + +--- + +## Conclusion + +All three SDKs provide robust support for sub-agent/background task execution with streaming events. The choice depends on your needs: + +- **OpenCode**: Best for SSE-based streaming with simple event model +- **Copilot SDK**: Best for rich content types and granular lifecycle tracking +- **Claude SDK**: Best for flexible hook-based interception with MCP compatibility + +For UI implementation with OpenTUI, use Flexbox patterns with `flexGrow`/`flexShrink` for dynamic agent trees and `Scrollbox` for large hierarchies. diff --git a/research/docs/2026-02-23-thinking-tag-stream-grouping.md b/research/docs/2026-02-23-thinking-tag-stream-grouping.md new file mode 100644 index 000000000..514bc1ba3 --- /dev/null +++ b/research/docs/2026-02-23-thinking-tag-stream-grouping.md @@ -0,0 +1,105 @@ +--- +date: 2026-02-23 00:45:53 UTC +researcher: Copilot +git_commit: b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092 +branch: fix/tui-streaming-rendering +repository: atomic +topic: "Fix the thinking tag text getting grouped together across multiple streams" +tags: [research, codebase, streaming, thinking-tags, tui-rendering] +status: complete +last_updated: 2026-02-23 +last_updated_by: Copilot +--- + +# Research + +## Research Question + +Fix the thinking tag text getting grouped together across multiple streams. + +Observed output example: + +```text +∴ Thinking... +Planning parallel commit setupFinalizing commit with pre-checks and co-author trailer +``` + +## Summary + +Current code paths collect thinking text by concatenating incoming `thinking` chunks into a single `thinkingText` string for a stream loop, then repeatedly upsert one streaming `ReasoningPart` with the full accumulated text. The UI update path for thinking metadata targets the current `streamingMessageIdRef`, which is a single mutable reference used by stream callbacks, and `ThinkingMetaEvent` carries no stream identifier. Existing codebase patterns show per-session/per-execution separation through keyed `Map` structures in multiple areas, while thinking metadata updates currently flow through unkeyed message-level updates. + +## Detailed Findings + +### 1) Claude SDK emits thinking deltas as chunked events + +- The Claude client handles `content_block_delta` and emits `type: "thinking"` when `event.delta.type === "thinking_delta"` ([src/sdk/clients/claude.ts:730-818](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/sdk/clients/claude.ts#L730-L818)). +- Each emitted thinking message includes `content` and `metadata.streamingStats.thinkingMs/outputTokens` ([src/sdk/clients/claude.ts:801-817](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/sdk/clients/claude.ts#L801-L817)). + +### 2) UI stream loop accumulates thinking text by string concatenation + +- In `streamAndProcess`, `thinkingText` starts as `""` and is appended with each thinking chunk via `thinkingText += message.content` ([src/ui/index.ts:1338](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/index.ts#L1338), [src/ui/index.ts:1425-1427](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/index.ts#L1425-L1427)). +- The loop publishes accumulated metadata through `onMeta({ ..., thinkingText })` after processing thinking events ([src/ui/index.ts:1446](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/index.ts#L1446)). + +### 3) Thinking metadata is applied to the current streaming message + +- The chat component updates state by reading `streamingMessageIdRef.current` and applying a `thinking-meta` event to that message ([src/ui/chat.tsx:3440-3456](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/chat.tsx#L3440-L3456), [src/ui/chat.tsx:5138-5155](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/chat.tsx#L5138-L5155)). +- `streamingMessageIdRef` is a single shared ref in this component ([src/ui/chat.tsx:1773](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/chat.tsx#L1773)). + +### 4) Stream pipeline keeps one active streaming reasoning part + +- `ThinkingMetaEvent` includes `thinkingText`/`thinkingMs` and optional `includeReasoningPart`, with no stream/source ID field ([src/ui/parts/stream-pipeline.ts:49-58](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/parts/stream-pipeline.ts#L49-L58)). +- `upsertThinkingMeta` finds the last streaming reasoning part and replaces its content with `event.thinkingText`; otherwise it creates one reasoning part ([src/ui/parts/stream-pipeline.ts:414-463](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/parts/stream-pipeline.ts#L414-L463)). +- Reasoning display prints a single heading (`Thinking...` while streaming) and renders the accumulated markdown content ([src/ui/components/parts/reasoning-part-display.tsx:46-63](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/components/parts/reasoning-part-display.tsx#L46-L63)). + +### 5) Existing separation patterns elsewhere in codebase + +- Per-session state in SDK clients uses keyed `Map` structures (e.g., `sessions: Map`) ([src/sdk/clients/claude.ts:273](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/sdk/clients/claude.ts#L273)). +- Copilot session state uses nested maps for per-session/per-tool identity mapping ([src/sdk/clients/copilot.ts:126](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/sdk/clients/copilot.ts#L126)). +- UI streaming state also uses keyed map structures for tool executions ([src/ui/hooks/use-streaming-state.ts:35-50](https://github.com/flora131/atomic/blob/b29d8b8d3c1fa82a7ad43fc2da310719ac3c4092/src/ui/hooks/use-streaming-state.ts#L35-L50)). + +### 6) External documentation references + +- Anthropic TypeScript SDK stream event types define `content_block_delta` and `thinking_delta` (`ThinkingDelta`) in the official source: https://raw.githubusercontent.com/anthropics/anthropic-sdk-typescript/main/src/resources/messages/messages.ts +- Anthropic TypeScript SDK helper docs describe `MessageStream` event callbacks and accumulated snapshot behavior: https://raw.githubusercontent.com/anthropics/anthropic-sdk-typescript/main/helpers.md +- OpenTUI repository docs describe component rendering/update model (`@opentui/core`, `@opentui/react`) used by this project's UI layer: https://github.com/anomalyco/opentui + +## Code References + +- `src/sdk/clients/claude.ts:730-818` - Stream event handling for `content_block_*` and `thinking_delta`. +- `src/sdk/clients/claude.ts:148-187` - Thinking extraction from completed beta message blocks. +- `src/ui/index.ts:1332-1447` - Thinking accumulator, timing updates, and `onMeta` emission. +- `src/ui/chat.tsx:1773` - Shared `streamingMessageIdRef`. +- `src/ui/chat.tsx:3440-3456` - `handleMeta` updates streaming message via `thinking-meta`. +- `src/ui/chat.tsx:5138-5155` - Additional `handleMeta` path with same update pattern. +- `src/ui/parts/stream-pipeline.ts:49-58` - `ThinkingMetaEvent` shape. +- `src/ui/parts/stream-pipeline.ts:414-463` - `upsertThinkingMeta` behavior. +- `src/ui/components/parts/reasoning-part-display.tsx:46-63` - Single reasoning heading/content render. +- `src/sdk/clients/copilot.ts:126` - Per-session keyed map in client state. +- `src/ui/hooks/use-streaming-state.ts:35-50` - Keyed tool execution state in UI hook. + +## Architecture Documentation + +- Thinking output travels through a pipeline of: SDK stream event -> UI stream loop accumulation -> message metadata event -> part upsert -> reasoning renderer. +- Accumulation happens as whole-string growth (`thinkingText += chunk`) before rendering. +- Rendering path keeps one active streaming reasoning part per message and updates that part with full accumulated content. +- Message-target selection for thinking updates is resolved through current streaming message reference in chat state. + +## Historical Context (from research/) + +- `research/docs/2026-02-12-tui-layout-streaming-content-ordering.md` - Documents stream content ordering via offsets and mixed rendering paths. +- `research/docs/2026-02-15-ui-inline-streaming-vs-pinned-elements.md` - Documents inline stream segments vs pinned UI elements. +- `research/docs/2026-02-09-token-count-thinking-timer-bugs.md` - Documents streaming metadata/timing behavior across SDKs. +- `research/docs/2026-02-17-message-truncation-dual-view-system.md` - Documents message windowing and transcript architecture around active streaming UI. + +## Related Research + +- `research/docs/2026-02-16-opentui-rendering-architecture.md` +- `research/docs/2026-02-16-opencode-message-rendering-patterns.md` +- `research/docs/2026-02-16-atomic-chat-architecture-current.md` +- `research/docs/2026-02-16-sub-agent-tree-inline-state-lifecycle-research.md` + +## Open Questions + +- Under which runtime paths multiple simultaneous thinking producers can target the same streaming message in practice (foreground stream overlap, background handoff, or queued replacement windows). +- Whether all SDK clients provide equivalent source identity for thinking events at the point they enter `streamAndProcess`. +- How often concurrent `handleMeta` callbacks can interleave around streaming message ID transitions during round-robin interruption paths. diff --git a/research/tickets/2026-02-23-0258-background-agents-ui.md b/research/tickets/2026-02-23-0258-background-agents-ui.md new file mode 100644 index 000000000..09eae9337 --- /dev/null +++ b/research/tickets/2026-02-23-0258-background-agents-ui.md @@ -0,0 +1,127 @@ +--- +date: 2026-02-23 02:06:44 UTC +researcher: OpenCode +git_commit: f674c962807d5926f0f19633e66192e7d8ce1039 +branch: fix/tui-streaming-rendering +repository: fix-tui-streaming-rendering +topic: "#258 — Background agents UI (footer status bar, Ctrl+F termination flow, tree hints)" +tags: [research, codebase, tui, background-agents, issue-258, keyboard] +status: complete +last_updated: 2026-02-23 +last_updated_by: OpenCode +--- + +# Research + +## Research Question +Thoroughly research GitHub issue `#258` (Background agents UI), extract all issue data and images with `gh`, and map current codebase behavior for: +- Footer status bar behavior for background agents +- Ctrl+F termination flow (including confirmation) +- Tree view hint text (including Ctrl+O wording) +- Scope across dev and production paths + +## Summary +Issue `#258` describes missing background-agent UI features, but this repository state already contains concrete implementations for background footer rendering, Ctrl+F double-press termination, confirmation text, and tree hint text generation. + +At this commit, the actively mounted footer path is `BackgroundAgentFooter` (not `FooterStatus`), Ctrl+F behavior is implemented through utility-driven decision/interrupt helpers and a parent callback bridge, and tree header hints are generated by a dedicated hint utility with explicit background-aware strings. + +The same `ChatApp` and UI modules are used by both dev (`bun run src/cli.ts`) and production (`bun build src/cli.ts --compile --outfile atomic`) entry paths, so behavior is shared across both runtime modes. + +## Detailed Findings + +### GitHub issue extraction and assets +- Issue metadata, timeline events, referenced commit, and screenshot URLs were extracted via `gh`; full extraction is documented in `research/docs/2026-02-23-gh-issue-258-background-agents-ui.md`. +- Issue URL: `https://github.com/flora131/atomic/issues/258`. +- Timeline includes a `referenced` event pointing to commit `92b9badb08a9b3d0a3243cd4ec8c140c190d2744` (adds `research/branch-tasks.md`). + +### Footer status implementation surfaces +- `FooterStatus` exists as a general status component with verbose/queue/model/permission formatting and a Ctrl+O hint (`src/ui/components/footer-status.tsx:99`, `src/ui/components/footer-status.tsx:156`). +- `FooterStatus` types and exports exist (`src/ui/types.ts:27`, `src/ui/types.ts:45`, `src/ui/components/index.ts:130`). +- `ChatApp` currently mounts `BackgroundAgentFooter` at the bottom of the root layout (`src/ui/chat.tsx:30`, `src/ui/chat.tsx:5914`). +- `BackgroundAgentFooter` renders label text and appends `ctrl+f terminate` hint (`src/ui/components/background-agent-footer.tsx:16`, `src/ui/components/background-agent-footer.tsx:31`, `src/ui/components/background-agent-footer.tsx:32`). +- Footer agent selection resolves from live agents first, then message snapshots (`src/ui/utils/background-agent-footer.ts:24`, `src/ui/utils/background-agent-footer.ts:28`, `src/ui/utils/background-agent-footer.ts:33`). + +### Ctrl+F termination flow (double-press + confirmation) +- Ctrl+F key detection is centralized in `isBackgroundTerminationKey` (`src/ui/utils/background-agent-termination.ts:22`). +- Decision logic for first-press warning and second-press terminate is in `getBackgroundTerminationDecision` (`src/ui/utils/background-agent-termination.ts:29`). +- Interruption transformation of active background agents is in `interruptActiveBackgroundAgents` (`src/ui/utils/background-agent-termination.ts:57`). +- `ChatApp` keyboard handler applies this flow in the Ctrl+F branch (`src/ui/chat.tsx:4380`, `src/ui/chat.tsx:4388`, `src/ui/chat.tsx:4401`). +- First press sets confirmation state and timer (`src/ui/chat.tsx:4434`, `src/ui/chat.tsx:4439`), and warning text is rendered in UI (`src/ui/chat.tsx:5903`, `src/ui/chat.tsx:5906`). +- Second press updates message-level parallel-agent snapshot, prunes live background agents, calls parent termination callback, and appends chat confirmation `All background agents killed` (`src/ui/chat.tsx:4411`, `src/ui/chat.tsx:4423`, `src/ui/chat.tsx:4429`, `src/ui/chat.tsx:4430`). +- Parent callback in UI integration resets stream/run state and aborts session if supported (`src/ui/index.ts:1809`, `src/ui/index.ts:1814`, `src/ui/index.ts:1823`). + +### Tree view hint generation and Ctrl+O relationship +- Tree header hints are generated by `buildParallelAgentsHeaderHint` (`src/ui/utils/background-agent-tree-hints.ts:22`). +- Hint strings include: + - `background running · ctrl+f terminate` (`src/ui/utils/background-agent-tree-hints.ts:29`) + - `background complete · ctrl+o to expand` (`src/ui/utils/background-agent-tree-hints.ts:33`) + - `ctrl+o to expand` (`src/ui/utils/background-agent-tree-hints.ts:37`) +- `ParallelAgentsTree` renders this hint next to header text (`src/ui/components/parallel-agents-tree.tsx:550`, `src/ui/components/parallel-agents-tree.tsx:562`). +- Ctrl+O keyboard behavior toggles transcript mode globally (`src/ui/chat.tsx:4448`, `src/ui/chat.tsx:4449`), switching to `` (`src/ui/chat.tsx:5712`, `src/ui/components/transcript-view.tsx:73`). +- Transcript footer line includes `ctrl+o to toggle` (`src/ui/utils/transcript-formatter.ts:249`). + +### Background agent lifecycle integration +- Agent status type includes `background` and `interrupted` (`src/ui/components/parallel-agents-tree.tsx:27`). +- Tool-complete finalization guard excludes background agents (`src/ui/parts/guards.ts:19`, `src/ui/parts/guards.ts:20`, `src/ui/parts/guards.ts:21`). +- Foreground-only deferred-finalization gate is used to avoid blocking on background agents (`src/ui/parts/guards.ts:29`, `src/ui/parts/guards.ts:40`, `src/ui/chat.tsx:3479`, `src/ui/chat.tsx:5204`). +- Background agents are baked into message parts via `parallel-agents` stream events and `mergeParallelAgentsIntoParts` (`src/ui/parts/stream-pipeline.ts:863`, `src/ui/parts/stream-pipeline.ts:637`, `src/ui/chat.tsx:2737`). +- Agent tree rendering in message parts is mounted by registry and `AgentPartDisplay` (`src/ui/components/parts/registry.tsx:26`, `src/ui/components/parts/agent-part-display.tsx:24`). + +### Dev and production path coverage +- Dev entry script: `bun run src/cli.ts` (`package.json:33`). +- Production compile target: `bun build src/cli.ts --compile --outfile atomic` (`package.json:34`). +- CLI default `chat` command routes into `chatCommand` then `startChatUI` (`src/cli.ts:94`, `src/cli.ts:144`, `src/commands/chat.ts:196`, `src/commands/chat.ts:283`). +- `ChatApp` and the same UI/keyboard/background modules are used in that shared path (`src/ui/index.ts:1901`, `src/ui/chat.tsx:1636`). + +### Test coverage related to this issue area +- Background footer helpers: active detection, snapshot fallback, label formatting (`src/ui/utils/background-agent-footer.test.ts:20`). +- Background termination helpers: Ctrl+F key detection, double-press decisions, interrupt transformation (`src/ui/utils/background-agent-termination.test.ts:23`, `src/ui/utils/background-agent-termination.test.ts:60`). +- Tree hint strings: active/complete/default hint variants (`src/ui/utils/background-agent-tree-hints.test.ts:4`). +- Lifecycle guards and background persistence behavior: `shouldFinalizeOnToolComplete` and mixed state transitions (`src/ui/parallel-agent-background-lifecycle.test.ts:155`, `src/ui/parts/background-agent-e2e.test.ts:44`). + +## Code References +- `src/ui/chat.tsx:30` - `BackgroundAgentFooter` import in chat UI. +- `src/ui/chat.tsx:4380` - Ctrl+F keyboard branch. +- `src/ui/chat.tsx:4401` - Background-agent interruption call. +- `src/ui/chat.tsx:4430` - Chat confirmation message for killed background agents. +- `src/ui/chat.tsx:4448` - Ctrl+O transcript toggle handling. +- `src/ui/chat.tsx:5619` - Footer agent resolution from live + message state. +- `src/ui/chat.tsx:5914` - Footer mount location. +- `src/ui/components/background-agent-footer.tsx:12` - Footer component implementation. +- `src/ui/components/footer-status.tsx:99` - FooterStatus component implementation. +- `src/ui/utils/background-agent-footer.ts:24` - Footer agent resolver. +- `src/ui/utils/background-agent-termination.ts:29` - Double-press decision helper. +- `src/ui/utils/background-agent-tree-hints.ts:22` - Tree header hint builder. +- `src/ui/components/parallel-agents-tree.tsx:550` - Tree header hint assignment. +- `src/ui/index.ts:1809` - Parent callback for background termination. +- `src/ui/parts/guards.ts:19` - Guard excluding background from tool-complete finalization. +- `src/ui/parts/stream-pipeline.ts:637` - Parallel-agent merge into message parts. +- `package.json:33` - Dev command entry. +- `package.json:34` - Production compile entry. +- `src/commands/chat.ts:283` - `startChatUI` invocation. + +## Architecture Documentation +- Keyboard input is centralized in a single `useKeyboard` callback and branches by shortcut before rendering updates (`src/ui/chat.tsx:4140`). +- Background termination behavior is split between UI decision/mutation logic (`src/ui/chat.tsx`) and parent session abort/reset integration (`src/ui/index.ts`). +- Footer background-agent display is driven by a resolver utility that prefers live process state and falls back to message snapshots. +- Parallel-agent visualization is parts-driven: stream events update message parts, and part renderers mount `ParallelAgentsTree`. +- Transcript mode (`Ctrl+O`) is a global display toggle between scrollbox chat view and transcript view, with shared message/history sources. + +## Historical Context (from research/) +- `research/branch-tasks.md` - Branch grouping note explicitly lists issue `#258` and states Dev + Production impact (`research/branch-tasks.md:12`, `research/branch-tasks.md:13`). +- `research/docs/2026-02-16-sub-agent-tree-inline-state-lifecycle-research.md` - Prior research documented lifecycle and background-status handling context for sub-agent trees. +- `research/docs/2026-02-17-message-truncation-dual-view-system.md` - Prior research documented Ctrl+O transcript dual-view behavior. +- `research/docs/2026-02-21-workflow-sdk-inline-mode-research.md` - Prior research documented interrupt/control-flow behavior around streaming and workflow sessions. +- `research/docs/2026-02-01-claude-code-ui-patterns-for-atomic.md` - Earlier UI pattern capture for footer/hint language references. + +## Related Research +- `research/docs/2026-02-23-gh-issue-258-background-agents-ui.md` +- `research/docs/2026-02-16-sub-agent-tree-inline-state-lifecycle-research.md` +- `research/docs/2026-02-17-message-truncation-dual-view-system.md` +- `research/docs/2026-02-21-workflow-sdk-inline-mode-research.md` +- `research/docs/2026-02-01-claude-code-ui-patterns-for-atomic.md` + +## Open Questions +- The issue text claims several features are absent, while this code snapshot includes implementations for these surfaces; branch/revision alignment between issue report and runtime observation is not encoded in issue metadata. +- `FooterStatus` exists and is exported, while runtime footer rendering is performed by `BackgroundAgentFooter`; the issue body references `FooterStatus` explicitly. +- Current branch is `fix/tui-streaming-rendering` and local status is `ahead 1`; repository file references in this document therefore use local paths instead of commit permalinks. diff --git a/specs/background-agents-sdk-pipeline-fix.md b/specs/background-agents-sdk-pipeline-fix.md new file mode 100644 index 000000000..fc6594b1e --- /dev/null +++ b/specs/background-agents-sdk-pipeline-fix.md @@ -0,0 +1,392 @@ +# Background Agents SDK Pipeline Fix (Issue #258) Technical Design Document / RFC + +| Document Metadata | Details | +| ---------------------- | ---------------------------- | +| Author(s) | lavaman131 | +| Status | Draft (WIP) | +| Team / Owner | Atomic CLI TUI | +| Created / Last Updated | 2026-02-23 | + +## 1. Executive Summary + +Issue [#258](https://github.com/flora131/atomic/issues/258) reports that background agents fail to render UI for OpenCode and Copilot SDKs (no UI at all), and that Claude Agent SDK's background agent UI has incorrect chatbox/tree layout. Root cause analysis ([Research: `research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md`]) reveals a 6-stage pipeline architecture where each SDK must emit the right events in the right order for background agents to appear. **Copilot has two issues:** (1) its built-in `task` tool uses `mode: "background"` but the UI integration only checks `run_in_background` — the background flag is missed; and (2) if `tool.execution_start` doesn't fire for the `task` tool, the `subagent.start` handler's correlation guard at `src/ui/index.ts:1071` blocks the event entirely. **OpenCode** should work architecturally but needs runtime verification. **Claude's layout** works end-to-end but may need visual adjustments. This RFC proposes: adding `mode === "background"` detection, relaxing the correlation guard for session-owned events, enriching Copilot/OpenCode event data, and adding debug logging for runtime verification. + +## 2. Context and Motivation + +### 2.1 Current State + +Background agent rendering depends on a 6-stage pipeline: + +``` +SDK Client events → UI Integration agent creation → ChatApp state → +synthetic parallel-agents stream events → AgentPart message parts → ParallelAgentsTree rendering +``` + +[Research: `research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md`, Section "Shared UI Architecture"] + +The UI integration layer at `src/ui/index.ts` uses a **two-path agent creation model**: +- **Path A (Eager):** When `tool.start` fires with `toolName === "Task"`, a `ParallelAgent` is created immediately and queued in `pendingTaskEntries` (lines 641-683). +- **Path B (Correlation):** When `subagent.start` fires, the handler correlates with pending entries or SDK correlation IDs (lines 1028-1164). + +Both paths must succeed for the `ParallelAgentsTree` component to render. + +### 2.2 The Problem + +**Copilot — No UI renders at all (two root causes):** + +1. **Background detection mismatch:** Copilot's built-in `task` tool uses `mode: "background"` to indicate background execution, but the UI integration only checks `input.run_in_background === true` (at `src/ui/index.ts:644,704,1092`). Even when `tool.execution_start` fires for the `task` tool (creating an eager agent), the background flag is missed — agents render as foreground. + +2. **Correlation guard blocks `subagent.start`:** If `tool.execution_start` does NOT fire for the `task` tool (SDK may handle sub-agent dispatch internally via `customAgents` config at `copilot.ts:865`), then `pendingTaskEntries` stays empty. The `subagent.start` handler's guard at `src/ui/index.ts:1071` requires `pendingTaskEntry || hasSdkCorrelationMatch` — Copilot has neither, because `sdkCorrelationToRunMap` is only populated during `tool.start` events. The `subagent.started` events (mapped at `copilot.ts:627-632`) are silently dropped. + +Additionally, Copilot's `subagent.started` event data doesn't include `toolCallId` as a separate field — only `subagentId` (set to the same value). This prevents SDK correlation even if the run map were populated. + +[Research: `research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md`, Section 4 "Copilot SDK — Root Cause"] + +**OpenCode — No UI renders (reported):** +- OpenCode's code architecture is identical to Claude's in the UI layer. +- `subagent.start` events from `part.type === "agent"` lack `toolUseID`/`toolCallId` (only `subagentId` and `subagentType` at `opencode.ts:713-715`), so `sdkCorrelationId` resolves to `undefined`. +- The handler falls through to FIFO queue matching at line 1058-1065, which depends on `pendingTaskEntries` being populated from a prior `tool.start` with `toolName === "Task"` or `"task"`. +- If the native OpenCode SDK doesn't name its sub-agent dispatch tool "Task" (or emits events in a different order), the pipeline breaks identically to Copilot. + +[Research: `research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md`, Section 3 "OpenCode SDK"] + +**Claude — Layout issues:** +- `ParallelAgentsTree` renders inside `MessageBubbleParts` within the scrollbox (`chat.tsx:5807-5992`). +- `BackgroundAgentFooter` renders outside the scrollbox as a sibling at line 5996. +- Input area is inside the scrollbox (lines 5876+). +- `FooterStatus` component exists (`footer-status.tsx:99`) but is NOT mounted anywhere. +- The exact nature of the "incorrect layout" needs visual verification against issue screenshots. + +[Research: `research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md`, Section 2 "Claude Agent SDK"] + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] **Copilot background agents render in UI** — `subagent.start` events from Copilot create `ParallelAgent` objects and display in the tree. +- [ ] **OpenCode background agents render in UI** — `subagent.start` events from OpenCode (both `agent` and `subtask` part types) create agents reliably. +- [ ] **Claude layout matches expected design** — Tree/footer positioning matches issue #258 expectations. +- [ ] **All three SDKs show agent lifecycle** — Running → background → completed status transitions work across all providers. +- [ ] **Background agent footer appears** — Shows agent count and ctrl+f hint when background agents are active. +- [ ] **Ctrl+F termination works** — Double-press termination flow functions for all three SDKs. +- [ ] **Provider parity test coverage** — Event pipeline tests cover all three SDKs' subagent event flows. + +### 3.2 Non-Goals (Out of Scope) + +- [ ] We will NOT redesign the overall chat layout or scrollbox architecture. +- [ ] We will NOT modify the external SDK APIs or protocols. +- [ ] We will NOT add new keyboard shortcuts beyond clarifying existing Ctrl+F/Ctrl+O behavior. +- [ ] We will NOT implement cross-session agent management. +- [ ] We will NOT change the `ParallelAgent` type definition or stream pipeline architecture. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +flowchart TB + subgraph SDKClients["SDK Clients"] + Claude["Claude SDK\n✅ tool.start(Task)\n✅ subagent.start"] + OpenCode["OpenCode SDK\n✅ tool.start(Task)\n⚠️ subagent.start\n(no correlation ID)"] + Copilot["Copilot SDK\n❌ NO tool.start(Task)\n✅ subagent.start\n(no correlation ID)"] + end + + subgraph UIIntegration["UI Integration (src/ui/index.ts)"] + ToolStart["tool.start handler\nEager agent creation\n+ pendingTaskEntries"] + SubStart["subagent.start handler\nCorrelation + fresh creation"] + Guard["CURRENT GUARD (line 1071)\nRequires pendingTaskEntry\nOR sdkCorrelationMatch"] + NewGuard["PROPOSED GUARD\nAllow session-owned events\nwith active streaming state"] + end + + Claude -->|"tool.start + subagent.start"| ToolStart + OpenCode -->|"tool.start + subagent.start"| ToolStart + Copilot -->|"subagent.start ONLY"| SubStart + + ToolStart -->|"populate queue"| SubStart + SubStart --> Guard + Guard -->|"❌ BLOCKS Copilot"| Dropped["Events dropped"] + SubStart --> NewGuard + NewGuard -->|"✅ Creates agent"| AgentCreated["ParallelAgent created"] + + classDef blocked fill:#fee2e2,stroke:#ef4444,stroke-width:2px + classDef fixed fill:#dcfce7,stroke:#22c55e,stroke-width:2px + classDef current fill:#e0e7ff,stroke:#6366f1,stroke-width:2px + + class Guard blocked + class NewGuard fixed + class Dropped blocked + class AgentCreated fixed +``` + +### 4.2 Architectural Pattern + +**Session-Owned Event Trust** — For SDKs that don't use a Task tool (Copilot, potentially some OpenCode flows), allow `subagent.start` events to create agents directly when the event belongs to an owned session AND the stream is active. The existing `pendingTaskEntries` / `sdkCorrelationToRunMap` correlation remains the preferred path for SDKs that support it (Claude, OpenCode with Task tools). + +### 4.3 Key Components + +| Component | Change | Justification | +|-----------|--------|---------------| +| `src/ui/index.ts:1066-1071` | Relax correlation guard for session-owned events | Root cause of Copilot blockage; allows fresh agent creation path at lines 1135-1150 to execute | +| `src/sdk/clients/copilot.ts:627-632` | Enrich `subagent.started` event data with `toolCallId` | Enables future SDK correlation; provides `toolCallId` for agent tracking | +| `src/sdk/clients/opencode.ts:710-716` | Add `toolCallId` to `agent` part events | Matches `subtask` part handling; enables SDK correlation | +| `src/ui/chat.tsx:5779-5998` | Adjust layout positioning if needed | Address Claude layout issues per issue screenshots | +| Test files | Add subagent event pipeline tests for Copilot and OpenCode | Prevent regression; verify all SDKs create agents | + +## 5. Detailed Design + +### 5.1 Fix 1: Add `mode === "background"` Background Detection (Copilot Fix) + +**File:** `src/ui/index.ts` +**Lines:** 644, 704, 1092 + +Copilot's `task` tool uses `mode: "background"` while Claude/OpenCode use `run_in_background: true`. The UI integration must check both. + +**Change at line 644:** +```typescript +// BEFORE: +const isBackground = input.run_in_background === true; + +// AFTER: +const isBackground = input.run_in_background === true || input.mode === "background"; +``` + +**Change at line 704:** +```typescript +// BEFORE: +const isBackground = input.run_in_background === true; + +// AFTER: +const isBackground = input.run_in_background === true || input.mode === "background"; +``` + +**Change at line 1092:** +```typescript +// BEFORE: +?? (fallbackInput?.run_in_background === true); + +// AFTER: +?? (fallbackInput?.run_in_background === true) +|| (fallbackInput?.mode === "background"); +``` + +**Rationale:** This is the minimum change to support Copilot's background detection. The `run_in_background` check remains for Claude/OpenCode backward compatibility. + +### 5.2 Fix 2: Relax `subagent.start` Correlation Guard (Copilot + OpenCode Fix) + +**File:** `src/ui/index.ts` +**Lines:** 1066-1071 + +**Current code:** +```typescript +const hasSdkCorrelationMatch = sdkRunId !== undefined && sdkRunId === activeRunId; +const sessionOwned = eventBelongsToOwnedSession(event.sessionId); +if (!sessionOwned && !pendingTaskEntry && !hasSdkCorrelationMatch) return; +// Fail closed for uncorrelated events to prevent cross-run leakage, +// but allow flows with SDK correlation IDs even if no Task entry exists. +if (!pendingTaskEntry && !hasSdkCorrelationMatch) return; +``` + +**Problem:** Line 1071 blocks ALL events without `pendingTaskEntry` or `sdkCorrelationMatch`, regardless of session ownership. For Copilot (no Task tool) and some OpenCode flows (agent parts without correlation IDs), this blocks legitimate `subagent.start` events. + +**Proposed change:** +```typescript +const hasSdkCorrelationMatch = sdkRunId !== undefined && sdkRunId === activeRunId; +const sessionOwned = eventBelongsToOwnedSession(event.sessionId); +if (!sessionOwned && !pendingTaskEntry && !hasSdkCorrelationMatch) return; +// Fail closed for uncorrelated events to prevent cross-run leakage, +// but allow flows with SDK correlation IDs even if no Task entry exists. +// Also allow session-owned events during active streaming — this supports +// SDKs like Copilot that dispatch custom agents without a Task tool. +if (!pendingTaskEntry && !hasSdkCorrelationMatch && !sessionOwned) return; +``` + +**Rationale:** Session ownership (`eventBelongsToOwnedSession`) already provides sufficient cross-run isolation. The streaming state guard at line 1039 (`if (!state.isStreaming) return;`) further prevents stale events. This change allows the fresh agent creation path at lines 1135-1150 to execute for Copilot and OpenCode. + +### 5.3 Fix 3: Enrich Copilot `subagent.started` Event Data + +**File:** `src/sdk/clients/copilot.ts` +**Lines:** 627-632 + +**Current code:** +```typescript +case "subagent.started": + eventData = { + subagentId: data.toolCallId, + subagentType: data.agentName, + }; + break; +``` + +**Proposed change:** +```typescript +case "subagent.started": + eventData = { + subagentId: data.toolCallId, + subagentType: data.agentName, + toolCallId: data.toolCallId, + task: data.description ?? data.prompt ?? data.agentName, + }; + break; +``` + +**Rationale:** Adding `toolCallId` enables SDK correlation in the UI integration layer (populates `sdkCorrelationId` at `src/ui/index.ts:1045`). Adding `task` provides meaningful display text for the agent tree instead of falling back to "Sub-agent". + +### 5.4 Fix 4: Add `toolCallId` to OpenCode `agent` Part Events + +**File:** `src/sdk/clients/opencode.ts` +**Lines:** 710-716 + +**Current code:** +```typescript +} else if (part?.type === "agent") { + this.emitEvent("subagent.start", partSessionId, { + subagentId: (part?.id as string) ?? "", + subagentType: (part?.name as string) ?? "", + }); +``` + +**Proposed change:** +```typescript +} else if (part?.type === "agent") { + this.emitEvent("subagent.start", partSessionId, { + subagentId: (part?.id as string) ?? "", + subagentType: (part?.name as string) ?? "", + toolCallId: (part?.callID as string) ?? (part?.id as string), + }); +``` + +**Rationale:** Provides `toolCallId` for SDK correlation in the UI integration layer. Uses `part.callID` when available (same as tool part events), falling back to `part.id`. This matches the pattern already used by `subtask` part events (lines 717-733) which include `toolInput` with correlation data. + +### 5.5 Fix 5: Claude Layout Verification and Adjustment + +**File:** `src/ui/chat.tsx` +**Lines:** 5779-5998 + +**Current layout hierarchy:** +``` +Root Box (column, 100% height) +├── AtomicHeader (flexShrink=0) +├── Chat Mode Box (column, flexGrow=1) +│ └── Scrollbox (flexGrow=1, stickyScroll) +│ ├── Messages[] → MessageBubble → MessageBubbleParts → AgentPartDisplay → ParallelAgentsTree +│ ├── Input Area (border, flexShrink=0) +│ ├── Streaming/workflow hints +│ ├── Ctrl+C warning +│ └── Ctrl+F warning +└── BackgroundAgentFooter (flexShrink=0) +``` + +**Assessment:** The current layout places the tree INSIDE message parts (within the scrollbox) and the footer OUTSIDE. This means: +- The tree scrolls with messages ✅ (correct — tree is contextual to the message) +- The footer stays pinned at the bottom ✅ (correct — persistent status indicator) +- The input area is inside the scrollbox ✅ (correct — allows scroll to input) + +**Action needed:** Visual verification against issue #258 screenshots to determine if spacing, padding, or ordering adjustments are required. The architectural layout is sound. Specific adjustments (if any) will be determined during implementation by running the TUI and comparing against expected screenshots. + +### 5.6 Fix 6: Copilot Background Flag Support + +**Resolved:** Copilot's `task` tool uses `mode: "background"` — handled by Fix 1 above. When `tool.execution_start` fires with `toolName: "task"` and `arguments: { ..., mode: "background" }`, the updated background detection at line 644 (`input.mode === "background"`) will correctly set `isBackground = true`. No additional changes needed for background flag support. + +### 5.7 Fix 7: Debug Logging for OpenCode Event Verification + +**File:** `src/sdk/clients/opencode.ts` + +Add temporary debug logging at key event emission points to verify at runtime: +- Whether `tool.start` fires with `toolName: "Task"` or `"task"` for sub-agent dispatch +- Whether `subagent.start` fires from `agent`/`subtask` part types +- What fields are present in the event data + +This logging should be gated behind a debug flag or `NODE_ENV !== "production"` check. Remove after verification is complete. + +### 5.8 Test Coverage Additions + +**File:** `src/sdk/clients/copilot.test.ts` — Add tests for: +- `subagent.started` → `subagent.start` event mapping with enriched data +- `subagent.completed` → `subagent.complete` event mapping +- `subagent.failed` → `subagent.complete` event mapping with `success: false` + +**File:** `src/sdk/clients/opencode.events.test.ts` — Add tests for: +- `part.type === "agent"` → `subagent.start` with `toolCallId` +- `part.type === "step-finish"` → `subagent.complete` + +**File:** `src/ui/index.ts` or new test file — Add integration tests for: +- `subagent.start` handler creating agents without prior Task tool entry (session-owned path) +- `subagent.start` handler creating agents via FIFO queue (existing path verification) +- Correlation guard allowing session-owned events through + +## 6. Alternatives Considered + +| Option | Pros | Cons | Decision | +|--------|------|------|----------| +| A: Add a synthetic Task tool to Copilot SDK | Matches existing pipeline exactly | Adds complexity to Copilot client; SDK doesn't support it natively | Rejected — fighting the SDK's design | +| B: Relax guard to allow session-owned events (Selected) | Minimal change (~3 lines); works for all SDKs; preserves cross-run safety via session ownership | Slightly less strict than correlation-based gating | **Selected** — session ownership is already a trusted boundary | +| C: Create separate agent creation paths per SDK | Most explicit; no shared code risk | Duplicates logic; maintenance burden; violates unified event model | Rejected — against architecture principles | +| D: Populate `sdkCorrelationToRunMap` during `subagent.start` | Enables correlation-based matching | Map is designed for tool → agent correlation; self-correlation is circular | Rejected — misuses the correlation model | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +- The relaxed guard still requires session ownership (`eventBelongsToOwnedSession`) — events from unrelated sessions are still blocked at line 1068. +- Streaming state guard (`!state.isStreaming` at line 1039) prevents stale/late events from creating phantom agents. +- No sensitive data is exposed; agent names and task descriptions are already user-visible. + +### 7.2 Observability Strategy + +- Existing parallel agent lifecycle test (`src/ui/parallel-agent-background-lifecycle.test.ts`) validates the state machine. +- Provider parity test (`src/ui/utils/background-agent-provider-parity.test.ts`) validates cross-SDK behavior. +- Add debug logging at the guard relaxation point to track which path creates agents (Task correlation vs session-owned). + +### 7.3 Scalability and Capacity Planning + +- No performance impact — the guard change is a single boolean check. +- Agent creation frequency is bounded by SDK event emission rate (typically 1-10 per session). + +## 8. Migration, Rollout, and Testing + +### 8.1 Deployment Strategy + +- [ ] Phase 1: Fix background detection — add `mode === "background"` check at 3 locations in `src/ui/index.ts`. +- [ ] Phase 2: Fix Copilot pipeline — relax correlation guard for session-owned events, enrich `subagent.started` event data with `toolCallId`, add tests. +- [ ] Phase 3: Fix OpenCode pipeline — add `toolCallId` to `agent` part events, add debug logging, verify end-to-end, add tests. +- [ ] Phase 4: Verify Claude layout — run TUI, compare against issue screenshots, adjust spacing/positioning if needed. +- [ ] Phase 5: Run full test suite and provider parity validation. + +### 8.2 Data Migration Plan + +- No data migration required. All changes are in-memory event processing. + +### 8.3 Test Plan + +- **Unit Tests:** + - Copilot event mapping tests (`copilot.test.ts`) — verify `subagent.started` emits enriched `subagent.start` events. + - OpenCode event mapping tests (`opencode.events.test.ts`) — verify `agent` part emits `subagent.start` with `toolCallId`. + - Guard logic tests — verify session-owned events pass through without `pendingTaskEntry`. + +- **Integration Tests:** + - End-to-end agent creation from `subagent.start` without prior Task tool (Copilot flow). + - End-to-end agent creation from `tool.start(Task)` + `subagent.start` (Claude/OpenCode flow). + - Background agent footer appears when agents are active. + - Ctrl+F termination works for agents created via session-owned path. + +- **End-to-End Tests:** + - Provider matrix: Claude ✅, OpenCode ⚠️ (verify events fire), Copilot ✅ (primary fix). + - Visual verification: Run TUI with each SDK and verify tree/footer rendering. + +## 9. Open Questions / Unresolved Issues + +- [x] **OQ-1: OpenCode tool naming (Resolved):** Apply the session-owned guard fix for both Copilot AND OpenCode. This provides a robust fallback path regardless of tool naming conventions. +- [x] **OQ-2: Copilot background mode (Resolved):** Copilot's `task` tool uses `mode: "background"`. Add `input.mode === "background"` check alongside `input.run_in_background === true` in the UI integration layer (3 locations: lines 644, 704, 1092). +- [x] **OQ-3: Claude layout specifics (Resolved):** Defer layout adjustments to visual verification during implementation. The architectural layout (tree in messages, footer pinned) appears correct. +- [x] **OQ-4: OpenCode runtime verification (Resolved):** Add debug logging during implementation to verify OpenCode's actual event emission before making additional code changes. + +## 10. Research References + +- Primary pipeline analysis: `research/docs/2026-02-23-258-background-agents-sdk-event-pipeline.md` +- External SDK API docs: `research/docs/2026-02-23-sdk-subagent-api-research.md` +- Issue extraction: `research/docs/2026-02-23-gh-issue-258-background-agents-ui.md` +- Prior issue-to-code mapping: `research/tickets/2026-02-23-0258-background-agents-ui.md` +- Sub-agent lifecycle: `research/docs/2026-02-16-sub-agent-tree-inline-state-lifecycle-research.md` +- SDK parity baseline: `research/docs/2026-02-12-sdk-ui-standardization-research.md` +- Prior hardening spec: `specs/background-agents-ui-issue-258-parity-hardening.md` +- Prior lifecycle fix spec: `specs/sub-agent-tree-inline-state-lifecycle-fix.md` diff --git a/specs/background-agents-ui-issue-258-parity-hardening.md b/specs/background-agents-ui-issue-258-parity-hardening.md new file mode 100644 index 000000000..d98db2e2f --- /dev/null +++ b/specs/background-agents-ui-issue-258-parity-hardening.md @@ -0,0 +1,271 @@ +# Atomic CLI Background Agents UI Parity and Hardening (Issue #258) Technical Design Document / RFC + +| Document Metadata | Details | +| ---------------------- | --------------------------------------- | +| Author(s) | lavaman131 | +| Status | Draft (WIP) | +| Team / Owner | Atomic CLI TUI | +| Created / Last Updated | Phase-tracked document (no date anchor) | + +## 1. Executive Summary + +Issue `#258` reports missing background-agent UX behavior in Atomic TUI across Claude Code, OpenCode, and Copilot: footer status visibility, Ctrl+F termination confirmation flow, and tree view hint/state behavior. Existing research shows these surfaces are present in the current branch, but the expected behavior is not captured as a stable product contract and can drift across runtime paths, provider adapters, and future refactors. [Research: `research/docs/2026-02-23-gh-issue-258-background-agents-ui.md`], [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] + +This RFC proposes a contract-first hardening pass: define canonical UX rules for background-agent footer text, Ctrl+F double-press semantics, termination scope, and tree hint wording; align keyboard/state handling with that contract; and enforce parity via provider/mode test matrices and screenshot-anchored acceptance checks. + +Value: this closes ambiguity between issue expectations and runtime behavior, reduces regressions in high-frequency keyboard flows, and provides a clear “done” bar for Dev and Production parity without redesigning the existing TUI architecture. + +## 2. Context and Motivation + +### 2.1 Current State + +- The issue evidence for `#258` includes five screenshots and an explicit bug statement around footer status, Ctrl+F termination UX, and tree hints. [Research: `research/docs/2026-02-23-gh-issue-258-background-agents-ui.md`] +- Current implementation surfaces are split across dedicated modules: + - Footer rendering uses `BackgroundAgentFooter` mounted in `ChatApp`. + - Ctrl+F behavior uses utility-driven key detection + decision logic + interruption transforms. + - Tree header hint text is generated by a dedicated hint builder utility. + - Parent session handling bridges UI termination into run/session abort behavior. + [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] +- The same chat/UI runtime path is used by both dev (`bun run src/cli.ts`) and production (`bun build src/cli.ts --compile --outfile atomic`) entry points. [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] +- The architecture already relies on a unified event model across SDK clients, with UI components intended to be SDK-agnostic. [Research: `research/docs/2026-02-12-sdk-ui-standardization-research.md`] + +### 2.2 The Problem + +- **User impact:** Behavior can appear inconsistent with issue expectations because there is no single accepted UX contract for copy, scope, and keyflow semantics. +- **Business/Product impact:** Open bug reports can remain unresolved when implementation exists but acceptance criteria are undefined or unverifiable. +- **Technical debt:** Logic is spread across keyboard handlers, utilities, and renderers; without a specification-backed test matrix, provider parity regressions can reappear. +- **Evidence gap:** Issue body expectations and code snapshot observations are not explicitly reconciled in one approved design artifact. [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] Define and enforce canonical footer behavior for background agents, including count display and Ctrl+F hint presence. +- [ ] Define and enforce a deterministic Ctrl+F termination state machine (first press warning, second press kill). +- [ ] Define and enforce canonical tree header hint strings for background-running, background-complete, and default cases. +- [ ] Guarantee parity across Claude Code, OpenCode, and Copilot provider flows through shared contract tests. +- [ ] Guarantee parity between dev and production runtime paths for the background-agent UX surfaces covered by issue `#258`. +- [ ] Provide screenshot/fixture-based acceptance validation aligned to issue assets. + +### 3.2 Non-Goals (Out of Scope) + +- [ ] We will NOT redesign the overall chat layout, typography, or tree visualization style. +- [ ] We will NOT introduce new keyboard shortcuts beyond clarifying existing Ctrl+F/Ctrl+O semantics. +- [ ] We will NOT change provider SDK protocol contracts in this phase unless required for parity fixes. +- [ ] We will NOT add global multi-session kill behavior unless explicitly approved as scope. +- [ ] We will NOT broaden this RFC to unrelated sub-agent rendering issues outside issue `#258`. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +flowchart LR + User[User Keyboard Input] --> K[Chat Keyboard Handler] + K --> D[Termination Decision Helper] + D -->|Warn| W[UI Confirmation State] + D -->|Terminate| I[Interrupt Active Background Agents] + + Stream[Unified SDK Events\nClaude/OpenCode/Copilot] --> S[Parallel Agent State Store] + I --> S + S --> F[Background Agent Footer] + S --> T[Parallel Agents Tree] + S --> M[Chat System Messages] + + H[Tree Hint Builder] --> T + R[Footer Agent Resolver\nLive-first then Snapshot] --> F + + I --> P[Parent Session Abort Bridge] + + classDef ui fill:#e8f0fe,stroke:#4a6fa5,stroke-width:1px; + classDef logic fill:#eef8e6,stroke:#4f7d3a,stroke-width:1px; + classDef state fill:#fff4e6,stroke:#a66a00,stroke-width:1px; + + class K,D,I,H,R logic; + class S state; + class F,T,M,W,P ui; +``` + +### 4.2 Architectural Pattern + +This RFC applies a **Contract-Driven UI State Machine** pattern: + +- A canonical behavior contract is defined first (copy rules, keyflow, termination scope, hint precedence). +- Existing module boundaries remain in place (footer utility, termination utility, tree hint utility, chat integration). +- Provider adapters continue normalizing into the unified stream model; UI behavior is validated at the contract layer rather than per-SDK bespoke logic. + +### 4.3 Key Components + +| Component | Responsibility | Technology Stack | Justification | +| --------------------------------------- | ---------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ChatApp` keyboard branch | Routes Ctrl+F/Ctrl+O flows and renders confirmation feedback | TypeScript + OpenTUI components | Centralized input control point already owns lifecycle transitions. [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] | +| `background-agent-termination` utils | Key detection, double-press decisioning, interruption transforms | TypeScript utility module | Encapsulates kill decision semantics; best place for contract-level assertions. [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] | +| `BackgroundAgentFooter` + resolver util | Computes displayable agents and renders footer hint/copy | TypeScript + UI component | Existing mounted path for issue-reported footer behavior. [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] | +| `ParallelAgentsTree` + hint builder | Computes and displays contextual tree hint text | TypeScript + UI component | Existing hint generation path; natural insertion point for canonical wording rules. [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] | +| `startChatUI` shared runtime path | Provides dev/prod parity surface | Bun CLI + shared UI entry | Ensures behavior consistency regardless of build mode. [Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] | + +## 5. Detailed Design + +### 5.1 API Interfaces + +This feature is internal to the TUI runtime; no new external HTTP API is introduced. We define internal contracts to eliminate ambiguity. + +```ts +type BackgroundTerminationDecision = + | { action: "none" } + | { action: "warn"; message: string } + | { action: "terminate"; message: string }; + +interface BackgroundFooterContract { + showWhenAgentCountAtLeast: number; + includeTerminateHint: boolean; + terminateHintText: string; + countFormat: "agents" | "tasks"; +} + +interface BackgroundTreeHintContract { + whenRunning: string; + whenComplete: string; + defaultHint: string; +} +``` + +Contract enforcement points: + +- `background-agent-termination` utility validates key-to-decision semantics. +- Footer resolver + footer component validate count/hint display semantics. +- Tree hint builder validates hint precedence semantics. +- Chat-level integration validates message emission and session-abort callback semantics. + +### 5.2 Data Model / Schema + +The core data model remains in-memory UI state; this RFC formalizes expected fields and derivations. + +| State Surface | Field | Constraints | Description | +| ---------------------------- | --------------------------------------------- | -------------------------------------------- | ------------------------------------------------------ | +| Background termination state | `isAwaitingBackgroundTerminationConfirmation` | Boolean; true only after first Ctrl+F warn | Governs warn vs terminate branch in double-press flow. | +| Termination timer state | `backgroundTerminationConfirmationExpiresAt` | Optional timestamp-like value | Clears stale confirmation if user does not confirm. | +| Live background agents | `backgroundAgents[]` | Derived from active stream/session agents | Primary source for footer/tree active state. | +| Snapshot fallback agents | `message.parallelAgents[]` | Used only when live list is unavailable | Preserves footer/tree continuity post-stream updates. | +| Tree hint text | `buildParallelAgentsHeaderHint(...)` output | Exactly one hint string chosen by precedence | Converts lifecycle summary to user-facing hint copy. | + +Derivation rule precedence: + +1. Prefer live background agent state. +2. Fallback to message snapshots if live state is absent. +3. Resolve footer text/hints and tree hints from derived state. + +[Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`] + +### 5.3 Algorithms and State Management + +#### 5.3.1 Ctrl+F Double-Press Termination State Machine + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Warned: Ctrl+F && activeBackgroundAgents + Warned --> Terminated: Ctrl+F before confirmation expires + Warned --> Idle: timeout or state reset event + Idle --> Idle: Ctrl+F && noActiveBackgroundAgents + Terminated --> Idle: kill applied + confirmation message emitted +``` + +Normative behavior: + +- First Ctrl+F with active background agents emits a warning interface aligned with the single-press Ctrl+C warning pattern, and includes explicit guidance to press Ctrl+F again to terminate background agents. +- Confirmation enters an expiring armed state and auto-resets if not confirmed or if context changes. +- Second Ctrl+F within the active confirmation window interrupts all background agents in the current session scope. +- Successful termination appends a chat/system confirmation message indicating background agents were terminated. +- Parent abort callback executes after state mutation so run/session cleanup remains consistent. + +#### 5.3.2 Tree Hint Precedence + +- If any scoped background agents are running, use running hint text. +- If no background agents are running and at least one completed entry exists, use complete hint text. +- Otherwise use default expansion hint text. + +[Research: `research/tickets/2026-02-23-0258-background-agents-ui.md`], [Research: `research/docs/2026-02-12-sdk-ui-standardization-research.md`] + +#### 5.3.3 Dev/Prod and Provider Parity Contract + +- Execute identical behavior assertions on shared chat runtime for: + - Claude provider stream normalization + - OpenCode provider stream normalization + - Copilot provider stream normalization +- Validate on both dev and compiled production entry paths that route through `startChatUI`. + +## 6. Alternatives Considered + +| Option | Pros | Cons | Reason for Rejection | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Option A: Close issue as already implemented | Minimal engineering work | No durable acceptance contract; regressions likely; ambiguity remains | Rejected because issue evidence and implementation evidence are still misaligned without formal acceptance criteria. | +| Option B: Add tests only, no contract doc | Improves confidence quickly | Tests encode assumptions implicitly; hard to align with product expectations and copy standards | Rejected because this issue hinges on UX semantics, not just behavior mechanics. | +| Option C: Contract-first hardening + parity test matrix (Selected) | Explicit acceptance criteria, durable across providers/modes, easier bug triage | Requires upfront alignment decisions (copy/scope/timing) | **Selected** because it resolves both behavior and specification ambiguity. | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +- Termination actions must be scoped to the active chat/session context only unless explicitly expanded. +- Termination should never target unrelated sessions/processes. +- User-facing confirmation copy should avoid exposing internal IDs or provider internals. + +### 7.2 Observability Strategy + +- Track keyboard termination flow metrics: + - `background_termination_warn_count` + - `background_termination_execute_count` + - `background_termination_noop_count` +- Track parity failures in CI matrix by provider/runtime mode. +- Add structured debug logs around state transitions to simplify regression diagnosis. + +### 7.3 Scalability and Capacity Planning + +- Ensure footer and tree hint computation remains O(n) in number of tracked agents with no repeated full-copy transforms in hot keyboard paths. +- Ensure rapid key-repeat does not trigger duplicate termination execution. +- Preserve responsive rendering when multiple background agents stream concurrently. + +## 8. Migration, Rollout, and Testing + +### 8.1 Deployment Strategy + +- [ ] Phase 1: Approve canonical UX contract for footer, Ctrl+F flow, hint text, and termination scope. +- [ ] Phase 2: Apply implementation alignment to existing modules without architectural rewrites. +- [ ] Phase 3: Enable contract checks in CI for provider/runtime parity. +- [ ] Phase 4: Remove temporary compatibility paths after parity is confirmed stable. + +### 8.2 Data Migration Plan + +- No persistent data migration is required. +- Existing in-memory state structures are sufficient; changes are behavioral/contractual. + +### 8.3 Test Plan + +- **Unit Tests:** + - Termination decision logic (`none`/`warn`/`terminate`) for keypress sequences. + - Footer resolver precedence (live vs snapshot fallback). + - Tree hint precedence and exact copy contract. +- **Integration Tests:** + - Chat-level Ctrl+F first/second press behavior and emitted confirmation message. + - First Ctrl+F warning UI parity with single-press Ctrl+C warning style and instruction text. + - Parent callback invocation after termination. + - Ctrl+O transcript toggle behavior remains intact and non-conflicting. +- **End-to-End Tests:** + - Provider matrix: Claude, OpenCode, Copilot. + - Runtime matrix: dev (`bun run src/cli.ts`) and compiled binary path. + - Screenshot parity checks against issue assets for footer/tree/confirmation surfaces. + +## 9. Open Questions / Unresolved Issues + +- [x] **OQ-1 Footer Surface Canonicalization (Resolved):** Runtime will continue using `BackgroundAgentFooter` as the canonical footer path for issue `#258`; `FooterStatus` consolidation is out of scope for this RFC phase. +- [x] **OQ-2 Ctrl+F Confirmation Window (Resolved):** First Ctrl+F enters an expiring armed state; confirmation auto-resets on expiry or relevant context changes. +- [x] **OQ-3 Termination Scope (Resolved):** “Terminate all background agents” applies to the current session scope only. +- [x] **OQ-4 Canonical Copy Strictness (Resolved):** For Ctrl+F flow, first press must present a Ctrl+C-like warning interface with explicit instruction to press Ctrl+F again to kill background agents; acceptance is behavior-first with required instruction semantics rather than strict full-string matching. + +## 10. Research References + +- Primary issue extraction: `research/docs/2026-02-23-gh-issue-258-background-agents-ui.md` +- Issue-to-code mapping: `research/tickets/2026-02-23-0258-background-agents-ui.md` +- Sub-agent lifecycle context: `research/docs/2026-02-16-sub-agent-tree-inline-state-lifecycle-research.md` +- SDK/UI parity baseline: `research/docs/2026-02-12-sdk-ui-standardization-research.md` diff --git a/specs/thinking-tag-stream-grouping.md b/specs/thinking-tag-stream-grouping.md new file mode 100644 index 000000000..8a7966f21 --- /dev/null +++ b/specs/thinking-tag-stream-grouping.md @@ -0,0 +1,255 @@ +# Atomic CLI Thinking Stream Isolation — Technical Design Document / RFC + +| Document Metadata | Details | +| ---------------------- | ----------------- | +| Author(s) | lavaman131 | +| Status | Draft (WIP) | +| Team / Owner | Atomic CLI (TUI) | +| Created / Last Updated | Pending RFC entry | + +## 1. Executive Summary + +The current TUI thinking pipeline can incorrectly group reasoning text from different stream producers into a single `Thinking...` block, resulting in mixed and confusing assistant output. The primary issue is identity loss: thinking deltas are accumulated as one mutable string, then routed through metadata and rendering paths that do not consistently preserve stream-level source identity. This RFC proposes end-to-end stream isolation for thinking updates by introducing a stable `thinkingSourceKey`, keyed accumulation maps, and deterministic message/part targeting in the stream pipeline. The result is that each stream updates only its own reasoning state, even when callbacks interleave during stream handoffs or interruptions. Expected impact: elimination of cross-stream reasoning contamination, improved correctness under concurrent or overlapping stream paths, and reduced regressions by aligning this feature with existing keyed-state patterns already used in SDK and UI layers. Research-driven references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-16-atomic-chat-architecture-current.md`, `research/docs/2026-02-12-tui-layout-streaming-content-ordering.md`. + +## 2. Context and Motivation + +### 2.1 Current State + +Thinking output currently flows through this path: + +```mermaid +flowchart LR + A[SDK stream events\nthinking deltas] --> B[ui/index.ts\nstreamAndProcess] + B --> C[onMeta\nthinking-meta event] + C --> D[ui/chat.tsx\nhandleMeta + streamingMessageIdRef] + D --> E[stream-pipeline upsert\nlast streaming reasoning part] + E --> F[ReasoningPartDisplay\nThinking...] +``` + +- In `streamAndProcess`, thinking chunks are appended via single-string concatenation (`thinkingText += message.content`) and repeatedly emitted as one aggregated value. +- `ThinkingMetaEvent` currently carries text/timing fields but no stream/source identifier. +- Chat metadata handling applies updates to a shared `streamingMessageIdRef`, which can change as stream ownership transitions. +- The parts pipeline updates the last streaming reasoning part positionally, not by source identity. + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-09-token-count-thinking-timer-bugs.md`, `research/docs/2026-02-16-atomic-chat-architecture-current.md`. + +### 2.2 The Problem + +- **User Impact:** Thinking text can appear merged across producers, showing malformed concatenated reasoning lines and reducing trust in output. +- **Product Impact:** Perceived instability in the TUI stream renderer, especially in sessions with interruptions, queueing, or sub-agent activity. +- **Technical Debt:** Metadata and rendering paths rely on shared mutable pointers and positional upserts rather than keyed correlation, which is inconsistent with other map-based state patterns in the codebase. + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-12-tui-layout-streaming-content-ordering.md`. + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] Guarantee that thinking deltas from different producers are never merged into a single reasoning stream unless explicitly intended. +- [ ] Preserve correct message targeting for thinking updates during stream interleaving, interruption, and stream generation changes. +- [ ] Keep reasoning rendering behavior compatible with current parts-based UI while making source routing deterministic. +- [ ] Maintain SDK parity (Claude/OpenCode/Copilot) by requiring provider-native source IDs for thinking metadata routing and failing fast when thinking events lack identity. +- [ ] Add automated regression coverage for cross-stream thinking contamination scenarios. + +### 3.2 Non-Goals (Out of Scope) + +- [ ] Full redesign of all message/text/tool streaming architecture. +- [ ] Changes to markdown rendering semantics for non-thinking content. +- [ ] New user-facing settings for thinking display format. +- [ ] Reworking pinned/inline task panel behavior unrelated to thinking stream identity. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +flowchart TB + classDef sdk fill:#4a90e2,stroke:#357abd,color:#fff + classDef ui fill:#667eea,stroke:#5a67d8,color:#fff + classDef pipe fill:#48bb78,stroke:#38a169,color:#fff + classDef store fill:#718096,stroke:#4a5568,color:#fff + + A[SDK clients\nthinking events]:::sdk --> B[Stream Identity Adapter\nresolve thinkingSourceKey]:::ui + B --> C[streamAndProcess\nkeyed accumulators]:::ui + C --> D[thinking-meta event\n{ thinkingSourceKey, targetMessageId, ... }]:::ui + D --> E[chat meta router\nvalidate stream generation + target]:::ui + E --> F[stream-pipeline upsert\nby source key]:::pipe + F --> G[Reasoning part registry\nsourceKey -> partId]:::store + G --> H[ReasoningPartDisplay\nstable isolated blocks]:::pipe +``` + +### 4.2 Architectural Pattern + +- Adopt a **Keyed Stream Correlation** pattern for thinking metadata. +- Treat thinking updates as **source-scoped state transitions** rather than message-global mutable text. +- Use deterministic correlation objects (`thinkingSourceKey`, `targetMessageId`, optional provider metadata) to route and upsert safely. + +### 4.3 Key Components + +| Component | Responsibility | Technology Stack | Justification | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | -------------------------------- | ---------------------------------------------------------- | +| Stream Identity Adapter (`ui/index.ts`) | Resolve source key per thinking producer and attach correlation metadata | TypeScript, existing stream loop | Eliminates identity loss at ingress point | +| Thinking Meta Router (`ui/chat.tsx`) | Apply thinking events only to bound message and active generation | React state + refs | Prevents shared-ref misrouting during handoffs | +| Stream Pipeline Upsert (`ui/parts/stream-pipeline.ts`) | Upsert reasoning parts by source key registry instead of positional last-part update | TypeScript parts pipeline | Deterministic updates under interleaving | +| Reasoning Renderer (`reasoning-part-display.tsx`) | Render isolated thinking content with current UX semantics | OpenTUI React components | Preserves user-facing behavior while improving correctness | + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-16-opencode-message-rendering-patterns.md`, `research/docs/2026-02-16-atomic-chat-architecture-current.md`. + +## 5. Detailed Design + +### 5.1 Internal Event Interfaces + +Define a source-aware thinking metadata contract (names may be adjusted during implementation): + +```ts +type ThinkingSourceKey = string; + +interface ThinkingMetaEvent { + type: "thinking-meta"; + thinkingSourceKey: ThinkingSourceKey; + targetMessageId: string; + streamGeneration: number; + thinkingText: string; + thinkingMs?: number; + includeReasoningPart?: boolean; + provider?: "claude" | "opencode" | "copilot" | "unknown"; +} +``` + +Contract notes: + +- `thinkingSourceKey` is mandatory for all thinking events. +- `targetMessageId` is captured at stream start and remains immutable for that source key. +- `streamGeneration` prevents stale callbacks from writing into newer streams. + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-16-atomic-chat-architecture-current.md`. + +### 5.2 Data Model / State Schema + +Use explicit keyed maps to track active thinking state: + +| Store | Key | Value | Lifecycle | +| ----------------------- | ------------------- | --------------------------- | ----------------------------------------------------------------------- | +| `thinkingTextBySource` | `thinkingSourceKey` | accumulated text snapshot | Created on first thinking chunk, cleared on stream completion/interrupt | +| `messageBySource` | `thinkingSourceKey` | immutable `targetMessageId` | Bound at stream start, cleared at source finalize | +| `reasoningPartBySource` | `thinkingSourceKey` | reasoning part id/index | Created on first upsert, reused for updates, removed at finalize | +| `generationBySource` | `thinkingSourceKey` | `streamGeneration` | Used for stale-event rejection | + +This mirrors existing map-based patterns used in SDK session and UI streaming state management. + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-16-atomic-chat-architecture-current.md`. + +### 5.3 Algorithms and State Management + +Core algorithm for processing thinking events: + +```text +On stream start: +1) Resolve thinkingSourceKey from provider-native stream identity +2) Bind thinkingSourceKey -> targetMessageId and streamGeneration + +On thinking chunk: +3) Validate event streamGeneration matches generationBySource +4) Append chunk to thinkingTextBySource[sourceKey] +5) Emit ThinkingMetaEvent with sourceKey + targetMessageId + full snapshot + +On chat meta handling: +6) Reject event if targetMessageId no longer exists or generation mismatch +7) Route event directly to stream-pipeline with source key + +On pipeline upsert: +8) Find/create reasoning part via reasoningPartBySource[sourceKey] +9) Update only that part's content/timing + +On finalize/interrupt: +10) Mark source closed and cleanup all source-scoped maps +11) Ignore late events for closed source +``` + +### 5.4 Message and Rendering Behavior + +- Keep current `Thinking...` visual affordance and markdown rendering semantics. +- Render concurrent sources as separate reasoning blocks, each bound to a unique `thinkingSourceKey`. +- Preserve compatibility with single-source streams (no visible behavior change expected in standard runs). + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-12-tui-layout-streaming-content-ordering.md`, `research/docs/2026-02-15-ui-inline-streaming-vs-pinned-elements.md`. + +### 5.5 Failure Modes and Guards + +- **Late event after finalize (selected policy):** strictly drop event and increment diagnostic counter. +- **Missing provider-native ID with thinking event present:** fail fast with an explicit stream error (contract violation). +- **Non-reasoning models:** do not error solely due to missing reasoning identity when no thinking events are emitted. +- **Generation mismatch:** reject as stale callback. +- **Missing message binding:** no-op with debug log entry. +- **Registry desync:** recreate mapping on first valid event and continue. + +### 5.6 Testing Approach (Design-Level) + +- Unit tests for source key generation and message binding invariants. +- Unit tests for stale generation rejection. +- Unit tests for per-source upsert in stream pipeline. +- Integration tests simulating interleaved thinking chunks across two active sources. +- Regression test for observed broken output example (concatenated thinking text). +- SDK parity tests ensuring all clients provide stable provider-native source keys for reasoning-capable paths. +- Contract tests ensuring missing source identity on emitted thinking events raises a clear error, while non-reasoning runs proceed normally. + +## 6. Alternatives Considered + +| Option | Pros | Cons | Reason for Rejection | +| ------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- | +| Keep current shared string + shared message ref | Minimal code change | Cross-stream contamination remains; race-prone | Does not solve root cause | +| Message-level isolation only (no source key) | Simpler than full correlation | Fails when multiple producers target same message during overlap | Insufficient for concurrent paths | +| Provider-specific fixes only (Claude path first) | Fast narrow mitigation | Leaves SDK parity gaps; complexity duplicated per provider | Inconsistent and fragile | +| **Selected: Source-keyed correlation end-to-end** | Deterministic routing, SDK parity, regression-resistant | Requires coordinated type/state updates and strict identity validation | Best alignment with existing keyed-map architecture | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +- No new external API surface or privilege boundary changes. +- No additional sensitive data persisted; only transient in-memory routing keys. +- Logging for diagnostics should avoid recording full thinking text in non-debug mode. + +### 7.2 Observability Strategy + +- Add counters for dropped stale thinking events and missing binding events. +- Add lightweight debug traces for source-key lifecycle: create/update/finalize/drop. +- Keep telemetry additive and guard under existing diagnostics flags. + +### 7.3 Scalability and Capacity Planning + +- Map-based state is bounded by number of active streams in a session. +- Cleanup on finalize/interrupt prevents unbounded growth. +- Expected overhead is low relative to existing per-stream metadata handling. + +## 8. Migration, Rollout, and Testing + +### 8.1 Deployment Strategy + +- [ ] **Phase 1:** Add source-aware metadata types and stream identity adapter. +- [ ] **Phase 2:** Route thinking meta through chat and pipeline using keyed targeting. +- [ ] **Phase 3:** Add regression tests for interleaving/stale callbacks and verify SDK parity. +- [ ] **Phase 4:** Enable diagnostics counters and validate behavior across CLI chat paths. + +### 8.2 Data Migration Plan + +- No persisted schema migration required. +- In-memory state migration is handled by default initialization and cleanup logic. + +### 8.3 Test Plan + +- **Unit Tests:** source key generation, event validation, keyed upsert behavior. +- **Integration Tests:** multi-source interleaving, stream handoff/interrupt transitions. +- **End-to-End Tests:** reproduce prior grouping bug and verify isolated reasoning rendering. +- **Manual Validation:** run chat sessions with each SDK, interruption flows, and queued follow-up prompts. + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`, `research/docs/2026-02-09-token-count-thinking-timer-bugs.md`. + +## 9. Open Questions / Unresolved Issues + +- [x] **Canonical source key format:** `thinkingSourceKey` is provider-native only; no synthetic fallback for thinking metadata routing. +- [x] **UI behavior for concurrent sources:** Render separate reasoning blocks per `thinkingSourceKey`. +- [x] **Late-event policy:** Strictly drop post-finalize thinking events and track diagnostics. +- [x] **Provider parity contract:** Fail fast when a thinking event lacks stable source identity; do not fail non-reasoning model runs that emit no thinking events. + +Research references: `research/docs/2026-02-23-thinking-tag-stream-grouping.md`. diff --git a/src/sdk/clients/claude.test.ts b/src/sdk/clients/claude.test.ts index da780dee6..3c5118613 100644 --- a/src/sdk/clients/claude.test.ts +++ b/src/sdk/clients/claude.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { ClaudeAgentClient } from "./index.ts"; +import { extractMessageContent } from "./claude.ts"; describe("ClaudeAgentClient.getModelDisplayInfo", () => { test("normalizes default to opus", async () => { @@ -89,6 +90,25 @@ describe("ClaudeAgentClient.setActiveSessionModel", () => { }); }); +describe("extractMessageContent thinking source identity", () => { + test("returns thinking content with provider-native block index source key", () => { + const message = { + message: { + content: [ + { type: "metadata" }, + { type: "thinking", thinking: "check invariants" }, + ], + }, + } as unknown as Parameters[0]; + + const extracted = extractMessageContent(message); + + expect(extracted.type).toBe("thinking"); + expect(extracted.content).toBe("check invariants"); + expect(extracted.thinkingSourceKey).toBe("1"); + }); +}); + describe("ClaudeAgentClient observability and parity", () => { test("emits v1 runtime selection marker through unified usage events", () => { const client = new ClaudeAgentClient(); diff --git a/src/sdk/clients/claude.ts b/src/sdk/clients/claude.ts index 9f9fb973c..9f57310d6 100644 --- a/src/sdk/clients/claude.ts +++ b/src/sdk/clients/claude.ts @@ -143,9 +143,10 @@ function mapEventTypeToHookEvent(eventType: EventType): HookEvent | null { * streaming layer can accurately count tool invocations even when the * model emits thinking or text blocks before the tool_use block). */ -function extractMessageContent(message: SDKAssistantMessage): { +export function extractMessageContent(message: SDKAssistantMessage): { type: MessageContentType; content: string | unknown; + thinkingSourceKey?: string; } { const betaMessage = message.message; if (betaMessage.content.length === 0) { @@ -155,8 +156,10 @@ function extractMessageContent(message: SDKAssistantMessage): { // Scan all blocks — prioritize tool_use, then text, then thinking let textContent: string | null = null; let thinkingContent: string | null = null; + let thinkingSourceKey: string | undefined; - for (const block of betaMessage.content) { + for (let blockIndex = 0; blockIndex < betaMessage.content.length; blockIndex++) { + const block = betaMessage.content[blockIndex]!; if (block.type === "tool_use") { // Return immediately — tool_use has highest priority. // Include toolUseId so the UI can deduplicate partial messages @@ -175,6 +178,7 @@ function extractMessageContent(message: SDKAssistantMessage): { } if (block.type === "thinking" && thinkingContent === null) { thinkingContent = (block as { thinking: string }).thinking; + thinkingSourceKey = String(blockIndex); } } @@ -183,12 +187,33 @@ function extractMessageContent(message: SDKAssistantMessage): { } if (thinkingContent !== null) { - return { type: "thinking", content: thinkingContent }; + return { + type: "thinking", + content: thinkingContent, + thinkingSourceKey, + }; } return { type: "text", content: "" }; } +function getClaudeContentBlockIndex(event: Record): number | null { + const directIndex = event.index; + if (typeof directIndex === "number") { + return directIndex; + } + + const contentBlock = event.content_block; + if (contentBlock && typeof contentBlock === "object") { + const blockIndex = (contentBlock as Record).index; + if (typeof blockIndex === "number") { + return blockIndex; + } + } + + return null; +} + function mapAuthStatusFromMcpServerStatus( status: McpServerStatus["status"], ): McpAuthStatus | undefined { @@ -630,7 +655,7 @@ export class ClaudeAgentClient implements CodingAgentClient { } if (sdkMessage.type === "assistant") { - const { type, content } = + const { type, content, thinkingSourceKey } = extractMessageContent(sdkMessage); lastAssistantMessage = { type, @@ -648,6 +673,12 @@ export class ClaudeAgentClient implements CodingAgentClient { model: sdkMessage.message.model, stopReason: sdkMessage.message.stop_reason ?? undefined, + ...(type === "thinking" + ? { + provider: "claude", + thinkingSourceKey, + } + : {}), }, }; } @@ -714,6 +745,7 @@ export class ClaudeAgentClient implements CodingAgentClient { let thinkingStartMs: number | null = null; let thinkingDurationMs = 0; let currentBlockIsThinking = false; + let activeThinkingSourceKey: string | null = null; // Output token tracking from message_delta events let outputTokens = 0; let sawTerminalEvent = false; @@ -729,6 +761,9 @@ export class ClaudeAgentClient implements CodingAgentClient { // Track thinking block boundaries if (event.type === "content_block_start") { + const blockIndex = getClaudeContentBlockIndex( + event as Record, + ); const blockType = ( event as Record ).content_block @@ -744,12 +779,26 @@ export class ClaudeAgentClient implements CodingAgentClient { blockType === "thinking"; if (currentBlockIsThinking) { thinkingStartMs = Date.now(); + activeThinkingSourceKey = + blockIndex !== null + ? String(blockIndex) + : null; } } if ( event.type === "content_block_stop" && currentBlockIsThinking ) { + if (activeThinkingSourceKey === null) { + const blockIndex = + getClaudeContentBlockIndex( + event as Record, + ); + if (blockIndex !== null) { + activeThinkingSourceKey = + String(blockIndex); + } + } if (thinkingStartMs !== null) { thinkingDurationMs += Date.now() - thinkingStartMs; @@ -761,12 +810,17 @@ export class ClaudeAgentClient implements CodingAgentClient { content: "", role: "assistant", metadata: { + provider: "claude", + thinkingSourceKey: + activeThinkingSourceKey ?? + undefined, streamingStats: { thinkingMs: thinkingDurationMs, outputTokens, }, }, }; + activeThinkingSourceKey = null; } // Track output tokens from message_delta usage @@ -793,6 +847,18 @@ export class ClaudeAgentClient implements CodingAgentClient { event.delta.type === "thinking_delta" ) { hasYieldedDeltas = true; + const blockIndex = + getClaudeContentBlockIndex( + event as Record, + ); + const resolvedThinkingSourceKey: string | null = + blockIndex !== null + ? String(blockIndex) + : activeThinkingSourceKey; + if (resolvedThinkingSourceKey !== null) { + activeThinkingSourceKey = + resolvedThinkingSourceKey; + } const currentThinkingMs = thinkingDurationMs + (thinkingStartMs !== null @@ -808,6 +874,10 @@ export class ClaudeAgentClient implements CodingAgentClient { ).thinking as string, role: "assistant", metadata: { + provider: "claude", + thinkingSourceKey: + resolvedThinkingSourceKey ?? + undefined, streamingStats: { thinkingMs: currentThinkingMs, @@ -818,7 +888,7 @@ export class ClaudeAgentClient implements CodingAgentClient { } } } else if (sdkMessage.type === "assistant") { - const { type, content } = + const { type, content, thinkingSourceKey } = extractMessageContent(sdkMessage); // Always yield tool_use messages so callers can track tool @@ -861,6 +931,12 @@ export class ClaudeAgentClient implements CodingAgentClient { stopReason: sdkMessage.message .stop_reason ?? undefined, + ...(type === "thinking" + ? { + provider: "claude", + thinkingSourceKey, + } + : {}), }, }; } diff --git a/src/sdk/clients/copilot.test.ts b/src/sdk/clients/copilot.test.ts index 7535e8c32..acacd4f9f 100644 --- a/src/sdk/clients/copilot.test.ts +++ b/src/sdk/clients/copilot.test.ts @@ -75,4 +75,357 @@ describe("CopilotClient abort support", () => { await session.abort!(); expect(mockSdkSession.abort).toHaveBeenCalled(); }); + + test("streams reasoning deltas with provider-native thinking source metadata", async () => { + const listeners: Array<(event: { + type: string; + data: Record; + }) => void> = []; + + const mockSdkSession = { + sessionId: "copilot-thinking-session", + on: mock((handler: (event: { type: string; data: Record }) => void) => { + listeners.push(handler); + return () => { + const idx = listeners.indexOf(handler); + if (idx >= 0) listeners.splice(idx, 1); + }; + }), + send: mock(async () => { + for (const listener of [...listeners]) { + listener({ + type: "assistant.reasoning_delta", + data: { + reasoningId: "reasoning_123", + deltaContent: "planning", + }, + }); + } + for (const listener of [...listeners]) { + listener({ + type: "session.idle", + data: {}, + }); + } + }), + sendAndWait: mock(() => Promise.resolve({ data: { content: "" } })), + destroy: mock(() => Promise.resolve()), + abort: mock(() => Promise.resolve()), + }; + + const client = new CopilotClient({}); + const wrapSession = (client as unknown as { + wrapSession: ( + sdkSession: { + sessionId: string; + on: (handler: (event: { type: string; data: Record }) => void) => () => void; + send: (args: { prompt: string }) => Promise; + sendAndWait: (args: { prompt: string }) => Promise<{ data: { content: string } }>; + destroy: () => Promise; + abort: () => Promise; + }, + config: Record, + ) => { + stream: (message: string, options?: { agent?: string }) => AsyncIterable<{ + type: string; + content: unknown; + metadata?: Record; + }>; + }; + }).wrapSession.bind(client); + + const session = wrapSession(mockSdkSession, {}); + const streamed: Array<{ + type: string; + content: unknown; + metadata?: Record; + }> = []; + for await (const chunk of session.stream("hello")) { + streamed.push(chunk); + } + + expect(streamed).toHaveLength(1); + const thinkingChunk = streamed[0]!; + expect(thinkingChunk.type).toBe("thinking"); + expect(thinkingChunk.content).toBe("planning"); + expect(thinkingChunk.metadata?.provider).toBe("copilot"); + expect(thinkingChunk.metadata?.thinkingSourceKey).toBe("reasoning_123"); + expect( + (thinkingChunk.metadata?.streamingStats as { outputTokens?: number } | undefined) + ?.outputTokens, + ).toBe(0); + }); +}); + +describe("CopilotClient subagent event mapping", () => { + test("maps subagent.started to subagent.start with enriched data", async () => { + const events: Array<{ type: string; sessionId: string; data: Record }> = []; + + const mockSdkSession = { + sessionId: "test-session", + on: mock(() => () => {}), + send: mock(() => Promise.resolve()), + sendAndWait: mock(() => Promise.resolve({ data: { content: "test" } })), + destroy: mock(() => Promise.resolve()), + abort: mock(() => Promise.resolve()), + }; + + const mockSdkClient = { + start: mock(() => Promise.resolve()), + stop: mock(() => Promise.resolve()), + createSession: mock(() => Promise.resolve(mockSdkSession)), + listModels: mock(() => Promise.resolve([ + { + id: "test-model", + capabilities: { + limits: { max_context_window_tokens: 128000 }, + supports: {}, + }, + }, + ])), + }; + + const client = new CopilotClient({}); + (client as any).sdkClient = mockSdkClient; + (client as any).isRunning = true; + + // Register event listener + client.on("subagent.start", (event) => { + events.push({ type: "subagent.start", sessionId: event.sessionId, data: event.data }); + }); + + // Trigger the internal event handler + const handleSdkEvent = (client as any).handleSdkEvent.bind(client); + handleSdkEvent("test-session", { + type: "subagent.started", + data: { + toolCallId: "tc-123", + agentName: "worker", + description: "Fix bug", + }, + }); + + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("subagent.start"); + expect(events[0]!.sessionId).toBe("test-session"); + expect(events[0]!.data).toEqual({ + subagentId: "tc-123", + subagentType: "worker", + toolCallId: "tc-123", + task: "Fix bug", + }); + }); + + test("maps subagent.started with prompt fallback when description is missing", async () => { + const events: Array<{ type: string; sessionId: string; data: Record }> = []; + + const mockSdkSession = { + sessionId: "test-session", + on: mock(() => () => {}), + send: mock(() => Promise.resolve()), + sendAndWait: mock(() => Promise.resolve({ data: { content: "test" } })), + destroy: mock(() => Promise.resolve()), + abort: mock(() => Promise.resolve()), + }; + + const mockSdkClient = { + start: mock(() => Promise.resolve()), + stop: mock(() => Promise.resolve()), + createSession: mock(() => Promise.resolve(mockSdkSession)), + listModels: mock(() => Promise.resolve([ + { + id: "test-model", + capabilities: { + limits: { max_context_window_tokens: 128000 }, + supports: {}, + }, + }, + ])), + }; + + const client = new CopilotClient({}); + (client as any).sdkClient = mockSdkClient; + (client as any).isRunning = true; + + client.on("subagent.start", (event) => { + events.push({ type: "subagent.start", sessionId: event.sessionId, data: event.data }); + }); + + const handleSdkEvent = (client as any).handleSdkEvent.bind(client); + handleSdkEvent("test-session", { + type: "subagent.started", + data: { + toolCallId: "tc-456", + agentName: "debugger", + prompt: "Debug the error", + }, + }); + + expect(events).toHaveLength(1); + expect(events[0]!.data).toEqual({ + subagentId: "tc-456", + subagentType: "debugger", + toolCallId: "tc-456", + task: "Debug the error", + }); + }); + + test("maps subagent.started with agentName fallback when description and prompt are missing", async () => { + const events: Array<{ type: string; sessionId: string; data: Record }> = []; + + const mockSdkSession = { + sessionId: "test-session", + on: mock(() => () => {}), + send: mock(() => Promise.resolve()), + sendAndWait: mock(() => Promise.resolve({ data: { content: "test" } })), + destroy: mock(() => Promise.resolve()), + abort: mock(() => Promise.resolve()), + }; + + const mockSdkClient = { + start: mock(() => Promise.resolve()), + stop: mock(() => Promise.resolve()), + createSession: mock(() => Promise.resolve(mockSdkSession)), + listModels: mock(() => Promise.resolve([ + { + id: "test-model", + capabilities: { + limits: { max_context_window_tokens: 128000 }, + supports: {}, + }, + }, + ])), + }; + + const client = new CopilotClient({}); + (client as any).sdkClient = mockSdkClient; + (client as any).isRunning = true; + + client.on("subagent.start", (event) => { + events.push({ type: "subagent.start", sessionId: event.sessionId, data: event.data }); + }); + + const handleSdkEvent = (client as any).handleSdkEvent.bind(client); + handleSdkEvent("test-session", { + type: "subagent.started", + data: { + toolCallId: "tc-789", + agentName: "explorer", + }, + }); + + expect(events).toHaveLength(1); + expect(events[0]!.data).toEqual({ + subagentId: "tc-789", + subagentType: "explorer", + toolCallId: "tc-789", + task: "explorer", + }); + }); + + test("maps subagent.completed to subagent.complete with success: true", async () => { + const events: Array<{ type: string; sessionId: string; data: Record }> = []; + + const mockSdkSession = { + sessionId: "test-session", + on: mock(() => () => {}), + send: mock(() => Promise.resolve()), + sendAndWait: mock(() => Promise.resolve({ data: { content: "test" } })), + destroy: mock(() => Promise.resolve()), + abort: mock(() => Promise.resolve()), + }; + + const mockSdkClient = { + start: mock(() => Promise.resolve()), + stop: mock(() => Promise.resolve()), + createSession: mock(() => Promise.resolve(mockSdkSession)), + listModels: mock(() => Promise.resolve([ + { + id: "test-model", + capabilities: { + limits: { max_context_window_tokens: 128000 }, + supports: {}, + }, + }, + ])), + }; + + const client = new CopilotClient({}); + (client as any).sdkClient = mockSdkClient; + (client as any).isRunning = true; + + client.on("subagent.complete", (event) => { + events.push({ type: "subagent.complete", sessionId: event.sessionId, data: event.data }); + }); + + const handleSdkEvent = (client as any).handleSdkEvent.bind(client); + handleSdkEvent("test-session", { + type: "subagent.completed", + data: { + toolCallId: "tc-123", + }, + }); + + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("subagent.complete"); + expect(events[0]!.sessionId).toBe("test-session"); + expect(events[0]!.data).toEqual({ + subagentId: "tc-123", + success: true, + }); + }); + + test("maps subagent.failed to subagent.complete with success: false and error", async () => { + const events: Array<{ type: string; sessionId: string; data: Record }> = []; + + const mockSdkSession = { + sessionId: "test-session", + on: mock(() => () => {}), + send: mock(() => Promise.resolve()), + sendAndWait: mock(() => Promise.resolve({ data: { content: "test" } })), + destroy: mock(() => Promise.resolve()), + abort: mock(() => Promise.resolve()), + }; + + const mockSdkClient = { + start: mock(() => Promise.resolve()), + stop: mock(() => Promise.resolve()), + createSession: mock(() => Promise.resolve(mockSdkSession)), + listModels: mock(() => Promise.resolve([ + { + id: "test-model", + capabilities: { + limits: { max_context_window_tokens: 128000 }, + supports: {}, + }, + }, + ])), + }; + + const client = new CopilotClient({}); + (client as any).sdkClient = mockSdkClient; + (client as any).isRunning = true; + + client.on("subagent.complete", (event) => { + events.push({ type: "subagent.complete", sessionId: event.sessionId, data: event.data }); + }); + + const handleSdkEvent = (client as any).handleSdkEvent.bind(client); + handleSdkEvent("test-session", { + type: "subagent.failed", + data: { + toolCallId: "tc-456", + error: "Task execution failed", + }, + }); + + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("subagent.complete"); + expect(events[0]!.sessionId).toBe("test-session"); + expect(events[0]!.data).toEqual({ + subagentId: "tc-456", + success: false, + error: "Task execution failed", + }); + }); }); diff --git a/src/sdk/clients/copilot.ts b/src/sdk/clients/copilot.ts index c61e7b80b..3d95db8d8 100644 --- a/src/sdk/clients/copilot.ts +++ b/src/sdk/clients/copilot.ts @@ -380,6 +380,8 @@ export class CopilotClient implements CodingAgentClient { content: event.data.deltaContent, role: "assistant", metadata: { + provider: "copilot", + thinkingSourceKey: event.data.reasoningId, streamingStats: { thinkingMs: reasoningDurationMs + (Date.now() - reasoningStartMs), outputTokens: 0, @@ -626,6 +628,8 @@ export class CopilotClient implements CodingAgentClient { eventData = { subagentId: data.toolCallId, subagentType: data.agentName, + toolCallId: data.toolCallId, + task: data.description ?? data.prompt ?? data.agentName, }; break; case "skill.invoked": diff --git a/src/sdk/clients/opencode.events.test.ts b/src/sdk/clients/opencode.events.test.ts index f2dfb67d2..a61a6e5e4 100644 --- a/src/sdk/clients/opencode.events.test.ts +++ b/src/sdk/clients/opencode.events.test.ts @@ -154,4 +154,219 @@ describe("OpenCodeClient event mapping", () => { }, ]); }); + + test("emits thinking source identity for reasoning deltas", () => { + const client = new OpenCodeClient(); + const deltas: Array<{ + sessionId: string; + delta?: string; + contentType?: string; + thinkingSourceKey?: string; + }> = []; + + const unsubscribe = client.on("message.delta", (event) => { + const data = event.data as { + delta?: string; + contentType?: string; + thinkingSourceKey?: string; + }; + deltas.push({ + sessionId: event.sessionId, + delta: data.delta, + contentType: data.contentType, + thinkingSourceKey: data.thinkingSourceKey, + }); + }); + + (client as unknown as { handleSdkEvent: (event: Record) => void }).handleSdkEvent({ + type: "message.part.updated", + properties: { + part: { + id: "reasoning_part_1", + sessionID: "ses_reasoning", + messageID: "msg_reasoning", + type: "reasoning", + }, + delta: "inspect constraints", + }, + }); + + unsubscribe(); + + expect(deltas).toEqual([ + { + sessionId: "ses_reasoning", + delta: "inspect constraints", + contentType: "reasoning", + thinkingSourceKey: "reasoning_part_1", + }, + ]); + }); + + test("maps agent part to subagent.start with toolCallId from callID", () => { + const client = new OpenCodeClient(); + const starts: Array<{ + sessionId: string; + subagentId?: string; + subagentType?: string; + toolCallId?: string; + }> = []; + + const unsubStart = client.on("subagent.start", (event) => { + const data = event.data as { + subagentId?: string; + subagentType?: string; + toolCallId?: string; + }; + starts.push({ + sessionId: event.sessionId, + subagentId: data.subagentId, + subagentType: data.subagentType, + toolCallId: data.toolCallId, + }); + }); + + (client as unknown as { handleSdkEvent: (event: Record) => void }).handleSdkEvent({ + type: "message.part.updated", + properties: { + part: { + id: "agent-1", + sessionID: "ses_agent", + messageID: "msg_1", + type: "agent", + name: "explorer", + callID: "call-123", + }, + }, + }); + + unsubStart(); + + expect(starts).toEqual([ + { + sessionId: "ses_agent", + subagentId: "agent-1", + subagentType: "explorer", + toolCallId: "call-123", + }, + ]); + }); + + test("maps agent part to subagent.start with toolCallId fallback to id when callID is missing", () => { + const client = new OpenCodeClient(); + const starts: Array<{ + sessionId: string; + subagentId?: string; + subagentType?: string; + toolCallId?: string; + }> = []; + + const unsubStart = client.on("subagent.start", (event) => { + const data = event.data as { + subagentId?: string; + subagentType?: string; + toolCallId?: string; + }; + starts.push({ + sessionId: event.sessionId, + subagentId: data.subagentId, + subagentType: data.subagentType, + toolCallId: data.toolCallId, + }); + }); + + (client as unknown as { handleSdkEvent: (event: Record) => void }).handleSdkEvent({ + type: "message.part.updated", + properties: { + part: { + id: "agent-2", + sessionID: "ses_agent_no_callid", + messageID: "msg_2", + type: "agent", + name: "worker", + // callID is missing/undefined + }, + }, + }); + + unsubStart(); + + expect(starts).toEqual([ + { + sessionId: "ses_agent_no_callid", + subagentId: "agent-2", + subagentType: "worker", + toolCallId: "agent-2", // Falls back to id + }, + ]); + }); + + test("maps step-finish part to subagent.complete", () => { + const client = new OpenCodeClient(); + const completes: Array<{ + sessionId: string; + subagentId?: string; + success?: boolean; + result?: string; + }> = []; + + const unsubComplete = client.on("subagent.complete", (event) => { + const data = event.data as { + subagentId?: string; + success?: boolean; + result?: string; + }; + completes.push({ + sessionId: event.sessionId, + subagentId: data.subagentId, + success: data.success, + result: data.result, + }); + }); + + // Test successful completion + (client as unknown as { handleSdkEvent: (event: Record) => void }).handleSdkEvent({ + type: "message.part.updated", + properties: { + part: { + id: "step-1", + sessionID: "ses_step", + messageID: "msg_step", + type: "step-finish", + reason: "success", + }, + }, + }); + + // Test error completion + (client as unknown as { handleSdkEvent: (event: Record) => void }).handleSdkEvent({ + type: "message.part.updated", + properties: { + part: { + id: "step-2", + sessionID: "ses_step", + messageID: "msg_step_2", + type: "step-finish", + reason: "error", + }, + }, + }); + + unsubComplete(); + + expect(completes).toEqual([ + { + sessionId: "ses_step", + subagentId: "step-1", + success: true, + result: "success", + }, + { + sessionId: "ses_step", + subagentId: "step-2", + success: false, + result: "error", + }, + ]); + }); }); diff --git a/src/sdk/clients/opencode.ts b/src/sdk/clients/opencode.ts index eb93790f7..10034f62a 100644 --- a/src/sdk/clients/opencode.ts +++ b/src/sdk/clients/opencode.ts @@ -109,6 +109,15 @@ const DEFAULT_OPENCODE_BASE_URL = "http://localhost:4096"; const DEFAULT_MAX_RETRIES = 3; const DEFAULT_RETRY_DELAY = 1000; +/** + * Debug logging helper gated behind ATOMIC_DEBUG environment variable + * Used to verify event emission at runtime during development + */ +const debugLog = process.env.ATOMIC_DEBUG + ? (label: string, data: Record) => + console.debug(`[opencode:${label}]`, JSON.stringify(data, null, 2)) + : () => {}; + /** * Part types accepted by OpenCode SDK's session.prompt(). * These mirror the SDK's TextPartInput and AgentPartInput types. @@ -665,6 +674,7 @@ export class OpenCodeClient implements CodingAgentClient { this.emitEvent("message.delta", partSessionId, { delta, contentType: "reasoning", + thinkingSourceKey: (part?.id as string) ?? undefined, }); } else if (part?.type === "tool") { const toolState = part?.state as Record | undefined; @@ -676,6 +686,11 @@ export class OpenCodeClient implements CodingAgentClient { // Include the tool part ID so the UI can deduplicate events for // the same logical tool call (pending → running transitions). if (toolState?.status === "pending" || toolState?.status === "running") { + debugLog("tool.start", { + toolName, + toolId: part?.id as string, + hasToolInput: !!toolInput && Object.keys(toolInput).length > 0, + }); this.emitEvent("tool.start", partSessionId, { toolName, toolInput, @@ -709,9 +724,16 @@ export class OpenCodeClient implements CodingAgentClient { } else if (part?.type === "agent") { // AgentPart: { type: "agent", name, id, sessionID, messageID } // Map agent parts to subagent.start events + debugLog("subagent.start", { + partType: "agent", + subagentId: (part?.id as string) ?? "", + subagentType: (part?.name as string) ?? "", + toolCallId: (part?.callID as string) ?? (part?.id as string), + }); this.emitEvent("subagent.start", partSessionId, { subagentId: (part?.id as string) ?? "", subagentType: (part?.name as string) ?? "", + toolCallId: (part?.callID as string) ?? (part?.id as string), }); } else if (part?.type === "subtask") { // SubtaskPart: { type: "subtask", prompt, description, agent, ... } @@ -720,6 +742,11 @@ export class OpenCodeClient implements CodingAgentClient { const subtaskPrompt = (part?.prompt as string) ?? ""; const subtaskDescription = (part?.description as string) ?? ""; const subtaskAgent = (part?.agent as string) ?? ""; + debugLog("subagent.start", { + partType: "subtask", + subagentId: (part?.id as string) ?? "", + subagentType: subtaskAgent, + }); this.emitEvent("subagent.start", partSessionId, { subagentId: (part?.id as string) ?? "", subagentType: subtaskAgent, @@ -1145,6 +1172,7 @@ export class OpenCodeClient implements CodingAgentClient { const delta = event.data?.delta as string | undefined; const contentType = event.data?.contentType as string | undefined; + const thinkingSourceKey = event.data?.thinkingSourceKey as string | undefined; if (delta) { deltaQueue.push({ type: contentType === "reasoning" ? "thinking" as const : "text" as const, @@ -1152,6 +1180,8 @@ export class OpenCodeClient implements CodingAgentClient { role: "assistant" as const, ...(contentType === "reasoning" ? { metadata: { + provider: "opencode", + thinkingSourceKey, streamingStats: { thinkingMs: 0, outputTokens: 0, @@ -1223,11 +1253,14 @@ export class OpenCodeClient implements CodingAgentClient { if (reasoningStartMs === null) { reasoningStartMs = Date.now(); } + const reasoningPartId = (part as { id?: string }).id; yield { type: "thinking" as const, content: part.text, role: "assistant" as const, metadata: { + provider: "opencode", + thinkingSourceKey: reasoningPartId, streamingStats: { thinkingMs: reasoningDurationMs + (Date.now() - reasoningStartMs), outputTokens: 0, @@ -1331,7 +1364,9 @@ export class OpenCodeClient implements CodingAgentClient { reasoningStartMs = Date.now(); } const currentMs = reasoningDurationMs + (Date.now() - reasoningStartMs); + const existingMetadata = (msg.metadata ?? {}) as Record; msg.metadata = { + ...existingMetadata, streamingStats: { thinkingMs: currentMs, outputTokens: 0 }, }; } else if (reasoningStartMs !== null) { diff --git a/src/sdk/types.ts b/src/sdk/types.ts index 41ea860c7..08e5d350c 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -351,6 +351,8 @@ export interface MessageDeltaEventData extends BaseEventData { delta: string; /** Content type of the delta */ contentType?: MessageContentType; + /** Provider-native thinking source identity (for reasoning/thinking deltas) */ + thinkingSourceKey?: string; } /** diff --git a/src/sdk/unified-event-parity.test.ts b/src/sdk/unified-event-parity.test.ts index 1cec4cb1c..690276d77 100644 --- a/src/sdk/unified-event-parity.test.ts +++ b/src/sdk/unified-event-parity.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; import { ClaudeAgentClient, OpenCodeClient, CopilotClient } from "./clients/index.ts"; +import { extractMessageContent } from "./clients/claude.ts"; import type { EventType } from "./types.ts"; const PARITY_EVENTS: EventType[] = [ @@ -123,4 +124,158 @@ describe("Unified provider event parity", () => { expect(calls).toBe(1); } }); + + test("reasoning-capable paths emit stable thinkingSourceKey identity", async () => { + const claudeFirst = extractMessageContent({ + message: { + content: [ + { type: "metadata" }, + { type: "thinking", thinking: "first thought" }, + { type: "thinking", thinking: "other source" }, + ], + }, + } as unknown as Parameters[0]); + const claudeSecond = extractMessageContent({ + message: { + content: [ + { type: "metadata" }, + { type: "thinking", thinking: "second thought" }, + ], + }, + } as unknown as Parameters[0]); + + const claudeSourceKeys = [claudeFirst.thinkingSourceKey, claudeSecond.thinkingSourceKey]; + expect(claudeSourceKeys).toEqual(["1", "1"]); + + const openCodeClient = new OpenCodeClient(); + const openCodeSourceKeys: string[] = []; + const unsubscribeOpenCode = openCodeClient.on("message.delta", (event) => { + const data = event.data as { contentType?: string; thinkingSourceKey?: string }; + if (data.contentType === "reasoning" && data.thinkingSourceKey) { + openCodeSourceKeys.push(data.thinkingSourceKey); + } + }); + + ( + openCodeClient as unknown as { + handleSdkEvent: (event: Record) => void; + } + ).handleSdkEvent({ + type: "message.part.updated", + properties: { + part: { + id: "reasoning_part_a", + sessionID: "ses_reasoning", + messageID: "msg_reasoning", + type: "reasoning", + }, + delta: "alpha", + }, + }); + + ( + openCodeClient as unknown as { + handleSdkEvent: (event: Record) => void; + } + ).handleSdkEvent({ + type: "message.part.updated", + properties: { + part: { + id: "reasoning_part_a", + sessionID: "ses_reasoning", + messageID: "msg_reasoning", + type: "reasoning", + }, + delta: "beta", + }, + }); + + unsubscribeOpenCode(); + + expect(openCodeSourceKeys).toEqual(["reasoning_part_a", "reasoning_part_a"]); + + const copilotListeners: Array<(event: { type: string; data: Record }) => void> = + []; + const mockCopilotSession = { + sessionId: "copilot-thinking-session", + on: mock((handler: (event: { type: string; data: Record }) => void) => { + copilotListeners.push(handler); + return () => { + const index = copilotListeners.indexOf(handler); + if (index >= 0) { + copilotListeners.splice(index, 1); + } + }; + }), + send: mock(async () => { + for (const listener of [...copilotListeners]) { + listener({ + type: "assistant.reasoning_delta", + data: { + reasoningId: "reasoning_123", + deltaContent: "step-1", + }, + }); + } + for (const listener of [...copilotListeners]) { + listener({ + type: "assistant.reasoning_delta", + data: { + reasoningId: "reasoning_123", + deltaContent: "step-2", + }, + }); + } + for (const listener of [...copilotListeners]) { + listener({ + type: "session.idle", + data: {}, + }); + } + }), + sendAndWait: mock(() => Promise.resolve({ data: { content: "" } })), + destroy: mock(() => Promise.resolve()), + abort: mock(() => Promise.resolve()), + }; + + const copilotClient = new CopilotClient({}); + const wrapCopilotSession = ( + copilotClient as unknown as { + wrapSession: ( + sdkSession: { + sessionId: string; + on: ( + handler: (event: { type: string; data: Record }) => void, + ) => () => void; + send: (args: { prompt: string }) => Promise; + sendAndWait: (args: { prompt: string }) => Promise<{ data: { content: string } }>; + destroy: () => Promise; + abort: () => Promise; + }, + config: Record, + ) => { + stream: (message: string, options?: { agent?: string }) => AsyncIterable<{ + type: string; + content: unknown; + metadata?: Record; + }>; + }; + } + ).wrapSession.bind(copilotClient); + + const wrappedCopilotSession = wrapCopilotSession(mockCopilotSession, {}); + const copilotSourceKeys: string[] = []; + + for await (const chunk of wrappedCopilotSession.stream("hello")) { + if (chunk.type !== "thinking") { + continue; + } + const sourceKey = chunk.metadata?.thinkingSourceKey; + if (typeof sourceKey === "string") { + copilotSourceKeys.push(sourceKey); + } + } + + expect(copilotSourceKeys).toEqual(["reasoning_123", "reasoning_123"]); + }); }); diff --git a/src/telemetry/telemetry-tui.ts b/src/telemetry/telemetry-tui.ts index cef79f70b..371041ca9 100644 --- a/src/telemetry/telemetry-tui.ts +++ b/src/telemetry/telemetry-tui.ts @@ -15,6 +15,7 @@ import { getOrCreateTelemetryState, isTelemetryEnabledSync } from "./telemetry"; import type { AgentType, TelemetryEventBase, + TuiBackgroundTerminationEvent, TuiCommandCategory, TuiCommandExecutionEvent, TuiCommandTrigger, @@ -50,6 +51,9 @@ export interface TrackTuiCommandExecutionOptions { export interface TuiSessionSummary { durationMs: number; messageCount: number; + backgroundTerminationWarnCount?: number; + backgroundTerminationExecuteCount?: number; + backgroundTerminationNoopCount?: number; } function createCommonBaseEvent(anonymousId: string): TelemetryEventBase { @@ -76,6 +80,9 @@ export class TuiTelemetrySessionTracker { private commandCount: number; private toolCallCount: number; private interruptCount: number; + private backgroundTerminationWarnCount: number; + private backgroundTerminationExecuteCount: number; + private backgroundTerminationNoopCount: number; constructor(options: CreateTuiTelemetrySessionOptions) { this.agentType = options.agentType; @@ -85,6 +92,9 @@ export class TuiTelemetrySessionTracker { this.commandCount = 0; this.toolCallCount = 0; this.interruptCount = 0; + this.backgroundTerminationWarnCount = 0; + this.backgroundTerminationExecuteCount = 0; + this.backgroundTerminationNoopCount = 0; this.enabled = isTelemetryEnabledSync(); this.anonymousId = this.enabled ? getOrCreateTelemetryState().anonymousId : null; @@ -209,6 +219,33 @@ export class TuiTelemetrySessionTracker { appendEvent(event, this.agentType); } + trackBackgroundTermination(action: "noop" | "warn" | "execute", activeAgentCount: number, interruptedCount?: number): void { + if (!this.enabled || !this.anonymousId || this.ended) { + return; + } + + if (action === "noop") { + this.backgroundTerminationNoopCount++; + } else if (action === "warn") { + this.backgroundTerminationWarnCount++; + } else if (action === "execute") { + this.backgroundTerminationExecuteCount++; + } + + const event: TuiBackgroundTerminationEvent = { + ...createCommonBaseEvent(this.anonymousId), + eventType: "tui_background_termination", + source: "tui", + sessionId: this.sessionId, + agentType: this.agentType, + action, + activeAgentCount, + interruptedCount, + }; + + appendEvent(event, this.agentType); + } + end(summary: TuiSessionSummary): void { if (!this.enabled || !this.anonymousId || this.ended) { return; @@ -227,6 +264,9 @@ export class TuiTelemetrySessionTracker { commandCount: this.commandCount, toolCallCount: this.toolCallCount, interruptCount: this.interruptCount, + backgroundTerminationWarnCount: this.backgroundTerminationWarnCount || summary.backgroundTerminationWarnCount, + backgroundTerminationExecuteCount: this.backgroundTerminationExecuteCount || summary.backgroundTerminationExecuteCount, + backgroundTerminationNoopCount: this.backgroundTerminationNoopCount || summary.backgroundTerminationNoopCount, }; appendEvent(event, this.agentType); diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index 0d1293e9c..61bec187f 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -131,6 +131,9 @@ export interface TuiSessionEndEvent extends TelemetryEventBase { commandCount: number; toolCallCount: number; interruptCount: number; + backgroundTerminationWarnCount?: number; + backgroundTerminationExecuteCount?: number; + backgroundTerminationNoopCount?: number; } /** @@ -187,6 +190,19 @@ export interface TuiInterruptEvent extends TelemetryEventBase { sourceType: "ui" | "signal"; } +/** + * Event logged when a user attempts background termination with Ctrl+F. + */ +export interface TuiBackgroundTerminationEvent extends TelemetryEventBase { + eventType: "tui_background_termination"; + source: "tui"; + sessionId: string; + agentType: AgentType; + action: "noop" | "warn" | "execute"; + activeAgentCount: number; + interruptedCount?: number; +} + export type TelemetryEvent = | AtomicCommandEvent | CliCommandEvent @@ -196,4 +212,5 @@ export type TelemetryEvent = | TuiMessageSubmitEvent | TuiCommandExecutionEvent | TuiToolLifecycleEvent - | TuiInterruptEvent; + | TuiInterruptEvent + | TuiBackgroundTerminationEvent; diff --git a/src/ui/chat.completion-summary.test.ts b/src/ui/chat.completion-summary.test.ts index 6119ccbad..ac9b1ee95 100644 --- a/src/ui/chat.completion-summary.test.ts +++ b/src/ui/chat.completion-summary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { shouldShowCompletionSummary } from "./chat.tsx"; +import { shouldShowCompletionSummary } from "./utils/loading-state.ts"; describe("shouldShowCompletionSummary", () => { test("returns true for completed assistant messages >= 1s with no active background agents", () => { diff --git a/src/ui/chat.loading-state.test.ts b/src/ui/chat.loading-state.test.ts new file mode 100644 index 000000000..974a2b006 --- /dev/null +++ b/src/ui/chat.loading-state.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; +import type { ParallelAgent } from "./components/parallel-agents-tree.tsx"; +import { + hasLiveLoadingIndicator, + isTaskProgressComplete, + shouldShowMessageLoadingIndicator, +} from "./utils/loading-state.ts"; + +const backgroundAgent: ParallelAgent = { + id: "agent-1", + name: "reviewer", + task: "validate completion", + status: "background", + startedAt: new Date(0).toISOString(), + background: true, +}; + +describe("isTaskProgressComplete", () => { + test("returns true only when every task is completed", () => { + expect(isTaskProgressComplete([ + { status: "completed" }, + { status: "completed" }, + ])).toBe(true); + }); + + test("returns false when tasks are missing or still active", () => { + expect(isTaskProgressComplete(undefined)).toBe(false); + expect(isTaskProgressComplete([])).toBe(false); + expect(isTaskProgressComplete([ + { status: "completed" }, + { status: "in_progress" }, + ])).toBe(false); + }); +}); + +describe("shouldShowMessageLoadingIndicator", () => { + test("stops loading indicator once live task progress reaches 100%", () => { + expect( + shouldShowMessageLoadingIndicator( + { streaming: true }, + [ + { status: "completed" }, + { status: "completed" }, + ], + ), + ).toBe(false); + }); + + test("uses live streaming tasks when snapshot is stale", () => { + expect( + shouldShowMessageLoadingIndicator( + { + streaming: true, + taskItems: [ + { status: "completed" }, + { status: "pending" }, + ], + }, + [ + { status: "completed" }, + { status: "completed" }, + ], + ), + ).toBe(false); + }); + + test("keeps loading indicator for in-progress task rows", () => { + expect( + shouldShowMessageLoadingIndicator( + { streaming: true }, + [ + { status: "completed" }, + { status: "in_progress" }, + ], + ), + ).toBe(true); + }); + + test("treats error rows as non-complete progress", () => { + expect( + shouldShowMessageLoadingIndicator( + { streaming: true }, + [ + { status: "completed" }, + { status: "error" }, + ], + ), + ).toBe(true); + }); + + test("stops loading indicator for completed subagent/background snapshots", () => { + expect( + shouldShowMessageLoadingIndicator({ + streaming: false, + parallelAgents: [backgroundAgent], + taskItems: [ + { status: "completed" }, + { status: "completed" }, + ], + }), + ).toBe(false); + }); + + test("falls back to message task snapshot when live task rows are empty", () => { + expect( + shouldShowMessageLoadingIndicator( + { + streaming: true, + taskItems: [ + { status: "completed" }, + { status: "completed" }, + ], + }, + [], + ), + ).toBe(false); + + expect( + shouldShowMessageLoadingIndicator( + { + streaming: true, + taskItems: [ + { status: "completed" }, + { status: "pending" }, + ], + }, + [], + ), + ).toBe(true); + }); + + test("keeps loading indicator for background work when progress is not complete", () => { + expect( + shouldShowMessageLoadingIndicator({ + streaming: false, + parallelAgents: [backgroundAgent], + taskItems: [ + { status: "completed" }, + { status: "pending" }, + ], + }), + ).toBe(true); + }); + + test("ignores non-background agents when deciding loading visibility", () => { + const foregroundAgent: ParallelAgent = { + ...backgroundAgent, + id: "agent-foreground", + background: false, + status: "running", + }; + + expect( + shouldShowMessageLoadingIndicator({ + streaming: false, + parallelAgents: [foregroundAgent], + taskItems: [ + { status: "completed" }, + { status: "completed" }, + ], + }), + ).toBe(false); + }); +}); + +describe("hasLiveLoadingIndicator", () => { + test("stops the shared elapsed timer once all visible progress reaches completion", () => { + expect( + hasLiveLoadingIndicator( + [ + { + streaming: true, + taskItems: [ + { status: "pending" }, + { status: "pending" }, + ], + }, + ], + [ + { status: "completed" }, + { status: "completed" }, + ], + ), + ).toBe(false); + }); + + test("keeps the shared elapsed timer running while any message is mixed/non-complete", () => { + expect( + hasLiveLoadingIndicator( + [ + { + streaming: true, + taskItems: [ + { status: "completed" }, + { status: "completed" }, + ], + }, + { + streaming: true, + taskItems: [ + { status: "completed" }, + { status: "in_progress" }, + ], + }, + ], + ), + ).toBe(true); + }); + + test("treats active background snapshots as still-live timer work", () => { + expect( + hasLiveLoadingIndicator([ + { + streaming: false, + parallelAgents: [backgroundAgent], + taskItems: [ + { status: "completed" }, + { status: "pending" }, + ], + }, + ]), + ).toBe(true); + }); + + test("returns false when messages are fully completed and no background work remains", () => { + expect( + hasLiveLoadingIndicator([ + { + streaming: false, + taskItems: [ + { status: "completed" }, + { status: "completed" }, + ], + }, + ]), + ).toBe(false); + }); +}); diff --git a/src/ui/chat.thinking-meta-routing.test.ts b/src/ui/chat.thinking-meta-routing.test.ts new file mode 100644 index 000000000..d589865cc --- /dev/null +++ b/src/ui/chat.thinking-meta-routing.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { + mergeClosedThinkingSources, + resolveValidatedThinkingMetaEvent, + type ThinkingDropDiagnostics, + type StreamingMeta, +} from "./chat.tsx"; + +const sourceKey = "claude:block:0"; + +function createMeta(overrides?: Partial): StreamingMeta { + return { + outputTokens: 12, + thinkingMs: 250, + thinkingText: "aggregate", + thinkingSourceKey: sourceKey, + thinkingTextBySource: { [sourceKey]: "source thought" }, + thinkingGenerationBySource: { [sourceKey]: 3 }, + thinkingMessageBySource: { [sourceKey]: "msg-1" }, + ...overrides, + }; +} + +function createDiagnostics(): ThinkingDropDiagnostics { + return { + droppedStaleOrClosedThinkingEvents: 0, + droppedMissingBindingThinkingEvents: 0, + }; +} + +describe("resolveValidatedThinkingMetaEvent", () => { + test("returns null when source generation is stale", () => { + const meta = createMeta({ + thinkingGenerationBySource: { [sourceKey]: 2 }, + }); + const diagnostics = createDiagnostics(); + + const event = resolveValidatedThinkingMetaEvent(meta, "msg-1", 3, undefined, diagnostics); + + expect(event).toBeNull(); + expect(diagnostics).toEqual({ + droppedStaleOrClosedThinkingEvents: 1, + droppedMissingBindingThinkingEvents: 0, + }); + }); + + test("returns null when source message binding does not match current message", () => { + const meta = createMeta({ + thinkingMessageBySource: { [sourceKey]: "msg-stale" }, + }); + const diagnostics = createDiagnostics(); + + const event = resolveValidatedThinkingMetaEvent(meta, "msg-1", 3, undefined, diagnostics); + + expect(event).toBeNull(); + expect(diagnostics).toEqual({ + droppedStaleOrClosedThinkingEvents: 1, + droppedMissingBindingThinkingEvents: 0, + }); + }); + + test("increments missing-binding counter when source message binding is absent", () => { + const diagnostics = createDiagnostics(); + + const event = resolveValidatedThinkingMetaEvent(createMeta({ + thinkingMessageBySource: {}, + }), "msg-1", 3, undefined, diagnostics); + + expect(event).toBeNull(); + expect(diagnostics).toEqual({ + droppedStaleOrClosedThinkingEvents: 0, + droppedMissingBindingThinkingEvents: 1, + }); + }); + + test("returns a thinking-meta event when source binding and generation match", () => { + const meta = createMeta(); + const diagnostics = createDiagnostics(); + + const event = resolveValidatedThinkingMetaEvent(meta, "msg-1", 3, undefined, diagnostics); + + expect(event).toEqual({ + thinkingSourceKey: sourceKey, + targetMessageId: "msg-1", + streamGeneration: 3, + thinkingText: "source thought", + }); + expect(diagnostics).toEqual({ + droppedStaleOrClosedThinkingEvents: 0, + droppedMissingBindingThinkingEvents: 0, + }); + }); + + test("returns null when the source has already been finalized", () => { + const meta = createMeta(); + const closedSources = new Set([sourceKey]); + const diagnostics = createDiagnostics(); + + const event = resolveValidatedThinkingMetaEvent(meta, "msg-1", 3, closedSources, diagnostics); + + expect(event).toBeNull(); + expect(diagnostics).toEqual({ + droppedStaleOrClosedThinkingEvents: 1, + droppedMissingBindingThinkingEvents: 0, + }); + }); + + test("rejects late thinking events after finalize closes the source", () => { + const closedSources = mergeClosedThinkingSources(new Set(), createMeta()); + + const lateEvent = resolveValidatedThinkingMetaEvent( + createMeta({ + thinkingTextBySource: { [sourceKey]: "late thought" }, + }), + "msg-1", + 3, + closedSources, + ); + + expect(lateEvent).toBeNull(); + }); + + test("collects source keys for finalize cleanup", () => { + const closedSources = mergeClosedThinkingSources(new Set(["existing:source"]), createMeta({ + thinkingSourceKey: " source:current ", + thinkingTextBySource: { + [sourceKey]: "source thought", + "source:text": "text", + }, + thinkingGenerationBySource: { + "source:generation": 3, + }, + thinkingMessageBySource: { + " source:message ": "msg-2", + }, + })); + + expect(closedSources).toEqual(new Set([ + "existing:source", + sourceKey, + "source:current", + "source:text", + "source:generation", + "source:message", + ])); + }); +}); diff --git a/src/ui/chat.tsx b/src/ui/chat.tsx index 61d238310..c3110db94 100644 --- a/src/ui/chat.tsx +++ b/src/ui/chat.tsx @@ -27,6 +27,7 @@ import { QueueIndicator } from "./components/queue-indicator.tsx"; import { type ParallelAgent, } from "./components/parallel-agents-tree.tsx"; +import { BackgroundAgentFooter } from "./components/background-agent-footer.tsx"; import { TranscriptView } from "./components/transcript-view.tsx"; import { appendCompactionSummary, @@ -70,7 +71,24 @@ import type { AskUserQuestionEventData } from "../graph/index.ts"; import type { AgentType, ModelOperations } from "../models"; import type { McpServerConfig } from "../sdk/types.ts"; import { saveModelPreference, saveReasoningEffortPreference, clearReasoningEffortPreference } from "../utils/settings.ts"; -import { formatDuration } from "./utils/format.ts"; +import { formatDuration, normalizeMarkdownNewlines } from "./utils/format.ts"; +import { + hasLiveLoadingIndicator as hasAnyLiveLoadingIndicator, + shouldShowCompletionSummary, + shouldShowMessageLoadingIndicator, +} from "./utils/loading-state.ts"; +import { + getActiveBackgroundAgents, + resolveBackgroundAgentsForFooter, + formatBackgroundAgentFooterStatus, + isBackgroundAgent, +} from "./utils/background-agent-footer.ts"; +import { BACKGROUND_FOOTER_CONTRACT } from "./utils/background-agent-contracts.ts"; +import { + getBackgroundTerminationDecision, + interruptActiveBackgroundAgents, + isBackgroundTerminationKey, +} from "./utils/background-agent-termination.ts"; import { loadCommandHistory, appendCommandHistory } from "./utils/command-history.ts"; import { getRandomVerb, getRandomCompletionVerb } from "./constants/index.ts"; import type { McpServerToggleMap, McpSnapshotView } from "./utils/mcp-output.ts"; @@ -127,19 +145,35 @@ import { } from "./utils/ralph-task-state.ts"; import type { Part, - AgentPart, - ToolPart, TextPart, - ToolState, TaskListPart, SkillLoadPart, McpSnapshotPart, CompactionPart, PartId, + ToolPart, +} from "./parts/index.ts"; +import { + createPartId, + finalizeStreamingReasoningInMessage, + hasActiveForegroundAgents, + shouldFinalizeDeferredStream, + applyStreamPartEvent, + mergeParallelAgentsIntoParts, + shouldGroupSubagentTrees, + syncToolCallsIntoParts, } from "./parts/index.ts"; -import { createPartId, upsertPart, findLastPartIndex, handleTextDelta, shouldFinalizeOnToolComplete } from "./parts/index.ts"; import { MessageBubbleParts } from "./components/parts/message-bubble-parts.tsx"; + +export { shouldGroupSubagentTrees }; +export { + isTaskProgressComplete, + shouldShowMessageLoadingIndicator, + shouldShowCompletionSummary, +} from "./utils/loading-state.ts"; + + /** * Get autocomplete suggestions for @ mentions (agents and files). * Agent names are searched from the command registry (category "agent"). @@ -393,6 +427,148 @@ export interface StreamingMeta { outputTokens: number; thinkingMs: number; thinkingText: string; + /** Source key that produced the latest thinking-meta emission. */ + thinkingSourceKey?: string; + /** Snapshot of accumulated thinking text keyed by source identity. */ + thinkingTextBySource?: Record; + /** Stream generation/run association keyed by source identity. */ + thinkingGenerationBySource?: Record; + /** Message binding keyed by source identity when metadata is available. */ + thinkingMessageBySource?: Record; +} + +export interface ThinkingDropDiagnostics { + droppedStaleOrClosedThinkingEvents: number; + droppedMissingBindingThinkingEvents: number; +} + +type ThinkingSourceLifecycleAction = "create" | "update" | "finalize" | "drop"; + +const THINKING_SOURCE_DIAGNOSTICS_DEBUG = process.env.ATOMIC_THINKING_DIAGNOSTICS_DEBUG === "1"; + +function createThinkingDropDiagnostics(): ThinkingDropDiagnostics { + return { + droppedStaleOrClosedThinkingEvents: 0, + droppedMissingBindingThinkingEvents: 0, + }; +} + +export function traceThinkingSourceLifecycle( + action: ThinkingSourceLifecycleAction, + sourceKey: string, + detail?: string, +): void { + if (!THINKING_SOURCE_DIAGNOSTICS_DEBUG) { + return; + } + const suffix = detail ? ` ${detail}` : ""; + console.debug(`[thinking-source] ${action} ${sourceKey}${suffix}`); +} + +function addThinkingSourceKey(sourceKeys: Set, key: unknown): void { + if (typeof key !== "string") { + return; + } + const normalized = key.trim(); + if (normalized.length === 0) { + return; + } + sourceKeys.add(normalized); +} + +function addThinkingSourceKeysFromRecord( + sourceKeys: Set, + sourceRecord: Record | undefined, +): void { + if (!sourceRecord) { + return; + } + for (const key of Object.keys(sourceRecord)) { + addThinkingSourceKey(sourceKeys, key); + } +} + +export function mergeClosedThinkingSources( + closedSources: ReadonlySet, + meta: StreamingMeta | null | undefined, +): Set { + const merged = new Set(closedSources); + if (!meta) { + return merged; + } + + addThinkingSourceKey(merged, meta.thinkingSourceKey); + addThinkingSourceKeysFromRecord(merged, meta.thinkingTextBySource); + addThinkingSourceKeysFromRecord(merged, meta.thinkingGenerationBySource); + addThinkingSourceKeysFromRecord(merged, meta.thinkingMessageBySource); + + return merged; +} + +export function resolveValidatedThinkingMetaEvent( + meta: StreamingMeta, + expectedMessageId: string, + currentGeneration: number, + closedSources?: ReadonlySet, + diagnostics?: ThinkingDropDiagnostics, +): { + thinkingSourceKey: string; + targetMessageId: string; + streamGeneration: number; + thinkingText: string; +} | null { + const recordDrop = ( + category: "stale_or_closed" | "missing_binding", + sourceKey: string, + detail: string, + ): null => { + if (category === "stale_or_closed") { + if (diagnostics) { + diagnostics.droppedStaleOrClosedThinkingEvents += 1; + } + traceThinkingSourceLifecycle("drop", sourceKey, `(stale/closed) ${detail}`); + return null; + } + + if (diagnostics) { + diagnostics.droppedMissingBindingThinkingEvents += 1; + } + traceThinkingSourceLifecycle("drop", sourceKey, `(missing-binding) ${detail}`); + return null; + }; + + const sourceKey = typeof meta.thinkingSourceKey === "string" + ? meta.thinkingSourceKey.trim() + : ""; + if (sourceKey.length === 0) { + return null; + } + if (closedSources?.has(sourceKey)) { + return recordDrop("stale_or_closed", sourceKey, "source already finalized"); + } + + const sourceTargetMessageId = meta.thinkingMessageBySource?.[sourceKey]; + if (typeof sourceTargetMessageId !== "string" || sourceTargetMessageId.length === 0) { + return recordDrop("missing_binding", sourceKey, "missing targetMessageId binding"); + } + if (sourceTargetMessageId !== expectedMessageId) { + return recordDrop("stale_or_closed", sourceKey, "targetMessageId mismatch"); + } + + const sourceGeneration = meta.thinkingGenerationBySource?.[sourceKey]; + if (typeof sourceGeneration !== "number" || !Number.isFinite(sourceGeneration)) { + return recordDrop("missing_binding", sourceKey, "missing streamGeneration binding"); + } + if (sourceGeneration !== currentGeneration) { + return recordDrop("stale_or_closed", sourceKey, "streamGeneration mismatch"); + } + + return { + thinkingSourceKey: sourceKey, + targetMessageId: sourceTargetMessageId, + streamGeneration: sourceGeneration, + thinkingText: meta.thinkingTextBySource?.[sourceKey] ?? meta.thinkingText, + }; } /** @@ -492,6 +668,12 @@ export type OnPermissionRequest = ( */ export type OnInterrupt = () => void; +/** + * Callback signature for background-agent termination handler. + * Called when user confirms Ctrl+F termination for active background agents. + */ +export type OnTerminateBackgroundAgents = () => void | Promise; + /** * AskUserQuestion event callback signature. * Called when askUserNode emits a human_input_required signal. @@ -550,6 +732,8 @@ export interface ChatAppProps { * Called to abort the current operation. If not streaming, double press exits. */ onInterrupt?: OnInterrupt; + /** Callback when user confirms Ctrl+F background-agent termination. */ + onTerminateBackgroundAgents?: OnTerminateBackgroundAgents; /** Placeholder text for input */ placeholder?: string; /** Title for the chat window (deprecated, use header props instead) */ @@ -970,20 +1154,6 @@ export function CompletionSummary({ durationMs, outputTokens, thinkingMs }: Comp ); } -/** - * Decide whether to render completion summary metadata for an assistant message. - * Keep this aligned with transcript formatter behavior (>=1s). - */ -export function shouldShowCompletionSummary( - message: { streaming?: boolean; durationMs?: number }, - hasActiveBackgroundAgents: boolean, -): boolean { - return !message.streaming - && !hasActiveBackgroundAgents - && message.durationMs != null - && message.durationMs >= 1000; -} - // ============================================================================ // STREAMING BULLET PREFIX COMPONENT // ============================================================================ @@ -1130,302 +1300,31 @@ export function AtomicHeader({ * - User messages: highlighted inline box with just the text * - Assistant messages: parts-based content rendering */ -function toToolState( - status: ToolExecutionStatus, - output: unknown, - fallbackStartedAt: string, - existingState?: ToolState, -): ToolState { - switch (status) { - case "pending": - return { status: "pending" }; - case "running": - return { - status: "running", - startedAt: existingState?.status === "running" ? existingState.startedAt : fallbackStartedAt, - }; - case "completed": - return { - status: "completed", - output, - durationMs: existingState?.status === "completed" ? existingState.durationMs : 0, - }; - case "error": - return { - status: "error", - error: existingState?.status === "error" - ? existingState.error - : (typeof output === "string" && output.trim() ? output : "Tool execution failed"), - output, - }; - case "interrupted": - return { status: "interrupted", partialOutput: output }; - } -} - -function isActiveParallelAgent(agent: ParallelAgent): boolean { - return ( - agent.status === "running" - || agent.status === "pending" - || agent.status === "background" - ); -} - -function hasSubagentCall(message: Pick): boolean { - if ((message.parallelAgents?.length ?? 0) > 0) return true; - return (message.toolCalls ?? []).some( - (tc) => tc.toolName === "Task" || tc.toolName === "task" - ); -} - -function isGroupedAgentPart(part: Part): part is AgentPart { - return part.type === "agent" && part.parentToolPartId === undefined; -} - -export function shouldGroupSubagentTrees( - message: Pick, - isLastMessage: boolean, -): boolean { - if (!isLastMessage) return false; - const agents = message.parallelAgents ?? []; - if (agents.length === 0) return false; - if (!hasSubagentCall(message)) return false; - - const parts = message.parts ?? []; - let hasSeenTask = false; - for (const part of parts) { - if (part.type === "tool") { - const toolName = part.toolName; - if (toolName === "Task" || toolName === "task") { - hasSeenTask = true; - } else { - return false; - } - } else if (part.type === "text") { - if (hasSeenTask && part.content.trim().length > 0) { - return false; - } - } - } - - if (agents.some(isActiveParallelAgent)) return true; - return (message.parts ?? []).some(isGroupedAgentPart); -} - -function getAgentInsertIndex(parts: Part[]): number { - let lastTaskToolIdx = -1; - let lastToolIdx = -1; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part || part.type !== "tool") continue; - lastToolIdx = i; - const toolName = (part as ToolPart).toolName; - if (toolName === "Task" || toolName === "task") { - lastTaskToolIdx = i; - } - } - - let idx = parts.length; - if (lastTaskToolIdx >= 0) { - idx = lastTaskToolIdx + 1; - } else if (lastToolIdx >= 0) { - idx = lastToolIdx + 1; - } - - while (idx < parts.length && parts[idx]?.type === "agent") { - idx++; - } - return idx; -} - -function insertAgentPartAtTaskBoundary(parts: Part[], agentPart: AgentPart): Part[] { - const insertIdx = getAgentInsertIndex(parts); - return [ - ...parts.slice(0, insertIdx), - agentPart, - ...parts.slice(insertIdx), - ]; -} - -function mergeParallelAgentsIntoParts( - parts: Part[], - parallelAgents: ParallelAgent[], - messageTimestamp: string, - groupIntoSingleTree: boolean, -): Part[] { - const nonAgentParts: Part[] = parts.filter((p) => p.type !== "agent"); - const existingAgentParts = parts.filter((p): p is AgentPart => p.type === "agent"); - - if (parallelAgents.length === 0) { - return nonAgentParts; - } - - if (groupIntoSingleTree) { - const existingGroupedPart = existingAgentParts.find((p) => p.parentToolPartId === undefined) ?? existingAgentParts[0]; - const groupedPart: AgentPart = { - id: existingGroupedPart?.id ?? createPartId(), - type: "agent", - agents: parallelAgents, - parentToolPartId: undefined, - createdAt: existingGroupedPart?.createdAt ?? messageTimestamp, - }; - return insertAgentPartAtTaskBoundary(nonAgentParts, groupedPart); - } - - const existingByParent = new Map(); - for (const existing of existingAgentParts) { - if (!existingByParent.has(existing.parentToolPartId)) { - existingByParent.set(existing.parentToolPartId, existing); - } - } - - const agentsByToolCall = new Map(); - for (const agent of parallelAgents) { - const toolCallId = agent.taskToolCallId; - const grouped = agentsByToolCall.get(toolCallId) ?? []; - grouped.push(agent); - agentsByToolCall.set(toolCallId, grouped); - } - - const finalParts: Part[] = []; - const handledToolCallIds = new Set(); - - let currentGroup: ToolPart[] = []; - let currentGroupAgents: ParallelAgent[] = []; - - for (let i = 0; i < nonAgentParts.length; i++) { - const part = nonAgentParts[i]; - if (!part) continue; - finalParts.push(part); - - if (part.type === "tool" && ((part as ToolPart).toolName === "Task" || (part as ToolPart).toolName === "task")) { - const toolPart = part as ToolPart; - currentGroup.push(toolPart); - const agents = agentsByToolCall.get(toolPart.toolCallId); - if (agents) { - currentGroupAgents.push(...agents); - if (toolPart.toolCallId) { - handledToolCallIds.add(toolPart.toolCallId); - } - } - } - - let endsGroup = false; - if (currentGroup.length > 0) { - if (i === nonAgentParts.length - 1) { - endsGroup = true; - } else { - const nextPart = nonAgentParts[i + 1]; - if (!nextPart) { - endsGroup = true; - } else if (nextPart.type === "tool") { - const toolName = (nextPart as ToolPart).toolName; - if (toolName !== "Task" && toolName !== "task") { - endsGroup = true; - } - } else if (nextPart.type === "text") { - if ((nextPart as TextPart).content.trim().length > 0) { - endsGroup = true; - } - } - } - } - - if (endsGroup) { - if (currentGroupAgents.length > 0) { - const lastToolPart = currentGroup[currentGroup.length - 1]; - if (lastToolPart) { - const parentToolPartId = lastToolPart.id; - const existingPart = existingByParent.get(parentToolPartId); - - const agentPart: AgentPart = { - id: existingPart?.id ?? createPartId(), - type: "agent", - agents: currentGroupAgents, - parentToolPartId, - createdAt: existingPart?.createdAt ?? messageTimestamp, - }; - finalParts.push(agentPart); - } - } - currentGroup = []; - currentGroupAgents = []; - } - } - - const remainingAgents: ParallelAgent[] = []; - for (const [toolCallId, agents] of agentsByToolCall) { - if (!toolCallId || !handledToolCallIds.has(toolCallId)) { - remainingAgents.push(...agents); - } - } - - if (remainingAgents.length > 0) { - const existingPart = existingByParent.get(undefined); - const fallbackPart: AgentPart = { - id: existingPart?.id ?? createPartId(), - type: "agent", - agents: remainingAgents, - parentToolPartId: undefined, - createdAt: existingPart?.createdAt ?? messageTimestamp, - }; - // Insert remaining agents at the task boundary (end of nonAgentParts) - const insertIdx = getAgentInsertIndex(finalParts); - finalParts.splice(insertIdx, 0, fallbackPart); - } - - return finalParts; -} - function getRenderableAssistantParts( message: ChatMessage, taskItemsToShow: TaskItem[] | undefined, inlineTaskExpansion: boolean | undefined, isLastMessage: boolean, + hideAskUserQuestion: boolean, ): Part[] { let parts = [...(message.parts ?? [])]; // Keep ToolPart state synchronized with the source toolCalls array. - const toolCalls = message.toolCalls ?? []; - for (const tc of toolCalls) { - const existingIdx = parts.findIndex( - (p) => p.type === "tool" && (p as ToolPart).toolCallId === tc.id - ); - - if (existingIdx >= 0) { - const existing = parts[existingIdx] as ToolPart; - parts[existingIdx] = { - ...existing, - toolName: tc.toolName, - input: tc.input, - output: tc.output, - hitlResponse: tc.hitlResponse, - state: toToolState(tc.status, tc.output, message.timestamp, existing.state), - }; - continue; - } - - parts.push({ - id: `tool-${message.id}-${tc.id}`, - type: "tool", - toolCallId: tc.id, - toolName: tc.toolName, - input: tc.input, - output: tc.output, - hitlResponse: tc.hitlResponse, - state: toToolState(tc.status, tc.output, message.timestamp), - createdAt: message.timestamp, - } satisfies ToolPart); - } + parts = syncToolCallsIntoParts(parts, message.toolCalls ?? [], message.timestamp, message.id); + // Only merge parallel agents into parts if agent parts don't already exist. + // During streaming, agents are already added to parts via applyStreamPartEvent. + // This check prevents duplicate agent trees from being rendered. if (message.parallelAgents && message.parallelAgents.length > 0) { - parts = mergeParallelAgentsIntoParts( - parts, - message.parallelAgents, - message.timestamp, - shouldGroupSubagentTrees(message, isLastMessage), - ); + const hasExistingAgentParts = parts.some((part) => part.type === "agent"); + if (!hasExistingAgentParts) { + parts = mergeParallelAgentsIntoParts( + parts, + message.parallelAgents, + message.timestamp, + shouldGroupSubagentTrees(message, isLastMessage), + ); + } } const shouldRenderInlineTasks = taskItemsToShow && taskItemsToShow.length > 0 && inlineTaskExpansion !== false; @@ -1521,9 +1420,20 @@ function getRenderableAssistantParts( } } + if (hideAskUserQuestion) { + parts = parts.filter((part) => { + if (part.type !== "tool") return true; + const toolPart = part as ToolPart; + const isHitlTool = toolPart.toolName === "AskUserQuestion" + || toolPart.toolName === "question" + || toolPart.toolName === "ask_user"; + return !(isHitlTool && toolPart.pendingQuestion); + }); + } + return parts; } -export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestion: _hideAskUserQuestion = false, hideLoading = false, todoItems, tasksExpanded = false, inlineTasksEnabled = true, ralphSessionDir, showTodoPanel = true, elapsedMs, collapsed = false, streamingMeta }: MessageBubbleProps): React.ReactNode { +export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestion = false, hideLoading = false, todoItems, tasksExpanded = false, inlineTasksEnabled = true, ralphSessionDir, showTodoPanel = true, elapsedMs, collapsed = false, streamingMeta }: MessageBubbleProps): React.ReactNode { const themeColors = useThemeColors(); // Collapsed mode: show compact single-line summary for each message @@ -1606,13 +1516,19 @@ export function MessageBubble({ message, isLast, syntaxStyle, hideAskUserQuestio const inlineTaskExpansion = shouldRenderInlineTasks ? (tasksExpanded || undefined) : false; const renderableMessage = { ...message, - parts: getRenderableAssistantParts(message, taskItemsToShow, inlineTaskExpansion, Boolean(isLast)), + parts: getRenderableAssistantParts( + message, + taskItemsToShow, + inlineTaskExpansion, + Boolean(isLast), + hideAskUserQuestion, + ), }; // Detect active background agents on this message - const hasActiveBackgroundAgents = (message.parallelAgents ?? []).some( - (a) => a.background && a.status === "background" - ); + const hasActiveBackgroundAgents = getActiveBackgroundAgents(message.parallelAgents ?? []).length > 0; + const liveTaskItems = message.streaming ? todoItems : message.taskItems; + const showLoadingIndicator = shouldShowMessageLoadingIndicator(message, liveTaskItems); return ( )} - {/* Loading spinner — shown during streaming OR while background agents are still running */} - {(message.streaming || hasActiveBackgroundAgents) && !hideLoading && ( + {/* Loading spinner while work is active (stops once task progress is fully complete). */} + {showLoadingIndicator && !hideLoading && ( 0 ? SPACING.ELEMENT : SPACING.NONE}> | null>(null); + // Ctrl+F confirmation state for terminating active background agents. + const [backgroundTerminationCount, setBackgroundTerminationCount] = useState(0); + const [ctrlFPressed, setCtrlFPressed] = useState(false); + const backgroundTerminationTimeoutRef = useRef | null>(null); + const backgroundTerminationInFlightRef = useRef(false); + const clearBackgroundTerminationConfirmation = useCallback(() => { + setBackgroundTerminationCount(0); + setCtrlFPressed(false); + if (backgroundTerminationTimeoutRef.current) { + clearTimeout(backgroundTerminationTimeoutRef.current); + backgroundTerminationTimeoutRef.current = null; + } + }, []); + // Separate state for showing Ctrl+C warning (controlled by parent via signal handler) const [ctrlCPressed, setCtrlCPressed] = useState(false); const ctrlCTimeoutRef = useRef | null>(null); @@ -1923,6 +1854,11 @@ export function ChatApp({ const isStreamingRef = useRef(false); // Ref to keep a synchronous copy of streaming meta for baking into message on completion const streamingMetaRef = useRef(null); + // Source keys closed by stream finalize/interrupt/error. + // Used to drop late thinking events that arrive after stream teardown. + const closedThinkingSourcesRef = useRef>(new Set()); + // Cumulative drop counters for rejected thinking-meta events. + const thinkingDropDiagnosticsRef = useRef(createThinkingDropDiagnostics()); // Ref to track whether an interrupt (ESC/Ctrl+C) already finalized agents. // Prevents handleComplete from overwriting interrupted agents with "completed". const wasInterruptedRef = useRef(false); @@ -1932,6 +1868,9 @@ export function ChatApp({ // When the last agent finishes, the stored function is called to finalize // the message and process the next queued message. const pendingCompleteRef = useRef<(() => void) | null>(null); + // Small grace timer before running deferred completion. If new chunks arrive, + // we cancel this timer so the main stream can continue after sub-agent handoff. + const deferredCompleteTimeoutRef = useRef | null>(null); // Tracks whether the current stream is an @mention-only stream (no SDK onComplete). // Prevents the agent-only completion path from firing for SDK-spawned sub-agents. const isAgentOnlyStreamRef = useRef(false); @@ -1955,6 +1894,63 @@ export function ChatApp({ // Ref for deferred queue dispatch without circular callback deps const dispatchQueuedMessageRef = useRef<(queuedMessage: QueuedMessage) => void>(() => {}); + const clearDeferredCompletion = useCallback(() => { + pendingCompleteRef.current = null; + if (deferredCompleteTimeoutRef.current) { + clearTimeout(deferredCompleteTimeoutRef.current); + deferredCompleteTimeoutRef.current = null; + } + }, []); + + const resetThinkingSourceTracking = useCallback(() => { + closedThinkingSourcesRef.current = new Set(); + streamingMetaRef.current = null; + setStreamingMeta(null); + }, []); + + const finalizeThinkingSourceTracking = useCallback(() => { + const previousClosedSources = closedThinkingSourcesRef.current; + const mergedClosedSources = mergeClosedThinkingSources( + previousClosedSources, + streamingMetaRef.current, + ); + for (const sourceKey of mergedClosedSources) { + if (!previousClosedSources.has(sourceKey)) { + traceThinkingSourceLifecycle("finalize", sourceKey, "chat stream teardown"); + } + } + closedThinkingSourcesRef.current = mergedClosedSources; + streamingMetaRef.current = null; + setStreamingMeta(null); + }, []); + + /** + * Helper function to separate and interrupt agents. + * Ctrl+C should ONLY interrupt foreground agents, preserving background agents. + * Returns { interruptedAgents: all agents with foreground ones marked as interrupted, + * remainingLiveAgents: only background agents that should stay in refs } + */ + const separateAndInterruptAgents = useCallback((agents: ParallelAgent[]) => { + const backgroundAgents = agents.filter(isBackgroundAgent); + const foregroundAgents = agents.filter(a => !isBackgroundAgent(a)); + + // Only interrupt foreground agents + const interruptedAgents = [ + ...foregroundAgents.map((a) => + a.status === "running" || a.status === "pending" + ? { ...a, status: "interrupted" as const, currentTool: undefined, durationMs: Date.now() - new Date(a.startedAt).getTime() } + : a + ), + // Keep background agents as-is + ...backgroundAgents, + ]; + + return { + interruptedAgents, + remainingLiveAgents: backgroundAgents, + }; + }, []); + const continueQueuedConversation = useCallback(() => { dispatchNextQueuedMessage( () => messageQueue.dequeue(), @@ -1980,13 +1976,14 @@ export function ChatApp({ setMessages(next); }, []); - // Live elapsed time counter for streaming indicator - // Also keeps running while background agents are active (stream ended but work continues) - const hasActiveBackgroundAgentsGlobal = parallelAgents.some( - (a) => a.background && a.status === "background" + const hasLiveLoadingIndicator = useMemo( + () => hasAnyLiveLoadingIndicator(messages, todoItems), + [messages, todoItems], ); + + // Live elapsed time counter for the visible loading indicator. useEffect(() => { - if ((!isStreaming && !hasActiveBackgroundAgentsGlobal) || !streamingStartRef.current) { + if (!hasLiveLoadingIndicator || !streamingStartRef.current) { setStreamingElapsedMs(0); return; } @@ -1997,13 +1994,33 @@ export function ChatApp({ } }, 1000); return () => clearInterval(interval); - }, [isStreaming, hasActiveBackgroundAgentsGlobal]); + }, [hasLiveLoadingIndicator]); // Keep todoItemsRef in sync with state for use in completion callbacks useEffect(() => { todoItemsRef.current = todoItems; }, [todoItems]); + // Keep parallelAgentsRef synchronized with local state updates. + useEffect(() => { + parallelAgentsRef.current = parallelAgents; + }, [parallelAgents]); + + // Auto-clear Ctrl+F confirmation when no active background agents remain. + useEffect(() => { + if ( + getActiveBackgroundAgents(parallelAgents).length === 0 + && (backgroundTerminationCount > 0 || ctrlFPressed) + ) { + clearBackgroundTerminationConfirmation(); + } + }, [ + parallelAgents, + backgroundTerminationCount, + ctrlFPressed, + clearBackgroundTerminationConfirmation, + ]); + // Keep ralph session refs in sync with state useEffect(() => { ralphSessionDirRef.current = ralphSessionDir; @@ -2140,13 +2157,14 @@ export function ChatApp({ return prev.map((msg: ChatMessage) => msg.id === failedMessageId - ? { ...msg, streaming: false, modelId: currentModelRef.current } + ? { ...finalizeStreamingReasoningInMessage(msg), streaming: false, modelId: currentModelRef.current } : msg ); }); } stopSharedStreamState(); + finalizeThinkingSourceTracking(); const resolver = streamCompletionResolverRef.current; streamCompletionResolverRef.current = null; @@ -2154,7 +2172,7 @@ export function ChatApp({ if (resolver) { resolver({ content: lastStreamingContentRef.current, wasInterrupted: true }); } - }, [setMessagesWindowed, stopSharedStreamState]); + }, [setMessagesWindowed, stopSharedStreamState, finalizeThinkingSourceTracking]); const enqueueShortcutLabel = useMemo(() => getEnqueueShortcutLabel(), []); @@ -2208,6 +2226,10 @@ export function ChatApp({ runningAskQuestionToolIdsRef.current.add(toolId); } + if (toolName === "AskUserQuestion" || toolName === "question" || toolName === "ask_user") { + activeHitlToolCallIdRef.current = toolId; + } + // Add tool call to current streaming message. // If a tool call with the same ID already exists, update its input // (SDKs may send an initial event with empty input followed by a @@ -2217,57 +2239,12 @@ export function ChatApp({ setMessagesWindowed((prev) => prev.map((msg) => { if (msg.id === messageId) { - const existing = msg.toolCalls?.find(tc => tc.id === toolId); - if (existing) { - // Update existing tool call's input with the latest values - return { - ...msg, - toolCalls: msg.toolCalls?.map(tc => - tc.id === toolId ? { ...tc, input } : tc - ), - }; - } - - const newToolCall: MessageToolCall = { - id: toolId, + return applyStreamPartEvent(msg, { + type: "tool-start", + toolId, toolName, input, - status: "running", - }; - - // Track active HITL tool call for answer storage - if (toolName === "AskUserQuestion" || toolName === "question" || toolName === "ask_user") { - activeHitlToolCallIdRef.current = toolId; - } - - // Create updated message with new tool call - const updatedMsg = { - ...msg, - toolCalls: [...(msg.toolCalls || []), newToolCall], - }; - - // *** DUAL POPULATION: Create ToolPart and finalize TextPart *** - // Finalize any streaming TextPart - const parts = [...(msg.parts ?? [])]; - const lastTextIdx = findLastPartIndex(parts, p => p.type === "text" && (p as TextPart).isStreaming); - if (lastTextIdx >= 0) { - parts[lastTextIdx] = { ...parts[lastTextIdx], isStreaming: false } as TextPart; - } - - // Create ToolPart - const toolPart: ToolPart = { - id: createPartId(), - type: "tool", - toolCallId: toolId, - toolName: toolName, - input: input, - state: { status: "running", startedAt: new Date().toISOString() }, - createdAt: new Date().toISOString(), - }; - - updatedMsg.parts = upsertPart(parts, toolPart); - - return updatedMsg; + }); } return msg; }) @@ -2369,85 +2346,15 @@ export function ChatApp({ if (messageId) { setMessagesWindowed((prev) => { const updated = prev.map((msg) => { - if (msg.id === messageId && msg.toolCalls) { - const updatedMsg = { - ...msg, - toolCalls: msg.toolCalls.map((tc) => { - if (tc.id === toolId) { - // Merge input if provided and current input is empty - const updatedInput = (input && Object.keys(tc.input).length === 0) - ? input - : tc.input; - const isHitlTool = tc.toolName === "AskUserQuestion" - || tc.toolName === "question" - || tc.toolName === "ask_user"; - let mergedOutput = output !== undefined ? output : tc.output; - - // Preserve the canonical HITL answer text if tool.complete arrives later. - if (isHitlTool && tc.hitlResponse) { - const outputObject = ( - mergedOutput !== null - && typeof mergedOutput === "object" - ) - ? mergedOutput as Record - : {}; - mergedOutput = { - ...outputObject, - answer: tc.hitlResponse.answerText, - cancelled: tc.hitlResponse.cancelled, - responseMode: tc.hitlResponse.responseMode, - displayText: tc.hitlResponse.displayText, - }; - } - return { - ...tc, - input: updatedInput, - output: mergedOutput, - status: success ? "completed" as const : "error" as const, - }; - } - return tc; - }), - }; - - // *** DUAL POPULATION: Update ToolPart state *** - // Find matching ToolPart by toolCallId - const parts = [...(msg.parts ?? [])]; - const toolPartIdx = parts.findIndex( - p => p.type === "tool" && (p as ToolPart).toolCallId === toolId - ); - - if (toolPartIdx >= 0) { - const toolPart = parts[toolPartIdx] as ToolPart; - - // Compute durationMs from startedAt if available - let durationMs = 0; - if (toolPart.state.status === "running") { - durationMs = Date.now() - new Date(toolPart.state.startedAt).getTime(); - } - - // Merge input if provided (handles late input from OpenCode) - const updatedInput = (input && Object.keys(toolPart.input).length === 0) - ? input - : toolPart.input; - - // Create new state based on success/error - const newState: ToolState = success - ? { status: "completed", output, durationMs } - : { status: "error", error: error || "Unknown error", output }; - - // Update the ToolPart - parts[toolPartIdx] = { - ...toolPart, - input: updatedInput, - output, - state: newState, - }; - - updatedMsg.parts = parts; - } - - return updatedMsg; + if (msg.id === messageId) { + return applyStreamPartEvent(msg, { + type: "tool-complete", + toolId, + output, + success, + error, + input, + }); } return msg; }); @@ -2544,6 +2451,12 @@ export function ChatApp({ clearTimeout(interruptTimeoutRef.current); } clearAutoCompactionTimeout(); + if (deferredCompleteTimeoutRef.current) { + clearTimeout(deferredCompleteTimeoutRef.current); + } + if (backgroundTerminationTimeoutRef.current) { + clearTimeout(backgroundTerminationTimeoutRef.current); + } }; }, [clearAutoCompactionTimeout]); @@ -2568,14 +2481,14 @@ export function ChatApp({ const promptToSend = workflowState.initialPrompt!; // Clear stale todo items from previous turn when not in /ralph resetTodoItemsForNewStream(); + clearDeferredCompletion(); // Increment stream generation so stale handleComplete callbacks become no-ops const currentGeneration = ++streamGenerationRef.current; // Set streaming BEFORE calling onStreamMessage to prevent race conditions setIsStreaming(true); isStreamingRef.current = true; - streamingMetaRef.current = null; - setStreamingMeta(null); + resetThinkingSourceTracking(); // Call the stream handler - this is async but we don't await it // The callbacks will handle state updates @@ -2585,25 +2498,19 @@ export function ChatApp({ (chunk) => { // Drop chunks from stale streams (round-robin replaced this stream) if (!isCurrentStreamCallback(streamGenerationRef.current, currentGeneration)) return; + if (pendingCompleteRef.current) { + clearDeferredCompletion(); + } setMessagesWindowed((prev) => { const lastMsg = prev[prev.length - 1]; if (lastMsg && lastMsg.role === "assistant" && lastMsg.streaming) { - // Dual population: update both legacy content and parts array - const withParts = handleTextDelta(lastMsg, chunk); return [ ...prev.slice(0, -1), - { ...lastMsg, content: lastMsg.content + chunk, parts: withParts.parts }, + applyStreamPartEvent(lastMsg, { type: "text-delta", delta: chunk }), ]; } // Create new streaming message - const newMessage: ChatMessage = { - id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - role: "assistant", - content: chunk, - timestamp: new Date().toISOString(), - streaming: true, - toolCalls: [], - }; + const newMessage = createMessage("assistant", chunk, true); streamingMessageIdRef.current = newMessage.id; isAgentOnlyStreamRef.current = false; return [...prev, newMessage]; @@ -2629,7 +2536,13 @@ export function ChatApp({ if (lastMsg && lastMsg.role === "assistant" && lastMsg.streaming) { return [ ...prev.slice(0, -1), - { ...lastMsg, streaming: false, completedAt: new Date(), parallelAgents: finalizedAgents, taskItems: snapshotTaskItems(todoItemsRef.current) as TaskItem[] | undefined }, + { + ...finalizeStreamingReasoningInMessage(lastMsg), + streaming: false, + completedAt: new Date(), + parallelAgents: finalizedAgents, + taskItems: snapshotTaskItems(todoItemsRef.current) as TaskItem[] | undefined, + }, ]; } return prev; @@ -2643,7 +2556,12 @@ export function ChatApp({ if (lastMsg && lastMsg.role === "assistant" && lastMsg.streaming) { return [ ...prev.slice(0, -1), - { ...lastMsg, streaming: false, completedAt: new Date(), taskItems: snapshotTaskItems(todoItemsRef.current) as TaskItem[] | undefined }, + { + ...finalizeStreamingReasoningInMessage(lastMsg), + streaming: false, + completedAt: new Date(), + taskItems: snapshotTaskItems(todoItemsRef.current) as TaskItem[] | undefined, + }, ]; } return prev; @@ -2651,11 +2569,37 @@ export function ChatApp({ return currentAgents; }); stopSharedStreamState(); + finalizeThinkingSourceTracking(); }, // onMeta: update streaming metadata (meta: StreamingMeta) => { streamingMetaRef.current = meta; setStreamingMeta(meta); + const messageId = streamingMessageIdRef.current; + if (!messageId) return; + const thinkingMetaEvent = resolveValidatedThinkingMetaEvent( + meta, + messageId, + currentGeneration, + closedThinkingSourcesRef.current, + thinkingDropDiagnosticsRef.current, + ); + if (!thinkingMetaEvent) return; + setMessagesWindowed((prev: ChatMessage[]) => + prev.map((msg: ChatMessage) => + msg.id === messageId + ? applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: thinkingMetaEvent.thinkingSourceKey, + targetMessageId: thinkingMetaEvent.targetMessageId, + streamGeneration: thinkingMetaEvent.streamGeneration, + thinkingMs: meta.thinkingMs, + thinkingText: thinkingMetaEvent.thinkingText, + includeReasoningPart: true, + }) + : msg + ) + ); } )).catch((error) => { handleStreamStartupError(error, currentGeneration); @@ -2664,13 +2608,14 @@ export function ChatApp({ // Prevent unhandled errors from crashing the TUI console.error("[workflow auto-start] Error during context clear or streaming:", error); stopSharedStreamState(); + finalizeThinkingSourceTracking(); } })(); }, 100); return () => clearTimeout(timeoutId); } - }, [workflowState.workflowActive, workflowState.initialPrompt, isStreaming, onStreamMessage, handleStreamStartupError, stopSharedStreamState]); + }, [workflowState.workflowActive, workflowState.initialPrompt, isStreaming, onStreamMessage, handleStreamStartupError, stopSharedStreamState, finalizeThinkingSourceTracking, resetThinkingSourceTracking]); // Reset workflow started ref when workflow becomes inactive useEffect(() => { @@ -2739,7 +2684,7 @@ export function ChatApp({ options: Array<{ label: string; value: string; description?: string }>, respond: (answer: string | string[]) => void, header?: string, - _toolCallId?: string + toolCallId?: string ) => { // During Ralph autonomous execution, auto-approve permission requests if (workflowState.workflowActive) { @@ -2766,7 +2711,33 @@ export function ChatApp({ // Show the question dialog (custom UI overlay) handleHumanInputRequired(userQuestion); - }, [handleHumanInputRequired, workflowState.workflowActive]); + + const targetToolId = toolCallId ?? activeHitlToolCallIdRef.current; + if (targetToolId) { + setMessagesWindowed((prev) => + prev.map((msg) => { + const hasToolCall = msg.toolCalls?.some((toolCall) => toolCall.id === targetToolId) ?? false; + const hasToolPart = msg.parts?.some( + (part) => part.type === "tool" && part.toolCallId === targetToolId, + ) ?? false; + if (!hasToolCall && !hasToolPart) return msg; + + return applyStreamPartEvent(msg, { + type: "tool-hitl-request", + toolId: targetToolId, + request: { + requestId, + header: header || toolName, + question, + options, + multiSelect: false, + respond, + }, + }); + }), + ); + } + }, [handleHumanInputRequired, workflowState.workflowActive, setMessagesWindowed]); // Store the requestId for askUserNode questions (for workflow resumption) const askUserQuestionRequestIdRef = useRef(null); @@ -2856,14 +2827,11 @@ export function ChatApp({ setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage, index: number) => { if (msg.id === messageId && msg.streaming) { - const updatedParts = mergeParallelAgentsIntoParts( - msg.parts ?? [], - parallelAgents, - msg.timestamp, - shouldGroupSubagentTrees({ ...msg, parallelAgents }, index === prev.length - 1), - ); - - return { ...msg, parallelAgents, parts: updatedParts }; + return applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: parallelAgents, + isLastMessage: index === prev.length - 1, + }); } return msg; }) @@ -2877,22 +2845,17 @@ export function ChatApp({ setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage, index: number) => { if (msg.id === bgMsgId) { - const updatedParts = mergeParallelAgentsIntoParts( - msg.parts ?? [], - parallelAgents, - msg.timestamp, - shouldGroupSubagentTrees({ ...msg, parallelAgents }, index === prev.length - 1), - ); - - return { ...msg, parallelAgents, parts: updatedParts }; + return applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: parallelAgents, + isLastMessage: index === prev.length - 1, + }); } return msg; }) ); // Clear refs once all background agents have reached terminal state - const hasActiveBg = parallelAgents.some( - (a) => a.background && a.status === "background" - ); + const hasActiveBg = getActiveBackgroundAgents(parallelAgents).length > 0; if (!hasActiveBg) { backgroundAgentMessageIdRef.current = null; streamingStartRef.current = null; @@ -2904,19 +2867,34 @@ export function ChatApp({ // This fires whenever parallelAgents changes (from SDK events OR interrupt handler) // or when tools complete (via toolCompletionVersion). useEffect(() => { - const hasActive = parallelAgents.some( - (a) => a.status === "running" || a.status === "pending" + const canFinalizeDeferred = shouldFinalizeDeferredStream( + parallelAgents, + hasRunningToolRef.current, ); - // Also check if tools are still running. - // Background agents are excluded — they must not block spinner - // termination. They continue running after the main stream ends - // and their progress is tracked separately via hasActiveBackgroundAgents. - if (hasActive || hasRunningToolRef.current) return; + if (!canFinalizeDeferred) { + if (deferredCompleteTimeoutRef.current) { + clearTimeout(deferredCompleteTimeoutRef.current); + deferredCompleteTimeoutRef.current = null; + } + return; + } if (pendingCompleteRef.current) { - const complete = pendingCompleteRef.current; - pendingCompleteRef.current = null; - complete(); + if (deferredCompleteTimeoutRef.current) { + return; + } + const pendingComplete = pendingCompleteRef.current; + deferredCompleteTimeoutRef.current = setTimeout(() => { + deferredCompleteTimeoutRef.current = null; + if (pendingCompleteRef.current !== pendingComplete) { + return; + } + if (!shouldFinalizeDeferredStream(parallelAgentsRef.current, hasRunningToolRef.current)) { + return; + } + pendingCompleteRef.current = null; + pendingComplete(); + }, 0); return; } @@ -2953,15 +2931,15 @@ export function ChatApp({ // Collect sub-agent result text into the message content so it // renders in the main conversation (like Claude Code's Task tool). const agentOutputParts = finalizedAgents - .filter((a) => a.result && a.result.trim()) - .map((a) => a.result!.trim()); + .map((a) => (typeof a.result === "string" ? normalizeMarkdownNewlines(a.result) : "")) + .filter((result) => result.length > 0); const agentOutput = agentOutputParts.join("\n\n"); setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage) => msg.id === messageId ? { - ...msg, + ...finalizeStreamingReasoningInMessage(msg), content: (msg.toolCalls?.length ?? 0) > 0 ? msg.content : (agentOutput || msg.content), streaming: false, completedAt: new Date(), @@ -2972,8 +2950,9 @@ export function ChatApp({ : msg ) ); + finalizeThinkingSourceTracking(); // Keep background agents in live state for post-stream completion tracking - const remainingBg = parallelAgents.filter((a) => a.background && a.status === "background"); + const remainingBg = getActiveBackgroundAgents(parallelAgents); if (remainingBg.length > 0 && messageId) { stopSharedStreamState({ preserveStreamingStart: true }); backgroundAgentMessageIdRef.current = messageId; @@ -2989,7 +2968,7 @@ export function ChatApp({ // the SDK handleComplete callback, so we must dequeue here. continueQueuedConversation(); } - }, [parallelAgents, continueQueuedConversation, toolCompletionVersion, messages, stopSharedStreamState]); + }, [parallelAgents, continueQueuedConversation, toolCompletionVersion, messages, stopSharedStreamState, finalizeThinkingSourceTracking]); // Initialize SubagentGraphBridge when createSubagentSession is available useEffect(() => { @@ -3066,54 +3045,17 @@ export function ChatApp({ setMessagesWindowed((prev) => prev.map((msg) => { - // Update legacy toolCalls array - const hasMatchingToolCall = msg.toolCalls?.some(tc => tc.id === hitlToolId); - const updatedToolCalls = hasMatchingToolCall - ? msg.toolCalls!.map((tc) => - tc.id === hitlToolId - ? { - ...tc, - output: { - ...(tc.output && typeof tc.output === "object" - ? tc.output as Record - : {}), - answer: normalizedHitl.answerText, - cancelled: normalizedHitl.cancelled, - responseMode: normalizedHitl.responseMode, - displayText: normalizedHitl.displayText, - }, - hitlResponse: normalizedHitl, - } - : tc - ) - : msg.toolCalls; - - // Update parts array: clear pendingQuestion and set hitlResponse on matching ToolPart - let updatedParts = msg.parts; - if (msg.parts && msg.parts.length > 0) { - const parts = [...msg.parts]; - const toolPartIdx = parts.findIndex( - p => p.type === "tool" && (p as ToolPart).toolCallId === hitlToolId - ); - - if (toolPartIdx >= 0) { - const toolPart = parts[toolPartIdx] as ToolPart; - parts[toolPartIdx] = { - ...toolPart, - pendingQuestion: undefined, // Clear the pending question - hitlResponse: normalizedHitl, // Set the response - }; - updatedParts = parts; - } - } - - // Return updated message if anything changed - if (updatedToolCalls !== msg.toolCalls || updatedParts !== msg.parts) { - return { - ...msg, - toolCalls: updatedToolCalls, - parts: updatedParts, - }; + const hasMatchingToolCall = msg.toolCalls?.some((toolCall) => toolCall.id === hitlToolId) ?? false; + const hasMatchingToolPart = msg.parts?.some( + (part) => part.type === "tool" && part.toolCallId === hitlToolId, + ) ?? false; + + if (hasMatchingToolCall || hasMatchingToolPart) { + return applyStreamPartEvent(msg, { + type: "tool-hitl-response", + toolId: hitlToolId, + response: normalizedHitl, + }); } return msg; }) @@ -3220,9 +3162,8 @@ export function ChatApp({ isAgentOnlyStreamRef.current = true; isStreamingRef.current = true; streamingStartRef.current = Date.now(); - streamingMetaRef.current = null; + resetThinkingSourceTracking(); setIsStreaming(true); - setStreamingMeta(null); resetTodoItemsForNewStream(); setMessagesWindowed((prev: ChatMessage[]) => [...prev, assistantMsg]); @@ -3238,7 +3179,7 @@ export function ChatApp({ queuedMessage.skipUserMessage ? { skipUserMessage: true } : undefined ); } - }, []); + }, [resetThinkingSourceTracking, resetTodoItemsForNewStream]); useEffect(() => { dispatchQueuedMessageRef.current = dispatchQueuedMessage; @@ -3513,7 +3454,7 @@ export function ChatApp({ setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage) => msg.id === prevStreamingId && msg.streaming - ? { ...msg, streaming: false } + ? { ...finalizeStreamingReasoningInMessage(msg), streaming: false } : msg ).filter((msg: ChatMessage) => // Remove the previous placeholder if it has no content @@ -3527,14 +3468,14 @@ export function ChatApp({ isStreamingRef.current = true; setIsStreaming(true); streamingStartRef.current = Date.now(); - streamingMetaRef.current = null; - setStreamingMeta(null); + resetThinkingSourceTracking(); // Clear stale todo items from previous turn when not in /ralph resetTodoItemsForNewStream(); // Reset streaming content accumulator for step 1 → step 2 task parsing lastStreamingContentRef.current = ""; // Reset tool tracking for the new stream hasRunningToolRef.current = false; + clearDeferredCompletion(); // Create placeholder assistant message for the response const assistantMessage = createMessage("assistant", "", true); @@ -3546,6 +3487,12 @@ export function ChatApp({ if (!isStreamingRef.current) return; // Drop chunks from stale streams (round-robin replaced this stream) if (!isCurrentStreamCallback(streamGenerationRef.current, currentGeneration)) return; + // If completion was deferred waiting on sub-agents/tools but the + // model resumes emitting chunks, cancel the deferred completion and + // keep the current stream alive. + if (pendingCompleteRef.current) { + clearDeferredCompletion(); + } // Accumulate content for step 1 → step 2 task parsing lastStreamingContentRef.current += chunk; // Skip rendering in message when content is hidden (e.g., step 1 JSON output) @@ -3555,9 +3502,7 @@ export function ChatApp({ setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage) => { if (msg.id === messageId) { - // Dual population: update both legacy content and parts array - const withParts = handleTextDelta(msg, chunk); - return { ...msg, content: msg.content + chunk, parts: withParts.parts }; + return applyStreamPartEvent(msg, { type: "text-delta", delta: chunk }); } return msg; }) @@ -3583,15 +3528,22 @@ export function ChatApp({ setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage) => msg.id === messageId - ? { ...msg, streaming: false, durationMs, modelId: currentModelRef.current, outputTokens: finalMeta?.outputTokens, thinkingMs: finalMeta?.thinkingMs, thinkingText: finalMeta?.thinkingText || undefined } + ? { + ...finalizeStreamingReasoningInMessage(msg), + streaming: false, + durationMs, + modelId: currentModelRef.current, + outputTokens: finalMeta?.outputTokens, + thinkingMs: finalMeta?.thinkingMs, + thinkingText: finalMeta?.thinkingText || undefined, + } : msg ) ); } setParallelAgents([]); stopSharedStreamState(); - - // Resolve streamAndWait promise with interrupted flag + finalizeThinkingSourceTracking(); const resolver = streamCompletionResolverRef.current; if (resolver) { streamCompletionResolverRef.current = null; @@ -3613,9 +3565,7 @@ export function ChatApp({ // Background agents are excluded — they must not block completion; // they continue running after the main stream ends and are tracked // separately via hasActiveBackgroundAgents. - const hasActiveAgents = parallelAgentsRef.current.some( - (a) => (a.status === "running" || a.status === "pending") && shouldFinalizeOnToolComplete(a) - ); + const hasActiveAgents = hasActiveForegroundAgents(parallelAgentsRef.current); if (hasActiveAgents || hasRunningToolRef.current) { pendingCompleteRef.current = handleComplete; return; @@ -3637,7 +3587,7 @@ export function ChatApp({ prev.map((msg: ChatMessage) => msg.id === messageId ? { - ...msg, + ...finalizeStreamingReasoningInMessage(msg), streaming: false, durationMs, modelId: currentModelRef.current, @@ -3653,7 +3603,7 @@ export function ChatApp({ ); } // Keep background agents in live state for post-stream completion tracking - const remaining = currentAgents.filter((a) => a.background && a.status === "background"); + const remaining = getActiveBackgroundAgents(currentAgents); if (remaining.length > 0 && messageId) { backgroundAgentMessageIdRef.current = messageId; } @@ -3662,10 +3612,9 @@ export function ChatApp({ // Preserve streamingStartRef when background agents are still running // so the elapsed timer continues tracking total work duration - const hasRemainingBg = parallelAgentsRef.current.some( - (a) => a.background && a.status === "background" - ); + const hasRemainingBg = getActiveBackgroundAgents(parallelAgentsRef.current).length > 0; stopSharedStreamState({ preserveStreamingStart: hasRemainingBg }); + finalizeThinkingSourceTracking(); // If a streamAndWait call is pending, resolve its promise // instead of processing the message queue. @@ -3687,6 +3636,31 @@ export function ChatApp({ const handleMeta = (meta: StreamingMeta) => { streamingMetaRef.current = meta; setStreamingMeta(meta); + const messageId = streamingMessageIdRef.current; + if (!messageId) return; + const thinkingMetaEvent = resolveValidatedThinkingMetaEvent( + meta, + messageId, + currentGeneration, + closedThinkingSourcesRef.current, + thinkingDropDiagnosticsRef.current, + ); + if (!thinkingMetaEvent) return; + setMessagesWindowed((prev: ChatMessage[]) => + prev.map((msg: ChatMessage) => + msg.id === messageId + ? applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: thinkingMetaEvent.thinkingSourceKey, + targetMessageId: thinkingMetaEvent.targetMessageId, + streamGeneration: thinkingMetaEvent.streamGeneration, + thinkingMs: meta.thinkingMs, + thinkingText: thinkingMetaEvent.thinkingText, + includeReasoningPart: true, + }) + : msg + ) + ); }; void Promise.resolve(onStreamMessage(content, handleChunk, handleComplete, handleMeta, options)).catch((error) => { @@ -4278,23 +4252,17 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // Invalidate current stream callbacks before interrupting the SDK, // so synchronous onComplete callbacks from an interrupt become stale. streamGenerationRef.current = invalidateActiveStreamGeneration(streamGenerationRef.current); - pendingCompleteRef.current = null; + clearDeferredCompletion(); // Abort the stream FIRST so chunks stop arriving immediately onInterrupt?.(); // Read agents synchronously from ref (avoids nested dispatch issues) const currentAgents = parallelAgentsRef.current; - const interruptedAgents = currentAgents.length > 0 - ? currentAgents.map((a) => - a.status === "running" || a.status === "pending" - ? { ...a, status: "interrupted" as const, currentTool: undefined, durationMs: Date.now() - new Date(a.startedAt).getTime() } - : a - ) - : undefined; + const { interruptedAgents, remainingLiveAgents } = separateAndInterruptAgents(currentAgents); - // Clear live agents and update ref immediately - parallelAgentsRef.current = []; - setParallelAgents([]); + // Keep background agents alive in refs + parallelAgentsRef.current = remainingLiveAgents; + setParallelAgents(remainingLiveAgents); // Finalize in_progress task items -> pending and bake into message const interruptedTaskItems = finalizeTaskItemsOnInterrupt(); @@ -4306,7 +4274,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro prev.map((msg: ChatMessage) => msg.id === interruptedId ? { - ...msg, + ...finalizeStreamingReasoningInMessage(msg), wasInterrupted: true, streaming: false, parallelAgents: interruptedAgents, @@ -4321,12 +4289,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // Stop streaming state immediately so UI reflects interrupted state wasInterruptedRef.current = false; stopSharedStreamState(); - - // Sub-agent cancellation handled by SDK session interrupt - - // Clear any pending ask-user question so dialog dismisses - setActiveQuestion(null); - askUserQuestionRequestIdRef.current = null; + finalizeThinkingSourceTracking(); activeHitlToolCallIdRef.current = null; // Resolve streamAndWait promise with interrupted flag so workflow can react @@ -4389,17 +4352,15 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // If not streaming but subagents are still running, mark them interrupted { const currentAgents = parallelAgentsRef.current; - const hasRunningAgents = currentAgents.some( + // Only check for foreground agents - background agents should continue running + const foregroundAgents = currentAgents.filter(a => !isBackgroundAgent(a)); + const hasRunningForegroundAgents = foregroundAgents.some( (a) => a.status === "running" || a.status === "pending" ); - if (hasRunningAgents) { + if (hasRunningForegroundAgents) { // Inform parent integration so SDK-side run/correlation state is reset too. onInterrupt?.(); - const interruptedAgents = currentAgents.map((a) => - a.status === "running" || a.status === "pending" - ? { ...a, status: "interrupted" as const, currentTool: undefined, durationMs: Date.now() - new Date(a.startedAt).getTime() } - : a - ); + const { interruptedAgents, remainingLiveAgents } = separateAndInterruptAgents(currentAgents); // Finalize in_progress task items -> pending and bake into message const interruptedTaskItems = finalizeTaskItemsOnInterrupt(); @@ -4418,10 +4379,12 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro ) ); } - parallelAgentsRef.current = []; - setParallelAgents([]); + parallelAgentsRef.current = remainingLiveAgents; + setParallelAgents(remainingLiveAgents); + clearDeferredCompletion(); wasInterruptedRef.current = false; stopSharedStreamState(); + finalizeThinkingSourceTracking(); continueQueuedConversation(); return; } @@ -4473,6 +4436,100 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro return; } + // While a dialog is active, it owns keyboard input exclusively. + // Keep Ctrl+C handling above for copy/interrupt semantics. + if (activeQuestion || showModelSelector) { + return; + } + + // Ctrl+F - terminate active background agents (double press confirmation) + if (isBackgroundTerminationKey(event)) { + // Keep foreground stream interruption on ESC/Ctrl+C only. + if (isStreamingRef.current) { + return; + } + + const currentAgents = parallelAgentsRef.current; + const activeBackgroundAgents = getActiveBackgroundAgents(currentAgents); + const decision = getBackgroundTerminationDecision( + backgroundTerminationCount, + activeBackgroundAgents.length, + ); + + console.debug("[background-termination] decision:", decision.action, { + pressCount: backgroundTerminationCount, + activeAgents: activeBackgroundAgents.length, + }); + + if (decision.action === "none") { + console.debug("[background-termination] noop: no active background agents"); + clearBackgroundTerminationConfirmation(); + return; + } + + if (decision.action === "terminate") { + if (backgroundTerminationInFlightRef.current) { + return; + } + backgroundTerminationInFlightRef.current = true; + clearBackgroundTerminationConfirmation(); + + const { agents: interruptedAgents, interruptedIds } = interruptActiveBackgroundAgents(currentAgents); + if (interruptedIds.length === 0) { + backgroundTerminationInFlightRef.current = false; + return; + } + + console.debug("[background-termination] executing termination", { + interruptedIds, + remainingCount: currentAgents.filter((agent) => !new Set(interruptedIds).has(agent.id)).length, + }); + + const interruptedIdSet = new Set(interruptedIds); + const remainingLiveAgents = currentAgents.filter((agent) => !interruptedIdSet.has(agent.id)); + + const interruptedMessageId = backgroundAgentMessageIdRef.current ?? streamingMessageIdRef.current; + if (interruptedMessageId) { + setMessagesWindowed((prev: ChatMessage[]) => + prev.map((msg: ChatMessage) => + msg.id === interruptedMessageId + ? { + ...msg, + parallelAgents: interruptedAgents, + } + : msg + ) + ); + } + + parallelAgentsRef.current = remainingLiveAgents; + setParallelAgents(remainingLiveAgents); + backgroundAgentMessageIdRef.current = null; + streamingStartRef.current = null; + clearDeferredCompletion(); + + void Promise.resolve(onTerminateBackgroundAgents?.()).catch((error) => { + console.error("[background-termination] parent callback failed:", error); + }); + addMessage("assistant", `${STATUS.error} ${decision.message}`); + backgroundTerminationInFlightRef.current = false; + return; + } + + console.debug("[background-termination] armed: awaiting confirmation"); + setBackgroundTerminationCount(1); + setCtrlFPressed(true); + if (backgroundTerminationTimeoutRef.current) { + clearTimeout(backgroundTerminationTimeoutRef.current); + } + backgroundTerminationTimeoutRef.current = setTimeout(() => { + setBackgroundTerminationCount(0); + setCtrlFPressed(false); + backgroundTerminationTimeoutRef.current = null; + }, 1000); + return; + } + // Ctrl+O - toggle transcript mode (full-screen detailed view) if (event.ctrl && event.name === "o") { setTranscriptMode(prev => !prev); @@ -4519,23 +4576,17 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // Invalidate current stream callbacks before interrupting the SDK, // so synchronous onComplete callbacks from an interrupt become stale. streamGenerationRef.current = invalidateActiveStreamGeneration(streamGenerationRef.current); - pendingCompleteRef.current = null; + clearDeferredCompletion(); // Abort the stream FIRST so chunks stop arriving immediately onInterrupt?.(); // Read agents synchronously from ref (avoids nested dispatch issues) const currentAgents = parallelAgentsRef.current; - const interruptedAgents = currentAgents.length > 0 - ? currentAgents.map((a) => - a.status === "running" || a.status === "pending" - ? { ...a, status: "interrupted" as const, currentTool: undefined, durationMs: Date.now() - new Date(a.startedAt).getTime() } - : a - ) - : undefined; + const { interruptedAgents, remainingLiveAgents } = separateAndInterruptAgents(currentAgents); - // Clear live agents and update ref immediately - parallelAgentsRef.current = []; - setParallelAgents([]); + // Keep background agents alive in refs + parallelAgentsRef.current = remainingLiveAgents; + setParallelAgents(remainingLiveAgents); // Finalize in_progress task items -> pending and bake into message const interruptedTaskItems = finalizeTaskItemsOnInterrupt(); @@ -4547,7 +4598,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro prev.map((msg: ChatMessage) => msg.id === interruptedId ? { - ...msg, + ...finalizeStreamingReasoningInMessage(msg), wasInterrupted: true, streaming: false, parallelAgents: interruptedAgents, @@ -4562,10 +4613,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // Stop streaming state immediately so UI reflects interrupted state wasInterruptedRef.current = false; stopSharedStreamState(); - - // Sub-agent cancellation handled by SDK session interrupt - - // Clear any pending ask-user question so dialog dismisses on ESC + finalizeThinkingSourceTracking(); setActiveQuestion(null); askUserQuestionRequestIdRef.current = null; activeHitlToolCallIdRef.current = null; @@ -4590,17 +4638,15 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // If not streaming but subagents are still running, mark them interrupted { const currentAgents = parallelAgentsRef.current; - const hasRunningAgents = currentAgents.some( + // Only check for foreground agents - background agents should continue running + const foregroundAgents = currentAgents.filter(a => !isBackgroundAgent(a)); + const hasRunningForegroundAgents = foregroundAgents.some( (a) => a.status === "running" || a.status === "pending" ); - if (hasRunningAgents) { + if (hasRunningForegroundAgents) { // Inform parent integration so SDK-side run/correlation state is reset too. onInterrupt?.(); - const interruptedAgents = currentAgents.map((a) => - a.status === "running" || a.status === "pending" - ? { ...a, status: "interrupted" as const, currentTool: undefined, durationMs: Date.now() - new Date(a.startedAt).getTime() } - : a - ); + const { interruptedAgents, remainingLiveAgents } = separateAndInterruptAgents(currentAgents); // Finalize in_progress task items -> pending and bake into message const interruptedTaskItems = finalizeTaskItemsOnInterrupt(); @@ -4619,8 +4665,9 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro ) ); } - parallelAgentsRef.current = []; - setParallelAgents([]); + parallelAgentsRef.current = remainingLiveAgents; + setParallelAgents(remainingLiveAgents); + clearDeferredCompletion(); wasInterruptedRef.current = false; stopSharedStreamState(); continueQueuedConversation(); @@ -5072,7 +5119,38 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro syncInputScrollbar(); }, 0); }, - [onExit, onInterrupt, isStreaming, interruptCount, handleCopy, workflowState.showAutocomplete, workflowState.selectedSuggestionIndex, workflowState.autocompleteInput, workflowState.autocompleteMode, autocompleteSuggestions, updateWorkflowState, handleInputChange, syncInputScrollbar, executeCommand, activeQuestion, showModelSelector, ctrlCPressed, messageQueue, setIsEditingQueue, parallelAgents, compactionSummary, addMessage, renderer, emitMessageSubmitTelemetry, finalizeTaskItemsOnInterrupt, stopSharedStreamState] + [ + onExit, + onInterrupt, + onTerminateBackgroundAgents, + isStreaming, + interruptCount, + backgroundTerminationCount, + handleCopy, + workflowState.showAutocomplete, + workflowState.selectedSuggestionIndex, + workflowState.autocompleteInput, + workflowState.autocompleteMode, + autocompleteSuggestions, + updateWorkflowState, + handleInputChange, + syncInputScrollbar, + executeCommand, + activeQuestion, + showModelSelector, + ctrlCPressed, + messageQueue, + setIsEditingQueue, + parallelAgents, + compactionSummary, + addMessage, + renderer, + emitMessageSubmitTelemetry, + finalizeTaskItemsOnInterrupt, + stopSharedStreamState, + clearBackgroundTerminationConfirmation, + finalizeThinkingSourceTracking, + ] ) ); @@ -5122,12 +5200,12 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // Track when streaming started for duration calculation streamingStartRef.current = Date.now(); // Reset streaming metadata - streamingMetaRef.current = null; - setStreamingMeta(null); + resetThinkingSourceTracking(); // Clear stale todo items from previous turn when not in /ralph resetTodoItemsForNewStream(); // Reset tool tracking for the new stream hasRunningToolRef.current = false; + clearDeferredCompletion(); // Create placeholder assistant message const assistantMessage = createMessage("assistant", "", true); @@ -5140,14 +5218,15 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro if (!isStreamingRef.current) return; // Drop chunks from stale streams (round-robin replaced this stream) if (!isCurrentStreamCallback(streamGenerationRef.current, currentGeneration)) return; + if (pendingCompleteRef.current) { + clearDeferredCompletion(); + } const messageId = streamingMessageIdRef.current; if (messageId) { setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage) => { if (msg.id === messageId) { - // Dual population: update both legacy content and parts array - const withParts = handleTextDelta(msg, chunk); - return { ...msg, content: msg.content + chunk, parts: withParts.parts }; + return applyStreamPartEvent(msg, { type: "text-delta", delta: chunk }); } return msg; }) @@ -5175,15 +5254,22 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro setMessagesWindowed((prev: ChatMessage[]) => prev.map((msg: ChatMessage) => msg.id === messageId - ? { ...msg, streaming: false, durationMs, modelId: currentModelRef.current, outputTokens: finalMeta?.outputTokens, thinkingMs: finalMeta?.thinkingMs, thinkingText: finalMeta?.thinkingText || undefined } + ? { + ...finalizeStreamingReasoningInMessage(msg), + streaming: false, + durationMs, + modelId: currentModelRef.current, + outputTokens: finalMeta?.outputTokens, + thinkingMs: finalMeta?.thinkingMs, + thinkingText: finalMeta?.thinkingText || undefined, + } : msg ) ); } setParallelAgents([]); stopSharedStreamState(); - - continueQueuedConversation(); + finalizeThinkingSourceTracking(); return; } @@ -5192,9 +5278,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // Background agents are excluded — they must not block completion; // they continue running after the main stream ends and are tracked // separately via hasActiveBackgroundAgents. - const hasActiveAgents = parallelAgentsRef.current.some( - (a) => (a.status === "running" || a.status === "pending") && shouldFinalizeOnToolComplete(a) - ); + const hasActiveAgents = hasActiveForegroundAgents(parallelAgentsRef.current); if (hasActiveAgents || hasRunningToolRef.current) { pendingCompleteRef.current = handleComplete; return; @@ -5216,7 +5300,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro prev.map((msg: ChatMessage) => msg.id === messageId ? { - ...msg, + ...finalizeStreamingReasoningInMessage(msg), streaming: false, durationMs, modelId: currentModelRef.current, @@ -5232,17 +5316,16 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro ); } // Keep background agents in live state for post-stream completion tracking - const remaining = currentAgents.filter((a) => a.background && a.status === "background"); + const remaining = getActiveBackgroundAgents(currentAgents); if (remaining.length > 0 && messageId) { backgroundAgentMessageIdRef.current = messageId; } return remaining; }); - const hasRemainingBg = parallelAgentsRef.current.some( - (a) => a.background && a.status === "background" - ); + const hasRemainingBg = getActiveBackgroundAgents(parallelAgentsRef.current).length > 0; stopSharedStreamState({ preserveStreamingStart: hasRemainingBg }); + finalizeThinkingSourceTracking(); continueQueuedConversation(); }; @@ -5250,6 +5333,31 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro const handleMeta = (meta: StreamingMeta) => { streamingMetaRef.current = meta; setStreamingMeta(meta); + const messageId = streamingMessageIdRef.current; + if (!messageId) return; + const thinkingMetaEvent = resolveValidatedThinkingMetaEvent( + meta, + messageId, + currentGeneration, + closedThinkingSourcesRef.current, + thinkingDropDiagnosticsRef.current, + ); + if (!thinkingMetaEvent) return; + setMessagesWindowed((prev: ChatMessage[]) => + prev.map((msg: ChatMessage) => + msg.id === messageId + ? applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: thinkingMetaEvent.thinkingSourceKey, + targetMessageId: thinkingMetaEvent.targetMessageId, + streamGeneration: thinkingMetaEvent.streamGeneration, + thinkingMs: meta.thinkingMs, + thinkingText: thinkingMetaEvent.thinkingText, + includeReasoningPart: true, + }) + : msg + ) + ); }; void Promise.resolve(onStreamMessage(content, handleChunk, handleComplete, handleMeta)).catch((error) => { @@ -5257,7 +5365,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro }); } }, - [onSendMessage, onStreamMessage, continueQueuedConversation, handleStreamStartupError, stopSharedStreamState] + [onSendMessage, onStreamMessage, continueQueuedConversation, handleStreamStartupError, stopSharedStreamState, finalizeThinkingSourceTracking, resetThinkingSourceTracking] ); // Keep the sendMessageRef in sync with sendMessage callback @@ -5460,9 +5568,8 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro isAgentOnlyStreamRef.current = true; isStreamingRef.current = true; streamingStartRef.current = Date.now(); - streamingMetaRef.current = null; + resetThinkingSourceTracking(); setIsStreaming(true); - setStreamingMeta(null); resetTodoItemsForNewStream(); setMessagesWindowed((prev) => [...prev, assistantMsg]); @@ -5481,9 +5588,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro if (isStreamingRef.current) { // Defer interrupt if sub-agents are actively working — fires when they finish // Background agents are excluded — they must not block interrupt. - const hasActiveSubagents = parallelAgentsRef.current.some( - (a) => (a.status === "running" || a.status === "pending") && shouldFinalizeOnToolComplete(a) - ); + const hasActiveSubagents = hasActiveForegroundAgents(parallelAgentsRef.current); if (hasActiveSubagents) { emitMessageSubmitTelemetry({ messageLength: trimmedValue.length, @@ -5506,7 +5611,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro prev.map((msg: ChatMessage) => msg.id === interruptedId ? { - ...msg, + ...finalizeStreamingReasoningInMessage(msg), streaming: false, durationMs, modelId: currentModelRef.current, @@ -5523,6 +5628,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro // Invalidate callbacks for the interrupted stream before aborting. streamGenerationRef.current = invalidateActiveStreamGeneration(streamGenerationRef.current); stopSharedStreamState(); + finalizeThinkingSourceTracking(); const streamResolver = streamCompletionResolverRef.current; if (streamResolver) { @@ -5536,6 +5642,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro streamResolver({ content: lastStreamingContentRef.current, wasInterrupted: true }); } + // Abort the SDK stream (stale handleComplete is a no-op via generation guard) onInterrupt?.(); // Send immediately — starts a new stream generation @@ -5560,11 +5667,15 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro }); sendMessage(processedValue); }, - [workflowState.showAutocomplete, workflowState.argumentHint, updateWorkflowState, addMessage, executeCommand, messageQueue, sendMessage, model, onInterrupt, emitMessageSubmitTelemetry, finalizeTaskItemsOnInterrupt, stopSharedStreamState] + [workflowState.showAutocomplete, workflowState.argumentHint, updateWorkflowState, addMessage, executeCommand, messageQueue, sendMessage, model, onInterrupt, emitMessageSubmitTelemetry, finalizeTaskItemsOnInterrupt, stopSharedStreamState, finalizeThinkingSourceTracking, resetThinkingSourceTracking] ); // All messages are kept in memory; no windowing/eviction. const renderMessages = messages; + const footerBackgroundAgents = useMemo( + () => resolveBackgroundAgentsForFooter(parallelAgents, messages), + [parallelAgents, messages], + ); // Auto-collapse boundary: messages before this index render as single-line summaries const collapseBoundaryIndex = Math.max(0, renderMessages.length - EXPANDED_MESSAGE_COUNT); @@ -5574,10 +5685,8 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro <> {/* Collapsed messages (older, auto-collapsed to single-line summaries) */} {renderMessages.slice(0, collapseBoundaryIndex).map((msg) => { - const msgHasActiveBg = (msg.parallelAgents ?? []).some( - (a) => a.background && a.status === "background" - ); - const showLive = msg.streaming || msgHasActiveBg; + const liveTaskItems = msg.streaming ? todoItems : undefined; + const showLive = shouldShowMessageLoadingIndicator(msg, liveTaskItems); return ( { - const msgHasActiveBg = (msg.parallelAgents ?? []).some( - (a) => a.background && a.status === "background" - ); - const showLive = msg.streaming || msgHasActiveBg; + const liveTaskItems = msg.streaming ? todoItems : undefined; + const showLive = shouldShowMessageLoadingIndicator(msg, liveTaskItems); return ( {enqueueShortcutLabel} enqueue + {footerBackgroundAgents.length > 0 && ( + <> + {MISC.separator} + + {formatBackgroundAgentFooterStatus(footerBackgroundAgents)} + + {MISC.separator} {BACKGROUND_FOOTER_CONTRACT.terminateHintText} + + )} ) : null} {/* Workflow mode label with hints - shown when workflow is active */} @@ -5831,6 +5947,15 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro ctrl+c twice to exit workflow + {footerBackgroundAgents.length > 0 && ( + <> + {MISC.separator} + + {formatBackgroundAgentFooterStatus(footerBackgroundAgents)} + + {MISC.separator} {BACKGROUND_FOOTER_CONTRACT.terminateHintText} + + )} )} @@ -5859,10 +5984,19 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro )} + {ctrlFPressed && ( + + + Press Ctrl-F again to terminate background agents + + + )} )} + {!isStreaming && } + ); } diff --git a/src/ui/components/background-agent-footer.tsx b/src/ui/components/background-agent-footer.tsx new file mode 100644 index 000000000..cad2b45a2 --- /dev/null +++ b/src/ui/components/background-agent-footer.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import type { ParallelAgent } from "./parallel-agents-tree.tsx"; +import { useTheme } from "../theme.tsx"; +import { SPACING } from "../constants/spacing.ts"; +import { MISC } from "../constants/icons.ts"; +import { formatBackgroundAgentFooterStatus } from "../utils/background-agent-footer.ts"; +import { BACKGROUND_FOOTER_CONTRACT } from "../utils/background-agent-contracts.ts"; + +export interface BackgroundAgentFooterProps { + agents: readonly ParallelAgent[]; +} + +export function BackgroundAgentFooter({ + agents, +}: BackgroundAgentFooterProps): React.ReactNode { + const { theme } = useTheme(); + const label = formatBackgroundAgentFooterStatus(agents); + + if (!label) { + return null; + } + + return ( + + + {label} + {" "}{MISC.separator} {BACKGROUND_FOOTER_CONTRACT.terminateHintText} + + + ); +} + +export default BackgroundAgentFooter; diff --git a/src/ui/components/parallel-agents-tree.tsx b/src/ui/components/parallel-agents-tree.tsx index aac36ec3a..4720ef8ff 100644 --- a/src/ui/components/parallel-agents-tree.tsx +++ b/src/ui/components/parallel-agents-tree.tsx @@ -10,6 +10,7 @@ import React from "react"; import { useTheme, getCatppuccinPalette } from "../theme.tsx"; import { formatDuration as formatDurationObj, truncateText } from "../utils/format.ts"; +import { buildParallelAgentsHeaderHint } from "../utils/background-agent-tree-hints.ts"; import { STATUS, TREE, CONNECTOR } from "../constants/icons.ts"; import { SPACING } from "../constants/spacing.ts"; @@ -196,7 +197,6 @@ export function buildAgentHeaderLabel(count: number, dominantType: string): stri return `${count} ${normalized} agent${plural ? "s" : ""}`; } - /** * Get elapsed time since start. */ @@ -547,6 +547,8 @@ export function ParallelAgentsTree({ ? `${buildAgentHeaderLabel(completedCount, dominantType)} finished` : `${buildAgentHeaderLabel(pendingCount, dominantType)} pending`; + const headerHint = buildParallelAgentsHeaderHint(agents, runningCount === 0); + return ( {headerIcon} {headerText} - {runningCount === 0 && ( - (ctrl+o to expand) - )} + {headerHint && ({headerHint})} {/* Agent tree */} diff --git a/src/ui/components/parts/message-bubble-parts.test.ts b/src/ui/components/parts/message-bubble-parts.test.ts new file mode 100644 index 000000000..2a4ba4dbb --- /dev/null +++ b/src/ui/components/parts/message-bubble-parts.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import type { Part, ReasoningPart, TextPart } from "../../parts/types.ts"; +import { buildPartRenderKeys } from "./message-bubble-parts.tsx"; + +function createReasoningPart(id: string, thinkingSourceKey: string): ReasoningPart { + return { + id, + type: "reasoning", + thinkingSourceKey, + content: "thinking", + durationMs: 100, + isStreaming: true, + createdAt: "2026-02-23T00:00:00.000Z", + }; +} + +function createTextPart(id: string): TextPart { + return { + id, + type: "text", + content: "answer", + isStreaming: true, + createdAt: "2026-02-23T00:00:00.000Z", + }; +} + +describe("buildPartRenderKeys", () => { + test("renders concurrent reasoning sources as isolated source-bound keys", () => { + const parts: Part[] = [ + createReasoningPart("part_1", "source:a"), + createReasoningPart("part_2", "source:b"), + createTextPart("part_3"), + ]; + + expect(buildPartRenderKeys(parts)).toEqual([ + "reasoning-source:source:a", + "reasoning-source:source:b", + "part_3", + ]); + }); + + test("keeps reasoning identity stable across source updates", () => { + const firstRender = buildPartRenderKeys([createReasoningPart("part_old", "source:a")]); + const secondRender = buildPartRenderKeys([createReasoningPart("part_new", "source:a")]); + + expect(firstRender).toEqual(["reasoning-source:source:a"]); + expect(secondRender).toEqual(["reasoning-source:source:a"]); + }); + + test("suffixes duplicate source keys to avoid key collisions", () => { + const parts: Part[] = [ + createReasoningPart("part_1", "source:a"), + createReasoningPart("part_2", "source:a"), + ]; + + expect(buildPartRenderKeys(parts)).toEqual([ + "reasoning-source:source:a", + "reasoning-source:source:a#1", + ]); + }); +}); diff --git a/src/ui/components/parts/message-bubble-parts.tsx b/src/ui/components/parts/message-bubble-parts.tsx index 93f912d42..58ee0ebc3 100644 --- a/src/ui/components/parts/message-bubble-parts.tsx +++ b/src/ui/components/parts/message-bubble-parts.tsx @@ -8,6 +8,7 @@ import React from "react"; import type { SyntaxStyle } from "@opentui/core"; import type { ChatMessage } from "../../chat.tsx"; +import type { Part } from "../../parts/types.ts"; import { PART_REGISTRY } from "./registry.tsx"; import { SPACING } from "../../constants/spacing.ts"; @@ -16,6 +17,44 @@ export interface MessageBubblePartsProps { syntaxStyle?: SyntaxStyle; } +function getReasoningSourceKey(part: Part): string { + if (part.type !== "reasoning") { + return ""; + } + + const sourceKey = part.thinkingSourceKey; + if (typeof sourceKey !== "string") { + return ""; + } + + return sourceKey.trim(); +} + +function getPartRenderKeyBase(part: Part): string { + const sourceKey = getReasoningSourceKey(part); + if (sourceKey.length > 0) { + return `reasoning-source:${sourceKey}`; + } + + return part.id; +} + +export function buildPartRenderKeys(parts: ReadonlyArray): string[] { + const seen = new Map(); + + return parts.map((part) => { + const baseKey = getPartRenderKeyBase(part); + const existingCount = seen.get(baseKey) ?? 0; + seen.set(baseKey, existingCount + 1); + + if (existingCount === 0) { + return baseKey; + } + + return `${baseKey}#${existingCount}`; + }); +} + /** * Renders a message from its parts array using the PART_REGISTRY. * Returns null if the message has no parts. @@ -27,6 +66,7 @@ export interface MessageBubblePartsProps { */ export function MessageBubbleParts({ message, syntaxStyle }: MessageBubblePartsProps): React.ReactNode { const parts = message.parts ?? []; + const renderKeys = buildPartRenderKeys(parts); if (parts.length === 0) { return null; @@ -39,7 +79,7 @@ export function MessageBubbleParts({ message, syntaxStyle }: MessageBubblePartsP if (!Renderer) return null; return ( { + test("returns empty string for non-positive durations", () => { + expect(formatReasoningDurationSeconds(0)).toBe(""); + expect(formatReasoningDurationSeconds(-100)).toBe(""); + }); + + test("formats duration as whole-number seconds", () => { + expect(formatReasoningDurationSeconds(100)).toBe("1s"); + expect(formatReasoningDurationSeconds(1400)).toBe("1s"); + expect(formatReasoningDurationSeconds(1500)).toBe("2s"); + expect(formatReasoningDurationSeconds(5400)).toBe("5s"); + }); +}); diff --git a/src/ui/components/parts/reasoning-part-display.tsx b/src/ui/components/parts/reasoning-part-display.tsx index 05c45a1f0..6f2d1ba32 100644 --- a/src/ui/components/parts/reasoning-part-display.tsx +++ b/src/ui/components/parts/reasoning-part-display.tsx @@ -12,6 +12,7 @@ import type { SyntaxStyle } from "@opentui/core"; import type { ReasoningPart } from "../../parts/types.ts"; import { createDimmedSyntaxStyle, createMarkdownSyntaxStyle, useTheme, useThemeColors } from "../../theme.tsx"; import { SPACING } from "../../constants/spacing.ts"; +import { MISC } from "../../constants/icons.ts"; import { normalizeMarkdownNewlines } from "../../utils/format.ts"; export interface ReasoningPartDisplayProps { @@ -20,13 +21,16 @@ export interface ReasoningPartDisplayProps { syntaxStyle?: SyntaxStyle; } +export function formatReasoningDurationSeconds(durationMs: number): string { + if (durationMs <= 0) return ""; + return `${Math.max(1, Math.round(durationMs / 1000))}s`; +} + export function ReasoningPartDisplay({ part, syntaxStyle }: ReasoningPartDisplayProps): React.ReactNode { const colors = useThemeColors(); const { isDark } = useTheme(); const normalizedContent = normalizeMarkdownNewlines(part.content); - const durationLabel = part.durationMs > 0 - ? `${(part.durationMs / 1000).toFixed(1)}s` - : ""; + const durationLabel = formatReasoningDurationSeconds(part.durationMs); const fallbackSyntaxStyle = useMemo( () => createMarkdownSyntaxStyle(colors, isDark), @@ -42,7 +46,9 @@ export function ReasoningPartDisplay({ part, syntaxStyle }: ReasoningPartDisplay return ( - {part.isStreaming ? "💭 Thinking..." : `💭 Thought${durationLabel ? ` (${durationLabel})` : ""}`} + {part.isStreaming + ? `${MISC.thinking} Thinking...` + : `${MISC.thinking} Thought${durationLabel ? ` (${durationLabel})` : ""}`} {normalizedContent && ( diff --git a/src/ui/components/parts/text-part-display.tsx b/src/ui/components/parts/text-part-display.tsx index 18e238803..adb59957a 100644 --- a/src/ui/components/parts/text-part-display.tsx +++ b/src/ui/components/parts/text-part-display.tsx @@ -12,7 +12,6 @@ import type { SyntaxStyle } from "@opentui/core"; import type { TextPart } from "../../parts/types.ts"; import { useThrottledValue } from "../../hooks/use-throttled-value.ts"; import { createMarkdownSyntaxStyle, useTheme, useThemeColors } from "../../theme.tsx"; -import { STATUS } from "../../constants/icons.ts"; import { normalizeMarkdownNewlines } from "../../utils/format.ts"; export interface TextPartDisplayProps { @@ -39,14 +38,14 @@ export function TextPartDisplay({ part, syntaxStyle }: TextPartDisplayProps) { {syntaxStyle ? ( ) : ( - {/* Active HITL: dialog handles rendering, show nothing here */} - {part.pendingQuestion && ( - { - part.pendingQuestion?.respond(answer); - }} - /> - )} + {/* Active HITL: rendered by the dedicated dialog in chat.tsx */} {/* Completed HITL: transparent record with question + answer */} {part.hitlResponse && !part.pendingQuestion && ( diff --git a/src/ui/constants/icons.ts b/src/ui/constants/icons.ts index f8ec76cfe..a526362ee 100644 --- a/src/ui/constants/icons.ts +++ b/src/ui/constants/icons.ts @@ -13,7 +13,7 @@ export const STATUS = { pending: "○", // U+25CB White Circle active: "●", // U+25CF Black Circle error: "✗", // U+2717 Ballot X - background: "◌", // U+25CC Dotted Circle + background: "●", // U+25CF Black Circle (same as active, colored via theme) selected: "◉", // U+25C9 Fisheye success: "✓", // U+2713 Check Mark } as const; diff --git a/src/ui/index.protocol-ordering.test.ts b/src/ui/index.protocol-ordering.test.ts index 68dbf1a13..62f481d79 100644 --- a/src/ui/index.protocol-ordering.test.ts +++ b/src/ui/index.protocol-ordering.test.ts @@ -102,6 +102,9 @@ describe("startChatUI protocol escape ordering", () => { LoadingIndicator: () => null, StreamingBullet: () => null, defaultWorkflowChatState: {}, + traceThinkingSourceLifecycle: () => { + return; + }, })); mock.module("./theme.tsx", () => ({ diff --git a/src/ui/index.thinking-source-contract.test.ts b/src/ui/index.thinking-source-contract.test.ts new file mode 100644 index 000000000..7a393a738 --- /dev/null +++ b/src/ui/index.thinking-source-contract.test.ts @@ -0,0 +1,796 @@ +import { describe, expect, mock, spyOn, test } from "bun:test"; + +import type { CliRenderer } from "@opentui/core"; +import type { Root } from "@opentui/react"; +import * as opentuiCore from "@opentui/core"; +import * as opentuiReact from "@opentui/react"; + +import type { AgentMessage, CodingAgentClient, Session } from "../sdk/types.ts"; +import type { ChatAppProps, StreamingMeta } from "./chat.tsx"; + +interface ElementWithProps { + props?: { + children?: unknown; + }; +} + +interface StreamHarness { + onExit: NonNullable; + onInterrupt: NonNullable; + onStreamMessage: NonNullable; + uiPromise: Promise; + restore: () => void; +} + +type StreamIteratorResult = IteratorResult; + +interface ControlledAgentStream { + readonly iterable: AsyncIterable; + emit: (message: AgentMessage) => void; + end: () => void; +} + +interface ControlledClientBundle { + client: CodingAgentClient; + streams: ControlledAgentStream[]; +} + +function extractChatAppProps(rootElement: unknown): ChatAppProps | null { + const themeProvider = rootElement as ElementWithProps; + const boundary = themeProvider.props?.children as ElementWithProps | undefined; + const chatApp = boundary?.props?.children as { props?: ChatAppProps } | undefined; + return chatApp?.props ?? null; +} + +async function waitFor(condition: () => boolean, attempts = 200): Promise { + for (let i = 0; i < attempts; i++) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Condition not met in time"); +} + +function createFakeClient(streamMessages: AgentMessage[]): CodingAgentClient { + const session: Session = { + id: "session-thinking-contract", + send: async () => ({ type: "text", content: "", role: "assistant" }), + stream: async function* (): AsyncIterable { + for (const message of streamMessages) { + yield message; + } + }, + summarize: async () => { + return; + }, + getContextUsage: async () => ({ + inputTokens: 0, + outputTokens: 0, + maxTokens: 1, + usagePercentage: 0, + }), + getSystemToolsTokens: () => 0, + destroy: async () => { + return; + }, + }; + + return { + agentType: "claude", + createSession: async () => session, + resumeSession: async () => null, + on: () => () => { + return; + }, + registerTool: () => { + return; + }, + start: async () => { + return; + }, + stop: async () => { + return; + }, + getModelDisplayInfo: async () => ({ model: "test", tier: "test" }), + getSystemToolsTokens: () => null, + }; +} + +function createControlledAgentStream(): ControlledAgentStream { + const pending: StreamIteratorResult[] = []; + const resolvers: Array<(value: StreamIteratorResult) => void> = []; + let ended = false; + + const flush = (result: StreamIteratorResult): void => { + const resolve = resolvers.shift(); + if (resolve) { + resolve(result); + return; + } + pending.push(result); + }; + + const end = (): void => { + if (ended) { + return; + } + ended = true; + flush({ done: true, value: undefined }); + }; + + const iterable: AsyncIterable = { + [Symbol.asyncIterator](): AsyncIterator { + return { + next(): Promise { + if (pending.length > 0) { + const nextValue = pending.shift(); + if (nextValue) { + return Promise.resolve(nextValue); + } + } + return new Promise((resolve) => { + resolvers.push(resolve); + }); + }, + return(): Promise> { + end(); + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + + return { + iterable, + emit: (message: AgentMessage) => { + if (ended) { + return; + } + flush({ done: false, value: message }); + }, + end, + }; +} + +function createControlledClient(streamCount: number): ControlledClientBundle { + const streams = Array.from({ length: streamCount }, () => createControlledAgentStream()); + let streamCallIndex = 0; + let activeStream: ControlledAgentStream | null = null; + + const session: Session = { + id: "session-thinking-contract-controlled", + send: async () => ({ type: "text", content: "", role: "assistant" }), + stream: async function* (): AsyncIterable { + const stream = streams[streamCallIndex]; + streamCallIndex += 1; + if (!stream) { + return; + } + activeStream = stream; + try { + for await (const message of stream.iterable) { + yield message; + } + } finally { + if (activeStream === stream) { + activeStream = null; + } + } + }, + abort: async () => { + activeStream?.end(); + }, + summarize: async () => { + return; + }, + getContextUsage: async () => ({ + inputTokens: 0, + outputTokens: 0, + maxTokens: 1, + usagePercentage: 0, + }), + getSystemToolsTokens: () => 0, + destroy: async () => { + return; + }, + }; + + return { + streams, + client: { + agentType: "claude", + createSession: async () => session, + resumeSession: async () => null, + on: () => () => { + return; + }, + registerTool: () => { + return; + }, + start: async () => { + return; + }, + stop: async () => { + return; + }, + getModelDisplayInfo: async () => ({ model: "test", tier: "test" }), + getSystemToolsTokens: () => null, + }, + }; +} + +async function createStreamHarnessFromClient(client: CodingAgentClient): Promise { + let renderedTree: unknown = null; + + const fakeRenderer = { + destroy: () => { + return; + }, + } as CliRenderer; + + const fakeRoot = { + render: (tree: unknown) => { + renderedTree = tree; + }, + unmount: () => { + return; + }, + } as Root; + + mock.module("./chat.tsx", () => ({ + ChatApp: () => null, + CompletionSummary: () => null, + LoadingIndicator: () => null, + StreamingBullet: () => null, + traceThinkingSourceLifecycle: () => { + return; + }, + MAX_VISIBLE_MESSAGES: 100, + defaultWorkflowChatState: {}, + })); + + mock.module("./theme.tsx", () => ({ + ThemeProvider: ({ children }: { children?: unknown }) => children ?? null, + useTheme: () => null, + useThemeColors: () => null, + darkTheme: { isDark: true }, + lightTheme: { isDark: false }, + })); + + mock.module("./components/error-exit-screen.tsx", () => ({ + AppErrorBoundary: ({ children }: { children?: unknown }) => children ?? null, + })); + + const createRendererSpy = spyOn(opentuiCore, "createCliRenderer").mockImplementation( + (async () => fakeRenderer) as typeof opentuiCore.createCliRenderer, + ); + + const createRootSpy = spyOn(opentuiReact, "createRoot").mockImplementation( + (() => fakeRoot) as typeof opentuiReact.createRoot, + ); + + try { + const { startChatUI } = await import("./index.ts"); + const uiPromise = startChatUI(client); + + await waitFor(() => renderedTree !== null); + const chatAppProps = extractChatAppProps(renderedTree); + if (!chatAppProps?.onStreamMessage || !chatAppProps.onExit || !chatAppProps.onInterrupt) { + throw new Error("Failed to extract ChatApp stream callbacks"); + } + + return { + onExit: chatAppProps.onExit, + onInterrupt: chatAppProps.onInterrupt, + onStreamMessage: chatAppProps.onStreamMessage, + uiPromise, + restore: () => { + createRendererSpy.mockRestore(); + createRootSpy.mockRestore(); + mock.restore(); + }, + }; + } catch (error) { + createRendererSpy.mockRestore(); + createRootSpy.mockRestore(); + mock.restore(); + throw error; + } +} + +async function createStreamHarness(streamMessages: AgentMessage[]): Promise { + return createStreamHarnessFromClient(createFakeClient(streamMessages)); +} + +describe("startChatUI thinking source key contract", () => { + test("throws on thinking events that omit metadata.thinkingSourceKey", async () => { + const harness = await createStreamHarness([ + { + type: "thinking", + content: "reasoning", + role: "assistant", + metadata: { provider: "claude" }, + }, + ]); + + try { + let onCompleteCalls = 0; + await expect( + harness.onStreamMessage("hello", () => {}, () => { + onCompleteCalls += 1; + }), + ).rejects.toThrow( + "Contract violation: thinking stream message is missing required metadata.thinkingSourceKey", + ); + expect(onCompleteCalls).toBe(1); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); + + test("throws on thinking events with empty metadata.thinkingSourceKey", async () => { + const harness = await createStreamHarness([ + { + type: "thinking", + content: "reasoning", + role: "assistant", + metadata: { provider: "claude", thinkingSourceKey: " " }, + }, + ]); + + try { + await expect( + harness.onStreamMessage("hello", () => {}, () => { + return; + }), + ).rejects.toThrow( + "Contract violation: thinking stream message is missing required metadata.thinkingSourceKey", + ); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); + + test("keeps non-thinking streams working without source identity", async () => { + const harness = await createStreamHarness([ + { + type: "text", + content: "hello ", + role: "assistant", + }, + { + type: "text", + content: "world", + role: "assistant", + }, + ]); + + try { + const chunks: string[] = []; + let onCompleteCalls = 0; + await expect( + harness.onStreamMessage( + "hello", + (chunk) => { + chunks.push(chunk); + }, + () => { + onCompleteCalls += 1; + }, + ), + ).resolves.toBeUndefined(); + + expect(chunks.join("")).toBe("hello world"); + expect(onCompleteCalls).toBe(1); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); + + test("preserves thinking metadata behavior when source identity is present", async () => { + const sourceKey = "claude:block-0"; + const harness = await createStreamHarness([ + { + type: "thinking", + content: "analyzing", + role: "assistant", + metadata: { + provider: "claude", + thinkingSourceKey: sourceKey, + streamGeneration: 7, + targetMessageId: "msg-1", + }, + }, + { + type: "text", + content: "done", + role: "assistant", + }, + ]); + + try { + const metaEvents: StreamingMeta[] = []; + + await expect( + harness.onStreamMessage( + "hello", + () => { + return; + }, + () => { + return; + }, + (meta) => { + metaEvents.push(meta); + }, + ), + ).resolves.toBeUndefined(); + + const thinkingMeta = metaEvents.find((meta) => meta.thinkingSourceKey === sourceKey); + expect(thinkingMeta).toBeDefined(); + if (!thinkingMeta) { + throw new Error("Expected thinking metadata event"); + } + expect(thinkingMeta.thinkingTextBySource?.[sourceKey]).toBe("analyzing"); + expect(thinkingMeta.thinkingGenerationBySource?.[sourceKey]).toBe(7); + expect(thinkingMeta.thinkingMessageBySource?.[sourceKey]).toBe("msg-1"); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); + + test("keeps source message binding stable when later chunks omit message IDs", async () => { + const sourceKey = "copilot:reasoning_7"; + const harness = await createStreamHarness([ + { + type: "thinking", + content: "step one", + role: "assistant", + metadata: { + provider: "copilot", + thinkingSourceKey: sourceKey, + streamGeneration: 4, + messageId: "msg-fallback", + }, + }, + { + type: "thinking", + content: " + step two", + role: "assistant", + metadata: { + provider: "copilot", + thinkingSourceKey: sourceKey, + streamGeneration: 4, + }, + }, + { + type: "text", + content: "done", + role: "assistant", + }, + ]); + + try { + const metaEvents: StreamingMeta[] = []; + + await expect( + harness.onStreamMessage( + "hello", + () => { + return; + }, + () => { + return; + }, + (meta) => { + metaEvents.push(meta); + }, + ), + ).resolves.toBeUndefined(); + + const thinkingMeta = [...metaEvents] + .reverse() + .find((meta) => meta.thinkingSourceKey === sourceKey); + expect(thinkingMeta).toBeDefined(); + if (!thinkingMeta) { + throw new Error("Expected source-bound thinking metadata event"); + } + + expect(thinkingMeta.thinkingTextBySource?.[sourceKey]).toBe("step one + step two"); + expect(thinkingMeta.thinkingGenerationBySource?.[sourceKey]).toBe(4); + expect(thinkingMeta.thinkingMessageBySource?.[sourceKey]).toBe("msg-fallback"); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); + + test("keeps generation and message bindings isolated across sources", async () => { + const sourceA = "claude:block-0"; + const sourceB = "opencode:reasoning_part_9"; + const harness = await createStreamHarness([ + { + type: "thinking", + content: "alpha", + role: "assistant", + metadata: { + provider: "claude", + thinkingSourceKey: sourceA, + streamGeneration: 11, + targetMessageId: "msg-a", + }, + }, + { + type: "thinking", + content: "beta", + role: "assistant", + metadata: { + provider: "opencode", + thinkingSourceKey: sourceB, + streamGeneration: 12, + targetMessageId: "msg-b", + }, + }, + { + type: "text", + content: "done", + role: "assistant", + }, + ]); + + try { + const metaEvents: StreamingMeta[] = []; + + await expect( + harness.onStreamMessage( + "hello", + () => { + return; + }, + () => { + return; + }, + (meta) => { + metaEvents.push(meta); + }, + ), + ).resolves.toBeUndefined(); + + const thinkingMeta = [...metaEvents] + .reverse() + .find((meta) => meta.thinkingSourceKey === sourceB); + expect(thinkingMeta).toBeDefined(); + if (!thinkingMeta) { + throw new Error("Expected multi-source thinking metadata event"); + } + + expect(thinkingMeta.thinkingTextBySource?.[sourceA]).toBe("alpha"); + expect(thinkingMeta.thinkingTextBySource?.[sourceB]).toBe("beta"); + expect(thinkingMeta.thinkingGenerationBySource?.[sourceA]).toBe(11); + expect(thinkingMeta.thinkingGenerationBySource?.[sourceB]).toBe(12); + expect(thinkingMeta.thinkingMessageBySource?.[sourceA]).toBe("msg-a"); + expect(thinkingMeta.thinkingMessageBySource?.[sourceB]).toBe("msg-b"); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); + + test("regression: aggregates interleaved thinking chunks per source without concatenation bleed", async () => { + const sourceA = "claude:block-0"; + const sourceB = "opencode:reasoning_1"; + const harness = await createStreamHarness([ + { + type: "thinking", + content: "alpha-1 ", + role: "assistant", + metadata: { + provider: "claude", + thinkingSourceKey: sourceA, + streamGeneration: 9, + targetMessageId: "msg-a", + }, + }, + { + type: "thinking", + content: "beta-1 ", + role: "assistant", + metadata: { + provider: "opencode", + thinkingSourceKey: sourceB, + streamGeneration: 10, + targetMessageId: "msg-b", + }, + }, + { + type: "thinking", + content: "alpha-2", + role: "assistant", + metadata: { + provider: "claude", + thinkingSourceKey: sourceA, + streamGeneration: 9, + targetMessageId: "msg-a", + }, + }, + { + type: "thinking", + content: "beta-2", + role: "assistant", + metadata: { + provider: "opencode", + thinkingSourceKey: sourceB, + streamGeneration: 10, + targetMessageId: "msg-b", + }, + }, + { + type: "text", + content: "final", + role: "assistant", + }, + ]); + + try { + const metaEvents: StreamingMeta[] = []; + + await expect( + harness.onStreamMessage( + "hello", + () => { + return; + }, + () => { + return; + }, + (meta) => { + metaEvents.push(meta); + }, + ), + ).resolves.toBeUndefined(); + + const latest = [...metaEvents] + .reverse() + .find((meta) => meta.thinkingSourceKey === sourceB); + expect(latest).toBeDefined(); + if (!latest) { + throw new Error("Expected interleaved thinking metadata event"); + } + + expect(latest.thinkingTextBySource?.[sourceA]).toBe("alpha-1 alpha-2"); + expect(latest.thinkingTextBySource?.[sourceB]).toBe("beta-1 beta-2"); + expect(latest.thinkingTextBySource?.[sourceA]).not.toContain("beta"); + expect(latest.thinkingTextBySource?.[sourceB]).not.toContain("alpha"); + expect(latest.thinkingGenerationBySource?.[sourceA]).toBe(9); + expect(latest.thinkingGenerationBySource?.[sourceB]).toBe(10); + expect(latest.thinkingMessageBySource?.[sourceA]).toBe("msg-a"); + expect(latest.thinkingMessageBySource?.[sourceB]).toBe("msg-b"); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); + + test("interrupt handoff drops stale late thinking and keeps next stream isolated", async () => { + const { client, streams } = createControlledClient(2); + const firstStream = streams[0]; + const secondStream = streams[1]; + if (!firstStream || !secondStream) { + throw new Error("Expected two controlled streams"); + } + + const harness = await createStreamHarnessFromClient(client); + + try { + const firstMetaEvents: StreamingMeta[] = []; + let firstCompleteCalls = 0; + + const firstPromise = harness.onStreamMessage( + "first", + () => { + return; + }, + () => { + firstCompleteCalls += 1; + }, + (meta) => { + firstMetaEvents.push(meta); + }, + ); + + firstStream.emit({ + type: "thinking", + content: "old-thought", + role: "assistant", + metadata: { + provider: "claude", + thinkingSourceKey: "source:old", + streamGeneration: 1, + targetMessageId: "msg-old", + }, + }); + + await waitFor(() => firstMetaEvents.length === 1); + + harness.onInterrupt(); + + firstStream.emit({ + type: "thinking", + content: "+late-old", + role: "assistant", + metadata: { + provider: "claude", + thinkingSourceKey: "source:old", + streamGeneration: 1, + targetMessageId: "msg-old", + }, + }); + firstStream.end(); + await firstPromise; + + const secondMetaEvents: StreamingMeta[] = []; + + const secondPromise = harness.onStreamMessage( + "second", + () => { + return; + }, + () => { + return; + }, + (meta) => { + secondMetaEvents.push(meta); + }, + ); + + secondStream.emit({ + type: "thinking", + content: "new-thought", + role: "assistant", + metadata: { + provider: "copilot", + thinkingSourceKey: "source:new", + streamGeneration: 2, + targetMessageId: "msg-new", + }, + }); + secondStream.emit({ + type: "text", + content: "done", + role: "assistant", + }); + secondStream.end(); + await secondPromise; + + expect(firstCompleteCalls).toBe(1); + expect(firstMetaEvents).toHaveLength(1); + + const secondThinkingMeta = [...secondMetaEvents] + .reverse() + .find((meta) => meta.thinkingSourceKey === "source:new"); + expect(secondThinkingMeta).toBeDefined(); + if (!secondThinkingMeta) { + throw new Error("Expected next-stream thinking metadata"); + } + + expect(secondThinkingMeta.thinkingTextBySource?.["source:new"]).toBe("new-thought"); + expect(secondThinkingMeta.thinkingTextBySource?.["source:old"]).toBeUndefined(); + expect(secondThinkingMeta.thinkingGenerationBySource?.["source:old"]).toBeUndefined(); + expect(secondThinkingMeta.thinkingMessageBySource?.["source:old"]).toBeUndefined(); + } finally { + await Promise.resolve(harness.onExit()); + await harness.uiPromise; + harness.restore(); + } + }); +}); diff --git a/src/ui/index.ts b/src/ui/index.ts index a1205a90c..c581e2052 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -12,14 +12,17 @@ import { createCliRenderer, type CliRenderer } from "@opentui/core"; import { createRoot, type Root } from "@opentui/react"; import { ChatApp, + type StreamingMeta, type OnToolStart, type OnToolComplete, type OnSkillInvoked, type OnPermissionRequest as ChatOnPermissionRequest, type OnInterrupt, + type OnTerminateBackgroundAgents, type OnAskUserQuestion, type CommandExecutionTelemetry, type MessageSubmitTelemetry, + traceThinkingSourceLifecycle, } from "./chat.tsx"; import type { ParallelAgent } from "./components/parallel-agents-tree.tsx"; import { ThemeProvider, darkTheme, type Theme } from "./theme.tsx"; @@ -33,11 +36,13 @@ import type { } from "../sdk/types.ts"; import { UnifiedModelOperations } from "../models/model-operations.ts"; import { parseTaskToolResult } from "./tools/registry.ts"; +import { normalizeMarkdownNewlines } from "./utils/format.ts"; import { createTuiTelemetrySessionTracker, type TuiTelemetrySessionTracker, } from "../telemetry/index.ts"; import { shouldFinalizeOnToolComplete } from "./parts/index.ts"; +import { getActiveBackgroundAgents, isBackgroundAgent } from "./utils/background-agent-footer.ts"; /** * Build a system prompt section describing all registered capabilities. @@ -636,7 +641,7 @@ export async function startChatUI( if (isTaskToolName && data.toolInput && !isUpdate) { const input = data.toolInput as Record; const prompt = (input.prompt as string) ?? (input.description as string) ?? ""; - const isBackground = input.run_in_background === true; + const isBackground = input.run_in_background === true || input.mode === "background"; pendingTaskEntries.push({ toolId, prompt: prompt || undefined, isBackground, runId: activeRunId }); // Eagerly create a ParallelAgent so the tree appears immediately @@ -696,7 +701,7 @@ export async function startChatUI( ?? "Sub-agent task" ); const taskDesc = taskDescRaw.trim() || "Sub-agent task"; - const isBackground = input.run_in_background === true; + const isBackground = input.run_in_background === true || input.mode === "background"; const mappedAgentId = toolCallToAgentMap.get(toolId) ?? toolId; state.parallelAgents = state.parallelAgents.map((a) => @@ -851,9 +856,11 @@ export async function startChatUI( ) { // Extract clean result text using the shared parser const parsed = parseTaskToolResult(data.toolResult); - const resultStr = parsed.text ?? (typeof data.toolResult === "string" + const fallbackResultText = parsed.text ?? (typeof data.toolResult === "string" ? data.toolResult : JSON.stringify(data.toolResult)); + const normalizedResult = normalizeMarkdownNewlines(fallbackResultText); + const resultStr = normalizedResult.length > 0 ? normalizedResult : undefined; // Try ID-based correlation: SDK-level IDs first, then internal toolId const taskSdkCorrelationId = data.toolUseID ?? data.toolCallId ?? data.toolUseId; @@ -928,7 +935,7 @@ export async function startChatUI( // The SDK model may echo back the raw tool_response JSON as // streaming text — we suppress text that matches the result but // allow the model's real follow-up response through. - state.suppressPostTaskResult = resultStr; + state.suppressPostTaskResult = resultStr ?? null; } else if ( isTaskTool && state.parallelAgentHandler && @@ -1061,7 +1068,9 @@ export async function startChatUI( if (!sessionOwned && !pendingTaskEntry && !hasSdkCorrelationMatch) return; // Fail closed for uncorrelated events to prevent cross-run leakage, // but allow flows with SDK correlation IDs even if no Task entry exists. - if (!pendingTaskEntry && !hasSdkCorrelationMatch) return; + // Also allow session-owned events during active streaming — this supports + // SDKs like Copilot that dispatch custom agents without a Task tool. + if (!pendingTaskEntry && !hasSdkCorrelationMatch && !sessionOwned) return; // Use task from event data, or dequeue a pending Task tool prompt const fallbackInput = data.toolInput as Record | undefined; @@ -1082,7 +1091,8 @@ export async function startChatUI( ?? "agent" ).trim() || "agent"; const isBackground = pendingTaskEntry?.isBackground - ?? (fallbackInput?.run_in_background === true); + ?? ((fallbackInput?.run_in_background === true) + || (fallbackInput?.mode === "background")); // Check if an eager agent was already created from tool.start. // If so, update it in-place with the real subagentId instead of @@ -1185,6 +1195,9 @@ export async function startChatUI( if (state.parallelAgentHandler && data.subagentId) { const status = data.success !== false ? "completed" : "error"; + const normalizedResult = data.result == null + ? undefined + : normalizeMarkdownNewlines(String(data.result)); state.parallelAgents = state.parallelAgents.map((a) => a.id === data.subagentId ? { @@ -1193,7 +1206,7 @@ export async function startChatUI( // Clear currentTool so getSubStatusText falls through to // the status-based default ("Done" / error message) currentTool: undefined, - result: data.result ? String(data.result) : undefined, + result: normalizedResult && normalizedResult.length > 0 ? normalizedResult : undefined, durationMs: Date.now() - new Date(a.startedAt).getTime(), } : a @@ -1290,7 +1303,7 @@ export async function startChatUI( content: string, onChunk: (chunk: string) => void, onComplete: () => void, - onMeta?: (meta: { outputTokens: number; thinkingMs: number; thinkingText: string }) => void, + onMeta?: (meta: StreamingMeta) => void, options?: { agent?: string } ): Promise { // Single-owner stream model: any new stream handoff resets previous @@ -1311,6 +1324,38 @@ export async function startChatUI( state.streamAbortController = new AbortController(); state.currentRunId = ++state.runCounter; state.isStreaming = true; + const thinkingTextBySourceMap = new Map(); + const thinkingGenerationBySourceMap = new Map(); + const thinkingMessageBySourceMap = new Map(); + const activeThinkingSources = new Set(); + const closedThinkingSources = new Set(); + + const closeThinkingSourcesAndClearMaps = (): void => { + const finalizedSources = new Set(); + for (const sourceKey of thinkingTextBySourceMap.keys()) { + finalizedSources.add(sourceKey); + } + for (const sourceKey of thinkingGenerationBySourceMap.keys()) { + finalizedSources.add(sourceKey); + } + for (const sourceKey of thinkingMessageBySourceMap.keys()) { + finalizedSources.add(sourceKey); + } + for (const sourceKey of activeThinkingSources) { + finalizedSources.add(sourceKey); + } + for (const sourceKey of finalizedSources) { + if (!closedThinkingSources.has(sourceKey)) { + traceThinkingSourceLifecycle("finalize", sourceKey, "stream finalize"); + } + closedThinkingSources.add(sourceKey); + } + + thinkingTextBySourceMap.clear(); + thinkingGenerationBySourceMap.clear(); + thinkingMessageBySourceMap.clear(); + activeThinkingSources.clear(); + }; try { // Stream the response, wrapped so abort takes effect immediately @@ -1327,7 +1372,6 @@ export async function startChatUI( // Map SDK tool use IDs to internal tool IDs for stream-path deduplication const streamToolIdMap = new Map(); const allowStreamToolEvents = !state.toolEventsViaHooks || agentType === "opencode"; - let thinkingText = ""; // Reset the suppress state at the start of each stream state.suppressPostTaskResult = null; @@ -1339,6 +1383,72 @@ export async function startChatUI( let suppressAccumulator = ""; let suppressTarget: string | null = null; + const toStringRecord = (sourceMap: Map): Record => { + const record: Record = {}; + for (const [source, value] of sourceMap) { + record[source] = value; + } + return record; + }; + + const toNumberRecord = (sourceMap: Map): Record => { + const record: Record = {}; + for (const [source, value] of sourceMap) { + record[source] = value; + } + return record; + }; + + const resolveThinkingSourceKey = (message: AgentMessage): string => { + const metadata = message.metadata as Record | undefined; + const sourceFromMetadata = typeof metadata?.thinkingSourceKey === "string" + ? metadata.thinkingSourceKey.trim() + : ""; + if (sourceFromMetadata.length > 0) { + return sourceFromMetadata; + } + const contractError = new Error( + "Contract violation: thinking stream message is missing required metadata.thinkingSourceKey" + ); + contractError.name = "ThinkingSourceContractViolationError"; + throw contractError; + }; + + const bindThinkingSource = (sourceKey: string, message: AgentMessage): void => { + const metadata = message.metadata as Record | undefined; + const generationFromMetadata = metadata?.streamGeneration; + const resolvedGeneration = typeof generationFromMetadata === "number" + && Number.isFinite(generationFromMetadata) + ? generationFromMetadata + : (state.currentRunId ?? state.runCounter); + thinkingGenerationBySourceMap.set(sourceKey, resolvedGeneration); + + const targetMessageId = typeof metadata?.targetMessageId === "string" + ? metadata.targetMessageId + : (typeof metadata?.messageId === "string" ? metadata.messageId : undefined); + if (targetMessageId && targetMessageId.length > 0) { + thinkingMessageBySourceMap.set(sourceKey, targetMessageId); + } + }; + + const getThinkingTextSnapshot = (): string => { + let combined = ""; + for (const text of thinkingTextBySourceMap.values()) { + combined += text; + } + return combined; + }; + + const createStreamingMetaSnapshot = (thinkingSourceKey?: string): StreamingMeta => ({ + outputTokens: sdkOutputTokens, + thinkingMs, + thinkingText: getThinkingTextSnapshot(), + thinkingSourceKey, + thinkingTextBySource: toStringRecord(thinkingTextBySourceMap), + thinkingGenerationBySource: toNumberRecord(thinkingGenerationBySourceMap), + thinkingMessageBySource: toStringRecord(thinkingMessageBySourceMap), + }); + for await (const message of abortableStream) { // Handle text content if (message.type === "text" && typeof message.content === "string") { @@ -1404,10 +1514,24 @@ export async function startChatUI( sdkOutputTokens = stats.outputTokens; } - onMeta?.({ outputTokens: sdkOutputTokens, thinkingMs, thinkingText }); + onMeta?.(createStreamingMetaSnapshot()); } // Handle thinking metadata from SDK else if (message.type === "thinking") { + const thinkingSourceKey = resolveThinkingSourceKey(message); + if (closedThinkingSources.has(thinkingSourceKey)) { + traceThinkingSourceLifecycle("drop", thinkingSourceKey, "index closed-source rejection"); + continue; + } + const isNewSource = !activeThinkingSources.has(thinkingSourceKey); + if (isNewSource) { + activeThinkingSources.add(thinkingSourceKey); + traceThinkingSourceLifecycle("create", thinkingSourceKey, "index first-seen thinking event"); + } else { + traceThinkingSourceLifecycle("update", thinkingSourceKey, "index thinking event update"); + } + bindThinkingSource(thinkingSourceKey, message); + // Start local wall-clock timer on first thinking message if (thinkingStartLocal === null) { thinkingStartLocal = Date.now(); @@ -1415,7 +1539,8 @@ export async function startChatUI( // Capture thinking text content if (typeof message.content === "string") { - thinkingText += message.content; + const previous = thinkingTextBySourceMap.get(thinkingSourceKey) ?? ""; + thinkingTextBySourceMap.set(thinkingSourceKey, previous + message.content); } const stats = message.metadata?.streamingStats as @@ -1435,7 +1560,7 @@ export async function startChatUI( if (stats?.outputTokens && stats.outputTokens > 0) { sdkOutputTokens = stats.outputTokens; } - onMeta?.({ outputTokens: sdkOutputTokens, thinkingMs, thinkingText }); + onMeta?.(createStreamingMetaSnapshot(thinkingSourceKey)); } // Handle tool_use content - notify UI of tool invocation // OpenCode can complete the stream before hook events flush; keep a @@ -1488,12 +1613,19 @@ export async function startChatUI( } } + closeThinkingSourcesAndClearMaps(); state.messageCount++; onComplete(); } catch (error) { + closeThinkingSourcesAndClearMaps(); // Ignore AbortError - this is expected when user interrupts if (error instanceof Error && error.name === "AbortError") { // Stream was intentionally aborted + } else if (error instanceof Error && error.name === "ThinkingSourceContractViolationError") { + state.currentRunId = null; + state.resetParallelTracking?.("stream_error"); + onComplete(); + throw error; } state.currentRunId = null; state.resetParallelTracking?.("stream_error"); @@ -1535,17 +1667,21 @@ export async function startChatUI( // don't flow through and overwrite React state after interrupt state.isStreaming = false; state.currentRunId = null; + + // Preserve background agents across the reset — resetParallelTracking + // calls clearParallelAgents() which wipes ALL agents from state. + const backgroundAgents = state.parallelAgents.filter(isBackgroundAgent); state.resetParallelTracking?.("interrupt"); - state.streamAbortController?.abort(); - // If the session supports abort (e.g., Copilot), call it to cancel - // in-flight agent work including sub-agent invocations. - // This prevents cancelled sub-agent requests from being queued and - // executing when the next prompt is submitted. - if (state.session?.abort) { - void state.session.abort().catch((error) => { - console.error("Failed to abort session:", error); - }); + // Restore background agents that were cleared by resetParallelTracking + if (backgroundAgents.length > 0) { + state.parallelAgents = backgroundAgents; + state.parallelAgentHandler?.(state.parallelAgents); } + + state.streamAbortController?.abort(); + // NOTE: Do NOT call session.abort() here — it aborts the entire SDK + // session which kills ALL agents including background ones. The stream + // abort controller above is sufficient to cancel the foreground stream. state.telemetryTracker?.trackInterrupt(sourceType); // Reset interrupt state state.interruptCount = 0; @@ -1698,6 +1834,32 @@ export async function startChatUI( handleInterrupt("ui"); }; + const handleTerminateBackgroundAgentsFromUI: OnTerminateBackgroundAgents = () => { + const activeAgents = getActiveBackgroundAgents(state.parallelAgents); + if (activeAgents.length === 0) { + state.telemetryTracker?.trackBackgroundTermination("noop", 0); + return; + } + + const activeCount = activeAgents.length; + + // Clear background agents from state tracking + state.parallelAgents = state.parallelAgents.filter(a => !isBackgroundAgent(a)); + state.parallelAgentHandler?.(state.parallelAgents); + + // Abort the SDK session to actually kill background agent processes. + // This is safe because ctrl+f only fires when NOT streaming (guarded + // by isStreamingRef.current check in chat.tsx), so no foreground work + // will be affected. + if (state.session?.abort) { + void state.session.abort().catch((error) => { + console.error("Failed to abort session during background-agent termination:", error); + }); + } + + state.telemetryTracker?.trackBackgroundTermination("execute", activeCount, activeCount); + }; + /** * Get the current session for slash commands like /compact. */ @@ -1787,6 +1949,7 @@ export async function startChatUI( onExit: handleExit, onResetSession: resetSession, onInterrupt: handleInterruptFromUI, + onTerminateBackgroundAgents: handleTerminateBackgroundAgentsFromUI, registerToolStartHandler, registerToolCompleteHandler, registerSkillInvokedHandler, @@ -1937,6 +2100,7 @@ export { type OnToolStart, type OnToolComplete, type OnInterrupt, + type OnTerminateBackgroundAgents, type OnAskUserQuestion, defaultWorkflowChatState, } from "./chat.tsx"; diff --git a/src/ui/parts/guards.test.ts b/src/ui/parts/guards.test.ts index 294ff7986..ce84b8cc2 100644 --- a/src/ui/parts/guards.test.ts +++ b/src/ui/parts/guards.test.ts @@ -1,5 +1,9 @@ import { test, expect, describe } from "bun:test"; -import { shouldFinalizeOnToolComplete } from "./guards.ts"; +import { + shouldFinalizeOnToolComplete, + hasActiveForegroundAgents, + shouldFinalizeDeferredStream, +} from "./guards.ts"; import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; // Create minimal ParallelAgent objects for testing @@ -56,3 +60,45 @@ describe("shouldFinalizeOnToolComplete", () => { expect(shouldFinalizeOnToolComplete(agent)).toBe(true); }); }); + +describe("hasActiveForegroundAgents", () => { + test("returns true for running foreground agent", () => { + const agents = [createMockAgent({ status: "running", background: false })]; + expect(hasActiveForegroundAgents(agents)).toBe(true); + }); + + test("returns true for pending foreground agent", () => { + const agents = [createMockAgent({ status: "pending", background: false })]; + expect(hasActiveForegroundAgents(agents)).toBe(true); + }); + + test("returns false for running background-only agents", () => { + const agents = [createMockAgent({ status: "running", background: true })]; + expect(hasActiveForegroundAgents(agents)).toBe(false); + }); + + test("returns false when all agents are terminal", () => { + const agents = [ + createMockAgent({ status: "completed", background: false }), + createMockAgent({ id: "agent-2", status: "error", background: false }), + ]; + expect(hasActiveForegroundAgents(agents)).toBe(false); + }); +}); + +describe("shouldFinalizeDeferredStream", () => { + test("returns false while foreground agents are active", () => { + const agents = [createMockAgent({ status: "running", background: false })]; + expect(shouldFinalizeDeferredStream(agents, false)).toBe(false); + }); + + test("returns false while tools are still running", () => { + const agents = [createMockAgent({ status: "completed", background: false })]; + expect(shouldFinalizeDeferredStream(agents, true)).toBe(false); + }); + + test("returns true when only background agents remain", () => { + const agents = [createMockAgent({ status: "background", background: true })]; + expect(shouldFinalizeDeferredStream(agents, false)).toBe(true); + }); +}); diff --git a/src/ui/parts/guards.ts b/src/ui/parts/guards.ts index 6f5493111..54942ec83 100644 --- a/src/ui/parts/guards.ts +++ b/src/ui/parts/guards.ts @@ -21,3 +21,25 @@ export function shouldFinalizeOnToolComplete(agent: ParallelAgent): boolean { if (agent.status === "background") return false; return true; } + +/** + * Returns true when at least one foreground sub-agent is still in-flight. + * Background agents are intentionally excluded from this gate. + */ +export function hasActiveForegroundAgents(agents: readonly ParallelAgent[]): boolean { + return agents.some( + (agent) => + (agent.status === "running" || agent.status === "pending") + && shouldFinalizeOnToolComplete(agent), + ); +} + +/** + * Stream completion can proceed only when no blocking sub-agents or tools remain. + */ +export function shouldFinalizeDeferredStream( + agents: readonly ParallelAgent[], + hasRunningTool: boolean, +): boolean { + return !hasRunningTool && !hasActiveForegroundAgents(agents); +} diff --git a/src/ui/parts/index.ts b/src/ui/parts/index.ts index 7796d5de6..249dcf941 100644 --- a/src/ui/parts/index.ts +++ b/src/ui/parts/index.ts @@ -20,6 +20,20 @@ export { type Part, } from "./types.ts"; export { binarySearchById, upsertPart, findLastPartIndex } from "./store.ts"; -export { shouldFinalizeOnToolComplete } from "./guards.ts"; +export { + shouldFinalizeOnToolComplete, + hasActiveForegroundAgents, + shouldFinalizeDeferredStream, +} from "./guards.ts"; export { getMessageText } from "./helpers.ts"; export { handleTextDelta } from "./handlers.ts"; +export { + type StreamPartEvent, + applyStreamPartEvent, + toToolState, + shouldGroupSubagentTrees, + mergeParallelAgentsIntoParts, + syncToolCallsIntoParts, + finalizeStreamingReasoningParts, + finalizeStreamingReasoningInMessage, +} from "./stream-pipeline.ts"; diff --git a/src/ui/parts/stream-pipeline.test.ts b/src/ui/parts/stream-pipeline.test.ts new file mode 100644 index 000000000..dc8a92b8b --- /dev/null +++ b/src/ui/parts/stream-pipeline.test.ts @@ -0,0 +1,828 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import type { ChatMessage } from "../chat.tsx"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { _resetPartCounter, createPartId } from "./id.ts"; +import { + applyStreamPartEvent, + finalizeStreamingReasoningInMessage, + finalizeStreamingReasoningParts, +} from "./stream-pipeline.ts"; + +function createAssistantMessage(): ChatMessage { + return { + id: "msg-test", + role: "assistant", + content: "", + timestamp: new Date().toISOString(), + streaming: true, + parts: [], + toolCalls: [], + }; +} + +function findReasoningPartBySource(message: ChatMessage, sourceKey: string) { + return (message.parts ?? []).find( + (part) => part.type === "reasoning" && part.thinkingSourceKey === sourceKey, + ); +} + +beforeEach(() => { + _resetPartCounter(); +}); + +describe("applyStreamPartEvent", () => { + test("applies text delta to legacy content and parts", () => { + const msg = createAssistantMessage(); + const next = applyStreamPartEvent(msg, { type: "text-delta", delta: "Hello" }); + + expect(next.content).toBe("Hello"); + expect(next.parts).toHaveLength(1); + expect(next.parts?.[0]?.type).toBe("text"); + }); + + test("updates thinking metadata without creating reasoning parts by default", () => { + const msg = createAssistantMessage(); + const next = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 1200, + thinkingText: "analyzing", + }); + + expect(next.thinkingMs).toBe(1200); + expect(next.thinkingText).toBe("analyzing"); + expect(next.parts).toHaveLength(0); + }); + + test("streams thinking as a dedicated reasoning part when enabled", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 1200, + thinkingText: "analyzing options", + includeReasoningPart: true, + }); + + const next = applyStreamPartEvent(msg, { type: "text-delta", delta: "Final answer" }); + + expect(next.content).toBe("Final answer"); + expect(next.parts?.map((part) => part.type)).toEqual(["reasoning", "text"]); + + const reasoningPart = next.parts?.[0]; + expect(reasoningPart?.type).toBe("reasoning"); + if (reasoningPart?.type === "reasoning") { + expect(reasoningPart.content).toBe("analyzing options"); + expect(reasoningPart.durationMs).toBe(1200); + expect(reasoningPart.isStreaming).toBe(true); + } + + const textPart = next.parts?.[1]; + expect(textPart?.type).toBe("text"); + if (textPart?.type === "text") { + expect(textPart.content).toBe("Final answer"); + } + }); + + test("inserts late thinking metadata before text and updates same reasoning block", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { type: "text-delta", delta: "Answer " }); + + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 800, + thinkingText: "initial thought", + includeReasoningPart: true, + }); + + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 1250, + thinkingText: "initial thought with refinement", + includeReasoningPart: true, + }); + + const next = applyStreamPartEvent(msg, { type: "text-delta", delta: "continues" }); + + expect(next.parts?.map((part) => part.type)).toEqual(["reasoning", "text"]); + expect(next.content).toBe("Answer continues"); + + const reasoningPart = next.parts?.[0]; + expect(reasoningPart?.type).toBe("reasoning"); + if (reasoningPart?.type === "reasoning") { + expect(reasoningPart.content).toBe("initial thought with refinement"); + expect(reasoningPart.durationMs).toBe(1250); + } + + const textPart = next.parts?.[1]; + expect(textPart?.type).toBe("text"); + if (textPart?.type === "text") { + expect(textPart.content).toBe("Answer continues"); + } + }); + + test("upserts reasoning parts by thinking source key without cross-source overwrite", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:a", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 200, + thinkingText: "alpha draft", + includeReasoningPart: true, + }); + + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:b", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 300, + thinkingText: "beta draft", + includeReasoningPart: true, + }); + + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:a", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 420, + thinkingText: "alpha refined", + includeReasoningPart: true, + }); + + const next = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:b", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 560, + thinkingText: "beta refined", + includeReasoningPart: true, + }); + + const reasoningParts = (next.parts ?? []).filter((part) => part.type === "reasoning"); + expect(reasoningParts).toHaveLength(2); + + const sourceA = reasoningParts.find( + (part) => part.type === "reasoning" && part.thinkingSourceKey === "source:a", + ); + expect(sourceA?.type).toBe("reasoning"); + if (sourceA?.type === "reasoning") { + expect(sourceA.content).toBe("alpha refined"); + expect(sourceA.durationMs).toBe(420); + expect(sourceA.isStreaming).toBe(true); + } + + const sourceB = reasoningParts.find( + (part) => part.type === "reasoning" && part.thinkingSourceKey === "source:b", + ); + expect(sourceB?.type).toBe("reasoning"); + if (sourceB?.type === "reasoning") { + expect(sourceB.content).toBe("beta refined"); + expect(sourceB.durationMs).toBe(560); + expect(sourceB.isStreaming).toBe(true); + } + }); + + test("re-syncs per-source registry from reasoning parts when mapping is missing", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:a", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 200, + thinkingText: "alpha draft", + includeReasoningPart: true, + }); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:b", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 300, + thinkingText: "beta draft", + includeReasoningPart: true, + }); + + const sourceABefore = findReasoningPartBySource(msg, "source:a"); + const sourceBBefore = findReasoningPartBySource(msg, "source:b"); + expect(sourceABefore?.type).toBe("reasoning"); + expect(sourceBBefore?.type).toBe("reasoning"); + + const messageWithoutRegistry: ChatMessage = { + ...msg, + parts: [...(msg.parts ?? [])], + }; + + const next = applyStreamPartEvent(messageWithoutRegistry, { + type: "thinking-meta", + thinkingSourceKey: "source:b", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 560, + thinkingText: "beta refined after clone", + includeReasoningPart: true, + }); + + const sourceAAfter = findReasoningPartBySource(next, "source:a"); + expect(sourceAAfter?.type).toBe("reasoning"); + if (sourceAAfter?.type === "reasoning" && sourceABefore?.type === "reasoning") { + expect(sourceAAfter.id).toBe(sourceABefore.id); + expect(sourceAAfter.content).toBe("alpha draft"); + expect(sourceAAfter.durationMs).toBe(200); + } + + const sourceBAfter = findReasoningPartBySource(next, "source:b"); + expect(sourceBAfter?.type).toBe("reasoning"); + if (sourceBAfter?.type === "reasoning" && sourceBBefore?.type === "reasoning") { + expect(sourceBAfter.id).toBe(sourceBBefore.id); + expect(sourceBAfter.content).toBe("beta refined after clone"); + expect(sourceBAfter.durationMs).toBe(560); + } + + expect((next.parts ?? []).filter((part) => part.type === "reasoning")).toHaveLength(2); + }); + + test("re-syncs per-source registry when mapping points to a stale part id", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:a", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 200, + thinkingText: "alpha draft", + includeReasoningPart: true, + }); + + const sourceAInitial = findReasoningPartBySource(msg, "source:a"); + expect(sourceAInitial?.type).toBe("reasoning"); + if (sourceAInitial?.type === "reasoning") { + sourceAInitial.id = createPartId(); + } + + const next = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:a", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 420, + thinkingText: "alpha refined after stale mapping", + includeReasoningPart: true, + }); + + const sourceAAfter = findReasoningPartBySource(next, "source:a"); + expect(sourceAAfter?.type).toBe("reasoning"); + if (sourceAAfter?.type === "reasoning" && sourceAInitial?.type === "reasoning") { + expect(sourceAAfter.id).toBe(sourceAInitial.id); + expect(sourceAAfter.content).toBe("alpha refined after stale mapping"); + expect(sourceAAfter.durationMs).toBe(420); + expect(sourceAAfter.isStreaming).toBe(true); + } + + expect((next.parts ?? []).filter((part) => part.type === "reasoning")).toHaveLength(1); + }); + + test("handles tool start by finalizing text and inserting a tool part", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { type: "text-delta", delta: "Before tool" }); + + const next = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "tool_1", + toolName: "Read", + input: { filePath: "README.md" }, + }); + + expect(next.toolCalls).toHaveLength(1); + expect(next.toolCalls?.[0]?.status).toBe("running"); + expect(next.parts?.map((part) => part.type)).toEqual(["text", "tool"]); + expect(next.parts?.[0] && "isStreaming" in next.parts[0] ? next.parts[0].isStreaming : false).toBe(false); + }); + + test("handles tool completion and updates both representations", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "tool_1", + toolName: "Read", + input: { filePath: "README.md" }, + }); + + const next = applyStreamPartEvent(msg, { + type: "tool-complete", + toolId: "tool_1", + output: "ok", + success: true, + }); + + expect(next.toolCalls?.[0]?.status).toBe("completed"); + const toolPart = next.parts?.find((part) => part.type === "tool"); + expect(toolPart?.type).toBe("tool"); + if (toolPart?.type === "tool") { + expect(toolPart.state.status).toBe("completed"); + } + }); + + test("handles invalid running startedAt when completing a tool", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "tool_1", + toolName: "Read", + input: { filePath: "README.md" }, + }); + + msg = { + ...msg, + parts: (msg.parts ?? []).map((part) => { + if (part.type !== "tool" || part.toolCallId !== "tool_1" || part.state.status !== "running") { + return part; + } + return { + ...part, + state: { + ...part.state, + startedAt: "invalid-date", + }, + }; + }), + }; + + const next = applyStreamPartEvent(msg, { + type: "tool-complete", + toolId: "tool_1", + output: "ok", + success: true, + }); + + const toolPart = next.parts?.find((part) => part.type === "tool"); + expect(toolPart?.type).toBe("tool"); + if (toolPart?.type === "tool" && toolPart.state.status === "completed") { + expect(toolPart.state.durationMs).toBe(0); + } + }); + + test("keeps thinking, pre-tool text, and post-tool text segmented", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 640, + thinkingText: "break problem into steps", + includeReasoningPart: true, + }); + msg = applyStreamPartEvent(msg, { type: "text-delta", delta: "Draft answer" }); + + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "tool_1", + toolName: "Read", + input: { filePath: "README.md" }, + }); + + msg = applyStreamPartEvent(msg, { + type: "tool-complete", + toolId: "tool_1", + output: "ok", + success: true, + }); + + const next = applyStreamPartEvent(msg, { type: "text-delta", delta: " after tool" }); + + expect(next.content).toBe("Draft answer after tool"); + expect(next.parts?.map((part) => part.type)).toEqual(["reasoning", "text", "tool", "text"]); + + const firstText = next.parts?.[1]; + expect(firstText?.type).toBe("text"); + if (firstText?.type === "text") { + expect(firstText.content).toBe("Draft answer"); + expect(firstText.isStreaming).toBe(false); + } + + const secondText = next.parts?.[3]; + expect(secondText?.type).toBe("text"); + if (secondText?.type === "text") { + expect(secondText.content).toBe(" after tool"); + expect(secondText.isStreaming).toBe(true); + } + }); + + test("stores HITL request and response on matching tool part", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "tool_hitl", + toolName: "AskUserQuestion", + input: { question: "Continue?" }, + }); + + msg = applyStreamPartEvent(msg, { + type: "tool-hitl-request", + toolId: "tool_hitl", + request: { + requestId: "req_1", + header: "Question", + question: "Continue?", + options: [{ label: "Yes", value: "yes" }], + multiSelect: false, + respond: () => {}, + }, + }); + + const afterResponse = applyStreamPartEvent(msg, { + type: "tool-hitl-response", + toolId: "tool_hitl", + response: { + cancelled: false, + responseMode: "option", + answerText: "yes", + displayText: "Yes", + }, + }); + + const toolPart = afterResponse.parts?.find((part) => part.type === "tool"); + expect(toolPart?.type).toBe("tool"); + if (toolPart?.type === "tool") { + expect(toolPart.pendingQuestion).toBeUndefined(); + expect(toolPart.hitlResponse?.answerText).toBe("yes"); + } + }); + + test("merges parallel agents into agent part for subagent/background updates", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "task_1", + toolName: "Task", + input: { description: "Investigate" }, + }); + + const agents: ParallelAgent[] = [ + { + id: "agent_1", + taskToolCallId: "task_1", + name: "researcher", + task: "Investigate", + status: "background", + background: true, + startedAt: new Date().toISOString(), + }, + ]; + + const next = applyStreamPartEvent(msg, { + type: "parallel-agents", + agents, + isLastMessage: true, + }); + + expect(next.parallelAgents).toHaveLength(1); + const agentPart = next.parts?.find((part) => part.type === "agent"); + expect(agentPart?.type).toBe("agent"); + if (agentPart?.type === "agent") { + expect(agentPart.agents[0]?.background).toBe(true); + expect(agentPart.agents[0]?.status).toBe("background"); + } + }); + + test("returns to main text stream after subagent completion", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "task_1", + toolName: "Task", + input: { description: "Investigate" }, + }); + + const runningAgents: ParallelAgent[] = [ + { + id: "agent_1", + taskToolCallId: "task_1", + name: "researcher", + task: "Investigate", + status: "running", + startedAt: new Date().toISOString(), + }, + ]; + + msg = applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: runningAgents, + isLastMessage: true, + }); + + msg = applyStreamPartEvent(msg, { + type: "tool-complete", + toolId: "task_1", + output: "subagent output", + success: true, + }); + + const completedAgents: ParallelAgent[] = [ + { + ...runningAgents[0]!, + status: "completed", + result: "subagent output", + durationMs: 1200, + }, + ]; + + msg = applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: completedAgents, + isLastMessage: true, + }); + + const next = applyStreamPartEvent(msg, { + type: "text-delta", + delta: "Main assistant continues.", + }); + + expect(next.content).toBe("Main assistant continues."); + expect(next.parts?.map((part) => part.type)).toEqual(["tool", "agent", "text"]); + + const trailingText = next.parts?.[2]; + expect(trailingText?.type).toBe("text"); + if (trailingText?.type === "text") { + expect(trailingText.content).toBe("Main assistant continues."); + expect(trailingText.isStreaming).toBe(true); + } + }); + + test("keeps main continuation text separate from completed subagent result", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "task_1", + toolName: "Task", + input: { description: "Investigate" }, + }); + msg = applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: [ + { + id: "agent_1", + taskToolCallId: "task_1", + name: "researcher", + task: "Investigate", + status: "completed", + result: "subagent output", + startedAt: new Date().toISOString(), + durationMs: 900, + }, + ], + isLastMessage: true, + }); + + msg = applyStreamPartEvent(msg, { + type: "text-delta", + delta: "Main ", + }); + const next = applyStreamPartEvent(msg, { + type: "text-delta", + delta: "assistant reply", + }); + + const textParts = next.parts?.filter((part) => part.type === "text") ?? []; + expect(textParts).toHaveLength(1); + const textPart = textParts[0]; + expect(textPart?.type).toBe("text"); + if (textPart?.type === "text") { + expect(textPart.content).toBe("Main assistant reply"); + } + + const agentPart = next.parts?.find((part) => part.type === "agent"); + expect(agentPart?.type).toBe("agent"); + if (agentPart?.type === "agent") { + expect(agentPart.agents[0]?.result).toBe("subagent output"); + expect(agentPart.agents[0]?.status).toBe("completed"); + } + }); + + test("keeps control on main stream after subagent completion updates", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "task_1", + toolName: "Task", + input: { description: "Investigate" }, + }); + + msg = applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: [ + { + id: "agent_1", + taskToolCallId: "task_1", + name: "researcher", + task: "Investigate", + status: "completed", + result: "subagent output", + startedAt: new Date().toISOString(), + durationMs: 900, + }, + ], + isLastMessage: true, + }); + + msg = applyStreamPartEvent(msg, { type: "text-delta", delta: "Main starts" }); + + msg = applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: [ + { + id: "agent_1", + taskToolCallId: "task_1", + name: "researcher", + task: "Investigate", + status: "completed", + result: "subagent output\n\nwith details", + startedAt: new Date().toISOString(), + durationMs: 920, + }, + ], + isLastMessage: true, + }); + + const next = applyStreamPartEvent(msg, { type: "text-delta", delta: " and continues" }); + + expect(next.parts?.map((part) => part.type)).toEqual(["tool", "agent", "text"]); + expect(next.content).toBe("Main starts and continues"); + + const textPart = next.parts?.[2]; + expect(textPart?.type).toBe("text"); + if (textPart?.type === "text") { + expect(textPart.content).toBe("Main starts and continues"); + expect(textPart.isStreaming).toBe(true); + } + + const agentPart = next.parts?.[1]; + expect(agentPart?.type).toBe("agent"); + if (agentPart?.type === "agent") { + expect(agentPart.agents[0]?.result).toBe("subagent output\n\nwith details"); + expect(agentPart.agents[0]?.status).toBe("completed"); + } + }); + + test("normalizes subagent result formatting during streaming agent updates", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "task_1", + toolName: "Task", + input: { description: "Review implementation" }, + }); + + const next = applyStreamPartEvent(msg, { + type: "parallel-agents", + agents: [ + { + id: "agent_1", + taskToolCallId: "task_1", + name: "reviewer", + task: "Review implementation", + status: "completed", + result: "\r\n\r\n```json\r\n{\"ok\": true}\r\n```\r\n", + startedAt: new Date().toISOString(), + durationMs: 900, + }, + ], + isLastMessage: true, + }); + + expect(next.parallelAgents?.[0]?.result).toBe("```json\n{\"ok\": true}\n```"); + + const agentPart = next.parts?.find((part) => part.type === "agent"); + expect(agentPart?.type).toBe("agent"); + if (agentPart?.type === "agent") { + expect(agentPart.agents[0]?.result).toBe("```json\n{\"ok\": true}\n```"); + } + }); +}); + +describe("reasoning streaming finalizers", () => { + test("finalizeStreamingReasoningParts marks only streaming reasoning parts complete", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 300, + thinkingText: "inspect", + includeReasoningPart: true, + }); + msg = applyStreamPartEvent(msg, { type: "text-delta", delta: "answer" }); + + const finalized = finalizeStreamingReasoningParts(msg.parts ?? []); + + expect(finalized[0]?.type).toBe("reasoning"); + if (finalized[0]?.type === "reasoning") { + expect(finalized[0].isStreaming).toBe(false); + } + + expect(finalized[1]?.type).toBe("text"); + if (finalized[1]?.type === "text") { + expect(finalized[1].isStreaming).toBe(true); + } + }); + + test("finalizeStreamingReasoningInMessage returns updated message parts", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 450, + thinkingText: "plan", + includeReasoningPart: true, + }); + + const finalized = finalizeStreamingReasoningInMessage(msg); + + expect(finalized).not.toBe(msg); + expect(finalized.parts?.[0]?.type).toBe("reasoning"); + if (finalized.parts?.[0]?.type === "reasoning") { + expect(finalized.parts[0].isStreaming).toBe(false); + } + }); + + test("normal completion path finalizes reasoning part streaming state", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 600, + thinkingText: "analyzing", + includeReasoningPart: true, + }); + msg = applyStreamPartEvent(msg, { type: "text-delta", delta: "final answer" }); + + const completed = { + ...finalizeStreamingReasoningInMessage(msg), + streaming: false, + }; + + expect(completed.streaming).toBe(false); + const reasoning = completed.parts?.find((part) => part.type === "reasoning"); + expect(reasoning?.type).toBe("reasoning"); + if (reasoning?.type === "reasoning") { + expect(reasoning.isStreaming).toBe(false); + } + }); + + test("interrupted completion path finalizes reasoning part streaming state", () => { + let msg = createAssistantMessage(); + msg = applyStreamPartEvent(msg, { + type: "thinking-meta", + thinkingSourceKey: "source:test", + targetMessageId: "msg-test", + streamGeneration: 1, + thinkingMs: 700, + thinkingText: "checking constraints", + includeReasoningPart: true, + }); + msg = applyStreamPartEvent(msg, { type: "text-delta", delta: "working..." }); + msg = applyStreamPartEvent(msg, { + type: "tool-start", + toolId: "tool_1", + toolName: "Read", + input: { filePath: "README.md" }, + }); + + const interrupted = { + ...finalizeStreamingReasoningInMessage(msg), + streaming: false, + toolCalls: (msg.toolCalls ?? []).map((toolCall) => + toolCall.status === "running" ? { ...toolCall, status: "interrupted" as const } : toolCall + ), + }; + + expect(interrupted.streaming).toBe(false); + const reasoning = interrupted.parts?.find((part) => part.type === "reasoning"); + expect(reasoning?.type).toBe("reasoning"); + if (reasoning?.type === "reasoning") { + expect(reasoning.isStreaming).toBe(false); + } + expect(interrupted.toolCalls?.[0]?.status).toBe("interrupted"); + }); +}); diff --git a/src/ui/parts/stream-pipeline.ts b/src/ui/parts/stream-pipeline.ts new file mode 100644 index 000000000..786cb4e0c --- /dev/null +++ b/src/ui/parts/stream-pipeline.ts @@ -0,0 +1,879 @@ +/** + * Unified stream-part pipeline utilities. + * + * Provides a single event reducer for updating assistant messages from + * streaming events (text, thinking metadata, tools, HITL, and agents). + */ + +import type { ChatMessage, MessageToolCall } from "../chat.tsx"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import type { HitlResponseRecord } from "../utils/hitl-response.ts"; +import type { PermissionOption } from "../../sdk/types.ts"; +import { type PartId, createPartId } from "./id.ts"; +import { upsertPart, findLastPartIndex } from "./store.ts"; +import { handleTextDelta } from "./handlers.ts"; +import { normalizeMarkdownNewlines } from "../utils/format.ts"; +import type { + AgentPart, + Part, + ReasoningPart, + TextPart, + ToolPart, + ToolState, +} from "./types.ts"; + +type ToolStatus = MessageToolCall["status"]; + +interface ToolStartEvent { + type: "tool-start"; + toolId: string; + toolName: string; + input: Record; + startedAt?: string; +} + +interface ToolCompleteEvent { + type: "tool-complete"; + toolId: string; + output: unknown; + success: boolean; + error?: string; + input?: Record; +} + +interface TextDeltaEvent { + type: "text-delta"; + delta: string; +} + +export type ThinkingProvider = "claude" | "opencode" | "copilot" | "unknown"; + +export interface ThinkingMetaEvent { + type: "thinking-meta"; + thinkingSourceKey: string; + targetMessageId: string; + streamGeneration: number; + thinkingText: string; + thinkingMs: number; + /** + * Off by default to keep current UI behavior until dedicated + * reasoning rendering task is complete. + */ + includeReasoningPart?: boolean; + provider?: ThinkingProvider; +} + +interface HitlRequestEvent { + type: "tool-hitl-request"; + toolId: string; + request: { + requestId: string; + header: string; + question: string; + options: PermissionOption[]; + multiSelect: boolean; + respond: (answer: string | string[]) => void; + }; +} + +interface HitlResponseEvent { + type: "tool-hitl-response"; + toolId: string; + response: HitlResponseRecord; +} + +interface ParallelAgentsEvent { + type: "parallel-agents"; + agents: ParallelAgent[]; + isLastMessage: boolean; +} + +export type StreamPartEvent = + | TextDeltaEvent + | ThinkingMetaEvent + | ToolStartEvent + | ToolCompleteEvent + | HitlRequestEvent + | HitlResponseEvent + | ParallelAgentsEvent; + +const reasoningPartIdBySourceRegistry = new WeakMap>(); + +function isHitlToolName(toolName: string): boolean { + return toolName === "AskUserQuestion" || toolName === "question" || toolName === "ask_user"; +} + +export function toToolState( + status: ToolStatus, + output: unknown, + fallbackStartedAt: string, + existingState?: ToolState, +): ToolState { + switch (status) { + case "pending": + return { status: "pending" }; + case "running": + return { + status: "running", + startedAt: existingState?.status === "running" ? existingState.startedAt : fallbackStartedAt, + }; + case "completed": + return { + status: "completed", + output, + durationMs: existingState?.status === "completed" ? existingState.durationMs : 0, + }; + case "error": + return { + status: "error", + error: existingState?.status === "error" + ? existingState.error + : (typeof output === "string" && output.trim() ? output : "Tool execution failed"), + output, + }; + case "interrupted": + return { status: "interrupted", partialOutput: output }; + } +} + +function finalizeLastStreamingTextPart(parts: Part[]): Part[] { + const updated = [...parts]; + const lastTextIdx = findLastPartIndex( + updated, + (part) => part.type === "text" && (part as TextPart).isStreaming, + ); + if (lastTextIdx >= 0) { + updated[lastTextIdx] = { + ...(updated[lastTextIdx] as TextPart), + isStreaming: false, + }; + } + return updated; +} + +export function finalizeStreamingReasoningParts(parts: Part[]): Part[] { + let changed = false; + const updated = parts.map((part) => { + if (part.type !== "reasoning" || !part.isStreaming) { + return part; + } + changed = true; + return { + ...part, + isStreaming: false, + }; + }); + + return changed ? updated : parts; +} + +export function finalizeStreamingReasoningInMessage(message: T): T { + if (!message.parts || message.parts.length === 0) { + return message; + } + + const finalizedParts = finalizeStreamingReasoningParts(message.parts); + if (finalizedParts === message.parts) { + return message; + } + + return { + ...message, + parts: finalizedParts, + }; +} + +function mergeToolCallOutput( + toolCall: MessageToolCall, + output: unknown, +): unknown { + if (!isHitlToolName(toolCall.toolName) || !toolCall.hitlResponse) { + return output !== undefined ? output : toolCall.output; + } + const outputObject = ( + output !== null + && typeof output === "object" + ) + ? output as Record + : {}; + return { + ...outputObject, + answer: toolCall.hitlResponse.answerText, + cancelled: toolCall.hitlResponse.cancelled, + responseMode: toolCall.hitlResponse.responseMode, + displayText: toolCall.hitlResponse.displayText, + }; +} + +function upsertToolCallStart( + toolCalls: MessageToolCall[] | undefined, + event: ToolStartEvent, +): MessageToolCall[] { + const current = toolCalls ?? []; + let matched = false; + const updated = current.map((toolCall) => { + if (toolCall.id !== event.toolId) return toolCall; + matched = true; + return { + ...toolCall, + toolName: event.toolName, + input: event.input, + status: "running" as const, + }; + }); + if (matched) return updated; + return [ + ...updated, + { + id: event.toolId, + toolName: event.toolName, + input: event.input, + status: "running" as const, + }, + ]; +} + +function upsertToolPartStart(parts: Part[], event: ToolStartEvent): Part[] { + const existingIdx = parts.findIndex( + (part) => part.type === "tool" && (part as ToolPart).toolCallId === event.toolId, + ); + + if (existingIdx >= 0) { + const existing = parts[existingIdx] as ToolPart; + const updated = [...parts]; + const startedAt = existing.state.status === "running" + ? existing.state.startedAt + : (event.startedAt ?? new Date().toISOString()); + updated[existingIdx] = { + ...existing, + toolName: event.toolName, + input: event.input, + state: { status: "running", startedAt }, + }; + return updated; + } + + const finalized = finalizeLastStreamingTextPart(parts); + const toolPart: ToolPart = { + id: createPartId(), + type: "tool", + toolCallId: event.toolId, + toolName: event.toolName, + input: event.input, + state: { status: "running", startedAt: event.startedAt ?? new Date().toISOString() }, + createdAt: new Date().toISOString(), + }; + return upsertPart(finalized, toolPart); +} + +function upsertToolCallComplete( + toolCalls: MessageToolCall[] | undefined, + event: ToolCompleteEvent, +): MessageToolCall[] { + const current = toolCalls ?? []; + let matched = false; + const updated = current.map((toolCall) => { + if (toolCall.id !== event.toolId) return toolCall; + matched = true; + const updatedInput = (event.input && Object.keys(toolCall.input).length === 0) + ? event.input + : toolCall.input; + return { + ...toolCall, + input: updatedInput, + output: mergeToolCallOutput(toolCall, event.output), + status: event.success ? ("completed" as const) : ("error" as const), + }; + }); + + if (matched) return updated; + + return [ + ...updated, + { + id: event.toolId, + toolName: "unknown", + input: event.input ?? {}, + output: event.output, + status: event.success ? ("completed" as const) : ("error" as const), + }, + ]; +} + +function upsertToolPartComplete(parts: Part[], event: ToolCompleteEvent): Part[] { + const toolPartIdx = parts.findIndex( + (part) => part.type === "tool" && (part as ToolPart).toolCallId === event.toolId, + ); + + if (toolPartIdx >= 0) { + const existing = parts[toolPartIdx] as ToolPart; + let durationMs = 0; + if (existing.state.status === "running") { + const startedAtMs = new Date(existing.state.startedAt).getTime(); + durationMs = Number.isFinite(startedAtMs) + ? Math.max(0, Date.now() - startedAtMs) + : 0; + } + const updatedInput = (event.input && Object.keys(existing.input).length === 0) + ? event.input + : existing.input; + const newState: ToolState = event.success + ? { status: "completed", output: event.output, durationMs } + : { status: "error", error: event.error || "Unknown error", output: event.output }; + + const updated = [...parts]; + updated[toolPartIdx] = { + ...existing, + input: updatedInput, + output: event.output, + state: newState, + }; + return updated; + } + + const toolPart: ToolPart = { + id: createPartId(), + type: "tool", + toolCallId: event.toolId, + toolName: "unknown", + input: event.input ?? {}, + output: event.output, + state: event.success + ? { status: "completed", output: event.output, durationMs: 0 } + : { status: "error", error: event.error || "Unknown error", output: event.output }, + createdAt: new Date().toISOString(), + }; + return upsertPart(parts, toolPart); +} + +function upsertHitlRequest(parts: Part[], event: HitlRequestEvent): Part[] { + const toolPartIdx = parts.findIndex( + (part) => part.type === "tool" && (part as ToolPart).toolCallId === event.toolId, + ); + + if (toolPartIdx >= 0) { + const existing = parts[toolPartIdx] as ToolPart; + const updated = [...parts]; + updated[toolPartIdx] = { + ...existing, + pendingQuestion: event.request, + }; + return updated; + } + + const toolPart: ToolPart = { + id: createPartId(), + type: "tool", + toolCallId: event.toolId, + toolName: "AskUserQuestion", + input: {}, + state: { status: "running", startedAt: new Date().toISOString() }, + pendingQuestion: event.request, + createdAt: new Date().toISOString(), + }; + return upsertPart(parts, toolPart); +} + +function applyHitlResponse( + message: ChatMessage, + event: HitlResponseEvent, +): ChatMessage { + const nextToolCalls = (message.toolCalls ?? []).map((toolCall) => { + if (toolCall.id !== event.toolId) return toolCall; + return { + ...toolCall, + output: { + ...(toolCall.output && typeof toolCall.output === "object" + ? toolCall.output as Record + : {}), + answer: event.response.answerText, + cancelled: event.response.cancelled, + responseMode: event.response.responseMode, + displayText: event.response.displayText, + }, + hitlResponse: event.response, + }; + }); + + let nextParts = message.parts; + if (message.parts && message.parts.length > 0) { + const updatedParts = [...message.parts]; + const toolPartIdx = updatedParts.findIndex( + (part) => part.type === "tool" && (part as ToolPart).toolCallId === event.toolId, + ); + if (toolPartIdx >= 0) { + const toolPart = updatedParts[toolPartIdx] as ToolPart; + updatedParts[toolPartIdx] = { + ...toolPart, + pendingQuestion: undefined, + hitlResponse: event.response, + }; + nextParts = updatedParts; + } + } + + return { + ...message, + toolCalls: nextToolCalls, + parts: nextParts, + }; +} + +function upsertThinkingMeta( + message: ChatMessage, + event: ThinkingMetaEvent, +): ChatMessage { + if (!event.includeReasoningPart) { + const nextMessage: ChatMessage = { + ...message, + thinkingMs: event.thinkingMs, + thinkingText: event.thinkingText || undefined, + }; + return carryReasoningPartRegistry(message, nextMessage); + } + + const parts = [...(message.parts ?? [])]; + const registry = cloneReasoningPartRegistry(message); + + let existingIdx = -1; + const existingPartId = registry.get(event.thinkingSourceKey); + if (existingPartId) { + existingIdx = parts.findIndex( + (part) => part.id === existingPartId && part.type === "reasoning", + ); + if (existingIdx < 0) { + registry.delete(event.thinkingSourceKey); + } + } + + if (existingIdx < 0) { + existingIdx = parts.findIndex( + (part) => part.type === "reasoning" && (part as ReasoningPart).thinkingSourceKey === event.thinkingSourceKey, + ); + if (existingIdx >= 0) { + registry.set(event.thinkingSourceKey, parts[existingIdx]!.id); + } + } + + if (existingIdx >= 0) { + const existing = parts[existingIdx] as ReasoningPart; + parts[existingIdx] = { + ...existing, + thinkingSourceKey: event.thinkingSourceKey, + content: event.thinkingText, + durationMs: event.thinkingMs, + isStreaming: true, + }; + } else if (event.thinkingText.trim().length > 0) { + const reasoningPart: ReasoningPart = { + id: createPartId(), + type: "reasoning", + thinkingSourceKey: event.thinkingSourceKey, + content: event.thinkingText, + durationMs: event.thinkingMs, + isStreaming: true, + createdAt: new Date().toISOString(), + }; + const firstTextIdx = parts.findIndex((part) => part.type === "text"); + if (firstTextIdx >= 0) { + parts.splice(firstTextIdx, 0, reasoningPart); + } else { + parts.push(reasoningPart); + } + registry.set(event.thinkingSourceKey, reasoningPart.id); + } + + const nextMessage: ChatMessage = { + ...message, + parts, + thinkingMs: event.thinkingMs, + thinkingText: event.thinkingText || undefined, + }; + + reasoningPartIdBySourceRegistry.set(nextMessage, registry); + return nextMessage; +} + +function cloneReasoningPartRegistry(message: ChatMessage): Map { + const existing = reasoningPartIdBySourceRegistry.get(message); + if (existing) { + return new Map(existing); + } + + const rebuilt = new Map(); + for (const part of message.parts ?? []) { + if (part.type !== "reasoning") { + continue; + } + const sourceKey = (part as ReasoningPart).thinkingSourceKey; + if (sourceKey && sourceKey.trim().length > 0) { + rebuilt.set(sourceKey, part.id); + } + } + return rebuilt; +} + +function carryReasoningPartRegistry(from: ChatMessage, to: ChatMessage): ChatMessage { + const existing = reasoningPartIdBySourceRegistry.get(from); + if (existing) { + reasoningPartIdBySourceRegistry.set(to, new Map(existing)); + } + return to; +} + +function isActiveParallelAgent(agent: ParallelAgent): boolean { + return ( + agent.status === "running" + || agent.status === "pending" + || agent.status === "background" + ); +} + +function hasSubagentCall(message: Pick): boolean { + if ((message.parallelAgents?.length ?? 0) > 0) return true; + return (message.toolCalls ?? []).some( + (toolCall) => toolCall.toolName === "Task" || toolCall.toolName === "task", + ); +} + +function normalizeParallelAgentResult(result: string | undefined): string | undefined { + if (typeof result !== "string") return undefined; + const normalized = normalizeMarkdownNewlines(result); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeParallelAgents(agents: ParallelAgent[]): ParallelAgent[] { + let changed = false; + const normalizedAgents = agents.map((agent) => { + if (typeof agent.result !== "string") return agent; + const normalizedResult = normalizeParallelAgentResult(agent.result); + if (normalizedResult === agent.result) return agent; + + changed = true; + if (normalizedResult) { + return { + ...agent, + result: normalizedResult, + }; + } + + const { result: _result, ...rest } = agent; + return rest; + }); + + return changed ? normalizedAgents : agents; +} + +function isGroupedAgentPart(part: Part): part is AgentPart { + return part.type === "agent" && part.parentToolPartId === undefined; +} + +export function shouldGroupSubagentTrees( + message: Pick, + isLastMessage: boolean, +): boolean { + if (!isLastMessage) return false; + const agents = message.parallelAgents ?? []; + if (agents.length === 0) return false; + if (!hasSubagentCall(message)) return false; + + const parts = message.parts ?? []; + let hasSeenTask = false; + for (const part of parts) { + if (part.type === "tool") { + const toolName = part.toolName; + if (toolName === "Task" || toolName === "task") { + hasSeenTask = true; + } else { + return false; + } + } else if (part.type === "text") { + if (hasSeenTask && part.content.trim().length > 0) { + return false; + } + } + } + + if (agents.some(isActiveParallelAgent)) return true; + return (message.parts ?? []).some(isGroupedAgentPart); +} + +function getAgentInsertIndex(parts: Part[]): number { + let lastTaskToolIdx = -1; + let lastToolIdx = -1; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!part || part.type !== "tool") continue; + lastToolIdx = i; + const toolName = (part as ToolPart).toolName; + if (toolName === "Task" || toolName === "task") { + lastTaskToolIdx = i; + } + } + + let idx = parts.length; + if (lastTaskToolIdx >= 0) { + idx = lastTaskToolIdx + 1; + } else if (lastToolIdx >= 0) { + idx = lastToolIdx + 1; + } + + while (idx < parts.length && parts[idx]?.type === "agent") { + idx++; + } + return idx; +} + +function insertAgentPartAtTaskBoundary(parts: Part[], agentPart: AgentPart): Part[] { + const insertIdx = getAgentInsertIndex(parts); + return [ + ...parts.slice(0, insertIdx), + agentPart, + ...parts.slice(insertIdx), + ]; +} + +export function mergeParallelAgentsIntoParts( + parts: Part[], + parallelAgents: ParallelAgent[], + messageTimestamp: string, + groupIntoSingleTree: boolean, +): Part[] { + const normalizedAgents = normalizeParallelAgents(parallelAgents); + const nonAgentParts: Part[] = parts.filter((part) => part.type !== "agent"); + const existingAgentParts = parts.filter((part): part is AgentPart => part.type === "agent"); + + if (normalizedAgents.length === 0) { + return nonAgentParts; + } + + if (groupIntoSingleTree) { + const existingGroupedPart = existingAgentParts.find((part) => part.parentToolPartId === undefined) ?? existingAgentParts[0]; + const groupedPart: AgentPart = { + id: existingGroupedPart?.id ?? createPartId(), + type: "agent", + agents: normalizedAgents, + parentToolPartId: undefined, + createdAt: existingGroupedPart?.createdAt ?? messageTimestamp, + }; + return insertAgentPartAtTaskBoundary(nonAgentParts, groupedPart); + } + + const existingByParent = new Map(); + for (const existing of existingAgentParts) { + if (!existingByParent.has(existing.parentToolPartId)) { + existingByParent.set(existing.parentToolPartId, existing); + } + } + + const agentsByToolCall = new Map(); + for (const agent of normalizedAgents) { + const toolCallId = agent.taskToolCallId; + const grouped = agentsByToolCall.get(toolCallId) ?? []; + grouped.push(agent); + agentsByToolCall.set(toolCallId, grouped); + } + + const finalParts: Part[] = []; + const handledToolCallIds = new Set(); + + let currentGroup: ToolPart[] = []; + let currentGroupAgents: ParallelAgent[] = []; + + for (let i = 0; i < nonAgentParts.length; i++) { + const part = nonAgentParts[i]; + if (!part) continue; + finalParts.push(part); + + if (part.type === "tool" && (part.toolName === "Task" || part.toolName === "task")) { + const toolPart = part as ToolPart; + currentGroup.push(toolPart); + const agents = agentsByToolCall.get(toolPart.toolCallId); + if (agents) { + currentGroupAgents.push(...agents); + if (toolPart.toolCallId) { + handledToolCallIds.add(toolPart.toolCallId); + } + } + } + + let endsGroup = false; + if (currentGroup.length > 0) { + if (i === nonAgentParts.length - 1) { + endsGroup = true; + } else { + const nextPart = nonAgentParts[i + 1]; + if (!nextPart) { + endsGroup = true; + } else if (nextPart.type === "tool") { + const toolName = (nextPart as ToolPart).toolName; + if (toolName !== "Task" && toolName !== "task") { + endsGroup = true; + } + } else if (nextPart.type === "text") { + if ((nextPart as TextPart).content.trim().length > 0) { + endsGroup = true; + } + } + } + } + + if (endsGroup) { + if (currentGroupAgents.length > 0) { + const lastToolPart = currentGroup[currentGroup.length - 1]; + if (lastToolPart) { + const parentToolPartId = lastToolPart.id; + const existingPart = existingByParent.get(parentToolPartId); + + const agentPart: AgentPart = { + id: existingPart?.id ?? createPartId(), + type: "agent", + agents: currentGroupAgents, + parentToolPartId, + createdAt: existingPart?.createdAt ?? messageTimestamp, + }; + finalParts.push(agentPart); + } + } + currentGroup = []; + currentGroupAgents = []; + } + } + + const remainingAgents: ParallelAgent[] = []; + for (const [toolCallId, agents] of agentsByToolCall) { + if (!toolCallId || !handledToolCallIds.has(toolCallId)) { + remainingAgents.push(...agents); + } + } + + if (remainingAgents.length > 0) { + const existingPart = existingByParent.get(undefined); + const fallbackPart: AgentPart = { + id: existingPart?.id ?? createPartId(), + type: "agent", + agents: remainingAgents, + parentToolPartId: undefined, + createdAt: existingPart?.createdAt ?? messageTimestamp, + }; + const insertIdx = getAgentInsertIndex(finalParts); + finalParts.splice(insertIdx, 0, fallbackPart); + } + + return finalParts; +} + +export function syncToolCallsIntoParts( + parts: Part[], + toolCalls: MessageToolCall[], + messageTimestamp: string, + messageId?: string, +): Part[] { + let nextParts = [...parts]; + + for (const toolCall of toolCalls) { + const existingIdx = nextParts.findIndex( + (part) => part.type === "tool" && (part as ToolPart).toolCallId === toolCall.id, + ); + + if (existingIdx >= 0) { + const existing = nextParts[existingIdx] as ToolPart; + nextParts[existingIdx] = { + ...existing, + toolName: toolCall.toolName, + input: toolCall.input, + output: toolCall.output, + hitlResponse: toolCall.hitlResponse ?? existing.hitlResponse, + state: toToolState(toolCall.status, toolCall.output, messageTimestamp, existing.state), + }; + continue; + } + + const fallbackId = messageId + ? (`tool-${messageId}-${toolCall.id}` as unknown as PartId) + : createPartId(); + nextParts.push({ + id: fallbackId, + type: "tool", + toolCallId: toolCall.id, + toolName: toolCall.toolName, + input: toolCall.input, + output: toolCall.output, + hitlResponse: toolCall.hitlResponse, + state: toToolState(toolCall.status, toolCall.output, messageTimestamp), + createdAt: messageTimestamp, + } satisfies ToolPart); + } + + return nextParts; +} + +export function applyStreamPartEvent( + message: ChatMessage, + event: StreamPartEvent, +): ChatMessage { + switch (event.type) { + case "text-delta": { + const withParts = handleTextDelta(message, event.delta); + const nextMessage: ChatMessage = { + ...withParts, + content: message.content + event.delta, + }; + return carryReasoningPartRegistry(message, nextMessage); + } + + case "thinking-meta": + return upsertThinkingMeta(message, event); + + case "tool-start": { + const nextToolCalls = upsertToolCallStart(message.toolCalls, event); + const nextParts = upsertToolPartStart(message.parts ?? [], event); + const nextMessage: ChatMessage = { + ...message, + toolCalls: nextToolCalls, + parts: nextParts, + }; + return carryReasoningPartRegistry(message, nextMessage); + } + + case "tool-complete": { + const nextToolCalls = upsertToolCallComplete(message.toolCalls, event); + const nextParts = upsertToolPartComplete(message.parts ?? [], event); + const nextMessage: ChatMessage = { + ...message, + toolCalls: nextToolCalls, + parts: nextParts, + }; + return carryReasoningPartRegistry(message, nextMessage); + } + + case "tool-hitl-request": { + const nextParts = upsertHitlRequest(message.parts ?? [], event); + const nextMessage: ChatMessage = { + ...message, + parts: nextParts, + }; + return carryReasoningPartRegistry(message, nextMessage); + } + + case "tool-hitl-response": + return carryReasoningPartRegistry(message, applyHitlResponse(message, event)); + + case "parallel-agents": { + const normalizedAgents = normalizeParallelAgents(event.agents); + const nextParts = mergeParallelAgentsIntoParts( + message.parts ?? [], + normalizedAgents, + message.timestamp, + shouldGroupSubagentTrees({ ...message, parallelAgents: normalizedAgents }, event.isLastMessage), + ); + const nextMessage: ChatMessage = { + ...message, + parallelAgents: normalizedAgents, + parts: nextParts, + }; + return carryReasoningPartRegistry(message, nextMessage); + } + } +} diff --git a/src/ui/parts/types.ts b/src/ui/parts/types.ts index 3b0b0688f..cc220877a 100644 --- a/src/ui/parts/types.ts +++ b/src/ui/parts/types.ts @@ -56,6 +56,7 @@ export interface TextPart extends BasePart { export interface ReasoningPart extends BasePart { type: "reasoning"; + thinkingSourceKey?: string; content: string; durationMs: number; isStreaming: boolean; diff --git a/src/ui/subagent-guard-relaxation.test.ts b/src/ui/subagent-guard-relaxation.test.ts new file mode 100644 index 000000000..481f7ceb9 --- /dev/null +++ b/src/ui/subagent-guard-relaxation.test.ts @@ -0,0 +1,449 @@ +/** + * Tests for subagent.start correlation guard relaxation + * + * Context: We relaxed the correlation guard in src/ui/index.ts (line 1073) to allow + * session-owned events through without requiring pendingTaskEntry or sdkCorrelationMatch. + * This supports SDKs like Copilot that dispatch custom agents without a Task tool. + * + * Guard Logic (lines 1068-1073): + * 1. First guard (line 1068): if (!sessionOwned && !pendingTaskEntry && !hasSdkCorrelationMatch) return; + * - Blocks non-session-owned events that have no Task entry and no SDK correlation + * 2. Second guard (line 1073): if (!pendingTaskEntry && !hasSdkCorrelationMatch && !sessionOwned) return; + * - Blocks events that have no Task entry, no SDK correlation, AND are not session-owned + * - This is where we relaxed the guard by adding "&& !sessionOwned" + * + * These tests verify the guard relaxation works correctly. + */ + +import { describe, expect, test } from "bun:test"; + +// ============================================================================ +// PURE GUARD EVALUATION FUNCTIONS (extracted from implementation logic) +// ============================================================================ + +/** + * Evaluates the first correlation guard (line 1068). + * Returns true if the event should be blocked (early return). + * + * Logic: Block if event is NOT session-owned AND has no Task entry AND has no SDK correlation. + */ +function evaluateFirstGuard( + sessionOwned: boolean, + pendingTaskEntry: boolean, + hasSdkCorrelationMatch: boolean +): boolean { + // if (!sessionOwned && !pendingTaskEntry && !hasSdkCorrelationMatch) return; + return !sessionOwned && !pendingTaskEntry && !hasSdkCorrelationMatch; +} + +/** + * Evaluates the second correlation guard (line 1073). + * Returns true if the event should be blocked (early return). + * + * Logic: Block if event has no Task entry AND no SDK correlation AND is NOT session-owned. + * Note: This is the guard we relaxed by adding "&& !sessionOwned". + */ +function evaluateSecondGuard( + pendingTaskEntry: boolean, + hasSdkCorrelationMatch: boolean, + sessionOwned: boolean +): boolean { + // if (!pendingTaskEntry && !hasSdkCorrelationMatch && !sessionOwned) return; + return !pendingTaskEntry && !hasSdkCorrelationMatch && !sessionOwned; +} + +/** + * Evaluates both guards in sequence and returns whether the event passes through. + * Returns true if the event should be processed (NOT blocked). + */ +function shouldProcessEvent( + sessionOwned: boolean, + pendingTaskEntry: boolean, + hasSdkCorrelationMatch: boolean +): boolean { + // First guard + if (evaluateFirstGuard(sessionOwned, pendingTaskEntry, hasSdkCorrelationMatch)) { + return false; // Blocked by first guard + } + + // Second guard + if (evaluateSecondGuard(pendingTaskEntry, hasSdkCorrelationMatch, sessionOwned)) { + return false; // Blocked by second guard + } + + // Event passed both guards + return true; +} + +// ============================================================================ +// UNIT TESTS: Guard evaluation logic +// ============================================================================ + +describe("Subagent correlation guard relaxation", () => { + describe("First guard (line 1068): blocks non-session-owned events without correlation", () => { + test("blocks non-session-owned event with no Task entry and no SDK correlation", () => { + const shouldBlock = evaluateFirstGuard( + false, // sessionOwned = false + false, // pendingTaskEntry = false + false // hasSdkCorrelationMatch = false + ); + expect(shouldBlock).toBe(true); + }); + + test("allows non-session-owned event with Task entry", () => { + const shouldBlock = evaluateFirstGuard( + false, // sessionOwned = false + true, // pendingTaskEntry = true + false // hasSdkCorrelationMatch = false + ); + expect(shouldBlock).toBe(false); + }); + + test("allows non-session-owned event with SDK correlation", () => { + const shouldBlock = evaluateFirstGuard( + false, // sessionOwned = false + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true + ); + expect(shouldBlock).toBe(false); + }); + + test("allows session-owned event without correlation (KEY TEST for relaxation)", () => { + const shouldBlock = evaluateFirstGuard( + true, // sessionOwned = true + false, // pendingTaskEntry = false + false // hasSdkCorrelationMatch = false + ); + expect(shouldBlock).toBe(false); + }); + }); + + describe("Second guard (line 1073): relaxed to allow session-owned events", () => { + test("blocks non-session-owned event with no Task entry and no SDK correlation", () => { + const shouldBlock = evaluateSecondGuard( + false, // pendingTaskEntry = false + false, // hasSdkCorrelationMatch = false + false // sessionOwned = false + ); + expect(shouldBlock).toBe(true); + }); + + test("allows event with Task entry", () => { + const shouldBlock = evaluateSecondGuard( + true, // pendingTaskEntry = true + false, // hasSdkCorrelationMatch = false + false // sessionOwned = false + ); + expect(shouldBlock).toBe(false); + }); + + test("allows event with SDK correlation", () => { + const shouldBlock = evaluateSecondGuard( + false, // pendingTaskEntry = false + true, // hasSdkCorrelationMatch = true + false // sessionOwned = false + ); + expect(shouldBlock).toBe(false); + }); + + test("allows session-owned event without correlation (KEY TEST for relaxation)", () => { + const shouldBlock = evaluateSecondGuard( + false, // pendingTaskEntry = false + false, // hasSdkCorrelationMatch = false + true // sessionOwned = true + ); + expect(shouldBlock).toBe(false); + }); + }); +}); + +// ============================================================================ +// INTEGRATION TESTS: Combined guard behavior +// ============================================================================ + +describe("Combined guard behavior (full event processing flow)", () => { + test("Session-owned events without correlation pass through both guards", () => { + // This is the key test case for the guard relaxation: + // Copilot dispatches subagent.start events that are session-owned but have + // no pendingTaskEntry and no SDK correlation match. + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true + false, // pendingTaskEntry = false + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcess).toBe(true); + }); + + test("Non-session-owned events without correlation are blocked", () => { + // Events from other sessions without correlation should be blocked + // to prevent cross-run leakage. + const shouldProcess = shouldProcessEvent( + false, // sessionOwned = false + false, // pendingTaskEntry = false + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcess).toBe(false); + }); + + test("Events with SDK correlation match pass through regardless of session ownership", () => { + // SDK correlation is a strong signal — allow even if not session-owned + const shouldProcessNonOwned = shouldProcessEvent( + false, // sessionOwned = false + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true + ); + expect(shouldProcessNonOwned).toBe(true); + + const shouldProcessOwned = shouldProcessEvent( + true, // sessionOwned = true + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true + ); + expect(shouldProcessOwned).toBe(true); + }); + + test("Events with pending Task entry pass through regardless of session ownership", () => { + // Task entry is a strong signal — allow even if not session-owned + const shouldProcessNonOwned = shouldProcessEvent( + false, // sessionOwned = false + true, // pendingTaskEntry = true + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcessNonOwned).toBe(true); + + const shouldProcessOwned = shouldProcessEvent( + true, // sessionOwned = true + true, // pendingTaskEntry = true + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcessOwned).toBe(true); + }); + + test("Events with all three signals pass through", () => { + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true + true, // pendingTaskEntry = true + true // hasSdkCorrelationMatch = true + ); + expect(shouldProcess).toBe(true); + }); + + test("Session-owned events with Task entry pass through", () => { + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true + true, // pendingTaskEntry = true + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcess).toBe(true); + }); + + test("Session-owned events with SDK correlation pass through", () => { + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true + ); + expect(shouldProcess).toBe(true); + }); + + test("Non-session-owned events with both Task entry and SDK correlation pass through", () => { + const shouldProcess = shouldProcessEvent( + false, // sessionOwned = false + true, // pendingTaskEntry = true + true // hasSdkCorrelationMatch = true + ); + expect(shouldProcess).toBe(true); + }); +}); + +// ============================================================================ +// SCENARIO TESTS: Real-world use cases +// ============================================================================ + +describe("Real-world use case scenarios", () => { + test("Copilot custom agent without Task tool (session-owned)", () => { + // Copilot dispatches subagent.start for built-in agents like 'task', 'explore', etc. + // These are session-owned but don't have a pendingTaskEntry because the user + // didn't invoke the Task tool explicitly — Copilot dispatched them internally. + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true (Copilot session) + false, // pendingTaskEntry = false (no Task tool) + false // hasSdkCorrelationMatch = false (no correlation ID) + ); + expect(shouldProcess).toBe(true); + }); + + test("Claude Code Task tool with correlation ID", () => { + // Claude dispatches subagent.start with toolUseID that correlates to the Task tool. + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true (Claude session) + true, // pendingTaskEntry = true (Task tool was invoked) + true // hasSdkCorrelationMatch = true (toolUseID matches) + ); + expect(shouldProcess).toBe(true); + }); + + test("OpenCode agent with partial correlation", () => { + // OpenCode may have SDK correlation but no Task entry in some flows. + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true (OpenCode session) + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true (correlation ID matches) + ); + expect(shouldProcess).toBe(true); + }); + + test("External event from different session without correlation is blocked", () => { + // An event from a different session (e.g., telemetry replay or bug) + // should be blocked to prevent cross-run contamination. + const shouldProcess = shouldProcessEvent( + false, // sessionOwned = false (different session) + false, // pendingTaskEntry = false + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcess).toBe(false); + }); + + test("External event with valid correlation is allowed (despite being non-session-owned)", () => { + // Even if an event comes from a different session, if it has valid SDK + // correlation to the current run, we trust it (edge case for multi-session setups). + const shouldProcess = shouldProcessEvent( + false, // sessionOwned = false (different session) + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true (matches current run) + ); + expect(shouldProcess).toBe(true); + }); + + test("Late-arriving Task tool subagent.start (session-owned with Task entry)", () => { + // Normal flow: user invokes Task tool, pendingTaskEntry is created, + // then subagent.start arrives and consumes it. + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true + true, // pendingTaskEntry = true (Task tool was invoked) + false // hasSdkCorrelationMatch = false (no correlation ID yet) + ); + expect(shouldProcess).toBe(true); + }); +}); + +// ============================================================================ +// EDGE CASE TESTS: Boundary conditions +// ============================================================================ + +describe("Edge cases and boundary conditions", () => { + test("All flags false: event is blocked", () => { + const shouldProcess = shouldProcessEvent(false, false, false); + expect(shouldProcess).toBe(false); + }); + + test("All flags true: event is allowed", () => { + const shouldProcess = shouldProcessEvent(true, true, true); + expect(shouldProcess).toBe(true); + }); + + test("Only sessionOwned is true: event is allowed (key relaxation)", () => { + const shouldProcess = shouldProcessEvent(true, false, false); + expect(shouldProcess).toBe(true); + }); + + test("Only pendingTaskEntry is true: event is allowed", () => { + const shouldProcess = shouldProcessEvent(false, true, false); + expect(shouldProcess).toBe(true); + }); + + test("Only hasSdkCorrelationMatch is true: event is allowed", () => { + const shouldProcess = shouldProcessEvent(false, false, true); + expect(shouldProcess).toBe(true); + }); + + test("sessionOwned and pendingTaskEntry both true: event is allowed", () => { + const shouldProcess = shouldProcessEvent(true, true, false); + expect(shouldProcess).toBe(true); + }); + + test("sessionOwned and hasSdkCorrelationMatch both true: event is allowed", () => { + const shouldProcess = shouldProcessEvent(true, false, true); + expect(shouldProcess).toBe(true); + }); + + test("pendingTaskEntry and hasSdkCorrelationMatch both true: event is allowed", () => { + const shouldProcess = shouldProcessEvent(false, true, true); + expect(shouldProcess).toBe(true); + }); +}); + +// ============================================================================ +// REGRESSION TESTS: Ensure guard relaxation doesn't break existing flows +// ============================================================================ + +describe("Regression tests: existing flows still work correctly", () => { + test("Standard Claude Task tool flow (with Task entry)", () => { + // Before relaxation: worked + // After relaxation: should still work + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true + true, // pendingTaskEntry = true + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcess).toBe(true); + }); + + test("Standard Claude Task tool flow (with SDK correlation)", () => { + // Before relaxation: worked + // After relaxation: should still work + const shouldProcess = shouldProcessEvent( + true, // sessionOwned = true + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true + ); + expect(shouldProcess).toBe(true); + }); + + test("Cross-session events without correlation are still blocked", () => { + // Before relaxation: blocked + // After relaxation: should still be blocked + const shouldProcess = shouldProcessEvent( + false, // sessionOwned = false + false, // pendingTaskEntry = false + false // hasSdkCorrelationMatch = false + ); + expect(shouldProcess).toBe(false); + }); + + test("Task entry alone allows event (regardless of session ownership)", () => { + // Before relaxation: worked + // After relaxation: should still work + const nonOwnedWithTask = shouldProcessEvent( + false, // sessionOwned = false + true, // pendingTaskEntry = true + false // hasSdkCorrelationMatch = false + ); + expect(nonOwnedWithTask).toBe(true); + + const ownedWithTask = shouldProcessEvent( + true, // sessionOwned = true + true, // pendingTaskEntry = true + false // hasSdkCorrelationMatch = false + ); + expect(ownedWithTask).toBe(true); + }); + + test("SDK correlation alone allows event (regardless of session ownership)", () => { + // Before relaxation: worked + // After relaxation: should still work + const nonOwnedWithCorrelation = shouldProcessEvent( + false, // sessionOwned = false + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true + ); + expect(nonOwnedWithCorrelation).toBe(true); + + const ownedWithCorrelation = shouldProcessEvent( + true, // sessionOwned = true + false, // pendingTaskEntry = false + true // hasSdkCorrelationMatch = true + ); + expect(ownedWithCorrelation).toBe(true); + }); +}); diff --git a/src/ui/tools/registry.test.ts b/src/ui/tools/registry.test.ts index 1ca25a26e..99401591f 100644 --- a/src/ui/tools/registry.test.ts +++ b/src/ui/tools/registry.test.ts @@ -223,6 +223,16 @@ describe("parseTaskToolResult", () => { expect(result.tokens).toBe(1500); }); + test("normalizes whitespace and line endings in extracted text", () => { + const output = { + content: [ + { type: "text", text: "\r\n line one\r\nline two\r\n" }, + ], + }; + const result = parseTaskToolResult(output); + expect(result.text).toBe("line one\nline two"); + }); + test("extracts text from documented TaskOutput format", () => { const output = { result: "Task completed", @@ -244,6 +254,10 @@ describe("parseTaskToolResult", () => { expect(parseTaskToolResult(undefined).text).toBeUndefined(); }); + test("returns undefined for whitespace-only text output", () => { + expect(parseTaskToolResult("\n\r\n ").text).toBeUndefined(); + }); + test("converts non-object types to string", () => { expect(parseTaskToolResult(42).text).toBe("42"); expect(parseTaskToolResult(true).text).toBe("true"); diff --git a/src/ui/tools/registry.ts b/src/ui/tools/registry.ts index 479fbd12d..250fb0800 100644 --- a/src/ui/tools/registry.ts +++ b/src/ui/tools/registry.ts @@ -13,6 +13,7 @@ import { MAIN_CHAT_TOOL_PREVIEW_LIMITS, truncateToolText, } from "../utils/tool-preview-truncation.ts"; +import { normalizeMarkdownNewlines } from "../utils/format.ts"; // ============================================================================ // TYPES @@ -620,6 +621,11 @@ export function parseTaskToolResult(output: unknown): { tokens?: number; isAsync?: boolean; } { + const normalizeTaskText = (text: string): string | undefined => { + const normalized = normalizeMarkdownNewlines(text); + return normalized.length > 0 ? normalized : undefined; + }; + if (output === undefined || output === null) { return { text: undefined }; } @@ -631,12 +637,12 @@ export function parseTaskToolResult(output: unknown): { const parsed = JSON.parse(output); return parseTaskToolResult(parsed); } catch { - return { text: output }; + return { text: normalizeTaskText(output) }; } } if (typeof output !== "object") { - return { text: String(output) }; + return { text: normalizeTaskText(String(output)) }; } const obj = output as Record; @@ -651,7 +657,7 @@ export function parseTaskToolResult(output: unknown): { ); const text = textBlock?.text as string | undefined; return { - text, + text: text ? normalizeTaskText(text) : undefined, durationMs: typeof obj.totalDurationMs === "number" ? obj.totalDurationMs : undefined, toolUses: typeof obj.totalToolUseCount === "number" ? obj.totalToolUseCount : undefined, tokens: typeof obj.totalTokens === "number" ? obj.totalTokens : undefined, @@ -662,18 +668,18 @@ export function parseTaskToolResult(output: unknown): { // Format 2: Documented TaskOutput with result field if (typeof obj.result === "string") { return { - text: obj.result, + text: normalizeTaskText(obj.result), durationMs: typeof obj.duration_ms === "number" ? obj.duration_ms : undefined, isAsync, }; } // Fallback: try common text fields - if (typeof obj.text === "string") return { text: obj.text, isAsync }; - if (typeof obj.output === "string") return { text: obj.output, isAsync }; + if (typeof obj.text === "string") return { text: normalizeTaskText(obj.text), isAsync }; + if (typeof obj.output === "string") return { text: normalizeTaskText(obj.output), isAsync }; // Last resort: stringify - return { text: JSON.stringify(output, null, 2), isAsync }; + return { text: normalizeTaskText(JSON.stringify(output, null, 2)), isAsync }; } // ============================================================================ diff --git a/src/ui/utils/background-agent-acceptance.test.ts b/src/ui/utils/background-agent-acceptance.test.ts new file mode 100644 index 000000000..9e1e86554 --- /dev/null +++ b/src/ui/utils/background-agent-acceptance.test.ts @@ -0,0 +1,262 @@ +/** + * Acceptance Tests for Issue #258: Background Agent UX Contracts + * + * This file provides fixture-based acceptance checks that validate the exact + * text and behavior documented in issue #258 against the canonical contract + * constants. These serve as the machine-readable equivalent of screenshot + * acceptance testing. + * + * @see https://github.com/user/repo/issues/258 + */ + +import { describe, expect, test } from "bun:test"; +import { + BACKGROUND_FOOTER_CONTRACT, + BACKGROUND_TREE_HINT_CONTRACT, +} from "./background-agent-contracts.ts"; +import { + getBackgroundTerminationDecision, + interruptActiveBackgroundAgents, +} from "./background-agent-termination.ts"; +import { + formatBackgroundAgentFooterStatus, + getActiveBackgroundAgents, +} from "./background-agent-footer.ts"; +import { buildParallelAgentsHeaderHint } from "./background-agent-tree-hints.ts"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; + +/** + * Creates a test agent with sensible defaults. + * Helper used throughout acceptance tests to construct minimal fixture data. + */ +function createAgent(overrides: Partial): ParallelAgent { + return { + id: overrides.id ?? "agent-1", + name: overrides.name ?? "task", + task: overrides.task ?? "Background task", + status: overrides.status ?? "background", + background: overrides.background, + startedAt: overrides.startedAt ?? new Date().toISOString(), + currentTool: overrides.currentTool, + durationMs: overrides.durationMs, + result: overrides.result, + }; +} + +describe("Issue #258 acceptance: background agent UX behavior", () => { + + describe("Acceptance: Footer behavior", () => { + test("footer shows terminate hint matching 'ctrl+f to kill agents'", () => { + // This is the exact text shown in the footer per issue #258 + expect(BACKGROUND_FOOTER_CONTRACT.terminateHintText).toBe("ctrl+f to kill agents"); + }); + + test("footer becomes visible with 1+ active agents", () => { + // Footer should appear as soon as the first background agent starts + expect(BACKGROUND_FOOTER_CONTRACT.showWhenAgentCountAtLeast).toBe(1); + }); + + test("footer includes terminate hint", () => { + // Confirms the footer contract specifies showing the hint + expect(BACKGROUND_FOOTER_CONTRACT.includeTerminateHint).toBe(true); + }); + + test("footer count format uses 'agents' labeling", () => { + // Confirms the contract uses "agents" (not "tasks") for count text + expect(BACKGROUND_FOOTER_CONTRACT.countFormat).toBe("agents"); + }); + + test("footer status format includes agent count", () => { + // Validate actual formatting function output + const agents = [ + createAgent({ id: "bg-1", background: true }), + createAgent({ id: "bg-2", background: true }), + createAgent({ id: "bg-3", background: true }), + ]; + + const status = formatBackgroundAgentFooterStatus(agents); + expect(status).toContain("3"); + expect(status).toContain("local"); + expect(status).toContain("agent"); + }); + }); + + describe("Acceptance: Ctrl+F termination flow", () => { + test("first Ctrl+F press with active agents → warning with instruction text", () => { + // First press should warn user, not immediately terminate + const decision = getBackgroundTerminationDecision(0, 2); + expect(decision.action).toBe("warn"); + expect(decision).toHaveProperty("message"); + + if (decision.action === "warn") { + expect(decision.message).toContain("Ctrl-F"); + expect(decision.message).toContain("terminate"); + } + }); + + test("second Ctrl+F press → terminates with confirmation", () => { + // Second press should execute termination + const decision = getBackgroundTerminationDecision(1, 2); + expect(decision.action).toBe("terminate"); + + if (decision.action === "terminate") { + expect(decision.message).toContain("killed"); + } + }); + + test("Ctrl+F with no active agents → no action", () => { + // When no active agents exist, Ctrl+F does nothing + const decision = getBackgroundTerminationDecision(0, 0); + expect(decision.action).toBe("none"); + }); + + test("full acceptance: warn → terminate → agents interrupted", () => { + // End-to-end validation of the complete termination flow + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background", background: true }), + createAgent({ id: "bg-2", status: "running", background: true }), + ]; + + // First press: warning + const activeCount = getActiveBackgroundAgents(agents).length; + expect(activeCount).toBe(2); + + const warn = getBackgroundTerminationDecision(0, activeCount); + expect(warn.action).toBe("warn"); + + // Second press: terminate + const terminate = getBackgroundTerminationDecision(1, activeCount); + expect(terminate.action).toBe("terminate"); + + // Execute termination + const result = interruptActiveBackgroundAgents(agents); + expect(result.interruptedIds).toEqual(["bg-1", "bg-2"]); + expect(result.agents.every(a => a.status === "interrupted")).toBe(true); + + // After termination, no more active agents + expect(getActiveBackgroundAgents(result.agents)).toHaveLength(0); + }); + }); + + describe("Acceptance: Tree hint behavior", () => { + test("running agents hint contains 'background running' and 'ctrl+f to kill agents'", () => { + // Exact wording for running state hint per issue #258 + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toBe("background running · ctrl+f to kill agents"); + }); + + test("completed agents hint contains 'background complete' and 'ctrl+o to expand'", () => { + // Exact wording for completed state hint per issue #258 + expect(BACKGROUND_TREE_HINT_CONTRACT.whenComplete).toBe("background complete · ctrl+o to expand"); + }); + + test("default hint is 'ctrl+o to expand'", () => { + // Fallback hint when no background agents are present + expect(BACKGROUND_TREE_HINT_CONTRACT.defaultHint).toBe("ctrl+o to expand"); + }); + + test("tree hint builder produces correct strings for each state", () => { + // Validate that the function correctly uses the contract constants + + // Running state + const runningAgents = [ + createAgent({ id: "bg-1", status: "running", background: true }), + ]; + const runningHint = buildParallelAgentsHeaderHint(runningAgents, true); + expect(runningHint).toBe(BACKGROUND_TREE_HINT_CONTRACT.whenRunning); + + // Completed state + const completedAgents = [ + createAgent({ id: "bg-1", status: "completed", background: true }), + ]; + const completedHint = buildParallelAgentsHeaderHint(completedAgents, true); + expect(completedHint).toBe(BACKGROUND_TREE_HINT_CONTRACT.whenComplete); + + // Default state (no background agents) + const noBackgroundAgents = [ + createAgent({ id: "fg-1", status: "running", background: false }), + ]; + const defaultHint = buildParallelAgentsHeaderHint(noBackgroundAgents, true); + expect(defaultHint).toBe(BACKGROUND_TREE_HINT_CONTRACT.defaultHint); + + // Empty agents array + const emptyHint = buildParallelAgentsHeaderHint([], true); + expect(emptyHint).toBe(BACKGROUND_TREE_HINT_CONTRACT.defaultHint); + }); + }); + + describe("Acceptance: Cross-surface consistency", () => { + test("footer terminate hint and tree running hint both reference ctrl+f", () => { + // Ensures consistent messaging across UI surfaces + expect(BACKGROUND_FOOTER_CONTRACT.terminateHintText).toContain("ctrl+f"); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toContain("ctrl+f"); + }); + + test("tree complete hint and default hint both reference ctrl+o", () => { + // Ensures consistent expand/toggle messaging + expect(BACKGROUND_TREE_HINT_CONTRACT.whenComplete).toContain("ctrl+o"); + expect(BACKGROUND_TREE_HINT_CONTRACT.defaultHint).toContain("ctrl+o"); + }); + + test("termination flow messages use consistent terminology", () => { + // Validate that warning and termination messages use consistent language + const warn = getBackgroundTerminationDecision(0, 1); + const terminate = getBackgroundTerminationDecision(1, 1); + + // Both should reference "background agents" + if (warn.action === "warn") { + expect(warn.message.toLowerCase()).toContain("background agent"); + } + + if (terminate.action === "terminate") { + expect(terminate.message.toLowerCase()).toContain("background agent"); + } + }); + + test("footer and tree hints use matching 'kill' keyword", () => { + // Confirms both surfaces use "kill" (not "terminate", "stop", etc.) + expect(BACKGROUND_FOOTER_CONTRACT.terminateHintText).toContain("kill"); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toContain("kill"); + }); + }); + + describe("Acceptance: UX polish requirements", () => { + test("hints use consistent separator style (· character)", () => { + // Validates use of middle dot separator for visual consistency + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toContain("·"); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenComplete).toContain("·"); + }); + + test("keybinding hints use lowercase 'ctrl+' prefix", () => { + // Ensures consistent casing for keyboard shortcuts + expect(BACKGROUND_FOOTER_CONTRACT.terminateHintText).toMatch(/ctrl\+f/); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toMatch(/ctrl\+f/); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenComplete).toMatch(/ctrl\+o/); + expect(BACKGROUND_TREE_HINT_CONTRACT.defaultHint).toMatch(/ctrl\+o/); + }); + + test("footer shows pluralization correctly", () => { + // Single agent + const singleAgent = [ + createAgent({ id: "bg-1", background: true }), + ]; + const singleStatus = formatBackgroundAgentFooterStatus(singleAgent); + expect(singleStatus).toContain("1 local agent"); + expect(singleStatus).not.toContain("agents"); // Should be singular + + // Multiple agents + const multipleAgents = [ + createAgent({ id: "bg-1", background: true }), + createAgent({ id: "bg-2", background: true }), + ]; + const multipleStatus = formatBackgroundAgentFooterStatus(multipleAgents); + expect(multipleStatus).toContain("2 local agents"); + expect(multipleStatus).not.toContain("running"); + }); + + test("empty agent list produces empty footer status", () => { + // Footer should not display when no agents are active + const emptyStatus = formatBackgroundAgentFooterStatus([]); + expect(emptyStatus).toBe(""); + }); + }); +}); diff --git a/src/ui/utils/background-agent-contracts.ts b/src/ui/utils/background-agent-contracts.ts new file mode 100644 index 000000000..4c9a9e006 --- /dev/null +++ b/src/ui/utils/background-agent-contracts.ts @@ -0,0 +1,74 @@ +/** + * Background Agent UX Contracts (Issue #258) + * + * Canonical behavior contracts for background-agent footer, termination flow, + * and tree hint wording. These contracts eliminate UX ambiguity and provide + * a stable specification for parity tests across providers and runtime modes. + * + * **CI Enforcement:** + * These contracts are enforced automatically in CI via parity tests that verify: + * - Provider matrix parity (Claude, OpenCode, Copilot) — background-agent-provider-parity.test.ts + * - Dev/prod runtime invariance — background-agent-runtime-parity.test.ts + * - Issue #258 acceptance criteria — background-agent-acceptance.test.ts + * - Ctrl+F integration behavior — background-agent-termination-integration.test.ts + * - Keybinding non-conflict — background-agent-keybinding-nonconflict.test.ts + * - Parent callback integration — background-agent-parent-callback.test.ts + * + * All contract parity tests can be run via: `bun run test:contracts` + */ + +// --------------------------------------------------------------------------- +// Termination Decision Contract +// --------------------------------------------------------------------------- + +/** + * Discriminated union describing the outcome of a Ctrl+F keypress + * against the current background-agent state. + */ +export type BackgroundTerminationDecision = + | { action: "none" } + | { action: "warn"; message: string } + | { action: "terminate"; message: string }; + +// --------------------------------------------------------------------------- +// Footer Display Contract +// --------------------------------------------------------------------------- + +export interface BackgroundFooterContract { + /** Minimum active agent count before the footer becomes visible. */ + showWhenAgentCountAtLeast: number; + /** Whether the footer includes a terminate-key hint. */ + includeTerminateHint: boolean; + /** Exact text for the terminate hint shown in the footer. */ + terminateHintText: string; + /** Labeling style for the agent count. */ + countFormat: "agents" | "tasks"; +} + +/** Canonical footer contract instance used at runtime. */ +export const BACKGROUND_FOOTER_CONTRACT: BackgroundFooterContract = { + showWhenAgentCountAtLeast: 1, + includeTerminateHint: true, + terminateHintText: "ctrl+f to kill agents", + countFormat: "agents", +}; + +// --------------------------------------------------------------------------- +// Tree Hint Contract +// --------------------------------------------------------------------------- + +export interface BackgroundTreeHintContract { + /** Hint shown while at least one background agent is actively running. */ + whenRunning: string; + /** Hint shown when all background agents have completed. */ + whenComplete: string; + /** Fallback hint when no background agents exist. */ + defaultHint: string; +} + +/** Canonical tree hint contract instance used at runtime. */ +export const BACKGROUND_TREE_HINT_CONTRACT: BackgroundTreeHintContract = { + whenRunning: "background running · ctrl+f to kill agents", + whenComplete: "background complete · ctrl+o to expand", + defaultHint: "ctrl+o to expand", +}; diff --git a/src/ui/utils/background-agent-footer.test.ts b/src/ui/utils/background-agent-footer.test.ts new file mode 100644 index 000000000..37a8e3757 --- /dev/null +++ b/src/ui/utils/background-agent-footer.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { + formatBackgroundAgentFooterStatus, + getActiveBackgroundAgents, + resolveBackgroundAgentsForFooter, +} from "./background-agent-footer.ts"; +import { BACKGROUND_FOOTER_CONTRACT } from "./background-agent-contracts.ts"; + +function createAgent(overrides: Partial = {}): ParallelAgent { + return { + id: "agent-1", + name: "researcher", + task: "Research repository", + status: "running", + startedAt: new Date(0).toISOString(), + ...overrides, + }; +} + +describe("background agent footer helpers", () => { + test("detects active background agents from flag and legacy status", () => { + const agents = [ + createAgent({ id: "active-flag", background: true, status: "running" }), + createAgent({ id: "active-status", status: "background" }), + createAgent({ id: "completed", background: true, status: "completed" }), + ]; + + const active = getActiveBackgroundAgents(agents); + expect(active.map((agent) => agent.id)).toEqual(["active-flag", "active-status"]); + }); + + test("includes pending/running background agents and excludes foreground ones", () => { + const agents = [ + createAgent({ id: "bg-running", background: true, status: "running" }), + createAgent({ id: "bg-pending", background: true, status: "pending" }), + createAgent({ id: "legacy", status: "background" }), + createAgent({ id: "fg-running", background: false, status: "running" }), + ]; + + const active = getActiveBackgroundAgents(agents); + expect(active.map((agent) => agent.id)).toEqual([ + "bg-running", + "bg-pending", + "legacy", + ]); + }); + + test("prefers live background state when available", () => { + const liveAgents = [ + createAgent({ id: "live", background: true, status: "background" }), + ]; + const messages = [ + { + parallelAgents: [ + createAgent({ id: "snapshot", background: true, status: "background" }), + ], + }, + ]; + + const selected = resolveBackgroundAgentsForFooter(liveAgents, messages); + expect(selected.map((agent) => agent.id)).toEqual(["live"]); + }); + + test("falls back to latest message snapshot in absence of live state", () => { + const selected = resolveBackgroundAgentsForFooter([], [ + { + parallelAgents: [ + createAgent({ id: "old", background: true, status: "background" }), + ], + }, + { + parallelAgents: [ + createAgent({ id: "latest", background: true, status: "background" }), + ], + }, + ]); + + expect(selected.map((agent) => agent.id)).toEqual(["latest"]); + }); + + test("walks back snapshots until active background agents are found", () => { + const selected = resolveBackgroundAgentsForFooter([], [ + { + parallelAgents: [ + createAgent({ id: "newest-complete", background: true, status: "completed" }), + ], + }, + { + parallelAgents: [ + createAgent({ id: "middle-empty", background: false, status: "running" }), + ], + }, + { + parallelAgents: [ + createAgent({ id: "old-active", background: true, status: "background" }), + ], + }, + ]); + + expect(selected.map((agent) => agent.id)).toEqual(["old-active"]); + }); + + test("does not surface completed-only snapshots", () => { + const selected = resolveBackgroundAgentsForFooter([], [ + { + parallelAgents: [ + createAgent({ id: "done", background: true, status: "completed" }), + ], + }, + ]); + + expect(selected).toHaveLength(0); + expect(formatBackgroundAgentFooterStatus(selected)).toBe(""); + }); + + test("formats singular and plural footer labels", () => { + expect( + formatBackgroundAgentFooterStatus([ + createAgent({ id: "one", background: true, status: "background" }), + ]), + ).toBe("1 local agent"); + + expect( + formatBackgroundAgentFooterStatus([ + createAgent({ id: "one", background: true, status: "background" }), + createAgent({ id: "two", background: true, status: "background" }), + ]), + ).toBe("2 local agents"); + }); + + test("footer contract defines expected canonical values", () => { + expect(BACKGROUND_FOOTER_CONTRACT.showWhenAgentCountAtLeast).toBe(1); + expect(BACKGROUND_FOOTER_CONTRACT.includeTerminateHint).toBe(true); + expect(BACKGROUND_FOOTER_CONTRACT.terminateHintText).toBe("ctrl+f to kill agents"); + expect(BACKGROUND_FOOTER_CONTRACT.countFormat).toBe("agents"); + }); + + test("footer shows status when agent count meets contract threshold", () => { + const agents = Array.from( + { length: BACKGROUND_FOOTER_CONTRACT.showWhenAgentCountAtLeast }, + (_, i) => createAgent({ id: `agent-${i}`, background: true, status: "background" }), + ); + expect(formatBackgroundAgentFooterStatus(agents)).not.toBe(""); + }); +}); diff --git a/src/ui/utils/background-agent-footer.ts b/src/ui/utils/background-agent-footer.ts new file mode 100644 index 000000000..7c4b95fcf --- /dev/null +++ b/src/ui/utils/background-agent-footer.ts @@ -0,0 +1,52 @@ +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; + +export interface BackgroundAgentFooterMessage { + parallelAgents?: readonly ParallelAgent[]; +} + +export function isBackgroundAgent(agent: ParallelAgent): boolean { + return agent.background === true || agent.status === "background"; +} + +function isActiveBackgroundStatus(status: ParallelAgent["status"]): boolean { + return status === "background" || status === "running" || status === "pending"; +} + +export function getActiveBackgroundAgents( + agents: readonly ParallelAgent[], +): ParallelAgent[] { + return agents.filter((agent) => { + if (!isBackgroundAgent(agent)) return false; + return isActiveBackgroundStatus(agent.status); + }); +} + +export function resolveBackgroundAgentsForFooter( + liveAgents: readonly ParallelAgent[], + messages: readonly BackgroundAgentFooterMessage[], +): ParallelAgent[] { + const activeLiveAgents = getActiveBackgroundAgents(liveAgents); + if (activeLiveAgents.length > 0) { + return activeLiveAgents; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const snapshot = getActiveBackgroundAgents( + messages[index]?.parallelAgents ?? [], + ); + if (snapshot.length > 0) { + return snapshot; + } + } + + return []; +} + +export function formatBackgroundAgentFooterStatus( + agents: readonly ParallelAgent[], +): string { + const count = agents.length; + if (count === 0) return ""; + if (count === 1) return "1 local agent"; + return `${count} local agents`; +} diff --git a/src/ui/utils/background-agent-keybinding-nonconflict.test.ts b/src/ui/utils/background-agent-keybinding-nonconflict.test.ts new file mode 100644 index 000000000..f46428316 --- /dev/null +++ b/src/ui/utils/background-agent-keybinding-nonconflict.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { isBackgroundTerminationKey } from "./background-agent-termination.ts"; + +describe("background-agent keybinding non-conflict", () => { + test("Ctrl+O is NOT detected as background termination key", () => { + // Ctrl+O is used for transcript toggle + expect(isBackgroundTerminationKey({ ctrl: true, name: "o" })).toBe(false); + }); + + test("Ctrl+C is NOT detected as background termination key", () => { + // Ctrl+C is used for interruption + expect(isBackgroundTerminationKey({ ctrl: true, name: "c" })).toBe(false); + }); + + test("Ctrl+F IS detected as background termination key", () => { + expect(isBackgroundTerminationKey({ ctrl: true, name: "f" })).toBe(true); + }); + + test("Ctrl+Shift+F is NOT detected (modifier exclusion)", () => { + expect(isBackgroundTerminationKey({ ctrl: true, shift: true, name: "f" })).toBe(false); + }); + + test("Ctrl+Meta+F is NOT detected (modifier exclusion)", () => { + expect(isBackgroundTerminationKey({ ctrl: true, meta: true, name: "f" })).toBe(false); + }); + + test("plain F without Ctrl is NOT detected", () => { + expect(isBackgroundTerminationKey({ name: "f" })).toBe(false); + }); + + test("common Ctrl+key combos do not conflict with background termination", () => { + const nonConflictingKeys = ["a", "b", "c", "d", "e", "g", "h", "i", "j", "k", "l", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"]; + for (const key of nonConflictingKeys) { + expect(isBackgroundTerminationKey({ ctrl: true, name: key })).toBe(false); + } + }); + + test("Ctrl+F detected while Ctrl+O is not — simultaneous non-conflict", () => { + const ctrlF = { ctrl: true, name: "f" }; + const ctrlO = { ctrl: true, name: "o" }; + + expect(isBackgroundTerminationKey(ctrlF)).toBe(true); + expect(isBackgroundTerminationKey(ctrlO)).toBe(false); + }); +}); diff --git a/src/ui/utils/background-agent-parent-callback.test.ts b/src/ui/utils/background-agent-parent-callback.test.ts new file mode 100644 index 000000000..e4f431a07 --- /dev/null +++ b/src/ui/utils/background-agent-parent-callback.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { + getBackgroundTerminationDecision, + interruptActiveBackgroundAgents, +} from "./background-agent-termination.ts"; +import { getActiveBackgroundAgents } from "./background-agent-footer.ts"; + +function createAgent(overrides: Partial): ParallelAgent { + return { + id: overrides.id ?? "agent-1", + name: overrides.name ?? "task", + task: overrides.task ?? "Background task", + status: overrides.status ?? "background", + background: overrides.background, + startedAt: overrides.startedAt ?? new Date().toISOString(), + currentTool: overrides.currentTool, + durationMs: overrides.durationMs, + result: overrides.result, + }; +} + +describe("parent callback integration: termination pipeline", () => { + test("termination produces interrupted IDs for parent callback", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background", background: true }), + createAgent({ id: "bg-2", status: "running", background: true }), + createAgent({ id: "fg-1", status: "running", background: false }), + ]; + + const result = interruptActiveBackgroundAgents(agents); + + // Parent callback expects interruptedIds to be non-empty + expect(result.interruptedIds.length).toBeGreaterThan(0); + expect(result.interruptedIds).toEqual(["bg-1", "bg-2"]); + + // Parent callback should only receive background agent IDs + for (const id of result.interruptedIds) { + const agent = agents.find((a) => a.id === id); + expect(agent?.background).toBe(true); + } + }); + + test("parent callback receives correct session scope (background agents only)", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-active", status: "background", background: true }), + createAgent({ id: "fg-running", status: "running", background: false }), + createAgent({ id: "fg-pending", status: "pending", background: false }), + ]; + + const result = interruptActiveBackgroundAgents(agents); + + // Only background agents should be interrupted + expect(result.interruptedIds).toEqual(["bg-active"]); + + // Foreground agents should remain untouched + const fgRunning = result.agents.find((a) => a.id === "fg-running"); + const fgPending = result.agents.find((a) => a.id === "fg-pending"); + expect(fgRunning?.status).toBe("running"); + expect(fgPending?.status).toBe("pending"); + + // Background agent should be interrupted + const bgAgent = result.agents.find((a) => a.id === "bg-active"); + expect(bgAgent?.status).toBe("interrupted"); + }); + + test("no callback needed when no agents interrupted", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-completed", status: "completed", background: true }), + createAgent({ id: "bg-interrupted", status: "interrupted", background: true }), + createAgent({ id: "fg-running", status: "running", background: false }), + ]; + + const result = interruptActiveBackgroundAgents(agents); + + // Empty interruptedIds means parent callback should NOT be invoked + expect(result.interruptedIds).toEqual([]); + expect(result.interruptedIds.length).toBe(0); + + // All agents should remain unchanged + expect(result.agents).toEqual(agents); + }); + + test("sequential termination flows don't double-fire callback", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background", background: true }), + createAgent({ id: "bg-2", status: "running", background: true }), + ]; + + // First termination pass + const firstPass = interruptActiveBackgroundAgents(agents); + expect(firstPass.interruptedIds).toEqual(["bg-1", "bg-2"]); + + // Second termination pass on already-interrupted agents + const secondPass = interruptActiveBackgroundAgents(firstPass.agents); + expect(secondPass.interruptedIds).toEqual([]); + expect(secondPass.interruptedIds.length).toBe(0); + + // All agents should remain interrupted (not re-interrupted) + const bg1 = secondPass.agents.find((a) => a.id === "bg-1"); + const bg2 = secondPass.agents.find((a) => a.id === "bg-2"); + expect(bg1?.status).toBe("interrupted"); + expect(bg2?.status).toBe("interrupted"); + }); + + test("full pipeline: decision → interrupt → callback data", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background", background: true }), + createAgent({ id: "bg-2", status: "running", background: true }), + createAgent({ id: "fg-1", status: "running", background: false }), + ]; + + // Step 1: Get active background agent count + const activeBackgroundAgents = getActiveBackgroundAgents(agents); + expect(activeBackgroundAgents.length).toBe(2); + + // Step 2: Get termination decision (first press) + const firstDecision = getBackgroundTerminationDecision(0, activeBackgroundAgents.length); + expect(firstDecision.action).toBe("warn"); + expect(firstDecision).toHaveProperty("message"); + + // Step 3: Get termination decision (second press) + const secondDecision = getBackgroundTerminationDecision(1, activeBackgroundAgents.length); + expect(secondDecision.action).toBe("terminate"); + expect(secondDecision).toHaveProperty("message"); + + // Step 4: Execute termination + const result = interruptActiveBackgroundAgents(agents); + + // Step 5: Verify callback data is correct + expect(result.interruptedIds).toEqual(["bg-1", "bg-2"]); + expect(result.interruptedIds.length).toBe(2); + + // Verify interrupted agents have correct status + const bg1 = result.agents.find((a) => a.id === "bg-1"); + const bg2 = result.agents.find((a) => a.id === "bg-2"); + expect(bg1?.status).toBe("interrupted"); + expect(bg2?.status).toBe("interrupted"); + expect(bg1?.currentTool).toBeUndefined(); + expect(bg2?.currentTool).toBeUndefined(); + + // Foreground agent unchanged + const fg1 = result.agents.find((a) => a.id === "fg-1"); + expect(fg1?.status).toBe("running"); + }); + + test("active agent count drops to 0 after termination", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background", background: true }), + createAgent({ id: "bg-2", status: "running", background: true }), + createAgent({ id: "bg-3", status: "pending", background: true }), + createAgent({ id: "fg-1", status: "running", background: false }), + ]; + + // Before termination: 3 active background agents + const beforeActive = getActiveBackgroundAgents(agents); + expect(beforeActive.length).toBe(3); + + // Execute termination + const result = interruptActiveBackgroundAgents(agents); + expect(result.interruptedIds.length).toBe(3); + + // After termination: 0 active background agents + const afterActive = getActiveBackgroundAgents(result.agents); + expect(afterActive.length).toBe(0); + expect(afterActive).toEqual([]); + + // All background agents should be interrupted + const bg1 = result.agents.find((a) => a.id === "bg-1"); + const bg2 = result.agents.find((a) => a.id === "bg-2"); + const bg3 = result.agents.find((a) => a.id === "bg-3"); + expect(bg1?.status).toBe("interrupted"); + expect(bg2?.status).toBe("interrupted"); + expect(bg3?.status).toBe("interrupted"); + + // Foreground agent still running + const fg1 = result.agents.find((a) => a.id === "fg-1"); + expect(fg1?.status).toBe("running"); + }); +}); diff --git a/src/ui/utils/background-agent-provider-parity.test.ts b/src/ui/utils/background-agent-provider-parity.test.ts new file mode 100644 index 000000000..003f00e92 --- /dev/null +++ b/src/ui/utils/background-agent-provider-parity.test.ts @@ -0,0 +1,432 @@ +/** + * E2E Provider Parity Matrix Test (Issue #258 Task #19) + * + * This test verifies that background agent UX contracts produce identical behavior + * across all three providers: Claude Code, OpenCode, and GitHub Copilot CLI. + * + * The background agent utilities are provider-agnostic — they do NOT take an + * `agentType` parameter. This test documents and enforces that guarantee. + */ + +import { describe, expect, test } from "bun:test"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { + getBackgroundTerminationDecision, + interruptActiveBackgroundAgents, + isBackgroundTerminationKey, +} from "./background-agent-termination.ts"; +import { + BACKGROUND_FOOTER_CONTRACT, + BACKGROUND_TREE_HINT_CONTRACT, +} from "./background-agent-contracts.ts"; +import { + getActiveBackgroundAgents, + resolveBackgroundAgentsForFooter, + formatBackgroundAgentFooterStatus, + type BackgroundAgentFooterMessage, +} from "./background-agent-footer.ts"; +import { buildParallelAgentsHeaderHint } from "./background-agent-tree-hints.ts"; + +// Provider types (AGENT_KEYS is not exported from config.ts, so we define inline) +const PROVIDERS = ["claude", "opencode", "copilot"] as const; +type ProviderKey = (typeof PROVIDERS)[number]; + +// --------------------------------------------------------------------------- +// Test Fixture Helpers +// --------------------------------------------------------------------------- + +function createAgent(overrides: Partial = {}): ParallelAgent { + return { + id: overrides.id ?? "agent-1", + name: overrides.name ?? "task", + task: overrides.task ?? "Background task", + status: overrides.status ?? "background", + background: overrides.background, + startedAt: overrides.startedAt ?? new Date().toISOString(), + currentTool: overrides.currentTool, + durationMs: overrides.durationMs, + result: overrides.result, + }; +} + +// --------------------------------------------------------------------------- +// Provider Parity Test Matrix +// --------------------------------------------------------------------------- + +describe("Background agent provider parity matrix", () => { + // Convert to mutable array for test.each + const providers: ProviderKey[] = [...PROVIDERS]; + + // --------------------------------------------------------------------------- + // 1. Termination Decision Parity + // --------------------------------------------------------------------------- + + describe("termination decision parity", () => { + test.each(providers)( + "provider %s: identical decision outputs for all press counts", + (provider) => { + // Document that provider is not used — contracts are provider-agnostic + const _providerContext = provider; + + // Test matrix: (pressCount, activeCount) → decision + expect(getBackgroundTerminationDecision(0, 0)).toEqual({ action: "none" }); + expect(getBackgroundTerminationDecision(0, 2)).toEqual({ + action: "warn", + message: "Press Ctrl-F again to terminate background agents", + }); + expect(getBackgroundTerminationDecision(1, 2)).toEqual({ + action: "terminate", + message: "All background agents killed", + }); + expect(getBackgroundTerminationDecision(5, 0)).toEqual({ action: "none" }); + expect(getBackgroundTerminationDecision(2, 3)).toEqual({ + action: "terminate", + message: "All background agents killed", + }); + }, + ); + + test.each(providers)( + "provider %s: keybinding detection is identical", + (provider) => { + const _providerContext = provider; + + // Ctrl+F detection + expect(isBackgroundTerminationKey({ ctrl: true, name: "f" })).toBe(true); + + // Rejection cases + expect(isBackgroundTerminationKey({ ctrl: true, shift: true, name: "f" })).toBe(false); + expect(isBackgroundTerminationKey({ ctrl: true, meta: true, name: "f" })).toBe(false); + expect(isBackgroundTerminationKey({ ctrl: true, name: "c" })).toBe(false); + expect(isBackgroundTerminationKey({ ctrl: true, name: "o" })).toBe(false); + }, + ); + }); + + // --------------------------------------------------------------------------- + // 2. Footer Contract Parity + // --------------------------------------------------------------------------- + + describe("footer contract parity", () => { + test.each(providers)( + "provider %s: BACKGROUND_FOOTER_CONTRACT is identical", + (provider) => { + const _providerContext = provider; + + // All providers see the same contract instance + expect(BACKGROUND_FOOTER_CONTRACT).toEqual({ + showWhenAgentCountAtLeast: 1, + includeTerminateHint: true, + terminateHintText: "ctrl+f to kill agents", + countFormat: "agents", + }); + }, + ); + + test.each(providers)( + "provider %s: footer status formatting is identical", + (provider) => { + const _providerContext = provider; + + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background" }), + createAgent({ id: "bg-2", status: "running", background: true }), + ]; + + expect(formatBackgroundAgentFooterStatus([])).toBe(""); + expect(formatBackgroundAgentFooterStatus([agents[0]!])).toBe("1 local agent"); + expect(formatBackgroundAgentFooterStatus(agents)).toBe("2 local agents"); + }, + ); + + test.each(providers)( + "provider %s: footer resolver precedence is identical", + (provider) => { + const _providerContext = provider; + + const liveAgents: ParallelAgent[] = [ + createAgent({ id: "live-1", status: "background" }), + ]; + + const messages: BackgroundAgentFooterMessage[] = [ + { parallelAgents: [createAgent({ id: "msg-1", status: "background" })] }, + ]; + + // Live agents take precedence + const result = resolveBackgroundAgentsForFooter(liveAgents, messages); + expect(result.length).toBe(1); + expect(result[0]!.id).toBe("live-1"); + + // Snapshot fallback when live is empty + const fallback = resolveBackgroundAgentsForFooter([], messages); + expect(fallback.length).toBe(1); + expect(fallback[0]!.id).toBe("msg-1"); + }, + ); + }); + + // --------------------------------------------------------------------------- + // 3. Tree Hint Contract Parity + // --------------------------------------------------------------------------- + + describe("tree hint contract parity", () => { + test.each(providers)( + "provider %s: BACKGROUND_TREE_HINT_CONTRACT is identical", + (provider) => { + const _providerContext = provider; + + // All providers see the same contract instance + expect(BACKGROUND_TREE_HINT_CONTRACT).toEqual({ + whenRunning: "background running · ctrl+f to kill agents", + whenComplete: "background complete · ctrl+o to expand", + defaultHint: "ctrl+o to expand", + }); + }, + ); + + test.each(providers)( + "provider %s: tree hint builder produces identical hints", + (provider) => { + const _providerContext = provider; + + const runningAgents = [ + createAgent({ id: "bg-1", status: "background" }), + ]; + + const completedAgents = [ + createAgent({ id: "bg-1", status: "completed", background: true }), + ]; + + // When running + expect(buildParallelAgentsHeaderHint(runningAgents, true)).toBe( + "background running · ctrl+f to kill agents", + ); + + // When complete + expect(buildParallelAgentsHeaderHint(completedAgents, true)).toBe( + "background complete · ctrl+o to expand", + ); + + // Default hint + expect(buildParallelAgentsHeaderHint([], true)).toBe("ctrl+o to expand"); + + // No hint when showExpandHint is false + expect(buildParallelAgentsHeaderHint([], false)).toBe(""); + }, + ); + }); + + // --------------------------------------------------------------------------- + // 4. Interrupt Behavior Parity + // --------------------------------------------------------------------------- + + describe("interrupt behavior parity", () => { + test.each(providers)( + "provider %s: interruptActiveBackgroundAgents produces identical results", + (provider) => { + const _providerContext = provider; + + const now = Date.now(); + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-active", + status: "background", + startedAt: new Date(now - 5000).toISOString(), + }), + createAgent({ + id: "fg-running", + status: "running", + background: false, + }), + createAgent({ + id: "bg-completed", + status: "completed", + background: true, + }), + ]; + + const result = interruptActiveBackgroundAgents(agents, now); + + // Only bg-active should be interrupted + expect(result.interruptedIds).toEqual(["bg-active"]); + + const interruptedAgent = result.agents.find((a) => a.id === "bg-active"); + expect(interruptedAgent?.status).toBe("interrupted"); + expect(interruptedAgent?.durationMs).toBeGreaterThanOrEqual(5000); + + // Foreground and completed agents remain unchanged + expect(result.agents.find((a) => a.id === "fg-running")?.status).toBe("running"); + expect(result.agents.find((a) => a.id === "bg-completed")?.status).toBe("completed"); + }, + ); + + test.each(providers)( + "provider %s: empty interruptedIds when no active agents", + (provider) => { + const _providerContext = provider; + + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "completed", background: true }), + ]; + + const result = interruptActiveBackgroundAgents(agents); + expect(result.interruptedIds).toEqual([]); + expect(result.agents[0]!.status).toBe("completed"); + }, + ); + }); + + // --------------------------------------------------------------------------- + // 5. Full Contract Integration Parity + // --------------------------------------------------------------------------- + + describe("full contract integration parity", () => { + test.each(providers)( + "provider %s: complete termination flow produces identical behavior", + (provider) => { + const _providerContext = provider; + + const now = Date.now(); + let pressCount = 0; + let agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + startedAt: new Date(now - 3000).toISOString(), + }), + createAgent({ + id: "bg-2", + status: "running", + background: true, + startedAt: new Date(now - 2000).toISOString(), + }), + ]; + + // First press: warn + const activeCount1 = getActiveBackgroundAgents(agents).length; + expect(activeCount1).toBe(2); + + const decision1 = getBackgroundTerminationDecision(pressCount, activeCount1); + expect(decision1).toEqual({ + action: "warn", + message: "Press Ctrl-F again to terminate background agents", + }); + + pressCount += 1; + + // Second press: terminate + const activeCount2 = getActiveBackgroundAgents(agents).length; + const decision2 = getBackgroundTerminationDecision(pressCount, activeCount2); + expect(decision2).toEqual({ + action: "terminate", + message: "All background agents killed", + }); + + // Execute termination + const result = interruptActiveBackgroundAgents(agents, now); + agents = result.agents; + + expect(result.interruptedIds).toEqual(["bg-1", "bg-2"]); + expect(agents.every((a) => a.status === "interrupted")).toBe(true); + + // After termination: no more active agents + const activeCount3 = getActiveBackgroundAgents(agents).length; + expect(activeCount3).toBe(0); + + const decision3 = getBackgroundTerminationDecision(pressCount, activeCount3); + expect(decision3).toEqual({ action: "none" }); + }, + ); + + test.each(providers)( + "provider %s: footer and tree hint contracts remain consistent", + (provider) => { + const _providerContext = provider; + + // Verify cross-contract consistency + const footerHint = BACKGROUND_FOOTER_CONTRACT.terminateHintText; + const treeHintRunning = BACKGROUND_TREE_HINT_CONTRACT.whenRunning; + + // Both should reference Ctrl+F + expect(footerHint).toContain("ctrl+f"); + expect(treeHintRunning).toContain("ctrl+f"); + + // Both should reference termination + expect(footerHint).toContain("kill"); + expect(treeHintRunning).toContain("kill"); + + // Tree hint complete should reference Ctrl+O + expect(BACKGROUND_TREE_HINT_CONTRACT.whenComplete).toContain("ctrl+o"); + expect(BACKGROUND_TREE_HINT_CONTRACT.defaultHint).toContain("ctrl+o"); + }, + ); + }); + + // --------------------------------------------------------------------------- + // 6. Edge Case Parity + // --------------------------------------------------------------------------- + + describe("edge case parity", () => { + test.each(providers)( + "provider %s: handles empty agent arrays identically", + (provider) => { + const _providerContext = provider; + + expect(getActiveBackgroundAgents([])).toEqual([]); + expect(formatBackgroundAgentFooterStatus([])).toBe(""); + expect(buildParallelAgentsHeaderHint([], true)).toBe("ctrl+o to expand"); + expect(interruptActiveBackgroundAgents([])).toEqual({ + agents: [], + interruptedIds: [], + }); + }, + ); + + test.each(providers)( + "provider %s: handles mixed background/foreground agents identically", + (provider) => { + const _providerContext = provider; + + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background" }), + createAgent({ id: "fg-1", status: "running", background: false }), + createAgent({ id: "bg-2", status: "running", background: true }), + ]; + + const activeBackground = getActiveBackgroundAgents(agents); + expect(activeBackground.length).toBe(2); + expect(activeBackground.map((a) => a.id).sort()).toEqual(["bg-1", "bg-2"]); + + const hint = buildParallelAgentsHeaderHint(agents, true); + expect(hint).toBe("background running · ctrl+f to kill agents"); + + const result = interruptActiveBackgroundAgents(agents); + expect(result.interruptedIds.length).toBe(2); + expect(result.agents.find((a) => a.id === "fg-1")?.status).toBe("running"); + }, + ); + + test.each(providers)( + "provider %s: handles invalid timestamps identically", + (provider) => { + const _providerContext = provider; + + const agents: ParallelAgent[] = [ + createAgent({ + id: "invalid-timestamp", + status: "background", + startedAt: "invalid-date", + durationMs: 12345, + }), + ]; + + const result = interruptActiveBackgroundAgents(agents, Date.now()); + + const interrupted = result.agents.find((a) => a.id === "invalid-timestamp"); + expect(interrupted?.status).toBe("interrupted"); + // Should preserve existing durationMs when timestamp is invalid + expect(interrupted?.durationMs).toBe(12345); + }, + ); + }); +}); diff --git a/src/ui/utils/background-agent-runtime-parity.test.ts b/src/ui/utils/background-agent-runtime-parity.test.ts new file mode 100644 index 000000000..31213a42d --- /dev/null +++ b/src/ui/utils/background-agent-runtime-parity.test.ts @@ -0,0 +1,581 @@ +/** + * E2E Runtime Parity Test (Issue #258 Task #20) + * + * This test verifies that background agent contract functions produce + * deterministic, consistent results that are invariant across runtime paths: + * dev (via `bun run`) vs compiled production binary. + * + * Per spec (specs/background-agents-ui-issue-258-parity-hardening.md), + * dev and production runtime paths go through `startChatUI` as a shared entry point. + * The contract functions are pure JavaScript with no runtime-conditional branching — + * they don't check process.env.NODE_ENV, Bun.main, or any build-mode flag. + * + * This test documents and enforces that invariance. + */ + +import { describe, expect, test } from "bun:test"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { + getBackgroundTerminationDecision, + interruptActiveBackgroundAgents, + isBackgroundTerminationKey, + type BackgroundTerminationDecision, +} from "./background-agent-termination.ts"; +import { + BACKGROUND_FOOTER_CONTRACT, + BACKGROUND_TREE_HINT_CONTRACT, + type BackgroundFooterContract, + type BackgroundTreeHintContract, +} from "./background-agent-contracts.ts"; +import { + getActiveBackgroundAgents, + formatBackgroundAgentFooterStatus, + resolveBackgroundAgentsForFooter, + type BackgroundAgentFooterMessage, +} from "./background-agent-footer.ts"; +import { buildParallelAgentsHeaderHint } from "./background-agent-tree-hints.ts"; + +// --------------------------------------------------------------------------- +// Test Fixture Helpers +// --------------------------------------------------------------------------- + +function createAgent(overrides: Partial = {}): ParallelAgent { + return { + id: overrides.id ?? "agent-1", + name: overrides.name ?? "task", + task: overrides.task ?? "Background task", + status: overrides.status ?? "background", + background: overrides.background, + startedAt: overrides.startedAt ?? new Date(1000000000000).toISOString(), + currentTool: overrides.currentTool, + durationMs: overrides.durationMs, + result: overrides.result, + }; +} + +// --------------------------------------------------------------------------- +// Runtime Parity Tests +// --------------------------------------------------------------------------- + +describe("Background agent runtime parity (dev/prod invariance)", () => { + // --------------------------------------------------------------------------- + // 1. Contract Constants Determinism + // --------------------------------------------------------------------------- + + describe("contract constant determinism", () => { + test("BACKGROUND_FOOTER_CONTRACT has expected frozen values", () => { + const contract = BACKGROUND_FOOTER_CONTRACT; + + // Verify exact values + expect(contract.showWhenAgentCountAtLeast).toBe(1); + expect(contract.includeTerminateHint).toBe(true); + expect(contract.terminateHintText).toBe("ctrl+f to kill agents"); + expect(contract.countFormat).toBe("agents"); + + // Verify object stability (same reference across calls) + expect(BACKGROUND_FOOTER_CONTRACT).toBe(contract); + + // Document that contract is runtime-invariant + expect(typeof contract).toBe("object"); + expect(contract).not.toBeNull(); + }); + + test("BACKGROUND_TREE_HINT_CONTRACT has expected frozen values", () => { + const contract = BACKGROUND_TREE_HINT_CONTRACT; + + // Verify exact values + expect(contract.whenRunning).toBe("background running · ctrl+f to kill agents"); + expect(contract.whenComplete).toBe("background complete · ctrl+o to expand"); + expect(contract.defaultHint).toBe("ctrl+o to expand"); + + // Verify object stability (same reference across calls) + expect(BACKGROUND_TREE_HINT_CONTRACT).toBe(contract); + + // Document that contract is runtime-invariant + expect(typeof contract).toBe("object"); + expect(contract).not.toBeNull(); + }); + + test("contract constants are not mutated by runtime", () => { + // Capture initial state + const footerSnapshot = JSON.stringify(BACKGROUND_FOOTER_CONTRACT); + const treeSnapshot = JSON.stringify(BACKGROUND_TREE_HINT_CONTRACT); + + // Perform various operations (these should not mutate contracts) + getBackgroundTerminationDecision(0, 1); + formatBackgroundAgentFooterStatus([createAgent()]); + buildParallelAgentsHeaderHint([createAgent()], true); + + // Verify contracts remain unchanged + expect(JSON.stringify(BACKGROUND_FOOTER_CONTRACT)).toBe(footerSnapshot); + expect(JSON.stringify(BACKGROUND_TREE_HINT_CONTRACT)).toBe(treeSnapshot); + }); + }); + + // --------------------------------------------------------------------------- + // 2. Pure Function Determinism + // --------------------------------------------------------------------------- + + describe("pure function determinism", () => { + test("getBackgroundTerminationDecision produces identical outputs for identical inputs", () => { + const testCases: Array<[number, number, BackgroundTerminationDecision]> = [ + [0, 0, { action: "none" } as const], + [0, 1, { action: "warn", message: "Press Ctrl-F again to terminate background agents" } as const], + [0, 3, { action: "warn", message: "Press Ctrl-F again to terminate background agents" } as const], + [1, 2, { action: "terminate", message: "All background agents killed" } as const], + [2, 5, { action: "terminate", message: "All background agents killed" } as const], + [5, 0, { action: "none" } as const], + ]; + + for (const [pressCount, activeCount, expected] of testCases) { + // Call multiple times with same inputs + const result1 = getBackgroundTerminationDecision(pressCount, activeCount); + const result2 = getBackgroundTerminationDecision(pressCount, activeCount); + const result3 = getBackgroundTerminationDecision(pressCount, activeCount); + + // All results must be identical + expect(result1).toEqual(expected); + expect(result2).toEqual(expected); + expect(result3).toEqual(expected); + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + } + }); + + test("isBackgroundTerminationKey produces identical outputs for identical inputs", () => { + const testCases = [ + [{ ctrl: true, name: "f" }, true], + [{ ctrl: true, shift: true, name: "f" }, false], + [{ ctrl: true, meta: true, name: "f" }, false], + [{ ctrl: true, name: "c" }, false], + [{ ctrl: false, name: "f" }, false], + [{ name: "f" }, false], + ] as const; + + for (const [event, expected] of testCases) { + // Call multiple times with same inputs + const result1 = isBackgroundTerminationKey(event); + const result2 = isBackgroundTerminationKey(event); + const result3 = isBackgroundTerminationKey(event); + + // All results must be identical + expect(result1).toBe(expected); + expect(result2).toBe(expected); + expect(result3).toBe(expected); + } + }); + + test("interruptActiveBackgroundAgents produces identical outputs for identical inputs", () => { + const fixedNowMs = 1000000005000; + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + startedAt: new Date(1000000000000).toISOString(), + }), + createAgent({ + id: "bg-2", + status: "running", + background: true, + startedAt: new Date(1000000002000).toISOString(), + }), + ]; + + // Call multiple times with same inputs + const result1 = interruptActiveBackgroundAgents(agents, fixedNowMs); + const result2 = interruptActiveBackgroundAgents(agents, fixedNowMs); + const result3 = interruptActiveBackgroundAgents(agents, fixedNowMs); + + // All results must be identical + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + + // Verify deterministic behavior + expect(result1.interruptedIds).toEqual(["bg-1", "bg-2"]); + expect(result1.agents[0]!.status).toBe("interrupted"); + expect(result1.agents[0]!.durationMs).toBe(5000); + expect(result1.agents[1]!.status).toBe("interrupted"); + expect(result1.agents[1]!.durationMs).toBe(3000); + }); + + test("getActiveBackgroundAgents produces identical outputs for identical inputs", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background" }), + createAgent({ id: "fg-1", status: "running", background: false }), + createAgent({ id: "bg-2", status: "running", background: true }), + createAgent({ id: "bg-3", status: "completed", background: true }), + ]; + + // Call multiple times with same inputs + const result1 = getActiveBackgroundAgents(agents); + const result2 = getActiveBackgroundAgents(agents); + const result3 = getActiveBackgroundAgents(agents); + + // All results must be identical + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + + // Verify deterministic filtering + expect(result1.length).toBe(2); + expect(result1.map((a) => a.id).sort()).toEqual(["bg-1", "bg-2"]); + }); + + test("formatBackgroundAgentFooterStatus produces identical outputs for identical inputs", () => { + const testCases = [ + [[], ""], + [[createAgent()], "1 local agent"], + [[createAgent({ id: "1" }), createAgent({ id: "2" })], "2 local agents"], + [ + [createAgent({ id: "1" }), createAgent({ id: "2" }), createAgent({ id: "3" })], + "3 local agents", + ], + ] as const; + + for (const [agents, expected] of testCases) { + // Call multiple times with same inputs + const result1 = formatBackgroundAgentFooterStatus(agents); + const result2 = formatBackgroundAgentFooterStatus(agents); + const result3 = formatBackgroundAgentFooterStatus(agents); + + // All results must be identical + expect(result1).toBe(expected); + expect(result2).toBe(expected); + expect(result3).toBe(expected); + } + }); + + test("buildParallelAgentsHeaderHint produces identical outputs for identical inputs", () => { + const testCases = [ + [[createAgent({ status: "background" })], true, "background running · ctrl+f to kill agents"], + [[createAgent({ status: "completed", background: true })], true, "background complete · ctrl+o to expand"], + [[], true, "ctrl+o to expand"], + [[], false, ""], + ] as const; + + for (const [agents, showHint, expected] of testCases) { + // Call multiple times with same inputs + const result1 = buildParallelAgentsHeaderHint(agents, showHint); + const result2 = buildParallelAgentsHeaderHint(agents, showHint); + const result3 = buildParallelAgentsHeaderHint(agents, showHint); + + // All results must be identical + expect(result1).toBe(expected); + expect(result2).toBe(expected); + expect(result3).toBe(expected); + } + }); + + test("resolveBackgroundAgentsForFooter produces identical outputs for identical inputs", () => { + const liveAgents: ParallelAgent[] = [ + createAgent({ id: "live-1", status: "background" }), + ]; + + const messages: BackgroundAgentFooterMessage[] = [ + { parallelAgents: [createAgent({ id: "msg-1", status: "background" })] }, + ]; + + // Call multiple times with same inputs + const result1 = resolveBackgroundAgentsForFooter(liveAgents, messages); + const result2 = resolveBackgroundAgentsForFooter(liveAgents, messages); + const result3 = resolveBackgroundAgentsForFooter(liveAgents, messages); + + // All results must be identical + expect(result1).toEqual(result2); + expect(result2).toEqual(result3); + + // Verify deterministic resolution + expect(result1.length).toBe(1); + expect(result1[0]!.id).toBe("live-1"); + }); + }); + + // --------------------------------------------------------------------------- + // 3. Idempotency + // --------------------------------------------------------------------------- + + describe("function idempotency", () => { + test("multiple sequential calls produce identical results (no internal mutation)", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "bg-1", status: "background" }), + createAgent({ id: "bg-2", status: "running", background: true }), + ]; + + const nowMs = 1000000005000; + + // Call multiple times sequentially + const results = Array.from({ length: 5 }, () => + interruptActiveBackgroundAgents(agents, nowMs), + ); + + // All results must be identical + for (let i = 1; i < results.length; i += 1) { + expect(results[i]).toEqual(results[0]); + } + + // Verify no side effects on input + expect(agents[0]!.status).toBe("background"); + expect(agents[1]!.status).toBe("running"); + }); + + test("decision logic is stateless across calls", () => { + // Call decision function many times + const results = Array.from({ length: 100 }, () => + getBackgroundTerminationDecision(0, 2), + ); + + // All results must be identical + const expected: BackgroundTerminationDecision = { + action: "warn", + message: "Press Ctrl-F again to terminate background agents", + }; + + for (const result of results) { + expect(result).toEqual(expected); + } + }); + + test("footer formatting is stateless across calls", () => { + const agents = [createAgent(), createAgent({ id: "2" })]; + + // Call formatting function many times + const results = Array.from({ length: 50 }, () => + formatBackgroundAgentFooterStatus(agents), + ); + + // All results must be identical + for (const result of results) { + expect(result).toBe("2 local agents"); + } + }); + + test("tree hint builder is stateless across calls", () => { + const agents = [createAgent({ status: "background" })]; + + // Call hint builder many times + const results = Array.from({ length: 50 }, () => + buildParallelAgentsHeaderHint(agents, true), + ); + + // All results must be identical + for (const result of results) { + expect(result).toBe("background running · ctrl+f to kill agents"); + } + }); + }); + + // --------------------------------------------------------------------------- + // 4. No Environment-Conditional Branching + // --------------------------------------------------------------------------- + + describe("no environment-conditional behavior", () => { + test("contract functions work without environment variables", () => { + // Document that functions don't reference process.env, import.meta.env, or Bun.env + // These functions should work identically regardless of NODE_ENV, build mode, etc. + + // Test decision logic + const decision = getBackgroundTerminationDecision(0, 1); + expect(decision.action).toBe("warn"); + + // Test key detection + const isTermKey = isBackgroundTerminationKey({ ctrl: true, name: "f" }); + expect(isTermKey).toBe(true); + + // Test interruption + const agents = [createAgent({ status: "background" })]; + const result = interruptActiveBackgroundAgents(agents, 1000000005000); + expect(result.interruptedIds).toEqual(["agent-1"]); + + // Test formatting + const status = formatBackgroundAgentFooterStatus(agents); + expect(status).toBe("1 local agent"); + + // Test hint building + const hint = buildParallelAgentsHeaderHint(agents, true); + expect(hint).toBe("background running · ctrl+f to kill agents"); + + // Test active agent filtering + const activeAgents = getActiveBackgroundAgents(agents); + expect(activeAgents.length).toBe(1); + }); + + test("contract constants are accessible without environment setup", () => { + // Verify contracts can be imported and used without any environment-specific setup + expect(BACKGROUND_FOOTER_CONTRACT).toBeDefined(); + expect(BACKGROUND_TREE_HINT_CONTRACT).toBeDefined(); + + // Verify they have expected structure + expect(BACKGROUND_FOOTER_CONTRACT).toHaveProperty("showWhenAgentCountAtLeast"); + expect(BACKGROUND_FOOTER_CONTRACT).toHaveProperty("includeTerminateHint"); + expect(BACKGROUND_FOOTER_CONTRACT).toHaveProperty("terminateHintText"); + expect(BACKGROUND_FOOTER_CONTRACT).toHaveProperty("countFormat"); + + expect(BACKGROUND_TREE_HINT_CONTRACT).toHaveProperty("whenRunning"); + expect(BACKGROUND_TREE_HINT_CONTRACT).toHaveProperty("whenComplete"); + expect(BACKGROUND_TREE_HINT_CONTRACT).toHaveProperty("defaultHint"); + }); + }); + + // --------------------------------------------------------------------------- + // 5. Module Import Stability + // --------------------------------------------------------------------------- + + describe("module export stability", () => { + test("all contract exports are accessible and have expected types", () => { + // Type exports + const _typeCheck1: BackgroundTerminationDecision = { action: "none" }; + const _typeCheck2: BackgroundFooterContract = { + showWhenAgentCountAtLeast: 1, + includeTerminateHint: true, + terminateHintText: "test", + countFormat: "agents", + }; + const _typeCheck3: BackgroundTreeHintContract = { + whenRunning: "test", + whenComplete: "test", + defaultHint: "test", + }; + + // Function exports + expect(typeof getBackgroundTerminationDecision).toBe("function"); + expect(typeof interruptActiveBackgroundAgents).toBe("function"); + expect(typeof isBackgroundTerminationKey).toBe("function"); + expect(typeof getActiveBackgroundAgents).toBe("function"); + expect(typeof formatBackgroundAgentFooterStatus).toBe("function"); + expect(typeof resolveBackgroundAgentsForFooter).toBe("function"); + expect(typeof buildParallelAgentsHeaderHint).toBe("function"); + + // Constant exports + expect(typeof BACKGROUND_FOOTER_CONTRACT).toBe("object"); + expect(typeof BACKGROUND_TREE_HINT_CONTRACT).toBe("object"); + }); + + test("function signatures remain stable", () => { + // Verify function arity (parameter count) + expect(getBackgroundTerminationDecision.length).toBe(2); + expect(isBackgroundTerminationKey.length).toBe(1); + expect(interruptActiveBackgroundAgents.length).toBe(1); // agents (nowMs has default value) + expect(getActiveBackgroundAgents.length).toBe(1); + expect(formatBackgroundAgentFooterStatus.length).toBe(1); + expect(resolveBackgroundAgentsForFooter.length).toBe(2); + expect(buildParallelAgentsHeaderHint.length).toBe(2); + }); + + test("exported contract values are stable and well-defined", () => { + // Document that contracts export well-defined constant values + // Note: TypeScript const exports provide compile-time immutability + // Runtime immutability could be added with Object.freeze if needed in the future + + // Verify contracts have stable, well-defined values + expect(BACKGROUND_FOOTER_CONTRACT.showWhenAgentCountAtLeast).toBe(1); + expect(BACKGROUND_FOOTER_CONTRACT.includeTerminateHint).toBe(true); + expect(BACKGROUND_FOOTER_CONTRACT.terminateHintText).toBeDefined(); + expect(BACKGROUND_FOOTER_CONTRACT.countFormat).toBe("agents"); + + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toBeDefined(); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenComplete).toBeDefined(); + expect(BACKGROUND_TREE_HINT_CONTRACT.defaultHint).toBeDefined(); + + // Verify contracts maintain same reference + const footerRef1 = BACKGROUND_FOOTER_CONTRACT; + const footerRef2 = BACKGROUND_FOOTER_CONTRACT; + expect(footerRef1).toBe(footerRef2); + + const treeRef1 = BACKGROUND_TREE_HINT_CONTRACT; + const treeRef2 = BACKGROUND_TREE_HINT_CONTRACT; + expect(treeRef1).toBe(treeRef2); + }); + }); + + // --------------------------------------------------------------------------- + // 6. Cross-Runtime Consistency (Dev/Prod Invariance) + // --------------------------------------------------------------------------- + + describe("dev/prod runtime invariance", () => { + test("decision logic produces deterministic results regardless of runtime", () => { + // These tests run identically in dev (bun run) and prod (compiled binary) + // because the functions have no runtime-conditional branching + + const scenarios = [ + { pressCount: 0, activeCount: 0, expectedAction: "none" }, + { pressCount: 0, activeCount: 2, expectedAction: "warn" }, + { pressCount: 1, activeCount: 2, expectedAction: "terminate" }, + ] as const; + + for (const scenario of scenarios) { + const result = getBackgroundTerminationDecision( + scenario.pressCount, + scenario.activeCount, + ); + expect(result.action).toBe(scenario.expectedAction); + } + }); + + test("footer status formatting is invariant across runtimes", () => { + const testCases = [ + { count: 0, expected: "" }, + { count: 1, expected: "1 local agent" }, + { count: 5, expected: "5 local agents" }, + ]; + + for (const { count, expected } of testCases) { + const agents = Array.from({ length: count }, (_, i) => + createAgent({ id: `agent-${i}`, status: "background" }), + ); + const result = formatBackgroundAgentFooterStatus(agents); + expect(result).toBe(expected); + } + }); + + test("tree hint precedence is invariant across runtimes", () => { + const runningAgents = [createAgent({ status: "background" })]; + const completedAgents = [createAgent({ status: "completed", background: true })]; + const noAgents: ParallelAgent[] = []; + + expect(buildParallelAgentsHeaderHint(runningAgents, true)).toBe( + "background running · ctrl+f to kill agents", + ); + expect(buildParallelAgentsHeaderHint(completedAgents, true)).toBe( + "background complete · ctrl+o to expand", + ); + expect(buildParallelAgentsHeaderHint(noAgents, true)).toBe("ctrl+o to expand"); + expect(buildParallelAgentsHeaderHint(noAgents, false)).toBe(""); + }); + + test("interruption behavior is deterministic across runtimes", () => { + const nowMs = 1000000005000; + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + startedAt: new Date(1000000000000).toISOString(), + }), + ]; + + const result = interruptActiveBackgroundAgents(agents, nowMs); + + expect(result.interruptedIds).toEqual(["bg-1"]); + expect(result.agents[0]!.status).toBe("interrupted"); + expect(result.agents[0]!.durationMs).toBe(5000); + }); + + test("contract constants are identical across runtimes", () => { + // Capture contract values + const footerContract = JSON.parse(JSON.stringify(BACKGROUND_FOOTER_CONTRACT)); + const treeContract = JSON.parse(JSON.stringify(BACKGROUND_TREE_HINT_CONTRACT)); + + // These values should be identical in dev and production + expect(footerContract).toEqual({ + showWhenAgentCountAtLeast: 1, + includeTerminateHint: true, + terminateHintText: "ctrl+f to kill agents", + countFormat: "agents", + }); + + expect(treeContract).toEqual({ + whenRunning: "background running · ctrl+f to kill agents", + whenComplete: "background complete · ctrl+o to expand", + defaultHint: "ctrl+o to expand", + }); + }); + }); +}); diff --git a/src/ui/utils/background-agent-termination-integration.test.ts b/src/ui/utils/background-agent-termination-integration.test.ts new file mode 100644 index 000000000..1c5e5932a --- /dev/null +++ b/src/ui/utils/background-agent-termination-integration.test.ts @@ -0,0 +1,444 @@ +/** + * Integration tests for Ctrl+F double-press background agent termination flow. + * + * These tests verify the FULL Ctrl+F double-press lifecycle end-to-end by composing + * the existing pure utility functions to simulate the state machine sequence that + * happens in chat.tsx. + * + * Test coverage: + * 1. Full Ctrl+F double-press lifecycle (press → warn → press → terminate → confirm) + * 2. First press shows correct warning message + * 3. Second press emits correct confirmation message + * 4. Timeout reset between presses (press → timeout → press → warn again) + * 5. No active agents → noop for any press count + * 6. Mixed active/completed agents (only active get interrupted) + * 7. Confirmation message content matches contract + */ + +import { describe, expect, test } from "bun:test"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { + getBackgroundTerminationDecision, + interruptActiveBackgroundAgents, + isBackgroundTerminationKey, +} from "./background-agent-termination.ts"; +import { + BACKGROUND_FOOTER_CONTRACT, + BACKGROUND_TREE_HINT_CONTRACT, +} from "./background-agent-contracts.ts"; +import { getActiveBackgroundAgents } from "./background-agent-footer.ts"; + +// ============================================================================ +// TEST HELPERS +// ============================================================================ + +function createAgent(overrides: Partial): ParallelAgent { + return { + id: overrides.id ?? "agent-1", + name: overrides.name ?? "task", + task: overrides.task ?? "Background task", + status: overrides.status ?? "background", + background: overrides.background, + startedAt: overrides.startedAt ?? new Date().toISOString(), + currentTool: overrides.currentTool, + durationMs: overrides.durationMs, + result: overrides.result, + }; +} + +/** + * Simulates the state machine flow for Ctrl+F presses. + * Returns the decision and whether termination was executed. + */ +interface CtrlFPressSimulation { + pressCount: number; + decision: ReturnType; + agents: ParallelAgent[]; + interruptedIds: string[]; + terminationExecuted: boolean; +} + +function simulateCtrlFPress( + pressCount: number, + agents: ParallelAgent[], + nowMs: number = Date.now(), +): CtrlFPressSimulation { + const activeCount = getActiveBackgroundAgents(agents).length; + const decision = getBackgroundTerminationDecision(pressCount, activeCount); + + if (decision.action === "terminate") { + const result = interruptActiveBackgroundAgents(agents, nowMs); + return { + pressCount: pressCount + 1, + decision, + agents: result.agents, + interruptedIds: result.interruptedIds, + terminationExecuted: true, + }; + } + + return { + pressCount: decision.action === "warn" ? pressCount + 1 : pressCount, + decision, + agents, + interruptedIds: [], + terminationExecuted: false, + }; +} + +// ============================================================================ +// INTEGRATION TESTS +// ============================================================================ + +describe("Ctrl+F double-press lifecycle integration", () => { + test("full double-press flow: press 1 → warn → press 2 → terminate → confirm", () => { + const now = Date.now(); + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + background: true, + startedAt: new Date(now - 5000).toISOString(), + currentTool: "Running in background...", + }), + createAgent({ + id: "bg-2", + status: "running", + background: true, + startedAt: new Date(now - 3000).toISOString(), + currentTool: "Processing...", + }), + ]; + + // Press 1: Should warn + const press1 = simulateCtrlFPress(0, agents, now); + expect(press1.decision.action).toBe("warn"); + expect(press1.decision).toHaveProperty("message"); + if (press1.decision.action === "warn") { + expect(press1.decision.message).toBe("Press Ctrl-F again to terminate background agents"); + } + expect(press1.terminationExecuted).toBe(false); + expect(press1.pressCount).toBe(1); + expect(press1.agents).toEqual(agents); // No changes yet + + // Press 2 (within timeout): Should terminate + const press2 = simulateCtrlFPress(press1.pressCount, press1.agents, now); + expect(press2.decision.action).toBe("terminate"); + expect(press2.decision).toHaveProperty("message"); + if (press2.decision.action === "terminate") { + expect(press2.decision.message).toBe("All background agents killed"); + } + expect(press2.terminationExecuted).toBe(true); + expect(press2.interruptedIds).toEqual(["bg-1", "bg-2"]); + + // Verify all agents are interrupted + expect(press2.agents.every((agent) => agent.status === "interrupted")).toBe(true); + expect(press2.agents.every((agent) => agent.currentTool === undefined)).toBe(true); + expect(press2.agents[0]?.durationMs).toBeGreaterThanOrEqual(5000); + expect(press2.agents[1]?.durationMs).toBeGreaterThanOrEqual(3000); + }); + + test("first press shows correct warning message", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-active", + status: "background", + background: true, + }), + ]; + + const press = simulateCtrlFPress(0, agents); + expect(press.decision.action).toBe("warn"); + if (press.decision.action === "warn") { + expect(press.decision.message).toBe("Press Ctrl-F again to terminate background agents"); + } + }); + + test("second press emits correct confirmation message", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-active", + status: "background", + background: true, + }), + ]; + + // Simulate press count = 1 (second press) + const press = simulateCtrlFPress(1, agents); + expect(press.decision.action).toBe("terminate"); + if (press.decision.action === "terminate") { + expect(press.decision.message).toBe("All background agents killed"); + } + }); + + test("timeout reset between presses: press → wait → press → warn again", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + background: true, + }), + ]; + + // First press: warn + const press1 = simulateCtrlFPress(0, agents); + expect(press1.decision.action).toBe("warn"); + expect(press1.pressCount).toBe(1); + + // Simulate timeout: press count resets to 0 + const pressCountAfterTimeout = 0; + + // Next press after timeout: should warn again, not terminate + const press2 = simulateCtrlFPress(pressCountAfterTimeout, agents); + expect(press2.decision.action).toBe("warn"); + if (press2.decision.action === "warn") { + expect(press2.decision.message).toBe("Press Ctrl-F again to terminate background agents"); + } + expect(press2.terminationExecuted).toBe(false); + }); + + test("no active agents → noop for any press count", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "completed", + status: "completed", + background: true, + durationMs: 1000, + }), + createAgent({ + id: "interrupted", + status: "interrupted", + background: true, + durationMs: 500, + }), + ]; + + // Press count 0 (first press) + const press0 = simulateCtrlFPress(0, agents); + expect(press0.decision.action).toBe("none"); + expect(press0.terminationExecuted).toBe(false); + + // Press count 1 (second press) + const press1 = simulateCtrlFPress(1, agents); + expect(press1.decision.action).toBe("none"); + expect(press1.terminationExecuted).toBe(false); + + // Press count 5 (multiple presses) + const press5 = simulateCtrlFPress(5, agents); + expect(press5.decision.action).toBe("none"); + expect(press5.terminationExecuted).toBe(false); + }); + + test("mixed active/completed agents: only active get interrupted", () => { + const now = Date.now(); + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-active-1", + status: "background", + background: true, + startedAt: new Date(now - 2000).toISOString(), + currentTool: "Running...", + }), + createAgent({ + id: "bg-completed", + status: "completed", + background: true, + startedAt: new Date(now - 5000).toISOString(), + durationMs: 3000, + }), + createAgent({ + id: "bg-active-2", + status: "pending", + background: true, + startedAt: new Date(now - 1000).toISOString(), + }), + createAgent({ + id: "fg-running", + status: "running", + background: false, + currentTool: "Foreground task", + }), + ]; + + // First press: warn + const press1 = simulateCtrlFPress(0, agents, now); + expect(press1.decision.action).toBe("warn"); + + // Second press: terminate + const press2 = simulateCtrlFPress(press1.pressCount, press1.agents, now); + expect(press2.decision.action).toBe("terminate"); + expect(press2.terminationExecuted).toBe(true); + + // Only active background agents should be interrupted + expect(press2.interruptedIds).toEqual(["bg-active-1", "bg-active-2"]); + + // Verify agent states + const bgActive1 = press2.agents.find((a) => a.id === "bg-active-1"); + expect(bgActive1?.status).toBe("interrupted"); + expect(bgActive1?.currentTool).toBeUndefined(); + + const bgCompleted = press2.agents.find((a) => a.id === "bg-completed"); + expect(bgCompleted?.status).toBe("completed"); + expect(bgCompleted?.durationMs).toBe(3000); // Preserved + + const bgActive2 = press2.agents.find((a) => a.id === "bg-active-2"); + expect(bgActive2?.status).toBe("interrupted"); + + const fgRunning = press2.agents.find((a) => a.id === "fg-running"); + expect(fgRunning?.status).toBe("running"); // Unaffected + expect(fgRunning?.currentTool).toBe("Foreground task"); + }); + + test("confirmation message content matches contract expectations", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + background: true, + }), + ]; + + // First press warning message + const press1 = simulateCtrlFPress(0, agents); + expect(press1.decision.action).toBe("warn"); + if (press1.decision.action === "warn") { + // Message should mention Ctrl-F and termination + expect(press1.decision.message).toContain("Ctrl-F"); + expect(press1.decision.message).toContain("terminate"); + expect(press1.decision.message).toContain("background agents"); + } + + // Second press confirmation message + const press2 = simulateCtrlFPress(press1.pressCount, press1.agents); + expect(press2.decision.action).toBe("terminate"); + if (press2.decision.action === "terminate") { + // Message should mention killing/termination + expect(press2.decision.message).toContain("background agents"); + expect(press2.decision.message).toContain("killed"); + } + }); + + test("confirmation messages are consistent with footer and tree hint contracts", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + background: true, + }), + ]; + + // Verify footer contract includes terminate hint + expect(BACKGROUND_FOOTER_CONTRACT.includeTerminateHint).toBe(true); + expect(BACKGROUND_FOOTER_CONTRACT.terminateHintText).toBe("ctrl+f to kill agents"); + + // Verify tree hint contract includes termination hint for running agents + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toContain("ctrl+f"); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toContain("kill"); + + // Verify decision messages reference the same key combination + const press1 = simulateCtrlFPress(0, agents); + if (press1.decision.action === "warn") { + expect(press1.decision.message.toLowerCase()).toContain("ctrl"); + expect(press1.decision.message.toLowerCase()).toContain("f"); + } + + const press2 = simulateCtrlFPress(press1.pressCount, press1.agents); + if (press2.decision.action === "terminate") { + // Confirmation message should be clear about what happened + expect(press2.decision.message).toBeTruthy(); + expect(press2.decision.message.length).toBeGreaterThan(0); + } + }); +}); + +describe("Ctrl+F keybinding detection", () => { + test("detects Ctrl+F correctly", () => { + expect(isBackgroundTerminationKey({ ctrl: true, name: "f" })).toBe(true); + }); + + test("rejects Ctrl+F with additional modifiers", () => { + expect(isBackgroundTerminationKey({ ctrl: true, shift: true, name: "f" })).toBe(false); + expect(isBackgroundTerminationKey({ ctrl: true, meta: true, name: "f" })).toBe(false); + }); + + test("rejects other Ctrl combinations", () => { + expect(isBackgroundTerminationKey({ ctrl: true, name: "c" })).toBe(false); + expect(isBackgroundTerminationKey({ ctrl: true, name: "o" })).toBe(false); + }); +}); + +describe("Edge cases and error handling", () => { + test("handles empty agent list", () => { + const press = simulateCtrlFPress(0, []); + expect(press.decision.action).toBe("none"); + expect(press.terminationExecuted).toBe(false); + }); + + test("handles agents with invalid startedAt timestamps", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "invalid-time", + status: "background", + background: true, + startedAt: "not-a-valid-date", + durationMs: 999, + }), + ]; + + const press1 = simulateCtrlFPress(0, agents); + expect(press1.decision.action).toBe("warn"); + + const press2 = simulateCtrlFPress(press1.pressCount, press1.agents); + expect(press2.decision.action).toBe("terminate"); + expect(press2.terminationExecuted).toBe(true); + + // Should preserve existing durationMs when startedAt is invalid + const interrupted = press2.agents[0]; + expect(interrupted?.status).toBe("interrupted"); + expect(interrupted?.durationMs).toBe(999); + }); + + test("handles rapid triple press (third press after termination)", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-1", + status: "background", + background: true, + }), + ]; + + // Press 1: warn + const press1 = simulateCtrlFPress(0, agents); + expect(press1.decision.action).toBe("warn"); + + // Press 2: terminate + const press2 = simulateCtrlFPress(press1.pressCount, press1.agents); + expect(press2.decision.action).toBe("terminate"); + expect(press2.terminationExecuted).toBe(true); + + // Press 3: should be noop since all agents are now interrupted + const press3 = simulateCtrlFPress(0, press2.agents); + expect(press3.decision.action).toBe("none"); + expect(press3.terminationExecuted).toBe(false); + }); + + test("multiple active background agents all get terminated", () => { + const now = Date.now(); + const agents: ParallelAgent[] = Array.from({ length: 5 }, (_, i) => + createAgent({ + id: `bg-${i}`, + status: "background", + background: true, + startedAt: new Date(now - (i + 1) * 1000).toISOString(), + }) + ); + + const press1 = simulateCtrlFPress(0, agents, now); + expect(press1.decision.action).toBe("warn"); + + const press2 = simulateCtrlFPress(press1.pressCount, press1.agents, now); + expect(press2.decision.action).toBe("terminate"); + expect(press2.interruptedIds.length).toBe(5); + expect(press2.agents.every((a) => a.status === "interrupted")).toBe(true); + }); +}); diff --git a/src/ui/utils/background-agent-termination.test.ts b/src/ui/utils/background-agent-termination.test.ts new file mode 100644 index 000000000..a6a9c8ad8 --- /dev/null +++ b/src/ui/utils/background-agent-termination.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { + getBackgroundTerminationDecision, + interruptActiveBackgroundAgents, + isBackgroundTerminationKey, +} from "./background-agent-termination.ts"; + +function createAgent(overrides: Partial): ParallelAgent { + return { + id: overrides.id ?? "agent-1", + name: overrides.name ?? "task", + task: overrides.task ?? "Background task", + status: overrides.status ?? "background", + background: overrides.background, + startedAt: overrides.startedAt ?? new Date().toISOString(), + currentTool: overrides.currentTool, + durationMs: overrides.durationMs, + result: overrides.result, + }; +} + +describe("background-agent termination keybinding", () => { + test("detects Ctrl+F and ignores other modifiers", () => { + expect(isBackgroundTerminationKey({ ctrl: true, name: "f" })).toBe(true); + expect(isBackgroundTerminationKey({ ctrl: true, shift: true, name: "f" })).toBe(false); + expect(isBackgroundTerminationKey({ ctrl: true, meta: true, name: "f" })).toBe(false); + expect(isBackgroundTerminationKey({ ctrl: true, name: "c" })).toBe(false); + }); + + test("requires two presses only when active background agents exist", () => { + expect(getBackgroundTerminationDecision(0, 0)).toEqual({ + action: "none", + }); + + expect(getBackgroundTerminationDecision(0, 2)).toEqual({ + action: "warn", + message: "Press Ctrl-F again to terminate background agents", + }); + + expect(getBackgroundTerminationDecision(1, 2)).toEqual({ + action: "terminate", + message: "All background agents killed", + }); + }); + + test("resets stale press counters when no active background agents remain", () => { + expect(getBackgroundTerminationDecision(5, 0)).toEqual({ + action: "none", + }); + }); +}); + +describe("background-agent termination flow", () => { + test("interrupts only active background agents and returns interrupted IDs", () => { + const now = Date.now(); + const agents: ParallelAgent[] = [ + createAgent({ + id: "bg-active", + status: "background", + background: true, + startedAt: new Date(now - 2000).toISOString(), + currentTool: "Running in background...", + }), + createAgent({ + id: "bg-completed", + status: "completed", + background: true, + startedAt: new Date(now - 4000).toISOString(), + }), + createAgent({ + id: "fg-running", + status: "running", + background: false, + currentTool: "Running foreground task", + }), + ]; + + const result = interruptActiveBackgroundAgents(agents, now); + expect(result.interruptedIds).toEqual(["bg-active"]); + + const interrupted = result.agents.find((agent) => agent.id === "bg-active"); + expect(interrupted?.status).toBe("interrupted"); + expect(interrupted?.currentTool).toBeUndefined(); + expect(interrupted?.durationMs).toBeGreaterThanOrEqual(2000); + + const completedBackground = result.agents.find((agent) => agent.id === "bg-completed"); + expect(completedBackground?.status).toBe("completed"); + + const foreground = result.agents.find((agent) => agent.id === "fg-running"); + expect(foreground?.status).toBe("running"); + expect(foreground?.currentTool).toBe("Running foreground task"); + }); + + test("interrupts pending/running background agents in one confirmation pass", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "pending", status: "pending", background: true }), + createAgent({ id: "running", status: "running", background: true }), + createAgent({ id: "done", status: "completed", background: true }), + ]; + + const result = interruptActiveBackgroundAgents(agents); + expect(result.interruptedIds).toEqual(["pending", "running"]); + expect(result.agents.find((agent) => agent.id === "pending")?.status).toBe("interrupted"); + expect(result.agents.find((agent) => agent.id === "running")?.status).toBe("interrupted"); + expect(result.agents.find((agent) => agent.id === "done")?.status).toBe("completed"); + }); + + test("preserves prior duration when startedAt is invalid", () => { + const agents: ParallelAgent[] = [ + createAgent({ + id: "invalid-start", + status: "background", + background: true, + startedAt: "not-a-date", + durationMs: 777, + }), + ]; + + const result = interruptActiveBackgroundAgents(agents, Date.now()); + const interrupted = result.agents[0]; + expect(interrupted?.status).toBe("interrupted"); + expect(interrupted?.durationMs).toBe(777); + }); + + test("is a safe no-op when no active background agents exist", () => { + const agents: ParallelAgent[] = [ + createAgent({ id: "fg", status: "running", background: false }), + createAgent({ id: "done", status: "completed", background: true }), + ]; + + const result = interruptActiveBackgroundAgents(agents); + expect(result.interruptedIds).toEqual([]); + expect(result.agents).toEqual(agents); + }); +}); diff --git a/src/ui/utils/background-agent-termination.ts b/src/ui/utils/background-agent-termination.ts new file mode 100644 index 000000000..cabde4f09 --- /dev/null +++ b/src/ui/utils/background-agent-termination.ts @@ -0,0 +1,84 @@ +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; +import { getActiveBackgroundAgents } from "./background-agent-footer.ts"; +import type { BackgroundTerminationDecision } from "./background-agent-contracts.ts"; + +export interface BackgroundTerminationKeyEvent { + name?: string | null; + ctrl?: boolean; + shift?: boolean; + meta?: boolean; +} + +// Re-export the canonical BackgroundTerminationDecision type +export type { BackgroundTerminationDecision }; + +export interface InterruptBackgroundAgentsResult { + agents: ParallelAgent[]; + interruptedIds: string[]; +} + +export function isBackgroundTerminationKey(event: BackgroundTerminationKeyEvent): boolean { + return event.ctrl === true + && event.shift !== true + && event.meta !== true + && event.name === "f"; +} + +export function getBackgroundTerminationDecision( + currentPressCount: number, + activeBackgroundAgentCount: number, +): BackgroundTerminationDecision { + if (activeBackgroundAgentCount <= 0) { + return { action: "none" }; + } + + if (currentPressCount >= 1) { + return { + action: "terminate", + message: "All background agents killed", + }; + } + + return { + action: "warn", + message: "Press Ctrl-F again to terminate background agents", + }; +} + +export function interruptActiveBackgroundAgents( + agents: readonly ParallelAgent[], + nowMs: number = Date.now(), +): InterruptBackgroundAgentsResult { + const activeBackgroundAgents = getActiveBackgroundAgents(agents); + const interruptedIds = activeBackgroundAgents.map((agent) => agent.id); + if (interruptedIds.length === 0) { + return { + agents: [...agents], + interruptedIds, + }; + } + + const interruptedIdSet = new Set(interruptedIds); + const nextAgents = agents.map((agent) => { + if (!interruptedIdSet.has(agent.id)) { + return agent; + } + + const startedAtMs = new Date(agent.startedAt).getTime(); + const durationMs = Number.isFinite(startedAtMs) + ? Math.max(0, nowMs - startedAtMs) + : agent.durationMs; + + return { + ...agent, + status: "interrupted" as const, + currentTool: undefined, + durationMs, + }; + }); + + return { + agents: nextAgents, + interruptedIds, + }; +} diff --git a/src/ui/utils/background-agent-tree-hints.test.ts b/src/ui/utils/background-agent-tree-hints.test.ts new file mode 100644 index 000000000..1880051d3 --- /dev/null +++ b/src/ui/utils/background-agent-tree-hints.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { buildParallelAgentsHeaderHint } from "./background-agent-tree-hints.ts"; +import { BACKGROUND_TREE_HINT_CONTRACT } from "./background-agent-contracts.ts"; + +describe("background agent tree hints", () => { + test("shows Ctrl+F terminate hint for active background agents", () => { + expect( + buildParallelAgentsHeaderHint( + [ + { + background: true, + status: "running", + }, + ], + false, + ), + ).toBe(BACKGROUND_TREE_HINT_CONTRACT.whenRunning); + }); + + test("prioritizes running hint over expand hint when background work is active", () => { + expect( + buildParallelAgentsHeaderHint( + [ + { + background: true, + status: "pending", + }, + ], + true, + ), + ).toBe(BACKGROUND_TREE_HINT_CONTRACT.whenRunning); + }); + + test("shows completion hint for completed background agents when tree is idle", () => { + expect( + buildParallelAgentsHeaderHint( + [ + { + background: true, + status: "completed", + }, + ], + true, + ), + ).toBe(BACKGROUND_TREE_HINT_CONTRACT.whenComplete); + }); + + test("keeps default expand hint when no background agents exist", () => { + expect( + buildParallelAgentsHeaderHint( + [ + { + status: "completed", + }, + ], + true, + ), + ).toBe(BACKGROUND_TREE_HINT_CONTRACT.defaultHint); + }); + + test("does not show terminate wording for foreground-only running agents", () => { + expect( + buildParallelAgentsHeaderHint( + [ + { + background: false, + status: "running", + }, + ], + true, + ), + ).toBe(BACKGROUND_TREE_HINT_CONTRACT.defaultHint); + }); + + test("recognizes legacy background status without explicit background flag", () => { + expect( + buildParallelAgentsHeaderHint( + [ + { + status: "background", + }, + ], + false, + ), + ).toBe(BACKGROUND_TREE_HINT_CONTRACT.whenRunning); + }); + + test("stays quiet when no hint should be shown", () => { + expect( + buildParallelAgentsHeaderHint( + [ + { + background: true, + status: "completed", + }, + ], + false, + ), + ).toBe(""); + }); + + test("contract constants define expected canonical hint strings", () => { + expect(BACKGROUND_TREE_HINT_CONTRACT.whenRunning).toBe("background running · ctrl+f to kill agents"); + expect(BACKGROUND_TREE_HINT_CONTRACT.whenComplete).toBe("background complete · ctrl+o to expand"); + expect(BACKGROUND_TREE_HINT_CONTRACT.defaultHint).toBe("ctrl+o to expand"); + }); +}); diff --git a/src/ui/utils/background-agent-tree-hints.ts b/src/ui/utils/background-agent-tree-hints.ts new file mode 100644 index 000000000..45b8aff6f --- /dev/null +++ b/src/ui/utils/background-agent-tree-hints.ts @@ -0,0 +1,43 @@ +import { BACKGROUND_TREE_HINT_CONTRACT } from "./background-agent-contracts.ts"; + +export type BackgroundAgentHintStatus = + | "pending" + | "running" + | "completed" + | "error" + | "background" + | "interrupted"; + +export interface BackgroundAgentHintAgent { + background?: boolean; + status: BackgroundAgentHintStatus; +} + +function isBackgroundAgent(agent: BackgroundAgentHintAgent): boolean { + return agent.background === true || agent.status === "background"; +} + +function isActiveBackgroundStatus(status: BackgroundAgentHintStatus): boolean { + return status === "background" || status === "running" || status === "pending"; +} + +export function buildParallelAgentsHeaderHint( + agents: readonly BackgroundAgentHintAgent[], + showExpandHint: boolean, +): string { + const backgroundAgents = agents.filter(isBackgroundAgent); + + if (backgroundAgents.some((agent) => isActiveBackgroundStatus(agent.status))) { + return BACKGROUND_TREE_HINT_CONTRACT.whenRunning; + } + + if (showExpandHint && backgroundAgents.length > 0) { + return BACKGROUND_TREE_HINT_CONTRACT.whenComplete; + } + + if (showExpandHint) { + return BACKGROUND_TREE_HINT_CONTRACT.defaultHint; + } + + return ""; +} diff --git a/src/ui/utils/format.test.ts b/src/ui/utils/format.test.ts index db6e69007..68bfb50b2 100644 --- a/src/ui/utils/format.test.ts +++ b/src/ui/utils/format.test.ts @@ -116,6 +116,24 @@ describe("normalizeMarkdownNewlines", () => { expect(normalizeMarkdownNewlines(content)).toBe("Paragraph one\n\nParagraph two"); }); + + test("normalizes Windows-style line endings", () => { + const content = "\r\nline one\r\nline two\r\n"; + + expect(normalizeMarkdownNewlines(content)).toBe("line one\nline two"); + }); + + test("converts markdown task checkboxes to unicode bullets", () => { + const content = "- [ ] pending task\n- [x] done task\n- [X] also done"; + + expect(normalizeMarkdownNewlines(content)).toBe("- ☐ pending task\n- ☑ done task\n- ☑ also done"); + }); + + test("converts ordered list task checkboxes to unicode bullets", () => { + const content = "1. [ ] first\n2. [x] second"; + + expect(normalizeMarkdownNewlines(content)).toBe("1. ☐ first\n2. ☑ second"); + }); }); describe("truncateText", () => { diff --git a/src/ui/utils/format.ts b/src/ui/utils/format.ts index d71884bf3..0234c8381 100644 --- a/src/ui/utils/format.ts +++ b/src/ui/utils/format.ts @@ -139,7 +139,22 @@ export function formatTimestamp(date: Date | string): FormattedTimestamp { * @returns Content trimmed at both ends, with internal newlines preserved */ export function normalizeMarkdownNewlines(content: string): string { - return content.trim(); + const normalized = content.replace(/\r\n?/g, "\n").trim(); + if (!normalized) { + return normalized; + } + + // OpenTUI's markdown renderer has partial GFM checkbox support. + // Convert markdown checkboxes to unicode to preserve readable list rendering. + return normalized + .replace( + /^(\s*(?:[-*+]|\d+[.)])\s+)\[ \]\s+/gm, + "$1☐ ", + ) + .replace( + /^(\s*(?:[-*+]|\d+[.)])\s+)\[(?:x|X)\]\s+/gm, + "$1☑ ", + ); } // ============================================================================ diff --git a/src/ui/utils/loading-state.ts b/src/ui/utils/loading-state.ts new file mode 100644 index 000000000..b196fcd0b --- /dev/null +++ b/src/ui/utils/loading-state.ts @@ -0,0 +1,71 @@ +import type { ParallelAgent } from "../components/parallel-agents-tree.tsx"; + +type TaskProgressStatus = "pending" | "in_progress" | "completed" | "error"; + +export interface TaskProgressItem { + status: TaskProgressStatus; +} + +export interface LoadingStateMessage { + streaming?: boolean; + parallelAgents?: readonly ParallelAgent[]; + taskItems?: readonly TaskProgressItem[]; +} + +function resolveTaskProgressItems( + message: { + streaming?: boolean; + taskItems?: readonly TaskProgressItem[]; + }, + liveTodoItems?: readonly TaskProgressItem[], +): readonly TaskProgressItem[] | undefined { + if (message.streaming && liveTodoItems && liveTodoItems.length > 0) { + return liveTodoItems; + } + return message.taskItems; +} + +export function isTaskProgressComplete(taskItems?: readonly TaskProgressItem[] | null): boolean { + if (!taskItems || taskItems.length === 0) { + return false; + } + return taskItems.every((task) => task.status === "completed"); +} + +export function shouldShowMessageLoadingIndicator( + message: LoadingStateMessage, + liveTodoItems?: readonly TaskProgressItem[], +): boolean { + const taskItems = resolveTaskProgressItems(message, liveTodoItems); + if (isTaskProgressComplete(taskItems)) { + return false; + } + + const hasActiveBackgroundAgents = (message.parallelAgents ?? []).some( + (agent) => agent.background && agent.status === "background", + ); + + return Boolean(message.streaming) || hasActiveBackgroundAgents; +} + +export function hasLiveLoadingIndicator( + messages: readonly LoadingStateMessage[], + liveTodoItems?: readonly TaskProgressItem[], +): boolean { + return messages.some((message) => + shouldShowMessageLoadingIndicator( + message, + message.streaming ? liveTodoItems : undefined, + ) + ); +} + +export function shouldShowCompletionSummary( + message: { streaming?: boolean; durationMs?: number }, + hasActiveBackgroundAgents: boolean, +): boolean { + return !message.streaming + && !hasActiveBackgroundAgents + && message.durationMs != null + && message.durationMs >= 1000; +}