diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 51c696886..80c1b0a90 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -334,7 +334,7 @@ function executeArenaCommand( }) .then( () => { - debugLogger.debug('Arena session completed'); + debugLogger.debug('Arena agents settled'); }, (error) => { const message = error instanceof Error ? error.message : String(error); @@ -344,13 +344,18 @@ function executeArenaCommand( // Clear the stored manager so subsequent /arena start calls // are not blocked by the stale reference after a startup failure. config.setArenaManager(null); + + // Detach listeners on failure — session is done for good. + for (const detach of detachListeners) { + detach(); + } }, - ) - .finally(() => { - for (const detach of detachListeners) { - detach(); - } - }); + ); + + // NOTE: listeners are NOT detached when start() resolves because agents + // may still be alive (IDLE) and accept follow-up tasks. The listeners + // reference this manager's emitter, so they are garbage collected when + // the manager is cleaned up and replaced. // Store so that stop can wait for start() to fully unwind before cleanup manager.setLifecyclePromise(lifecycle); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 193549245..78eefabc3 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -104,8 +104,8 @@ export const Composer = () => { {/* Exclusive area: only one component visible at a time */} {/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */} - {!showSuggestions && - uiState.streamingState !== StreamingState.WaitingForConfirmation && + {uiState.isInputActive && + !showSuggestions && (showShortcuts ? ( ) : ( diff --git a/packages/cli/src/ui/components/arena/ArenaCards.tsx b/packages/cli/src/ui/components/arena/ArenaCards.tsx index fe6db8075..1ad7d8e2a 100644 --- a/packages/cli/src/ui/components/arena/ArenaCards.tsx +++ b/packages/cli/src/ui/components/arena/ArenaCards.tsx @@ -148,11 +148,13 @@ export const ArenaSessionCard: React.FC = ({ const colChanges = 10; const titleLabel = - sessionStatus === 'completed' - ? 'Arena Complete' - : sessionStatus === 'cancelled' - ? 'Arena Cancelled' - : 'Arena Failed'; + sessionStatus === 'idle' + ? 'Agents Status · Idle' + : sessionStatus === 'completed' + ? 'Arena Complete' + : sessionStatus === 'cancelled' + ? 'Arena Cancelled' + : 'Arena Failed'; return ( = ({ {/* Hint */} + {sessionStatus === 'idle' && ( + + + Switch to an agent tab to continue, or{' '} + /arena select to pick a + winner. + + + )} {sessionStatus === 'completed' && ( diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index 0786cbac0..1a126c102 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -12,7 +12,7 @@ import { type ArenaAgentState, type InProcessBackend, type AgentStatsSummary, - isTerminalStatus, + isSettledStatus, ArenaSessionStatus, DISPLAY_MODE, } from '@qwen-code/qwen-code-core'; @@ -46,7 +46,7 @@ function pad( } function getElapsedMs(agent: ArenaAgentState): number { - if (isTerminalStatus(agent.status)) { + if (isSettledStatus(agent.status)) { return agent.stats.durationMs; } return Date.now() - agent.startedAt; @@ -61,6 +61,8 @@ function getSessionStatusLabel(status: ArenaSessionStatus): { return { text: 'Running', color: theme.status.success }; case ArenaSessionStatus.INITIALIZING: return { text: 'Initializing', color: theme.status.warning }; + case ArenaSessionStatus.IDLE: + return { text: 'Idle', color: theme.status.success }; case ArenaSessionStatus.COMPLETED: return { text: 'Completed', color: theme.status.success }; case ArenaSessionStatus.CANCELLED: diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts index 7cb29d312..0f7db9220 100644 --- a/packages/cli/src/ui/hooks/useArenaInProcess.ts +++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts @@ -18,9 +18,11 @@ import { useEffect, useRef } from 'react'; import { ArenaEventType, + ArenaSessionStatus, DISPLAY_MODE, type ArenaManager, type ArenaAgentStartEvent, + type ArenaSessionCompleteEvent, type Config, type InProcessBackend, } from '@qwen-code/qwen-code-core'; @@ -123,9 +125,9 @@ export function useArenaInProcess(config: Config): void { 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 = () => { + // Tear down agent tabs, remove listeners, and resume polling for + // a genuinely new manager instance. + const teardown = () => { actionsRef.current.unregisterAll(); for (const timeout of retryTimeouts) { clearTimeout(timeout); @@ -133,8 +135,8 @@ export function useArenaInProcess(config: Config): void { 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); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete); + emitter.off(ArenaEventType.SESSION_ERROR, teardown); detachListeners = null; // Keep attachedManager reference — prevents reattach to this // same (completed) manager on the next poll tick. @@ -144,14 +146,24 @@ export function useArenaInProcess(config: Config): void { } }; + // When agents settle to IDLE the session is still alive — keep + // the tab bar so users can continue interacting with agents. + // Only tear down on truly terminal session statuses. + const onSessionComplete = (event: ArenaSessionCompleteEvent) => { + if (event.result.status === ArenaSessionStatus.IDLE) { + return; + } + teardown(); + }; + emitter.on(ArenaEventType.AGENT_START, onAgentStart); - emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionEnd); - emitter.on(ArenaEventType.SESSION_ERROR, onSessionEnd); + emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete); + emitter.on(ArenaEventType.SESSION_ERROR, teardown); detachListeners = () => { emitter.off(ArenaEventType.AGENT_START, onAgentStart); - emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd); - emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete); + emitter.off(ArenaEventType.SESSION_ERROR, teardown); }; }; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 5faa39a2f..5cfdc782f 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -67,8 +67,8 @@ export const DefaultAppLayout: React.FC = () => { - {/* Tab bar: visible whenever in-process agents exist */} - {hasAgents && } + {/* Tab bar: visible whenever in-process agents exist and input is active */} + {hasAgents && !uiState.dialogsVisible && } ); }; diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts index 7f422e250..4f8fabb16 100644 --- a/packages/cli/src/ui/utils/displayUtils.ts +++ b/packages/cli/src/ui/utils/displayUtils.ts @@ -17,6 +17,8 @@ export interface StatusLabel { export function getArenaStatusLabel(status: AgentStatus): StatusLabel { switch (status) { + case AgentStatus.IDLE: + return { icon: '✓', text: 'Idle', color: theme.status.success }; case AgentStatus.COMPLETED: return { icon: '✓', text: 'Done', color: theme.status.success }; case AgentStatus.CANCELLED: diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 172ef632f..b17341fc5 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -36,7 +36,11 @@ import { ARENA_MAX_AGENTS, safeAgentId, } from './types.js'; -import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; +import { + AgentStatus, + isTerminalStatus, + isSettledStatus, +} from '../runtime/agent-types.js'; import { logArenaSessionStarted, logArenaAgentCompleted, @@ -374,9 +378,10 @@ export class ArenaManager { this.sessionStatus = ArenaSessionStatus.RUNNING; await this.runAgents(); - // Only mark as completed if not already cancelled/timed out + // Mark session as idle (agents finished but still alive) unless + // already cancelled/timed out. if (this.sessionStatus === ArenaSessionStatus.RUNNING) { - this.sessionStatus = ArenaSessionStatus.COMPLETED; + this.sessionStatus = ArenaSessionStatus.IDLE; } // Collect results (uses this.sessionStatus for result status) @@ -1114,6 +1119,25 @@ export class ArenaManager { timestamp: Date.now(), }); + // Emit progress messages for follow-up transitions (only after + // the initial task — the session is IDLE once all agents first settle). + if (this.sessionStatus === ArenaSessionStatus.IDLE) { + const displayName = agent.model.displayName || agent.model.modelId; + if ( + previousStatus === AgentStatus.IDLE && + newStatus === AgentStatus.RUNNING + ) { + this.emitProgress( + `Agent ${displayName} is working on a follow-up task…`, + ); + } else if ( + previousStatus === AgentStatus.RUNNING && + newStatus === AgentStatus.IDLE + ) { + this.emitProgress(`Agent ${displayName} finished follow-up task.`); + } + } + // Emit AGENT_COMPLETE when agent reaches a terminal status if (isTerminalStatus(newStatus)) { const result = this.buildAgentResult(agent); @@ -1194,7 +1218,7 @@ export class ArenaManager { return new Promise((resolve) => { const checkSettled = () => { for (const agent of this.agents.values()) { - if (!isTerminalStatus(agent.status)) { + if (!isSettledStatus(agent.status)) { return false; } } @@ -1283,7 +1307,7 @@ export class ArenaManager { agent.error = interactive.getLastRoundError() || interactive.getError(); } - if (isTerminalStatus(resolved)) { + if (isSettledStatus(resolved)) { agent.stats.durationMs = Date.now() - agent.startedAt; } this.updateAgentStatus(agent.agentId, resolved); @@ -1337,9 +1361,9 @@ export class ArenaManager { const consolidatedAgents: Record = {}; for (const agent of this.agents.values()) { - // Only poll agents that are still alive (RUNNING) + // Only poll agents that are actively working if ( - isTerminalStatus(agent.status) || + isSettledStatus(agent.status) || agent.status === AgentStatus.INITIALIZING ) { continue; diff --git a/packages/core/src/agents/arena/types.ts b/packages/core/src/agents/arena/types.ts index b99059cbd..aaf3e2dae 100644 --- a/packages/core/src/agents/arena/types.ts +++ b/packages/core/src/agents/arena/types.ts @@ -21,7 +21,9 @@ export enum ArenaSessionStatus { INITIALIZING = 'initializing', /** Session is running */ RUNNING = 'running', - /** Session completed (all agents finished) */ + /** All agents finished their current task and are idle (can accept follow-ups) */ + IDLE = 'idle', + /** Session completed for good (winner selected or explicit end) */ COMPLETED = 'completed', /** Session was cancelled */ CANCELLED = 'cancelled', diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index 24b898bb4..5109c91bd 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -20,7 +20,7 @@ import { createContentGenerator, } from '../../core/contentGenerator.js'; import { AUTH_ENV_MAPPINGS } from '../../models/constants.js'; -import { AgentStatus } from '../runtime/agent-types.js'; +import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; import { AgentCore } from '../runtime/agent-core.js'; import { AgentEventEmitter } from '../runtime/agent-events.js'; import { ContextState } from '../runtime/agent-headless.js'; @@ -130,9 +130,14 @@ export class InProcessBackend implements Backend { const context = new ContextState(); await interactive.start(context); - // Watch for completion and fire exit callback + // Watch for completion and fire exit callback — but only for + // truly terminal statuses. IDLE means the agent is still alive + // and can accept follow-up messages. void interactive.waitForCompletion().then(() => { const status = interactive.getStatus(); + if (!isTerminalStatus(status)) { + return; + } const exitCode = status === AgentStatus.COMPLETED ? 0 diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 9c3162d22..f0ac9fb88 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -114,7 +114,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); expect(core.runReasoningLoop).toHaveBeenCalledOnce(); @@ -123,6 +123,7 @@ describe('AgentInteractive', () => { expect(agent.getMessages()[0]?.content).toBe('Do something'); await agent.shutdown(); + expect(agent.getStatus()).toBe('completed'); }); it('should process enqueued messages', async () => { @@ -134,7 +135,7 @@ describe('AgentInteractive', () => { agent.enqueueMessage('Hello'); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); expect(core.runReasoningLoop).toHaveBeenCalledOnce(); @@ -197,7 +198,7 @@ describe('AgentInteractive', () => { // Second message works fine agent.enqueueMessage('recover'); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); expect(callCount).toBe(2); }); @@ -313,7 +314,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const assistantMsgs = agent @@ -352,12 +353,12 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); agent.enqueueMessage('second message'); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); expect(runCount).toBe(2); }); @@ -399,7 +400,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const messages = agent.getMessages(); @@ -458,7 +459,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const messages = agent.getMessages(); @@ -517,7 +518,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const messages = agent.getMessages(); diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 4970077e0..7e35a96db 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -323,12 +323,16 @@ export class AgentInteractive { // ─── Private Helpers ─────────────────────────────────────── - /** Emit terminal status for the just-completed round. */ + /** + * Settle status after the run loop empties. + * On success → IDLE (agent stays alive for follow-up messages). + * On error → FAILED (terminal). + */ private settleRoundStatus(): void { if (this.lastRoundError) { this.setStatus(AgentStatus.FAILED); } else { - this.setStatus(AgentStatus.COMPLETED); + this.setStatus(AgentStatus.IDLE); } } diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index 2684406c1..ca7e283f6 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -99,28 +99,34 @@ export enum AgentTerminateMode { * Canonical lifecycle status for any agent (headless, interactive, arena). * * State machine: - * INITIALIZING → RUNNING ⇄ COMPLETED / FAILED / CANCELLED + * INITIALIZING → RUNNING → IDLE ⇄ RUNNING → … → COMPLETED / FAILED / CANCELLED * * - INITIALIZING: Setting up (creating chat, loading tools). * - RUNNING: Actively processing (model thinking / tool execution). - * - COMPLETED: Finished successfully (may re-enter RUNNING on new input). + * - IDLE: Finished current work, waiting — can accept new messages. + * - COMPLETED: Finished for good (explicit shutdown). No further interaction. * - FAILED: Finished with error (API failure, process crash, etc.). * - CANCELLED: Cancelled by user or system. */ export enum AgentStatus { INITIALIZING = 'initializing', RUNNING = 'running', + IDLE = 'idle', COMPLETED = 'completed', FAILED = 'failed', CANCELLED = 'cancelled', } -/** True for COMPLETED, FAILED, CANCELLED — agent is done working. */ +/** True for COMPLETED, FAILED, CANCELLED — agent is done for good. */ export const isTerminalStatus = (s: AgentStatus): boolean => s === AgentStatus.COMPLETED || s === AgentStatus.FAILED || s === AgentStatus.CANCELLED; +/** True for terminal statuses OR IDLE — agent has settled (not actively working). */ +export const isSettledStatus = (s: AgentStatus): boolean => + s === AgentStatus.IDLE || isTerminalStatus(s); + /** * Lightweight configuration for an AgentInteractive instance. * Carries only interactive-specific parameters; the heavy runtime