From 47334fa4f39782c8a3b4c225f36a18477fd2f6d4 Mon Sep 17 00:00:00 2001 From: pufit Date: Wed, 24 Jun 2026 22:10:30 -0400 Subject: [PATCH] Keep background sub-agent output in the side panel, not the main chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background sub-agents (the Agent tool with run_in_background) spilled all their tools and thoughts into the main chat instead of their side-panel. Root cause: the live streaming handlers routed a sub-agent's child events (those carrying parent_tool_use_id) to a panel only when a panel with that id was still status === 'running'. A background sub-agent's Agent tool returns immediately with a task id, so the immediate tool_result and the backend's subagent_complete marked the panel complete (and scheduled auto-close) right away. The sub-agent's real activity streams afterward, by which point no panel is 'running', so every child event fell through into the main chat. Reload looked correct only because the replay path enforces a stronger invariant (any parent_tool_use_id event belongs to its panel, never the main chat) — but new live events kept hitting the buggy path. Fix, mirroring the replay invariant and the existing Workflow pattern: - Route any parent_tool_use_id event (thinking/token/tool_use/tool_result) to its panel by id regardless of status, and never into the main chat. - Flag run_in_background sub-agent panels (background). Treat the immediate Agent result like a Workflow launch: record it on the inline card but keep the panel open and running. Ignore the premature subagent_complete and skip these panels in finalizeRunningPanels so the launching turn's done doesn't close them. - Settle background panels when no background task is still running (handleBackgroundTasksUpdate) — the natural completion signal, since there is no per-tool_use_id done event for a detached sub-agent. An explicit /stop settles them too. - Carry the background flag through buffer replay for reconnect parity. --- web/src/stores/handlers/auxiliaryHandlers.ts | 38 +++- web/src/stores/handlers/panelHandlers.ts | 6 + web/src/stores/handlers/streamingHandlers.ts | 183 ++++++++++++------- web/src/stores/helpers/bufferReplay.ts | 2 + web/src/types/chat.ts | 5 + 5 files changed, 157 insertions(+), 77 deletions(-) diff --git a/web/src/stores/handlers/auxiliaryHandlers.ts b/web/src/stores/handlers/auxiliaryHandlers.ts index e2300a3..8755664 100644 --- a/web/src/stores/handlers/auxiliaryHandlers.ts +++ b/web/src/stores/handlers/auxiliaryHandlers.ts @@ -1,4 +1,5 @@ import type { WSMessage } from '../../api/websocket'; +import { scheduleAutoClose } from '../helpers/blockHelpers'; import type { Get, Set } from './types'; // ------------------------------------------------------------------ // @@ -79,15 +80,32 @@ export function handleBackgroundTasksUpdate( get: Get, set: Set, ): void { - if (msg.session_id === get().activeSession) { - set(s => { - // Merge: keep startedAt from existing entries, add new ones - const existing = new Map(s.backgroundTasks.map(t => [t.task_id, t])); - const updated = msg.tasks.map(t => ({ - ...t, - startedAt: existing.get(t.task_id)?.startedAt || Date.now(), - })); - return { backgroundTasks: updated }; - }); + if (msg.session_id !== get().activeSession) return; + set(s => { + // Merge: keep startedAt from existing entries, add new ones + const existing = new Map(s.backgroundTasks.map(t => [t.task_id, t])); + const updated = msg.tasks.map(t => ({ + ...t, + startedAt: existing.get(t.task_id)?.startedAt || Date.now(), + })); + return { backgroundTasks: updated }; + }); + + // Background sub-agent panels are held open (running) while their detached + // work streams — there's no per-tool_use_id "done" event for them. Once no + // background task is still running, the sub-agents can no longer emit, so + // settle any still-running background panels (and let them auto-close). + if (!msg.tasks.some(t => t.status === 'running')) { + const state = get(); + for (const panel of state.panels) { + if (panel.background && panel.status === 'running') { + state.updatePanelTab(panel.id, { + status: 'complete', + streaming: false, + completedAt: Date.now(), + }); + if (panel.type !== 'plan') scheduleAutoClose(panel.id, get); + } + } } } diff --git a/web/src/stores/handlers/panelHandlers.ts b/web/src/stores/handlers/panelHandlers.ts index 2d2bc01..9307dc3 100644 --- a/web/src/stores/handlers/panelHandlers.ts +++ b/web/src/stores/handlers/panelHandlers.ts @@ -80,6 +80,12 @@ export function handleSubagentComplete( // Server-side sub-agent lifecycle event — mark complete const tab = state.panels.find(p => p.id === msg.tool_use_id); if (tab) { + // A background sub-agent's "complete" fires immediately when the Agent tool + // returns its task id — but the sub-agent keeps streaming afterward. Keep + // the panel running; it settles when the background task actually ends + // (handleBackgroundTasksUpdate). Completing here would send its later + // tools/thoughts into the main chat instead of this panel. + if (tab.background) return; get().updatePanelTab(msg.tool_use_id, { status: msg.is_error ? 'error' : 'complete', isError: msg.is_error || false, diff --git a/web/src/stores/handlers/streamingHandlers.ts b/web/src/stores/handlers/streamingHandlers.ts index ffe605f..3a4d69f 100644 --- a/web/src/stores/handlers/streamingHandlers.ts +++ b/web/src/stores/handlers/streamingHandlers.ts @@ -38,20 +38,26 @@ export function handleThinking( ): void { const state = get(); const parentId = msg.parent_tool_use_id; - if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) { - set(s => ({ - panels: appendBlockToPanel(s.panels, parentId, { type: 'thinking', content: msg.content }), - })); - } else { - const blocks = [...state.streamingBlocks]; - const last = blocks[blocks.length - 1]; - if (last?.type === 'thinking') { - blocks[blocks.length - 1] = { ...last, content: last.content + msg.content }; - } else { - blocks.push({ type: 'thinking', content: msg.content }); + if (parentId) { + // Sub-agent output — belongs to its side-panel, never the main chat (mirrors + // the replay invariant in applyStreamEvent). Route to the panel by id + // regardless of its status: a background sub-agent's panel may already be + // marked complete by the time its thoughts stream in. + if (state.panels.some(p => p.id === parentId)) { + set(s => ({ + panels: appendBlockToPanel(s.panels, parentId, { type: 'thinking', content: msg.content }), + })); } - set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' } }); + return; } + const blocks = [...state.streamingBlocks]; + const last = blocks[blocks.length - 1]; + if (last?.type === 'thinking') { + blocks[blocks.length - 1] = { ...last, content: last.content + msg.content }; + } else { + blocks.push({ type: 'thinking', content: msg.content }); + } + set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' } }); } export function handleToken( @@ -61,20 +67,24 @@ export function handleToken( ): void { const state = get(); const parentId = msg.parent_tool_use_id; - if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) { - set(s => ({ - panels: appendBlockToPanel(s.panels, parentId, { type: 'text', content: msg.content }), - })); - } else { - const blocks = [...state.streamingBlocks]; - const last = blocks[blocks.length - 1]; - if (last?.type === 'text') { - blocks[blocks.length - 1] = { ...last, content: last.content + msg.content }; - } else { - blocks.push({ type: 'text', content: msg.content }); + if (parentId) { + // Sub-agent output — route to its side-panel by id (any status), never the + // main chat. See handleThinking for the rationale. + if (state.panels.some(p => p.id === parentId)) { + set(s => ({ + panels: appendBlockToPanel(s.panels, parentId, { type: 'text', content: msg.content }), + })); } - set({ streamingBlocks: blocks, agentStatus: { state: 'writing' } }); + return; } + const blocks = [...state.streamingBlocks]; + const last = blocks[blocks.length - 1]; + if (last?.type === 'text') { + blocks[blocks.length - 1] = { ...last, content: last.content + msg.content }; + } else { + blocks.push({ type: 'text', content: msg.content }); + } + set({ streamingBlocks: blocks, agentStatus: { state: 'writing' } }); } export function handleWakeup( @@ -130,6 +140,10 @@ export function handleToolUse( // Open panel tab const subagentType = String(msg.input?.subagent_type || msg.input?.model || 'agent'); const isPlan = subagentType === 'Plan'; + // Background sub-agents (run_in_background) detach: the Agent tool returns a + // task id immediately, then the sub-agent streams its work afterward. Flag + // the panel so the immediate result/complete don't close it prematurely. + const isBackground = msg.input?.run_in_background === true; get().openPanelTab({ id: toolUseId, type: isPlan ? 'plan' : 'subagent', @@ -143,6 +157,7 @@ export function handleToolUse( status: 'running', startedAt: Date.now(), blocks: [], + background: isBackground, }); return; } @@ -178,44 +193,50 @@ export function handleToolUse( return; } - // Is this a child tool call inside a running sub-agent? + // Is this a child tool call inside a sub-agent? Route to the panel by id — + // regardless of status, and never into the main chat (mirrors replay). A + // background sub-agent's panel is already 'complete' by the time its nested + // tools stream in; the old `status === 'running'` gate sent them to the chat. const parentId = msg.parent_tool_use_id; - if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) { - set(s => ({ - panels: appendBlockToPanel(s.panels, parentId, { - type: 'tool_call', - toolUseId: msg.tool_use_id || '', - tool: msg.tool, - input: msg.input, - status: 'running', - }), - })); - } else { - // Normal: add to main chat - const blocks = [...state.streamingBlocks]; - blocks.push({ - type: 'tool_call', - toolUseId: msg.tool_use_id || '', - tool: msg.tool, - input: msg.input, - status: 'running', - }); - const extraUpdate: Record = {}; - if (msg.tool === 'TodoWrite' && Array.isArray(msg.input?.todos)) { - extraUpdate.currentTodos = msg.input.todos as TodoItem[]; + if (parentId) { + if (state.panels.some(p => p.id === parentId)) { + set(s => ({ + panels: appendBlockToPanel(s.panels, parentId, { + type: 'tool_call', + toolUseId: msg.tool_use_id || '', + tool: msg.tool, + input: msg.input, + status: 'running', + }), + })); } - // Optimistically reflect Claude Code task tool calls in the panel before - // the result arrives. TaskCreate adds a placeholder row (real ID lands on - // tool_result); TaskUpdate mutates by taskId so the row reacts instantly. - if (msg.tool === 'TaskCreate') { - const input = (msg.input ?? {}) as Record; - extraUpdate.currentCCTasks = applyCCTaskCreateInput(state.currentCCTasks, input, msg.tool_use_id || ''); - } else if (msg.tool === 'TaskUpdate') { - const input = (msg.input ?? {}) as Record; - extraUpdate.currentCCTasks = applyCCTaskUpdateInput(state.currentCCTasks, input); - } - set({ streamingBlocks: blocks, agentStatus: { state: 'tool', toolName: msg.tool }, ...extraUpdate }); + return; + } + + // Normal: add to main chat + const blocks = [...state.streamingBlocks]; + blocks.push({ + type: 'tool_call', + toolUseId: msg.tool_use_id || '', + tool: msg.tool, + input: msg.input, + status: 'running', + }); + const extraUpdate: Record = {}; + if (msg.tool === 'TodoWrite' && Array.isArray(msg.input?.todos)) { + extraUpdate.currentTodos = msg.input.todos as TodoItem[]; + } + // Optimistically reflect Claude Code task tool calls in the panel before + // the result arrives. TaskCreate adds a placeholder row (real ID lands on + // tool_result); TaskUpdate mutates by taskId so the row reacts instantly. + if (msg.tool === 'TaskCreate') { + const input = (msg.input ?? {}) as Record; + extraUpdate.currentCCTasks = applyCCTaskCreateInput(state.currentCCTasks, input, msg.tool_use_id || ''); + } else if (msg.tool === 'TaskUpdate') { + const input = (msg.input ?? {}) as Record; + extraUpdate.currentCCTasks = applyCCTaskUpdateInput(state.currentCCTasks, input); } + set({ streamingBlocks: blocks, agentStatus: { state: 'tool', toolName: msg.tool }, ...extraUpdate }); } export function handleToolResult( @@ -239,6 +260,22 @@ export function handleToolResult( return; } + // A background sub-agent behaves like a workflow: the Agent tool returns its + // task id immediately while the sub-agent keeps streaming. Record the result + // on the inline chat card but DO NOT complete or auto-close the panel — its + // tools/thoughts are still arriving. The panel settles via + // handleBackgroundTasksUpdate when the background task is no longer running. + const backgroundTab = state.panels.find(p => p.id === msg.tool_use_id && p.background); + if (backgroundTab) { + const blocks = state.streamingBlocks.map(b => + b.type === 'tool_call' && b.toolUseId === msg.tool_use_id + ? { ...b, result: msg.result, isError: msg.is_error, status: 'complete' as const } + : b + ); + set({ streamingBlocks: blocks, agentStatus: { state: 'thinking' } }); + return; + } + // Is this a sub-agent (Task) completing? // Check if this tool_use_id matches a panel tab (= it's a Task result) const completingTab = state.panels.find(p => p.id === msg.tool_use_id && p.status === 'running'); @@ -267,13 +304,20 @@ export function handleToolResult( return; } - // Is this a child tool result inside a sub-agent? + // Is this a child tool result inside a sub-agent? Route to the panel by id + // (any status), never into the main chat — mirrors replay and matches the + // tool_use handler above so a background sub-agent's results land in its panel. const parentId = msg.parent_tool_use_id; - if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running')) { - set(s => ({ - panels: updateToolResultInPanel(s.panels, parentId, msg.tool_use_id || '', msg.result, msg.is_error), - })); - } else { + if (parentId) { + if (state.panels.some(p => p.id === parentId)) { + set(s => ({ + panels: updateToolResultInPanel(s.panels, parentId, msg.tool_use_id || '', msg.result, msg.is_error), + })); + } + return; + } + + { // Normal: update main chat const blocks = state.streamingBlocks.map(b => { if (b.type === 'tool_call' && b.toolUseId === msg.tool_use_id) { @@ -325,11 +369,15 @@ export function handleToolResult( // ------------------------------------------------------------------ // /** Mark any still-running panel tabs as complete & schedule auto-close. */ -function finalizeRunningPanels(get: Get): void { +function finalizeRunningPanels(get: Get, includeBackground = false): void { for (const panel of get().panels) { // Workflows run in the background past the launching turn — they settle // on their own terminal workflow_progress, not when this turn ends. if (panel.type === 'workflow') continue; + // Background sub-agents likewise keep streaming after the launching turn + // ends — leave their panels running until the background task settles + // (handleBackgroundTasksUpdate). An explicit /stop settles them anyway. + if (panel.background && !includeBackground) continue; if (panel.status === 'running') { get().updatePanelTab(panel.id, { status: 'complete', @@ -411,7 +459,8 @@ export function handleStopped( isStreaming: false, agentStatus: { state: 'idle' }, })); - finalizeRunningPanels(get); + // Explicit stop ends everything, including any detached background sub-agents. + finalizeRunningPanels(get, true); get().loadSessions(); } diff --git a/web/src/stores/helpers/bufferReplay.ts b/web/src/stores/helpers/bufferReplay.ts index fa34e11..339d682 100644 --- a/web/src/stores/helpers/bufferReplay.ts +++ b/web/src/stores/helpers/bufferReplay.ts @@ -131,6 +131,7 @@ export function rebuildPanelTabsFromBuffer( if (event.type === 'tool_use' && (event.tool === 'Agent' || event.tool === 'Task')) { const subagentType = String(event.input?.subagent_type || event.input?.model || 'agent'); const toolUseId = event.tool_use_id || ''; + const isBackground = event.input?.run_in_background === true; const block = blocks.find( b => b.type === 'tool_call' && b.toolUseId === toolUseId, ); @@ -154,6 +155,7 @@ export function rebuildPanelTabsFromBuffer( completedAt: isComplete ? Date.now() : undefined, isError: block?.type === 'tool_call' ? block.isError : false, blocks: [], + background: isBackground, }; panels.push(tab); panelMap.set(toolUseId, tab); diff --git a/web/src/types/chat.ts b/web/src/types/chat.ts index 1496a00..d17f980 100644 --- a/web/src/types/chat.ts +++ b/web/src/types/chat.ts @@ -134,6 +134,11 @@ export interface PanelTab { blocks: MessageBlock[]; // live sub-agent activity (same types as main chat) /** For type==='workflow': the live phase/agent progress tree. */ workflow?: WorkflowSnapshot; + /** True for a sub-agent spawned with run_in_background. The Agent tool returns + * immediately (a task id) while the sub-agent keeps streaming, so the panel + * must stay open + running until the background task settles — otherwise its + * later tools/thoughts spill into the main chat instead of this panel. */ + background?: boolean; } // --- Session modified files & diff types ---