From 5d07c495f1c311e911b690c7eb7dcb78eb739a2d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 23 Feb 2026 13:21:16 +0800 Subject: [PATCH] feat(cli): Add agent tab navigation and live tool output for in-process arena mode Add AgentViewContext, AgentTabBar, and AgentChatView components for tab-based agent switching. Add useArenaInProcess hook bridging ArenaManager events to React state. Add agentHistoryAdapter converting AgentMessage[] to HistoryItem[]. Core support changes: - Replace stream buffers with ROUND_TEXT events (complete round text) - Add TOOL_OUTPUT_UPDATE events for live tool output streaming - Add pendingApprovals/liveOutputs/shellPids state to AgentInteractive - Fix missing ROUND_END emission for final text rounds Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 17 +- packages/cli/src/ui/App.test.tsx | 66 +-- packages/cli/src/ui/AppContainer.test.tsx | 18 + packages/cli/src/ui/AppContainer.tsx | 28 +- .../cli/src/ui/components/DialogManager.tsx | 8 +- .../src/ui/components/HistoryItemDisplay.tsx | 2 +- .../cli/src/ui/components/InputPrompt.tsx | 4 +- .../components/agent-view/AgentChatView.tsx | 248 ++++++++ .../ui/components/agent-view/AgentTabBar.tsx | 137 +++++ .../agent-view/agentHistoryAdapter.test.ts | 528 ++++++++++++++++++ .../agent-view/agentHistoryAdapter.ts | 194 +++++++ .../cli/src/ui/components/agent-view/index.ts | 9 + .../{messages => arena}/ArenaCards.tsx | 0 .../{ => arena}/ArenaSelectDialog.tsx | 16 +- .../{ => arena}/ArenaStartDialog.tsx | 10 +- .../{ => arena}/ArenaStatusDialog.tsx | 151 +++-- .../{ => arena}/ArenaStopDialog.tsx | 12 +- .../cli/src/ui/contexts/AgentViewContext.tsx | 201 +++++++ .../cli/src/ui/hooks/useArenaInProcess.ts | 175 ++++++ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 38 +- .../core/src/agents/arena/ArenaManager.ts | 2 + .../src/agents/backends/InProcessBackend.ts | 11 +- .../core/src/agents/runtime/agent-core.ts | 134 ++++- .../core/src/agents/runtime/agent-events.ts | 30 + .../agents/runtime/agent-interactive.test.ts | 115 +--- .../src/agents/runtime/agent-interactive.ts | 234 +++++--- .../core/src/agents/runtime/agent-types.ts | 12 +- 27 files changed, 2086 insertions(+), 314 deletions(-) create mode 100644 packages/cli/src/ui/components/agent-view/AgentChatView.tsx create mode 100644 packages/cli/src/ui/components/agent-view/AgentTabBar.tsx create mode 100644 packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts create mode 100644 packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts create mode 100644 packages/cli/src/ui/components/agent-view/index.ts rename packages/cli/src/ui/components/{messages => arena}/ArenaCards.tsx (100%) rename packages/cli/src/ui/components/{ => arena}/ArenaSelectDialog.tsx (92%) rename packages/cli/src/ui/components/{ => arena}/ArenaStartDialog.tsx (93%) rename packages/cli/src/ui/components/{ => arena}/ArenaStatusDialog.tsx (54%) rename packages/cli/src/ui/components/{ => arena}/ArenaStopDialog.tsx (92%) create mode 100644 packages/cli/src/ui/contexts/AgentViewContext.tsx create mode 100644 packages/cli/src/ui/hooks/useArenaInProcess.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08c0631a8..b4bf51a15 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -35,6 +35,7 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { themeManager } from './ui/themes/theme-manager.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; @@ -162,13 +163,15 @@ export async function startInteractiveUI( > - + + + diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index be09fe52f..8df422f4b 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -9,6 +9,11 @@ import { render } from 'ink-testing-library'; import { Text, useIsScreenReaderEnabled } from 'ink'; import { App } from './App.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; +import { + UIActionsContext, + type UIActions, +} from './contexts/UIActionsContext.js'; +import { AgentViewProvider } from './contexts/AgentViewContext.js'; import { StreamingState } from './types.js'; vi.mock('ink', async (importOriginal) => { @@ -43,6 +48,10 @@ vi.mock('./components/Footer.js', () => ({ Footer: () => Footer, })); +vi.mock('./components/agent-view/AgentTabBar.js', () => ({ + AgentTabBar: () => null, +})); + describe('App', () => { const mockUIState: Partial = { streamingState: StreamingState.Idle, @@ -58,13 +67,24 @@ describe('App', () => { }, }; - it('should render main content and composer when not quitting', () => { - const { lastFrame } = render( - - - , + const mockUIActions = { + refreshStatic: vi.fn(), + } as unknown as UIActions; + + const renderWithProviders = (uiState: UIState) => + render( + + + + + + + , ); + it('should render main content and composer when not quitting', () => { + const { lastFrame } = renderWithProviders(mockUIState as UIState); + expect(lastFrame()).toContain('MainContent'); expect(lastFrame()).toContain('Composer'); }); @@ -75,11 +95,7 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(quittingUIState); expect(lastFrame()).toContain('Quitting...'); }); @@ -90,11 +106,7 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(dialogUIState); expect(lastFrame()).toContain('MainContent'); expect(lastFrame()).toContain('DialogManager'); @@ -107,11 +119,7 @@ describe('App', () => { ctrlCPressedOnce: true, } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(ctrlCUIState); expect(lastFrame()).toContain('Press Ctrl+C again to exit.'); }); @@ -123,11 +131,7 @@ describe('App', () => { ctrlDPressedOnce: true, } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(ctrlDUIState); expect(lastFrame()).toContain('Press Ctrl+D again to exit.'); }); @@ -135,11 +139,7 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', () => { (useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true); - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(mockUIState as UIState); expect(lastFrame()).toContain( 'Notifications\nFooter\nMainContent\nComposer', @@ -149,11 +149,7 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', () => { (useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false); - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(mockUIState as UIState); expect(lastFrame()).toContain('MainContent\nComposer'); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 57eacc797..d5a427b48 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -78,6 +78,24 @@ vi.mock('./hooks/useAutoAcceptIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); +vi.mock('./contexts/AgentViewContext.js', () => ({ + useAgentViewState: vi.fn(() => ({ + activeView: 'main', + agents: new Map(), + })), + useAgentViewActions: vi.fn(() => ({ + switchToMain: vi.fn(), + switchToAgent: vi.fn(), + switchToNext: vi.fn(), + switchToPrevious: vi.fn(), + registerAgent: vi.fn(), + unregisterAgent: vi.fn(), + unregisterAll: vi.fn(), + })), +})); +vi.mock('./hooks/useArenaInProcess.js', () => ({ + useArenaInProcess: vi.fn(), +})); vi.mock('./components/shared/text-buffer.js'); vi.mock('./hooks/useLogger.js'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 663a0782a..f321c7509 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -97,6 +97,8 @@ import { } from './hooks/useExtensionUpdates.js'; import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; +import { useAgentViewState } from './contexts/AgentViewContext.js'; +import { useArenaInProcess } from './hooks/useArenaInProcess.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; @@ -710,6 +712,8 @@ export const AppContainer = (props: AppContainerProps) => { shouldBlockTab: () => hasSuggestionsVisible, }); + const agentViewState = useAgentViewState(); + const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = useMessageQueue({ isConfigInitialized, @@ -720,9 +724,17 @@ export const AppContainer = (props: AppContainerProps) => { // Callback for handling final submit (must be after addMessage from useMessageQueue) const handleFinalSubmit = useCallback( (submittedValue: string) => { + // Route to active in-process agent if viewing a sub-agent tab. + if (agentViewState.activeView !== 'main') { + const agent = agentViewState.agents.get(agentViewState.activeView); + if (agent) { + agent.interactiveAgent.enqueueMessage(submittedValue.trim()); + return; + } + } addMessage(submittedValue); }, - [addMessage], + [addMessage, agentViewState], ); const handleArenaModelsSelected = useCallback( @@ -807,10 +819,17 @@ export const AppContainer = (props: AppContainerProps) => { } }, [buffer, terminalWidth, terminalHeight]); - // Compute available terminal height based on controls measurement + // agentViewState is declared earlier (before handleFinalSubmit) so it + // is available for input routing. Referenced here for layout computation. + + // Compute available terminal height based on controls measurement. + // When in-process agents are present the AgentTabBar renders an extra + // row at the top of the layout; subtract it so downstream consumers + // (shell, transcript, etc.) don't overestimate available space. + const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0; const availableTerminalHeight = Math.max( 0, - terminalHeight - controlsHeight - staticExtraHeight - 2, + terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight, ); config.setShellExecutionConfig({ @@ -826,6 +845,9 @@ export const AppContainer = (props: AppContainerProps) => { const isFocused = useFocus(); useBracketedPaste(); + // Bridge arena in-process events to AgentViewContext + useArenaInProcess(config); + // Context file names computation const contextFileNames = useMemo(() => { const fromSettings = settings.merged.context?.fileName; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index cb88ba76f..86f365ab2 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -20,10 +20,10 @@ import { AuthDialog } from '../auth/AuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; -import { ArenaStartDialog } from './ArenaStartDialog.js'; -import { ArenaSelectDialog } from './ArenaSelectDialog.js'; -import { ArenaStopDialog } from './ArenaStopDialog.js'; -import { ArenaStatusDialog } from './ArenaStatusDialog.js'; +import { ArenaStartDialog } from './arena/ArenaStartDialog.js'; +import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js'; +import { ArenaStopDialog } from './arena/ArenaStopDialog.js'; +import { ArenaStatusDialog } from './arena/ArenaStatusDialog.js'; import { ApprovalModeDialog } from './ApprovalModeDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 55b678739..5b3aa6055 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -39,7 +39,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; -import { ArenaAgentCard, ArenaSessionCard } from './messages/ArenaCards.js'; +import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js'; interface HistoryItemDisplayProps { item: HistoryItem; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8820e2126..d857f1fad 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -873,7 +873,9 @@ export const InputPrompt: React.FC = ({ ], ); - useKeypress(handleInput, { isActive: !isEmbeddedShellFocused }); + useKeypress(handleInput, { + isActive: !isEmbeddedShellFocused, + }); const linesToRender = buffer.viewportVisualLines; const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = diff --git a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx new file mode 100644 index 000000000..20eb0adc0 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentChatView — displays a single in-process agent's conversation. + * + * Renders the agent's message history using HistoryItemDisplay — the same + * component used by the main agent view. AgentMessage[] is converted to + * HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types + * are available without duplicating rendering logic. + * + * Layout: + * - Static area: finalized messages (efficient Ink ) + * - Live area: tool groups still executing / awaiting confirmation + * - Status line: spinner while the agent is running + * + * Model text output is shown only after each round completes (no live + * streaming), which avoids per-chunk re-renders and keeps the display simple. + */ + +import { Box, Text, Static } from 'ink'; +import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; +import { + AgentStatus, + AgentEventType, + type AgentStatusChangeEvent, +} from '@qwen-code/qwen-code-core'; +import { + useAgentViewState, + useAgentViewActions, +} from '../../contexts/AgentViewContext.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; +import { ToolCallStatus } from '../../types.js'; +import { theme } from '../../semantic-colors.js'; +import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; + +// ─── Main Component ───────────────────────────────────────── + +interface AgentChatViewProps { + agentId: string; +} + +export const AgentChatView = ({ agentId }: AgentChatViewProps) => { + const { agents } = useAgentViewState(); + const { setAgentShellFocused } = useAgentViewActions(); + const uiState = useUIState(); + const { historyRemountKey, availableTerminalHeight, constrainHeight } = + uiState; + const { columns: terminalWidth } = useTerminalSize(); + const agent = agents.get(agentId); + const contentWidth = terminalWidth - 4; + + // Force re-render on message updates and status changes. + // STREAM_TEXT is deliberately excluded — model text is shown only after + // each round completes (via committed messages), avoiding per-chunk re-renders. + const [, setRenderTick] = useState(0); + const tickRef = useRef(0); + const forceRender = useCallback(() => { + tickRef.current += 1; + setRenderTick(tickRef.current); + }, []); + + useEffect(() => { + if (!agent) return; + + const emitter = agent.interactiveAgent.getEventEmitter(); + if (!emitter) return; + + const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender(); + const onToolCall = () => forceRender(); + const onToolResult = () => forceRender(); + const onRoundEnd = () => forceRender(); + const onApproval = () => forceRender(); + const onOutputUpdate = () => forceRender(); + + emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.on(AgentEventType.TOOL_CALL, onToolCall); + emitter.on(AgentEventType.TOOL_RESULT, onToolResult); + emitter.on(AgentEventType.ROUND_END, onRoundEnd); + emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); + + return () => { + emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.off(AgentEventType.TOOL_CALL, onToolCall); + emitter.off(AgentEventType.TOOL_RESULT, onToolResult); + emitter.off(AgentEventType.ROUND_END, onRoundEnd); + emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); + }; + }, [agent, forceRender]); + + const interactiveAgent = agent?.interactiveAgent; + const messages = interactiveAgent?.getMessages() ?? []; + const pendingApprovals = interactiveAgent?.getPendingApprovals(); + const liveOutputs = interactiveAgent?.getLiveOutputs(); + const shellPids = interactiveAgent?.getShellPids(); + const status = interactiveAgent?.getStatus(); + const isRunning = + status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING; + + // Derive the active PTY PID: first shell PID among currently-executing tools. + // Resets naturally to undefined when the tool finishes (shellPids cleared). + const activePtyId = + shellPids && shellPids.size > 0 + ? shellPids.values().next().value + : undefined; + + // Track whether the user has toggled input focus into the embedded shell. + // Mirrors the main agent's embeddedShellFocused in AppContainer. + const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false); + + // Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation + // when an agent's embedded shell is focused. + useEffect(() => { + setAgentShellFocused(embeddedShellFocused); + return () => setAgentShellFocused(false); + }, [embeddedShellFocused, setAgentShellFocused]); + + // Reset focus when the shell exits (activePtyId disappears). + useEffect(() => { + if (!activePtyId) setEmbeddedShellFocusedLocal(false); + }, [activePtyId]); + + // Ctrl+F: toggle shell input focus when a PTY is active. + useKeypress( + (key) => { + if (key.ctrl && key.name === 'f') { + if (activePtyId || embeddedShellFocused) { + setEmbeddedShellFocusedLocal((prev) => !prev); + } + } + }, + { isActive: true }, + ); + + // Convert AgentMessage[] → HistoryItem[] via adapter. + // tickRef.current in deps ensures we rebuild when events fire even if + // messages.length and pendingApprovals.size haven't changed (e.g. a + // tool result updates an existing entry in place). + const allItems = useMemo( + () => + agentMessagesToHistoryItems( + messages, + pendingApprovals ?? new Map(), + liveOutputs, + shellPids, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + messages.length, + pendingApprovals?.size, + liveOutputs?.size, + shellPids?.size, + tickRef.current, + ], + ); + + // Split into committed (Static) and pending (live area). + // Any tool_group with an Executing or Confirming tool — plus everything + // after it — stays in the live area so confirmation dialogs remain + // interactive (Ink's cannot receive input). + const splitIndex = useMemo(() => { + for (let idx = allItems.length - 1; idx >= 0; idx--) { + const item = allItems[idx]!; + if ( + item.type === 'tool_group' && + item.tools.some( + (t) => + t.status === ToolCallStatus.Executing || + t.status === ToolCallStatus.Confirming, + ) + ) { + return idx; + } + } + return allItems.length; // all committed + }, [allItems]); + + const committedItems = allItems.slice(0, splitIndex); + const pendingItems = allItems.slice(splitIndex); + + if (!agent || !interactiveAgent) { + return ( + + + Agent "{agentId}" not found. + + + ); + } + + return ( + + {/* Committed message history. + key includes historyRemountKey: when refreshStatic() clears the + terminal it bumps the key, forcing Static to remount and re-emit + all items on the cleared screen. */} + ( + + ))} + > + {(item) => item} + + + {/* Live area — tool groups awaiting confirmation or still executing. + Must remain outside Static so confirmation dialogs are interactive. + Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */} + {pendingItems.map((item) => ( + + ))} + + {/* Spinner */} + {isRunning && ( + + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx new file mode 100644 index 000000000..1d526b9b0 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentTabBar — horizontal tab strip for in-process agent views. + * + * Rendered at the top of the terminal whenever in-process agents are registered. + * Left/Right arrow keys cycle through tabs when the input buffer is empty. + * + * Tab indicators: running, idle/completed, failed, cancelled + */ + +import { Box, Text } from 'ink'; +import { useState, useEffect, useCallback } from 'react'; +import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core'; +import { + useAgentViewState, + useAgentViewActions, + type RegisteredAgent, +} from '../../contexts/AgentViewContext.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { theme } from '../../semantic-colors.js'; + +// ─── Status Indicators ────────────────────────────────────── + +function statusIndicator(agent: RegisteredAgent): { + symbol: string; + color: string; +} { + const status = agent.interactiveAgent.getStatus(); + switch (status) { + case AgentStatus.RUNNING: + case AgentStatus.INITIALIZING: + return { symbol: '\u25CF', color: theme.status.warning }; // ● running + case AgentStatus.COMPLETED: + return { symbol: '\u2713', color: theme.status.success }; // ✓ completed + case AgentStatus.FAILED: + return { symbol: '\u2717', color: theme.status.error }; // ✗ failed + case AgentStatus.CANCELLED: + return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled + default: + return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback + } +} + +// ─── Component ────────────────────────────────────────────── + +export const AgentTabBar: React.FC = () => { + const { activeView, agents, agentShellFocused } = useAgentViewState(); + const { switchToNext, switchToPrevious } = useAgentViewActions(); + const { buffer, embeddedShellFocused } = useUIState(); + + // Left/Right arrow keys switch tabs when the input buffer is empty + // and no embedded shell (main or agent tab) has input focus. + useKeypress( + (key) => { + if (buffer.text !== '' || embeddedShellFocused || agentShellFocused) + return; + if (key.name === 'left') { + switchToPrevious(); + } else if (key.name === 'right') { + switchToNext(); + } + }, + { isActive: true }, + ); + + // Subscribe to STATUS_CHANGE events from all agents so the tab bar + // re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED). + // Without this, status indicators would be stale until the next unrelated render. + const [, setTick] = useState(0); + const forceRender = useCallback(() => setTick((t) => t + 1), []); + + useEffect(() => { + const cleanups: Array<() => void> = []; + for (const [, agent] of agents) { + const emitter = agent.interactiveAgent.getEventEmitter(); + if (emitter) { + emitter.on(AgentEventType.STATUS_CHANGE, forceRender); + cleanups.push(() => + emitter.off(AgentEventType.STATUS_CHANGE, forceRender), + ); + } + } + return () => cleanups.forEach((fn) => fn()); + }, [agents, forceRender]); + + return ( + + {/* Main tab */} + + + {' Main '} + + + + {/* Separator */} + {'\u2502'} + + {/* Agent tabs */} + {[...agents.entries()].map(([agentId, agent]) => { + const isActive = activeView === agentId; + const { symbol, color: indicatorColor } = statusIndicator(agent); + + return ( + + + {` ${agent.displayName} `} + + {` ${symbol}`} + + ); + })} + + {/* Navigation hint */} + + ←/→ + + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts new file mode 100644 index 000000000..c63093642 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts @@ -0,0 +1,528 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; +import type { + AgentMessage, + ToolCallConfirmationDetails, +} from '@qwen-code/qwen-code-core'; +import { ToolCallStatus } from '../../types.js'; + +// ─── Helpers ──────────────────────────────────────────────── + +function msg( + role: AgentMessage['role'], + content: string, + extra?: Partial, +): AgentMessage { + return { role, content, timestamp: 0, ...extra }; +} + +const noApprovals = new Map(); + +function toolCallMsg( + callId: string, + toolName: string, + opts?: { description?: string; renderOutputAsMarkdown?: boolean }, +): AgentMessage { + return msg('tool_call', `Tool call: ${toolName}`, { + metadata: { + callId, + toolName, + description: opts?.description ?? '', + renderOutputAsMarkdown: opts?.renderOutputAsMarkdown, + }, + }); +} + +function toolResultMsg( + callId: string, + toolName: string, + opts?: { + success?: boolean; + resultDisplay?: string; + outputFile?: string; + }, +): AgentMessage { + return msg('tool_result', `Tool ${toolName}`, { + metadata: { + callId, + toolName, + success: opts?.success ?? true, + resultDisplay: opts?.resultDisplay, + outputFile: opts?.outputFile, + }, + }); +} + +// ─── Role mapping ──────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — role mapping', () => { + it('maps user message', () => { + const items = agentMessagesToHistoryItems( + [msg('user', 'hello')], + noApprovals, + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ type: 'user', text: 'hello' }); + }); + + it('maps plain assistant message', () => { + const items = agentMessagesToHistoryItems( + [msg('assistant', 'response')], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' }); + }); + + it('maps thought assistant message', () => { + const items = agentMessagesToHistoryItems( + [msg('assistant', 'thinking...', { thought: true })], + noApprovals, + ); + expect(items[0]).toMatchObject({ + type: 'gemini_thought', + text: 'thinking...', + }); + }); + + it('maps assistant message with error metadata', () => { + const items = agentMessagesToHistoryItems( + [msg('assistant', 'oops', { metadata: { error: true } })], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'error', text: 'oops' }); + }); + + it('maps info message with no level → type info', () => { + const items = agentMessagesToHistoryItems( + [msg('info', 'note')], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'info', text: 'note' }); + }); + + it.each([ + ['warning', 'warning'], + ['success', 'success'], + ['error', 'error'], + ] as const)('maps info message with level=%s', (level, expectedType) => { + const items = agentMessagesToHistoryItems( + [msg('info', 'text', { metadata: { level } })], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: expectedType }); + }); + + it('maps unknown info level → type info', () => { + const items = agentMessagesToHistoryItems( + [msg('info', 'x', { metadata: { level: 'verbose' } })], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'info' }); + }); + + it('skips unknown roles without crashing', () => { + const items = agentMessagesToHistoryItems( + [ + msg('user', 'before'), + // force an unknown role + { role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 }, + msg('user', 'after'), + ], + noApprovals, + ); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ type: 'user', text: 'before' }); + expect(items[1]).toMatchObject({ type: 'user', text: 'after' }); + }); +}); + +// ─── Tool grouping ─────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — tool grouping', () => { + it('merges a tool_call + tool_result pair into one tool_group', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')], + noApprovals, + ); + expect(items).toHaveLength(1); + expect(items[0]!.type).toBe('tool_group'); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools).toHaveLength(1); + expect(group.tools[0]!.name).toBe('read_file'); + }); + + it('merges multiple parallel tool calls into one tool_group', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'read_file'), + toolCallMsg('c2', 'write_file'), + toolResultMsg('c1', 'read_file'), + toolResultMsg('c2', 'write_file'), + ], + noApprovals, + ); + expect(items).toHaveLength(1); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools).toHaveLength(2); + expect(group.tools[0]!.name).toBe('read_file'); + expect(group.tools[1]!.name).toBe('write_file'); + }); + + it('preserves tool call order by first appearance', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c2', 'second'), + toolCallMsg('c1', 'first'), + toolResultMsg('c1', 'first'), + toolResultMsg('c2', 'second'), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.name).toBe('second'); + expect(group.tools[1]!.name).toBe('first'); + }); + + it('breaks tool groups at non-tool messages', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'tool_a'), + toolResultMsg('c1', 'tool_a'), + msg('assistant', 'between'), + toolCallMsg('c2', 'tool_b'), + toolResultMsg('c2', 'tool_b'), + ], + noApprovals, + ); + expect(items).toHaveLength(3); + expect(items[0]!.type).toBe('tool_group'); + expect(items[1]!.type).toBe('gemini'); + expect(items[2]!.type).toBe('tool_group'); + }); + + it('handles tool_result arriving without a prior tool_call gracefully', () => { + const items = agentMessagesToHistoryItems( + [ + toolResultMsg('c1', 'orphan', { + success: true, + resultDisplay: 'output', + }), + ], + noApprovals, + ); + expect(items).toHaveLength(1); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.callId).toBe('c1'); + expect(group.tools[0]!.status).toBe(ToolCallStatus.Success); + }); +}); + +// ─── Tool status ───────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — tool status', () => { + it('Executing: tool_call with no result yet', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing); + }); + + it('Success: tool_result with success=true', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'read'), + toolResultMsg('c1', 'read', { success: true }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Success); + }); + + it('Error: tool_result with success=false', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'write'), + toolResultMsg('c1', 'write', { success: false }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Error); + }); + + it('Confirming: tool_call present in pendingApprovals', () => { + const fakeApproval = {} as ToolCallConfirmationDetails; + const approvals = new Map([['c1', fakeApproval]]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + approvals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming); + expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval); + }); + + it('Confirming takes priority over Executing', () => { + // pending approval AND no result yet → Confirming, not Executing + const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + approvals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming); + }); +}); + +// ─── Tool metadata ─────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — tool metadata', () => { + it('forwards resultDisplay from tool_result', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'read'), + toolResultMsg('c1', 'read', { + success: true, + resultDisplay: 'file contents', + }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBe('file contents'); + }); + + it('forwards outputFile from tool_result', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'shell'), + toolResultMsg('c1', 'shell', { + success: true, + outputFile: '/tmp/output.txt', + }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.outputFile).toBe('/tmp/output.txt'); + }); + + it('forwards renderOutputAsMarkdown from tool_call', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }), + toolResultMsg('c1', 'web_fetch', { success: true }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true); + }); + + it('forwards description from tool_call', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.description).toBe('reading src/index.ts'); + }); +}); + +// ─── liveOutputs overlay ───────────────────────────────────── + +describe('agentMessagesToHistoryItems — liveOutputs', () => { + it('uses liveOutput as resultDisplay for Executing tools', () => { + const liveOutputs = new Map([['c1', 'live stdout so far']]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + liveOutputs, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBe('live stdout so far'); + }); + + it('ignores liveOutput for completed tools', () => { + const liveOutputs = new Map([['c1', 'stale live output']]); + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'shell'), + toolResultMsg('c1', 'shell', { + success: true, + resultDisplay: 'final output', + }), + ], + noApprovals, + liveOutputs, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBe('final output'); + }); + + it('falls back to entry resultDisplay when no liveOutput for callId', () => { + const liveOutputs = new Map([['other-id', 'unrelated']]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + liveOutputs, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBeUndefined(); + }); +}); + +// ─── shellPids overlay ─────────────────────────────────────── + +describe('agentMessagesToHistoryItems — shellPids', () => { + it('sets ptyId for Executing tools with a known PID', () => { + const shellPids = new Map([['c1', 12345]]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + undefined, + shellPids, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.ptyId).toBe(12345); + }); + + it('does not set ptyId for completed tools', () => { + const shellPids = new Map([['c1', 12345]]); + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'shell'), + toolResultMsg('c1', 'shell', { success: true }), + ], + noApprovals, + undefined, + shellPids, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.ptyId).toBeUndefined(); + }); + + it('does not set ptyId when shellPids is not provided', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.ptyId).toBeUndefined(); + }); +}); + +// ─── ID stability ──────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — ID stability', () => { + it('assigns monotonically increasing IDs', () => { + const items = agentMessagesToHistoryItems( + [ + msg('user', 'u1'), + msg('assistant', 'a1'), + msg('info', 'i1'), + toolCallMsg('c1', 'tool'), + toolResultMsg('c1', 'tool'), + ], + noApprovals, + ); + const ids = items.map((i) => i.id); + expect(ids).toEqual([0, 1, 2, 3]); + }); + + it('tool_group consumes one ID regardless of how many calls it contains', () => { + const items = agentMessagesToHistoryItems( + [ + msg('user', 'go'), + toolCallMsg('c1', 'tool_a'), + toolCallMsg('c2', 'tool_b'), + toolResultMsg('c1', 'tool_a'), + toolResultMsg('c2', 'tool_b'), + msg('assistant', 'done'), + ], + noApprovals, + ); + // user=0, tool_group=1, assistant=2 + expect(items.map((i) => i.id)).toEqual([0, 1, 2]); + }); + + it('IDs from a prefix of messages are stable when more messages are appended', () => { + const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')]; + + const before = agentMessagesToHistoryItems(base, noApprovals); + const after = agentMessagesToHistoryItems( + [...base, msg('info', 'i')], + noApprovals, + ); + + expect(after[0]!.id).toBe(before[0]!.id); + expect(after[1]!.id).toBe(before[1]!.id); + expect(after[2]!.id).toBe(2); + }); +}); diff --git a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts new file mode 100644 index 000000000..951618abf --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview agentHistoryAdapter — converts AgentMessage[] to HistoryItem[]. + * + * This adapter bridges the sub-agent data model (AgentMessage[] from + * AgentInteractive) to the shared rendering model (HistoryItem[] consumed by + * HistoryItemDisplay). It lives in the CLI package so that packages/core types + * are never coupled to CLI rendering types. + * + * ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[] + * only ever grows. Index-based IDs are therefore stable — Ink's + * requires items never shift or be removed, which this guarantees. + */ + +import type { + AgentMessage, + ToolCallConfirmationDetails, + ToolResultDisplay, +} from '@qwen-code/qwen-code-core'; +import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js'; +import { ToolCallStatus } from '../../types.js'; + +/** + * Convert AgentMessage[] + pendingApprovals into HistoryItem[]. + * + * Consecutive tool_call / tool_result messages are merged into a single + * tool_group HistoryItem. pendingApprovals overlays confirmation state so + * ToolGroupMessage can render confirmation dialogs. + * + * liveOutputs (optional) provides real-time display data for executing tools. + * shellPids (optional) provides PTY PIDs for interactive shell tools so + * HistoryItemDisplay can render ShellInputPrompt on the active shell. + */ +export function agentMessagesToHistoryItems( + messages: readonly AgentMessage[], + pendingApprovals: ReadonlyMap, + liveOutputs?: ReadonlyMap, + shellPids?: ReadonlyMap, +): HistoryItem[] { + const items: HistoryItem[] = []; + let nextId = 0; + let i = 0; + + while (i < messages.length) { + const msg = messages[i]!; + + // ── user ────────────────────────────────────────────────── + if (msg.role === 'user') { + items.push({ type: 'user', text: msg.content, id: nextId++ }); + i++; + + // ── assistant ───────────────────────────────────────────── + } else if (msg.role === 'assistant') { + if (msg.metadata?.['error']) { + items.push({ type: 'error', text: msg.content, id: nextId++ }); + } else if (msg.thought) { + items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ }); + } else { + items.push({ type: 'gemini', text: msg.content, id: nextId++ }); + } + i++; + + // ── info / warning / success / error ────────────────────── + } else if (msg.role === 'info') { + const level = msg.metadata?.['level'] as string | undefined; + const type = + level === 'warning' || level === 'success' || level === 'error' + ? level + : 'info'; + items.push({ type, text: msg.content, id: nextId++ }); + i++; + + // ── tool_call / tool_result → tool_group ────────────────── + } else if (msg.role === 'tool_call' || msg.role === 'tool_result') { + const groupId = nextId++; + + const callMap = new Map< + string, + { + callId: string; + name: string; + description: string; + resultDisplay: ToolResultDisplay | string | undefined; + outputFile: string | undefined; + renderOutputAsMarkdown: boolean | undefined; + success: boolean | undefined; + } + >(); + const callOrder: string[] = []; + + while ( + i < messages.length && + (messages[i]!.role === 'tool_call' || + messages[i]!.role === 'tool_result') + ) { + const m = messages[i]!; + const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`; + + if (m.role === 'tool_call') { + if (!callMap.has(callId)) callOrder.push(callId); + callMap.set(callId, { + callId, + name: (m.metadata?.['toolName'] as string) ?? 'unknown', + description: (m.metadata?.['description'] as string) ?? '', + resultDisplay: undefined, + outputFile: undefined, + renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as + | boolean + | undefined, + success: undefined, + }); + } else { + // tool_result — attach to existing call entry + const entry = callMap.get(callId); + const resultDisplay = m.metadata?.['resultDisplay'] as + | ToolResultDisplay + | string + | undefined; + const outputFile = m.metadata?.['outputFile'] as string | undefined; + const success = m.metadata?.['success'] as boolean; + + if (entry) { + entry.success = success; + entry.resultDisplay = resultDisplay; + entry.outputFile = outputFile; + } else { + // Result arrived without a prior tool_call message (shouldn't + // normally happen, but handle gracefully) + callOrder.push(callId); + callMap.set(callId, { + callId, + name: (m.metadata?.['toolName'] as string) ?? 'unknown', + description: '', + resultDisplay, + outputFile, + renderOutputAsMarkdown: undefined, + success, + }); + } + } + i++; + } + + const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => { + const entry = callMap.get(callId)!; + const approval = pendingApprovals.get(callId); + + let status: ToolCallStatus; + if (approval) { + status = ToolCallStatus.Confirming; + } else if (entry.success === undefined) { + status = ToolCallStatus.Executing; + } else if (entry.success) { + status = ToolCallStatus.Success; + } else { + status = ToolCallStatus.Error; + } + + // For executing tools, use live output if available (Gap 4) + const resultDisplay = + status === ToolCallStatus.Executing && liveOutputs?.has(callId) + ? liveOutputs.get(callId) + : entry.resultDisplay; + + return { + callId: entry.callId, + name: entry.name, + description: entry.description, + resultDisplay, + outputFile: entry.outputFile, + renderOutputAsMarkdown: entry.renderOutputAsMarkdown, + status, + confirmationDetails: approval, + ptyId: + status === ToolCallStatus.Executing + ? shellPids?.get(callId) + : undefined, + }; + }); + + items.push({ type: 'tool_group', tools, id: groupId }); + } else { + // Skip unknown roles + i++; + } + } + + return items; +} diff --git a/packages/cli/src/ui/components/agent-view/index.ts b/packages/cli/src/ui/components/agent-view/index.ts new file mode 100644 index 000000000..30c4ea7b9 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { AgentTabBar } from './AgentTabBar.js'; +export { AgentChatView } from './AgentChatView.js'; +export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; diff --git a/packages/cli/src/ui/components/messages/ArenaCards.tsx b/packages/cli/src/ui/components/arena/ArenaCards.tsx similarity index 100% rename from packages/cli/src/ui/components/messages/ArenaCards.tsx rename to packages/cli/src/ui/components/arena/ArenaCards.tsx diff --git a/packages/cli/src/ui/components/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx similarity index 92% rename from packages/cli/src/ui/components/ArenaSelectDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx index 9d2f15806..19a322ed1 100644 --- a/packages/cli/src/ui/components/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx @@ -12,14 +12,14 @@ import { AgentStatus, type Config, } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType, type HistoryItemWithoutId } from '../types.js'; -import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; -import { formatDuration } from '../utils/formatters.js'; -import { getArenaStatusLabel } from '../utils/displayUtils.js'; -import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; -import type { DescriptiveRadioSelectItem } from './shared/DescriptiveRadioButtonSelect.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { MessageType, type HistoryItemWithoutId } from '../../types.js'; +import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js'; +import { formatDuration } from '../../utils/formatters.js'; +import { getArenaStatusLabel } from '../../utils/displayUtils.js'; +import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js'; +import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js'; interface ArenaSelectDialogProps { manager: ArenaManager; diff --git a/packages/cli/src/ui/components/ArenaStartDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx similarity index 93% rename from packages/cli/src/ui/components/ArenaStartDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaStartDialog.tsx index 2641dcba6..c60e6ddf5 100644 --- a/packages/cli/src/ui/components/ArenaStartDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx @@ -9,11 +9,11 @@ import { useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import Link from 'ink-link'; import { AuthType } from '@qwen-code/qwen-code-core'; -import { useConfig } from '../contexts/ConfigContext.js'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { MultiSelect } from './shared/MultiSelect.js'; -import { t } from '../../i18n/index.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { MultiSelect } from '../shared/MultiSelect.js'; +import { t } from '../../../i18n/index.js'; interface ArenaStartDialogProps { onClose: () => void; diff --git a/packages/cli/src/ui/components/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx similarity index 54% rename from packages/cli/src/ui/components/ArenaStatusDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index 211a9d9ba..cceed019d 100644 --- a/packages/cli/src/ui/components/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -5,20 +5,24 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { type ArenaManager, type ArenaAgentState, + type InProcessBackend, + type AgentStatsSummary, isTerminalStatus, ArenaSessionStatus, + DISPLAY_MODE, } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { formatDuration } from '../utils/formatters.js'; -import { getArenaStatusLabel } from '../utils/displayUtils.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { formatDuration } from '../../utils/formatters.js'; +import { getArenaStatusLabel } from '../../utils/displayUtils.js'; const STATUS_REFRESH_INTERVAL_MS = 2000; +const IN_PROCESS_REFRESH_INTERVAL_MS = 1000; interface ArenaStatusDialogProps { manager: ArenaManager; @@ -77,12 +81,20 @@ export function ArenaStatusDialog({ }: ArenaStatusDialogProps): React.JSX.Element { const [tick, setTick] = useState(0); + // Detect in-process backend for live stats reading + const backend = manager.getBackend(); + const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS; + const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null; + useEffect(() => { + const interval = isInProcess + ? IN_PROCESS_REFRESH_INTERVAL_MS + : STATUS_REFRESH_INTERVAL_MS; const timer = setInterval(() => { setTick((prev) => prev + 1); - }, STATUS_REFRESH_INTERVAL_MS); + }, interval); return () => clearInterval(timer); - }, []); + }, [isInProcess]); // Force re-read on every tick void tick; @@ -92,6 +104,20 @@ export function ArenaStatusDialog({ const agents = manager.getAgentStates(); const task = manager.getTask() ?? ''; + // For in-process mode, read live stats directly from AgentInteractive + const liveStats = useMemo(() => { + if (!inProcessBackend) return null; + const statsMap = new Map(); + for (const agent of agents) { + const interactive = inProcessBackend.getAgent(agent.agentId); + if (interactive) { + statsMap.set(agent.agentId, interactive.getStats()); + } + } + return statsMap; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inProcessBackend, agents, tick]); + const maxTaskLen = 60; const displayTask = task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task; @@ -130,6 +156,12 @@ export function ArenaStatusDialog({ · {sessionLabel.text} + {isInProcess && ( + <> + · + In-Process + + )} @@ -189,52 +221,73 @@ export function ArenaStatusDialog({ const { text: statusText, color } = getArenaStatusLabel(agent.status); const elapsed = getElapsedMs(agent); + // Use live stats from AgentInteractive when in-process, otherwise + // fall back to the cached ArenaAgentState.stats (file-polled). + const live = liveStats?.get(agent.agentId); + const totalTokens = live?.totalTokens ?? agent.stats.totalTokens; + const rounds = live?.rounds ?? agent.stats.rounds; + const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls; + const successfulToolCalls = + live?.successfulToolCalls ?? agent.stats.successfulToolCalls; + const failedToolCalls = + live?.failedToolCalls ?? agent.stats.failedToolCalls; + return ( - - - - {truncate(label, MAX_MODEL_NAME_LENGTH)} - - - - {statusText} - - - - {pad(formatDuration(elapsed), colTime - 1, 'right')} - - - - - {pad( - agent.stats.totalTokens.toLocaleString(), - colTokens - 1, - 'right', - )} - - - - - {pad(String(agent.stats.rounds), colRounds - 1, 'right')} - - - - {agent.stats.failedToolCalls > 0 ? ( - - - {agent.stats.successfulToolCalls} - - / - - {agent.stats.failedToolCalls} - - - ) : ( + + + - {pad(String(agent.stats.toolCalls), colTools - 1, 'right')} + {truncate(label, MAX_MODEL_NAME_LENGTH)} - )} + + + {statusText} + + + + {pad(formatDuration(elapsed), colTime - 1, 'right')} + + + + + {pad(totalTokens.toLocaleString(), colTokens - 1, 'right')} + + + + + {pad(String(rounds), colRounds - 1, 'right')} + + + + {failedToolCalls > 0 ? ( + + + {successfulToolCalls} + + / + {failedToolCalls} + + ) : ( + + {pad(String(toolCalls), colTools - 1, 'right')} + + )} + + {/* In-process mode: show extra detail row with cost + thought tokens */} + {live && (live.estimatedCost > 0 || live.thoughtTokens > 0) && ( + + + {live.estimatedCost > 0 && + `Cost: $${live.estimatedCost.toFixed(4)}`} + {live.estimatedCost > 0 && live.thoughtTokens > 0 && ' · '} + {live.thoughtTokens > 0 && + `Thinking: ${live.thoughtTokens.toLocaleString()} tok`} + {live.cachedTokens > 0 && + ` · Cached: ${live.cachedTokens.toLocaleString()} tok`} + + + )} ); })} diff --git a/packages/cli/src/ui/components/ArenaStopDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx similarity index 92% rename from packages/cli/src/ui/components/ArenaStopDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaStopDialog.tsx index da0022aa7..a790e20c2 100644 --- a/packages/cli/src/ui/components/ArenaStopDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx @@ -12,12 +12,12 @@ import { createDebugLogger, type Config, } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType, type HistoryItemWithoutId } from '../types.js'; -import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; -import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; -import type { DescriptiveRadioSelectItem } from './shared/DescriptiveRadioButtonSelect.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { MessageType, type HistoryItemWithoutId } from '../../types.js'; +import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js'; +import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js'; +import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js'; const debugLogger = createDebugLogger('ARENA_STOP_DIALOG'); diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx new file mode 100644 index 000000000..4a95b5a3e --- /dev/null +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentViewContext — React context for in-process agent view switching. + * + * Tracks which view is active (main or an agent tab) and the set of registered + * AgentInteractive instances. Consumed by AgentTabBar, AgentChatView, and + * DefaultAppLayout to implement tab-based agent navigation. + * + * Kept separate from UIStateContext to avoid bloating the main state with + * in-process-only concerns and to make the feature self-contained. + */ + +import { + createContext, + useContext, + useCallback, + useMemo, + useState, +} from 'react'; +import type { AgentInteractive } from '@qwen-code/qwen-code-core'; + +// ─── Types ────────────────────────────────────────────────── + +export interface RegisteredAgent { + interactiveAgent: AgentInteractive; + displayName: string; + color: string; +} + +export interface AgentViewState { + /** 'main' or an agentId */ + activeView: string; + /** Registered in-process agents keyed by agentId */ + agents: ReadonlyMap; + /** Whether any agent tab's embedded shell currently has input focus. */ + agentShellFocused: boolean; +} + +export interface AgentViewActions { + switchToMain(): void; + switchToAgent(agentId: string): void; + switchToNext(): void; + switchToPrevious(): void; + registerAgent( + agentId: string, + interactiveAgent: AgentInteractive, + displayName: string, + color: string, + ): void; + unregisterAgent(agentId: string): void; + unregisterAll(): void; + setAgentShellFocused(focused: boolean): void; +} + +// ─── Context ──────────────────────────────────────────────── + +const AgentViewStateContext = createContext(null); +const AgentViewActionsContext = createContext(null); + +// ─── Hook: useAgentViewState ──────────────────────────────── + +export function useAgentViewState(): AgentViewState { + const ctx = useContext(AgentViewStateContext); + if (!ctx) { + throw new Error( + 'useAgentViewState must be used within an AgentViewProvider', + ); + } + return ctx; +} + +// ─── Hook: useAgentViewActions ────────────────────────────── + +export function useAgentViewActions(): AgentViewActions { + const ctx = useContext(AgentViewActionsContext); + if (!ctx) { + throw new Error( + 'useAgentViewActions must be used within an AgentViewProvider', + ); + } + return ctx; +} + +// ─── Provider ─────────────────────────────────────────────── + +interface AgentViewProviderProps { + children: React.ReactNode; +} + +export function AgentViewProvider({ children }: AgentViewProviderProps) { + const [activeView, setActiveView] = useState('main'); + const [agents, setAgents] = useState>( + () => new Map(), + ); + const [agentShellFocused, setAgentShellFocused] = useState(false); + + // ── Navigation ── + + const switchToMain = useCallback(() => { + setActiveView('main'); + }, []); + + const switchToAgent = useCallback( + (agentId: string) => { + if (agents.has(agentId)) { + setActiveView(agentId); + } + }, + [agents], + ); + + const switchToNext = useCallback(() => { + const ids = ['main', ...agents.keys()]; + const currentIndex = ids.indexOf(activeView); + const nextIndex = (currentIndex + 1) % ids.length; + setActiveView(ids[nextIndex]!); + }, [agents, activeView]); + + const switchToPrevious = useCallback(() => { + const ids = ['main', ...agents.keys()]; + const currentIndex = ids.indexOf(activeView); + const prevIndex = (currentIndex - 1 + ids.length) % ids.length; + setActiveView(ids[prevIndex]!); + }, [agents, activeView]); + + // ── Registration ── + + const registerAgent = useCallback( + ( + agentId: string, + interactiveAgent: AgentInteractive, + displayName: string, + color: string, + ) => { + setAgents((prev) => { + const next = new Map(prev); + next.set(agentId, { interactiveAgent, displayName, color }); + return next; + }); + }, + [], + ); + + const unregisterAgent = useCallback((agentId: string) => { + setAgents((prev) => { + if (!prev.has(agentId)) return prev; + const next = new Map(prev); + next.delete(agentId); + return next; + }); + setActiveView((current) => (current === agentId ? 'main' : current)); + }, []); + + const unregisterAll = useCallback(() => { + setAgents(new Map()); + setActiveView('main'); + }, []); + + // ── Memoized values ── + + const state: AgentViewState = useMemo( + () => ({ activeView, agents, agentShellFocused }), + [activeView, agents, agentShellFocused], + ); + + const actions: AgentViewActions = useMemo( + () => ({ + switchToMain, + switchToAgent, + switchToNext, + switchToPrevious, + registerAgent, + unregisterAgent, + unregisterAll, + setAgentShellFocused, + }), + [ + switchToMain, + switchToAgent, + switchToNext, + switchToPrevious, + registerAgent, + unregisterAgent, + unregisterAll, + setAgentShellFocused, + ], + ); + + return ( + + + {children} + + + ); +} diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts new file mode 100644 index 000000000..7cb29d312 --- /dev/null +++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview useArenaInProcess — bridges ArenaManager in-process events + * to the AgentViewContext for React-based agent tab navigation. + * + * When an arena session starts with an InProcessBackend, this hook: + * 1. Listens to AGENT_START events from ArenaManager + * 2. Retrieves the AgentInteractive from InProcessBackend + * 3. Registers it with AgentViewContext + * 4. Cleans up on SESSION_COMPLETE / SESSION_ERROR / unmount + */ + +import { useEffect, useRef } from 'react'; +import { + ArenaEventType, + DISPLAY_MODE, + type ArenaManager, + type ArenaAgentStartEvent, + type Config, + type InProcessBackend, +} from '@qwen-code/qwen-code-core'; +import { useAgentViewActions } from '../contexts/AgentViewContext.js'; +import { theme } from '../semantic-colors.js'; + +// Palette of colors for agent tabs (cycles for >N agents) +const getAgentColors = () => [ + theme.text.accent, + theme.text.link, + theme.status.success, + theme.status.warning, + theme.text.code, + theme.status.error, +]; + +export function useArenaInProcess(config: Config): void { + const actions = useAgentViewActions(); + const actionsRef = useRef(actions); + actionsRef.current = actions; + + useEffect(() => { + // Poll for arena manager (it's set asynchronously by the /arena start command) + let checkInterval: ReturnType | null = null; + // Track the manager instance (not just a boolean) so we never + // reattach to the same completed manager after SESSION_COMPLETE. + let attachedManager: ArenaManager | null = null; + let detachListeners: (() => void) | null = null; + // Pending agent-registration retry timeouts (cancelled on session end & unmount). + const retryTimeouts = new Set>(); + + const tryAttach = () => { + const manager: ArenaManager | null = config.getArenaManager(); + // Skip if no manager or if it's the same instance we already handled + if (!manager || manager === attachedManager) return; + + const backend = manager.getBackend(); + if (!backend || backend.type !== DISPLAY_MODE.IN_PROCESS) return; + + attachedManager = manager; + if (checkInterval) { + clearInterval(checkInterval); + checkInterval = null; + } + + const inProcessBackend = backend as InProcessBackend; + const emitter = manager.getEventEmitter(); + const agentColors = getAgentColors(); + let colorIndex = 0; + + // Register agents that already started (race condition if events + // fired before we attached) + const existingAgents = manager.getAgentStates(); + for (const agentState of existingAgents) { + const interactive = inProcessBackend.getAgent(agentState.agentId); + if (interactive) { + const displayName = + agentState.model.displayName || agentState.model.modelId; + const color = agentColors[colorIndex % agentColors.length]!; + colorIndex++; + actionsRef.current.registerAgent( + agentState.agentId, + interactive, + displayName, + color, + ); + } + } + + // Listen for new agent starts. + // AGENT_START is emitted by ArenaManager *before* backend.spawnAgent() + // creates the AgentInteractive, so getAgent() may still return + // undefined. We retry with a short poll to bridge the gap. + const MAX_AGENT_RETRIES = 20; + const AGENT_RETRY_INTERVAL_MS = 50; + + const onAgentStart = (event: ArenaAgentStartEvent) => { + const tryRegister = (retriesLeft: number) => { + const interactive = inProcessBackend.getAgent(event.agentId); + if (interactive) { + const displayName = event.model.displayName || event.model.modelId; + const color = agentColors[colorIndex % agentColors.length]!; + colorIndex++; + actionsRef.current.registerAgent( + event.agentId, + interactive, + displayName, + color, + ); + return; + } + if (retriesLeft > 0) { + const timeout = setTimeout(() => { + retryTimeouts.delete(timeout); + tryRegister(retriesLeft - 1); + }, AGENT_RETRY_INTERVAL_MS); + retryTimeouts.add(timeout); + } + }; + tryRegister(MAX_AGENT_RETRIES); + }; + + // On session end, unregister agents, remove listeners from this + // manager, and resume polling for a genuinely new manager instance. + const onSessionEnd = () => { + actionsRef.current.unregisterAll(); + for (const timeout of retryTimeouts) { + clearTimeout(timeout); + } + retryTimeouts.clear(); + // Remove listeners eagerly so they don't fire again + emitter.off(ArenaEventType.AGENT_START, onAgentStart); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd); + emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd); + detachListeners = null; + // Keep attachedManager reference — prevents reattach to this + // same (completed) manager on the next poll tick. + // Polling will pick up a new manager once /arena start creates one. + if (!checkInterval) { + checkInterval = setInterval(tryAttach, 500); + } + }; + + emitter.on(ArenaEventType.AGENT_START, onAgentStart); + emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionEnd); + emitter.on(ArenaEventType.SESSION_ERROR, onSessionEnd); + + detachListeners = () => { + emitter.off(ArenaEventType.AGENT_START, onAgentStart); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd); + emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd); + }; + }; + + // Check immediately, then poll every 500ms + tryAttach(); + if (!attachedManager) { + checkInterval = setInterval(tryAttach, 500); + } + + return () => { + if (checkInterval) { + clearInterval(checkInterval); + } + for (const timeout of retryTimeouts) { + clearTimeout(timeout); + } + retryTimeouts.clear(); + detachListeners?.(); + }; + }, [config]); +} diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 93ad311c6..5faa39a2f 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -5,22 +5,54 @@ */ import type React from 'react'; +import { useEffect, useRef } from 'react'; import { Box } from 'ink'; import { MainContent } from '../components/MainContent.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; +import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; +import { AgentChatView } from '../components/agent-view/AgentChatView.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { useAgentViewState } from '../contexts/AgentViewContext.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; export const DefaultAppLayout: React.FC = () => { const uiState = useUIState(); + const { refreshStatic } = useUIActions(); + const { activeView, agents } = useAgentViewState(); const { columns: terminalWidth } = useTerminalSize(); + const hasAgents = agents.size > 0; + + // Clear terminal on view switch so previous view's output + // is removed. refreshStatic clears the terminal and bumps the + // historyRemountKey so MainContent's re-renders all items + // when switching back. + const prevViewRef = useRef(activeView); + useEffect(() => { + if (prevViewRef.current !== activeView) { + prevViewRef.current = activeView; + refreshStatic(); + } + }, [activeView, refreshStatic]); return ( - + {/* Content area: only the active view is rendered. + Conditional rendering avoids Ink's display="none" bug + where Static items remain visible even when the parent is hidden. + Each mount gets a fresh instance that re-renders items + on the cleared terminal. */} + {activeView !== 'main' && agents.has(activeView) ? ( + + ) : ( + + )} + {/* Shared footer — single instance keeps mainControlsRef attached + regardless of which tab is active so height measurement stays + current. */} {uiState.dialogsVisible ? ( @@ -32,9 +64,11 @@ export const DefaultAppLayout: React.FC = () => { ) : ( )} - + + {/* Tab bar: visible whenever in-process agents exist */} + {hasAgents && } ); }; diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index f6b098838..4eec705a2 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -668,6 +668,8 @@ export class ArenaManager { await this.spawnAgentPty(agent); } + this.emitProgress('All agents are now live and working on the task.'); + // For in-process mode, set up event bridges instead of file-based polling. // For PTY mode, start polling agent status files. if (isInProcess) { diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index 6ea1de34e..24b898bb4 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -173,11 +173,18 @@ export class InProcessBackend implements Backend { for (const agent of this.agents.values()) { agent.abort(); } - // Wait briefly for loops to settle + // Wait for loops to settle, but cap at 3s so CLI exit isn't blocked + // if an agent's reasoning loop doesn't terminate promptly after abort. + const CLEANUP_TIMEOUT_MS = 3000; const promises = Array.from(this.agents.values()).map((a) => a.waitForCompletion().catch(() => {}), ); - await Promise.allSettled(promises); + let timerId: ReturnType; + const timeout = new Promise((resolve) => { + timerId = setTimeout(resolve, CLEANUP_TIMEOUT_MS); + }); + await Promise.race([Promise.allSettled(promises), timeout]); + clearTimeout(timerId!); // Stop per-agent tool registries so tools like TaskTool can release // listeners registered on shared managers (e.g. SubagentManager). diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 4767c258d..466c77e3d 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -22,6 +22,7 @@ import { type ToolCallRequestInfo } from '../../core/turn.js'; import { CoreToolScheduler, type ToolCall, + type ExecutingToolCall, type WaitingToolCall, } from '../../core/coreToolScheduler.js'; import type { @@ -47,8 +48,10 @@ import type { import { AgentTerminateMode } from './agent-types.js'; import type { AgentRoundEvent, + AgentRoundTextEvent, AgentToolCallEvent, AgentToolResultEvent, + AgentToolOutputUpdateEvent, AgentUsageEvent, AgentHooks, } from './agent-events.js'; @@ -327,6 +330,13 @@ export class AgentCore { let terminateMode: AgentTerminateMode | null = null; while (true) { + // Check abort before starting a new round — prevents unnecessary API + // calls after processFunctionCalls was unblocked by an abort signal. + if (abortController.signal.aborted) { + terminateMode = AgentTerminateMode.CANCELLED; + break; + } + // Check termination conditions. if (options?.maxTurns && turnCounter >= options.maxTurns) { terminateMode = AgentTerminateMode.MAX_TURNS; @@ -375,6 +385,7 @@ export class AgentCore { const functionCalls: FunctionCall[] = []; let roundText = ''; + let roundThoughtText = ''; let lastUsage: GenerateContentResponseUsageMetadata | undefined = undefined; let currentResponseId: string | undefined = undefined; @@ -407,6 +418,7 @@ export class AgentCore { for (const p of parts) { const txt = p.text; const isThought = p.thought ?? false; + if (txt && isThought) roundThoughtText += txt; if (txt && !isThought) roundText += txt; if (txt) this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { @@ -421,6 +433,16 @@ export class AgentCore { } } + if (roundText || roundThoughtText) { + this.eventEmitter?.emit(AgentEventType.ROUND_TEXT, { + subagentId: this.subagentId, + round: turnCounter, + text: roundText, + thoughtText: roundThoughtText, + timestamp: Date.now(), + } as AgentRoundTextEvent); + } + this.executionStats.rounds = turnCounter; this.stats.setRounds(turnCounter); @@ -449,6 +471,15 @@ export class AgentCore { // No tool calls — treat this as the model's final answer. if (roundText && roundText.trim().length > 0) { finalText = roundText.trim(); + // Emit ROUND_END for the final round so all consumers see it. + // Previously this was skipped, requiring AgentInteractive to + // compensate with an explicit flushStreamBuffers() call. + this.eventEmitter?.emit(AgentEventType.ROUND_END, { + subagentId: this.subagentId, + round: turnCounter, + promptId, + timestamp: Date.now(), + } as AgentRoundEvent); // Clean up before breaking abortController.signal.removeEventListener('abort', onParentAbort); // null terminateMode = normal text completion @@ -525,6 +556,7 @@ export class AgentCore { name: toolName, args: fc.args ?? {}, description: `Tool "${toolName}" not found`, + isOutputMarkdown: false, timestamp: Date.now(), } as AgentToolCallEvent); @@ -564,11 +596,28 @@ export class AgentCore { // Build scheduler const responded = new Set(); let resolveBatch: (() => void) | null = null; + const emittedCallIds = new Set(); + // pidMap: callId → PTY PID, populated by onToolCallsUpdate when a shell + // tool spawns a PTY. Shared with outputUpdateHandler via closure so the + // PID is included in TOOL_OUTPUT_UPDATE events for interactive shell support. + const pidMap = new Map(); const scheduler = new CoreToolScheduler({ config: this.runtimeContext, - outputUpdateHandler: undefined, + outputUpdateHandler: (callId, outputChunk) => { + this.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { + subagentId: this.subagentId, + round: currentRound, + callId, + outputChunk, + pid: pidMap.get(callId), + timestamp: Date.now(), + } as AgentToolOutputUpdateEvent); + }, onAllToolCallsComplete: async (completedCalls) => { for (const call of completedCalls) { + if (emittedCallIds.has(call.request.callId)) continue; + emittedCallIds.add(call.request.callId); + const toolName = call.request.name; const duration = call.durationMs ?? 0; const success = call.status === 'success'; @@ -589,11 +638,8 @@ export class AgentCore { success, error: errorMessage, responseParts: call.response.responseParts, - resultDisplay: call.response.resultDisplay - ? typeof call.response.resultDisplay === 'string' - ? call.response.resultDisplay - : JSON.stringify(call.response.resultDisplay) - : undefined, + resultDisplay: call.response.resultDisplay, + outputFile: call.response.outputFile, durationMs: duration, timestamp: Date.now(), } as AgentToolResultEvent); @@ -628,6 +674,27 @@ export class AgentCore { }, onToolCallsUpdate: (calls: ToolCall[]) => { for (const call of calls) { + // Track PTY PIDs so TOOL_OUTPUT_UPDATE events can carry them. + if (call.status === 'executing') { + const pid = (call as ExecutingToolCall).pid; + if (pid !== undefined) { + const isNewPid = !pidMap.has(call.request.callId); + pidMap.set(call.request.callId, pid); + // Emit immediately so the UI can offer interactive shell + // focus (Ctrl+F) before the tool produces its first output. + if (isNewPid) { + this.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { + subagentId: this.subagentId, + round: currentRound, + callId: call.request.callId, + outputChunk: (call as ExecutingToolCall).liveOutput ?? '', + pid, + timestamp: Date.now(), + } as AgentToolOutputUpdateEvent); + } + } + } + if (call.status !== 'awaiting_approval') continue; const waiting = call as WaitingToolCall; @@ -681,6 +748,7 @@ export class AgentCore { }; const description = this.getToolDescription(toolName, args); + const isOutputMarkdown = this.getToolIsOutputMarkdown(toolName); this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { subagentId: this.subagentId, round: currentRound, @@ -688,6 +756,7 @@ export class AgentCore { name: toolName, args, description, + isOutputMarkdown, timestamp: Date.now(), } as AgentToolCallEvent); @@ -711,8 +780,52 @@ export class AgentCore { resolveBatch = null; }; }); + + // Auto-resolve on abort so processFunctionCalls doesn't block forever + // when tools are awaiting approval or executing without abort support. + const onAbort = () => { + resolveBatch?.(); + for (const req of requests) { + if (emittedCallIds.has(req.callId)) continue; + emittedCallIds.add(req.callId); + + const errorMessage = 'Tool call cancelled by user abort.'; + this.recordToolCallStats(req.name, false, 0, errorMessage); + + this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { + subagentId: this.subagentId, + round: currentRound, + callId: req.callId, + name: req.name, + success: false, + error: errorMessage, + responseParts: [ + { + functionResponse: { + id: req.callId, + name: req.name, + response: { error: errorMessage }, + }, + }, + ], + resultDisplay: errorMessage, + durationMs: 0, + timestamp: Date.now(), + } as AgentToolResultEvent); + } + }; + abortController.signal.addEventListener('abort', onAbort, { once: true }); + + // If already aborted before the listener was registered, resolve + // immediately to avoid blocking forever. + if (abortController.signal.aborted) { + onAbort(); + } + await scheduler.schedule(requests, abortController.signal); await batchDone; + + abortController.signal.removeEventListener('abort', onAbort); } // If all tool calls failed, inform the model so it can re-evaluate. @@ -783,6 +896,15 @@ export class AgentCore { } } + private getToolIsOutputMarkdown(toolName: string): boolean { + try { + const toolRegistry = this.runtimeContext.getToolRegistry(); + return toolRegistry.getTool(toolName)?.isOutputMarkdown ?? false; + } catch { + return false; + } + } + /** * Records tool call statistics for both successful and failed tool calls. */ diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index e02d8b692..643608681 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -28,9 +28,11 @@ export type AgentEvent = | 'start' | 'round_start' | 'round_end' + | 'round_text' | 'stream_text' | 'tool_call' | 'tool_result' + | 'tool_output_update' | 'tool_waiting_approval' | 'usage_metadata' | 'finish' @@ -41,9 +43,12 @@ export enum AgentEventType { START = 'start', ROUND_START = 'round_start', ROUND_END = 'round_end', + /** Complete round text, emitted once after streaming before tool calls. */ + ROUND_TEXT = 'round_text', STREAM_TEXT = 'stream_text', TOOL_CALL = 'tool_call', TOOL_RESULT = 'tool_result', + TOOL_OUTPUT_UPDATE = 'tool_output_update', TOOL_WAITING_APPROVAL = 'tool_waiting_approval', USAGE_METADATA = 'usage_metadata', FINISH = 'finish', @@ -68,6 +73,14 @@ export interface AgentRoundEvent { timestamp: number; } +export interface AgentRoundTextEvent { + subagentId: string; + round: number; + text: string; + thoughtText: string; + timestamp: number; +} + export interface AgentStreamTextEvent { subagentId: string; round: number; @@ -92,6 +105,8 @@ export interface AgentToolCallEvent { name: string; args: Record; description: string; + /** Whether the tool's output should be rendered as markdown. */ + isOutputMarkdown?: boolean; timestamp: number; } @@ -104,10 +119,23 @@ export interface AgentToolResultEvent { error?: string; responseParts?: Part[]; resultDisplay?: ToolResultDisplay; + /** Path to the temp file where oversized output was saved. */ + outputFile?: string; durationMs?: number; timestamp: number; } +export interface AgentToolOutputUpdateEvent { + subagentId: string; + round: number; + callId: string; + /** Latest accumulated output for this tool call (replaces previous). */ + outputChunk: ToolResultDisplay; + /** PTY process PID — present when the tool runs in an interactive shell. */ + pid?: number; + timestamp: number; +} + export interface AgentApprovalRequestEvent { subagentId: string; round: number; @@ -160,9 +188,11 @@ export interface AgentEventMap { [AgentEventType.START]: AgentStartEvent; [AgentEventType.ROUND_START]: AgentRoundEvent; [AgentEventType.ROUND_END]: AgentRoundEvent; + [AgentEventType.ROUND_TEXT]: AgentRoundTextEvent; [AgentEventType.STREAM_TEXT]: AgentStreamTextEvent; [AgentEventType.TOOL_CALL]: AgentToolCallEvent; [AgentEventType.TOOL_RESULT]: AgentToolResultEvent; + [AgentEventType.TOOL_OUTPUT_UPDATE]: AgentToolOutputUpdateEvent; [AgentEventType.TOOL_WAITING_APPROVAL]: AgentApprovalRequestEvent; [AgentEventType.USAGE_METADATA]: AgentUsageEvent; [AgentEventType.FINISH]: AgentFinishEvent; diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 633043ba7..9c3162d22 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -184,13 +184,13 @@ describe('AgentInteractive', () => { expect(callCount).toBe(1); }); - // Error recorded as assistant message with error metadata + // Error recorded as info message with error level const messages = agent.getMessages(); const errorMsg = messages.find( (m) => - m.role === 'assistant' && - m.content.includes('Error: Model error') && - m.metadata?.['error'] === true, + m.role === 'info' && + m.content.includes('Model error') && + m.metadata?.['level'] === 'error', ); expect(errorMsg).toBeDefined(); @@ -286,21 +286,22 @@ describe('AgentInteractive', () => { expect(agent.getCore()).toBe(core); }); - // ─── Stream Buffer & Message Recording ───────────────────── + // ─── Message Recording ───────────────────────────────────── - it('should record assistant text from stream events (not result.text)', async () => { + it('should record assistant text from ROUND_TEXT events', async () => { const { core, emitter } = createMockCore(); (core.runReasoningLoop as ReturnType).mockImplementation( () => { - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, - text: 'Hello from stream', + text: 'Hello from round', + thoughtText: '', timestamp: Date.now(), }); return Promise.resolve({ - text: 'Hello from stream', + text: 'Hello from round', terminateMode: null, turnsUsed: 1, }); @@ -318,24 +319,24 @@ describe('AgentInteractive', () => { const assistantMsgs = agent .getMessages() .filter((m) => m.role === 'assistant' && !m.thought); - // Exactly one — from stream flush, not duplicated by result.text expect(assistantMsgs).toHaveLength(1); - expect(assistantMsgs[0]?.content).toBe('Hello from stream'); + expect(assistantMsgs[0]?.content).toBe('Hello from round'); await agent.shutdown(); }); - it('should not carry stream buffer across messages', async () => { + it('should not cross-contaminate text across messages', async () => { const { core, emitter } = createMockCore(); let runCount = 0; (core.runReasoningLoop as ReturnType).mockImplementation( () => { runCount++; - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: `response-${runCount}`, + thoughtText: '', timestamp: Date.now(), }); return Promise.resolve({ @@ -360,7 +361,6 @@ describe('AgentInteractive', () => { expect(runCount).toBe(2); }); - // No message containing both responses (no cross-contamination) const messages = agent.getMessages(); const assistantMessages = messages.filter( (m) => m.role === 'assistant' && !m.thought, @@ -379,18 +379,11 @@ describe('AgentInteractive', () => { (core.runReasoningLoop as ReturnType).mockImplementation( () => { - emitter.emit(AgentEventType.STREAM_TEXT, { - subagentId: 'test', - round: 1, - text: 'Let me think...', - thought: true, - timestamp: Date.now(), - }); - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: 'Here is the answer', - thought: false, + thoughtText: 'Let me think...', timestamp: Date.now(), }); return Promise.resolve({ @@ -428,10 +421,11 @@ describe('AgentInteractive', () => { (core.runReasoningLoop as ReturnType).mockImplementation( () => { - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: 'I will read the file', + thoughtText: '', timestamp: Date.now(), }); emitter.emit(AgentEventType.TOOL_CALL, { @@ -451,12 +445,6 @@ describe('AgentInteractive', () => { success: true, timestamp: Date.now(), }); - emitter.emit(AgentEventType.ROUND_END, { - subagentId: 'test', - round: 1, - promptId: 'p1', - timestamp: Date.now(), - }); return Promise.resolve({ text: '', terminateMode: null, @@ -487,16 +475,16 @@ describe('AgentInteractive', () => { await agent.shutdown(); }); - it('should flush text before tool_call to preserve temporal ordering', async () => { + it('should place text before tool_call to preserve temporal ordering', async () => { const { core, emitter } = createMockCore(); (core.runReasoningLoop as ReturnType).mockImplementation( () => { - // Text arrives before tool call in the stream - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: 'Let me check', + thoughtText: '', timestamp: Date.now(), }); emitter.emit(AgentEventType.TOOL_CALL, { @@ -516,12 +504,6 @@ describe('AgentInteractive', () => { success: true, timestamp: Date.now(), }); - emitter.emit(AgentEventType.ROUND_END, { - subagentId: 'test', - round: 1, - promptId: 'p1', - timestamp: Date.now(), - }); return Promise.resolve({ text: '', terminateMode: null, @@ -539,10 +521,8 @@ describe('AgentInteractive', () => { }); const messages = agent.getMessages(); - // Filter to just the non-user messages for ordering check const nonUser = messages.filter((m) => m.role !== 'user'); - // Text should come before tool_call const textIdx = nonUser.findIndex( (m) => m.role === 'assistant' && m.content === 'Let me check', ); @@ -552,59 +532,6 @@ describe('AgentInteractive', () => { await agent.shutdown(); }); - it('should return in-progress stream state during streaming', async () => { - const { core, emitter } = createMockCore(); - - let capturedInProgress: ReturnType< - typeof AgentInteractive.prototype.getInProgressStream - > = null; - - (core.runReasoningLoop as ReturnType).mockImplementation( - () => { - emitter.emit(AgentEventType.STREAM_TEXT, { - subagentId: 'test', - round: 1, - text: 'thinking...', - thought: true, - timestamp: Date.now(), - }); - emitter.emit(AgentEventType.STREAM_TEXT, { - subagentId: 'test', - round: 1, - text: 'visible text', - timestamp: Date.now(), - }); - // Capture in-progress state before the loop returns - capturedInProgress = agent.getInProgressStream(); - return Promise.resolve({ - text: 'visible text', - terminateMode: null, - turnsUsed: 1, - }); - }, - ); - - const config = createConfig({ initialTask: 'test' }); - const agent = new AgentInteractive(config, core); - - await agent.start(context); - await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); - }); - - // During streaming, in-progress state was available - expect(capturedInProgress).toEqual({ - text: 'visible text', - thinking: 'thinking...', - round: 1, - }); - - // After flush, in-progress state is null - expect(agent.getInProgressStream()).toBeNull(); - - await agent.shutdown(); - }); - // ─── Events ──────────────────────────────────────────────── it('should emit status_change events', async () => { diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 66fa4faa5..4970077e0 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -7,30 +7,28 @@ /** * @fileoverview AgentInteractive — persistent interactive agent. * - * Composes AgentCore with on-demand message processing to provide an agent - * that processes user inputs sequentially and settles between batches. - * Used by InProcessBackend for Arena's in-process mode. - * - * AgentInteractive is the **sole consumer** of AgentCore events. It builds - * conversation state (messages + in-progress stream) that the UI reads. - * The UI never directly subscribes to AgentCore events for data — it reads - * from AgentInteractive and uses notifications to know when to re-render. - * - * Lifecycle: start() → (running ↔ completed/failed)* → shutdown()/abort() + * Composes AgentCore with on-demand message processing. Builds conversation + * state (messages, pending approvals, live outputs) that the UI reads. */ import { createDebugLogger } from '../../utils/debugLogger.js'; import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import type { - AgentStreamTextEvent, + AgentRoundTextEvent, AgentToolCallEvent, AgentToolResultEvent, + AgentToolOutputUpdateEvent, + AgentApprovalRequestEvent, } from './agent-events.js'; import type { AgentStatsSummary } from './agent-statistics.js'; import type { AgentCore } from './agent-core.js'; import type { ContextState } from './agent-headless.js'; import type { GeminiChat } from '../../core/geminiChat.js'; import type { FunctionDeclaration } from '@google/genai'; +import type { + ToolCallConfirmationDetails, + ToolResultDisplay, +} from '../../tools/tools.js'; import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js'; import { AgentTerminateMode, @@ -38,7 +36,6 @@ import { isTerminalStatus, type AgentInteractiveConfig, type AgentMessage, - type InProgressStreamState, } from './agent-types.js'; const debugLogger = createDebugLogger('AGENT_INTERACTIVE'); @@ -68,13 +65,23 @@ export class AgentInteractive { private toolsList: FunctionDeclaration[] = []; private processing = false; - // Stream accumulator — separate buffers for thought and non-thought text. - // Flushed to messages on ROUND_END (intermediate rounds), before TOOL_CALL - // events (to preserve temporal ordering), and after runReasoningLoop returns - // (final round, since ROUND_END doesn't fire for it). - private thoughtBuffer = ''; - private textBuffer = ''; - private streamRound = -1; + // Pending tool approval requests. Keyed by callId. + // Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when + // the user responds. The UI reads this to show confirmation dialogs. + private readonly pendingApprovals = new Map< + string, + ToolCallConfirmationDetails + >(); + + // Live streaming output for currently-executing tools. Keyed by callId. + // Populated by TOOL_OUTPUT_UPDATE (replaces previous), cleared on TOOL_RESULT. + // The UI reads this via getLiveOutputs() to show real-time stdout. + private readonly liveOutputs = new Map(); + + // PTY PIDs for currently-executing shell tools. Keyed by callId. + // Populated by TOOL_OUTPUT_UPDATE when pid is present, cleared on TOOL_RESULT. + // The UI reads this via getShellPids() to enable interactive shell input. + private readonly shellPids = new Map(); constructor(config: AgentInteractiveConfig, core: AgentCore) { this.config = config; @@ -169,29 +176,24 @@ export class AgentInteractive { }, ); - // Finalize any unflushed stream content from the last round. - // ROUND_END doesn't fire for the final text-producing round - // (AgentCore breaks before emitting it), so we flush here. - this.flushStreamBuffers(); - - // Surface non-normal termination so Arena (and other consumers) - // can distinguish limit-triggered stops from successful completions. + // Surface non-normal termination as a visible info message and as + // lastRoundError so Arena can distinguish limit stops from successes. if ( result.terminateMode && result.terminateMode !== AgentTerminateMode.GOAL ) { + const msg = terminateModeMessage(result.terminateMode); + if (msg) { + this.addMessage('info', msg.text, { metadata: { level: msg.level } }); + } this.lastRoundError = `Terminated: ${result.terminateMode}`; } } catch (err) { // Agent survives round errors — log and settle status in runLoop. - // Flush any partial stream content accumulated before the error. - this.flushStreamBuffers(); const errorMessage = err instanceof Error ? err.message : String(err); this.lastRoundError = errorMessage; debugLogger.error('AgentInteractive round error:', err); - this.addMessage('assistant', `Error: ${errorMessage}`, { - metadata: { error: true }, - }); + this.addMessage('info', errorMessage, { metadata: { level: 'error' } }); } finally { this.masterAbortController.signal.removeEventListener( 'abort', @@ -205,9 +207,14 @@ export class AgentInteractive { /** * Cancel only the current reasoning round. + * Adds a visible "cancelled" info message and clears pending approvals. */ cancelCurrentRound(): void { this.roundAbortController?.abort(); + this.pendingApprovals.clear(); + this.addMessage('info', 'Agent round cancelled.', { + metadata: { level: 'warning' }, + }); } /** @@ -232,6 +239,7 @@ export class AgentInteractive { abort(): void { this.masterAbortController.abort(); this.queue.drain(); + this.pendingApprovals.clear(); } // ─── Message Queue ───────────────────────────────────────── @@ -252,20 +260,6 @@ export class AgentInteractive { return this.messages; } - /** - * Returns the in-progress streaming state for UI mid-switch handoff. - * The UI reads this when attaching to an agent that's currently streaming - * to display content accumulated before the UI subscribed. - */ - getInProgressStream(): InProgressStreamState | null { - if (!this.textBuffer && !this.thoughtBuffer) return null; - return { - text: this.textBuffer, - thinking: this.thoughtBuffer, - round: this.streamRound, - }; - } - getStatus(): AgentStatus { return this.status; } @@ -290,6 +284,34 @@ export class AgentInteractive { return this.core.getEventEmitter(); } + /** + * Returns tool calls currently awaiting user approval. + * Keyed by callId → full ToolCallConfirmationDetails (with onConfirm). + * The UI reads this to render confirmation dialogs inside ToolGroupMessage. + */ + getPendingApprovals(): ReadonlyMap { + return this.pendingApprovals; + } + + /** + * Returns live output for currently-executing tools. + * Keyed by callId → latest ToolResultDisplay (replaces on each update). + * Entries are cleared when TOOL_RESULT arrives for the call. + */ + getLiveOutputs(): ReadonlyMap { + return this.liveOutputs; + } + + /** + * Returns PTY PIDs for currently-executing interactive shell tools. + * Keyed by callId → PID. Populated from TOOL_OUTPUT_UPDATE when pid is + * present; cleared when TOOL_RESULT arrives. The UI uses this to enable + * interactive shell input via HistoryItemDisplay's activeShellPtyId prop. + */ + getShellPids(): ReadonlyMap { + return this.shellPids; + } + /** * Wait for the run loop to finish (used by InProcessBackend). */ @@ -343,67 +365,47 @@ export class AgentInteractive { this.messages.push(message); } - /** - * Flush accumulated stream buffers to finalized messages. - * - * Thought text → assistant message with thought=true. - * Regular text → assistant message. - * Called on ROUND_END, before TOOL_CALL (ordering), and after - * runReasoningLoop returns (final round). - */ - private flushStreamBuffers(): void { - if (this.thoughtBuffer) { - this.addMessage('assistant', this.thoughtBuffer, { thought: true }); - this.thoughtBuffer = ''; - } - if (this.textBuffer) { - this.addMessage('assistant', this.textBuffer); - this.textBuffer = ''; - } - this.streamRound = -1; - } - - /** - * Set up listeners on AgentCore's event emitter. - * - * AgentInteractive is the sole consumer of these events. It builds - * the conversation state (messages + in-progress stream) that the - * UI reads. Listeners use canonical event types from agent-events.ts. - */ private setupEventListeners(): void { const emitter = this.core.eventEmitter; if (!emitter) return; - emitter.on(AgentEventType.STREAM_TEXT, (event: AgentStreamTextEvent) => { - // Round boundary: flush previous round's buffers before starting a new one - if (event.round !== this.streamRound && this.streamRound !== -1) { - this.flushStreamBuffers(); + emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => { + if (event.thoughtText) { + this.addMessage('assistant', event.thoughtText, { thought: true }); } - this.streamRound = event.round; - - if (event.thought) { - this.thoughtBuffer += event.text; - } else { - this.textBuffer += event.text; + if (event.text) { + this.addMessage('assistant', event.text); } }); emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => { - // Flush text buffers first — in the stream, text arrives before - // tool calls, so flushing preserves temporal ordering in messages. - this.flushStreamBuffers(); - this.addMessage('tool_call', `Tool call: ${event.name}`, { metadata: { callId: event.callId, toolName: event.name, args: event.args, + description: event.description, + renderOutputAsMarkdown: event.isOutputMarkdown, round: event.round, }, }); }); + emitter.on( + AgentEventType.TOOL_OUTPUT_UPDATE, + (event: AgentToolOutputUpdateEvent) => { + this.liveOutputs.set(event.callId, event.outputChunk); + if (event.pid !== undefined) { + this.shellPids.set(event.callId, event.pid); + } + }, + ); + emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => { + this.liveOutputs.delete(event.callId); + this.shellPids.delete(event.callId); + this.pendingApprovals.delete(event.callId); + const statusText = event.success ? 'succeeded' : 'failed'; const summary = event.error ? `Tool ${event.name} ${statusText}: ${event.error}` @@ -413,13 +415,67 @@ export class AgentInteractive { callId: event.callId, toolName: event.name, success: event.success, + resultDisplay: event.resultDisplay, + outputFile: event.outputFile, round: event.round, }, }); }); - emitter.on(AgentEventType.ROUND_END, () => { - this.flushStreamBuffers(); - }); + emitter.on( + AgentEventType.TOOL_WAITING_APPROVAL, + (event: AgentApprovalRequestEvent) => { + const fullDetails = { + ...event.confirmationDetails, + onConfirm: async ( + outcome: Parameters[0], + payload?: Parameters[1], + ) => { + this.pendingApprovals.delete(event.callId); + // Nudge the UI to re-render so the tool transitions visually + // from Confirming → Executing without waiting for the first + // real TOOL_OUTPUT_UPDATE from the tool's execution. + this.core.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { + subagentId: this.core.subagentId, + round: event.round, + callId: event.callId, + outputChunk: '', + timestamp: Date.now(), + } as AgentToolOutputUpdateEvent); + await event.respond(outcome, payload); + }, + } as ToolCallConfirmationDetails; + + this.pendingApprovals.set(event.callId, fullDetails); + }, + ); + } +} + +/** + * Map a non-GOAL terminate mode to a visible status message for the UI, + * or return null to suppress the message entirely. + * + * CANCELLED is suppressed here because cancelCurrentRound() already emits + * its own warning. SHUTDOWN is suppressed as a normal lifecycle end. + */ +function terminateModeMessage( + mode: AgentTerminateMode, +): { text: string; level: 'info' | 'warning' | 'error' } | null { + switch (mode) { + case AgentTerminateMode.MAX_TURNS: + return { + text: 'Agent stopped: maximum turns reached.', + level: 'warning', + }; + case AgentTerminateMode.TIMEOUT: + return { text: 'Agent stopped: time limit reached.', level: 'warning' }; + case AgentTerminateMode.ERROR: + return { text: 'Agent stopped due to an error.', level: 'error' }; + case AgentTerminateMode.CANCELLED: + case AgentTerminateMode.SHUTDOWN: + return null; + default: + return null; } } diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index df3e5fc9a..2684406c1 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -147,7 +147,7 @@ export interface AgentInteractiveConfig { */ export interface AgentMessage { /** Discriminator for the message kind. */ - role: 'user' | 'assistant' | 'tool_call' | 'tool_result'; + role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'info'; /** The text content of the message. */ content: string; /** When the message was created (ms since epoch). */ @@ -157,7 +157,15 @@ export interface AgentMessage { * Mirrors AgentStreamTextEvent.thought. Only meaningful when role is 'assistant'. */ thought?: boolean; - /** Optional metadata (e.g. tool call info, round number). */ + /** + * Optional metadata. + * + * For role='info': metadata.level?: 'info' | 'warning' | 'success' | 'error' + * Controls which status message component is rendered. Defaults to 'info'. + * For role='tool_call': callId, toolName, args, description, renderOutputAsMarkdown, round + * For role='tool_result': callId, toolName, success, resultDisplay, outputFile, round + * For role='assistant' with error: error=true + */ metadata?: Record; }