diff --git a/packages/cli/src/ui/commands/arenaCommand.test.ts b/packages/cli/src/ui/commands/arenaCommand.test.ts index 12def97bb..04f3f5597 100644 --- a/packages/cli/src/ui/commands/arenaCommand.test.ts +++ b/packages/cli/src/ui/commands/arenaCommand.test.ts @@ -257,7 +257,7 @@ describe('arenaCommand select subcommand', () => { messageType: 'error', content: 'No successful agent results to select from. All agents failed or were cancelled.\n' + - 'Use /arena select --discard to clean up worktrees, or /arena stop to end the session.', + 'Use /arena stop to end the session.', }); }); diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index b71b81596..5339f94ca 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -28,7 +28,7 @@ import { type ArenaSessionCompleteEvent, type ArenaSessionErrorEvent, type ArenaSessionStartEvent, - type ArenaSessionWarningEvent, + type ArenaSessionUpdateEvent, } from '@qwen-code/qwen-code-core'; import { MessageType, @@ -147,6 +147,26 @@ function buildArenaExecutionInput( }; } +/** + * Persists a single arena history item to the session JSONL file. + * + * Arena events fire asynchronously (after the slash command's recording + * window has closed), so each item must be recorded individually. + */ +function recordArenaItem(config: Config, item: HistoryItemWithoutId): void { + try { + const chatRecorder = config.getChatRecordingService(); + if (!chatRecorder) return; + chatRecorder.recordSlashCommand({ + phase: 'result', + rawCommand: '/arena', + outputHistoryItems: [{ ...item } as Record], + }); + } catch { + debugLogger.error('Failed to record arena history item'); + } +} + function executeArenaCommand( config: Config, ui: CommandContext['ui'], @@ -164,6 +184,15 @@ function executeArenaCommand( ui.addItem({ type, text }, Date.now()); }; + const addAndRecordArenaMessage = ( + type: 'info' | 'warning' | 'error' | 'success', + text: string, + ) => { + const item: HistoryItemWithoutId = { type, text }; + ui.addItem(item, Date.now()); + recordArenaItem(config, item); + }; + const handleSessionStart = (event: ArenaSessionStartEvent) => { const modelList = event.models .map( @@ -171,6 +200,9 @@ function executeArenaCommand( ` ${index + 1}. ${model.displayName || model.modelId}`, ) .join('\n'); + // SESSION_START fires synchronously before the first await in + // ArenaManager.start(), so the slash command processor's finally + // block already captures this item — no extra recording needed. addArenaMessage( MessageType.INFO, `Arena started with ${event.models.length} agents on task: "${event.task}"\nModels:\n${modelList}`, @@ -183,22 +215,33 @@ function executeArenaCommand( debugLogger.debug(`Arena agent started: ${label} (${event.agentId})`); }; - const handleSessionWarning = (event: ArenaSessionWarningEvent) => { + const handleSessionUpdate = (event: ArenaSessionUpdateEvent) => { const attachHintPrefix = 'To view agent panes, run: '; if (event.message.startsWith(attachHintPrefix)) { const command = event.message.slice(attachHintPrefix.length).trim(); - addArenaMessage( + addAndRecordArenaMessage( MessageType.INFO, `Arena panes are running in tmux. Attach with: \`${command}\``, ); return; } - addArenaMessage(MessageType.WARNING, `Arena warning: ${event.message}`); + + if (event.type === 'info') { + addAndRecordArenaMessage(MessageType.INFO, event.message); + } else { + addAndRecordArenaMessage( + MessageType.WARNING, + `Arena warning: ${event.message}`, + ); + } }; const handleAgentError = (event: ArenaAgentErrorEvent) => { const label = agentLabels.get(event.agentId) || event.agentId; - addArenaMessage(MessageType.ERROR, `[${label}] failed: ${event.error}`); + addAndRecordArenaMessage( + MessageType.ERROR, + `[${label}] failed: ${event.error}`, + ); }; const buildAgentCardData = ( @@ -233,7 +276,6 @@ function executeArenaCommand( }; const handleAgentComplete = (event: ArenaAgentCompleteEvent) => { - // Show message for completed (success), cancelled, and terminated (error) agents if ( event.result.status !== ArenaAgentStatus.COMPLETED && event.result.status !== ArenaAgentStatus.CANCELLED && @@ -243,30 +285,28 @@ function executeArenaCommand( } const agent = buildAgentCardData(event.result); - ui.addItem( - { - type: 'arena_agent_complete', - agent, - } as HistoryItemWithoutId, - Date.now(), - ); + const item = { + type: 'arena_agent_complete', + agent, + } as HistoryItemWithoutId; + ui.addItem(item, Date.now()); + recordArenaItem(config, item); }; const handleSessionError = (event: ArenaSessionErrorEvent) => { - addArenaMessage(MessageType.ERROR, `Arena failed: ${event.error}`); + addAndRecordArenaMessage(MessageType.ERROR, `Arena failed: ${event.error}`); }; const handleSessionComplete = (event: ArenaSessionCompleteEvent) => { - ui.addItem( - { - type: 'arena_session_complete', - sessionStatus: event.result.status, - task: event.result.task, - totalDurationMs: event.result.totalDurationMs ?? 0, - agents: event.result.agents.map(buildAgentCardData), - } as HistoryItemWithoutId, - Date.now(), - ); + const item = { + type: 'arena_session_complete', + sessionStatus: event.result.status, + task: event.result.task, + totalDurationMs: event.result.totalDurationMs ?? 0, + agents: event.result.agents.map(buildAgentCardData), + } as HistoryItemWithoutId; + ui.addItem(item, Date.now()); + recordArenaItem(config, item); }; emitter.on(ArenaEventType.SESSION_START, handleSessionStart); @@ -277,9 +317,9 @@ function executeArenaCommand( detachListeners.push(() => emitter.off(ArenaEventType.AGENT_START, handleAgentStart), ); - emitter.on(ArenaEventType.SESSION_WARNING, handleSessionWarning); + emitter.on(ArenaEventType.SESSION_UPDATE, handleSessionUpdate); detachListeners.push(() => - emitter.off(ArenaEventType.SESSION_WARNING, handleSessionWarning), + emitter.off(ArenaEventType.SESSION_UPDATE, handleSessionUpdate), ); emitter.on(ArenaEventType.AGENT_ERROR, handleAgentError); detachListeners.push(() => @@ -317,7 +357,7 @@ function executeArenaCommand( }, (error) => { const message = error instanceof Error ? error.message : String(error); - addArenaMessage(MessageType.ERROR, `Arena failed: ${message}`); + addAndRecordArenaMessage(MessageType.ERROR, `Arena failed: ${message}`); debugLogger.error('Arena session failed:', error); // Clear the stored manager so subsequent /arena start calls @@ -567,7 +607,7 @@ export const arenaCommand: SlashCommand = { messageType: 'error', content: 'No successful agent results to select from. All agents failed or were cancelled.\n' + - 'Use /arena select --discard to clean up worktrees, or /arena stop to end the session.', + 'Use /arena stop to end the session.', }; } diff --git a/packages/cli/src/ui/components/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/ArenaSelectDialog.tsx index 222d884e5..b42d8e8d1 100644 --- a/packages/cli/src/ui/components/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/ArenaSelectDialog.tsx @@ -14,7 +14,7 @@ import { } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType } from '../types.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'; @@ -36,18 +36,25 @@ export function ArenaSelectDialog({ }: ArenaSelectDialogProps): React.JSX.Element { const pushMessage = useCallback( (result: { messageType: 'info' | 'error'; content: string }) => { - addItem( - { - type: - result.messageType === 'info' - ? MessageType.INFO - : MessageType.ERROR, - text: result.content, - }, - Date.now(), - ); + const item: HistoryItemWithoutId = { + type: + result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR, + text: result.content, + }; + addItem(item, Date.now()); + + try { + const chatRecorder = config.getChatRecordingService(); + chatRecorder?.recordSlashCommand({ + phase: 'result', + rawCommand: '/arena select', + outputHistoryItems: [{ ...item } as Record], + }); + } catch { + // Best-effort recording + } }, - [addItem], + [addItem, config], ); const onSelect = useCallback( diff --git a/packages/cli/src/ui/components/ArenaStopDialog.tsx b/packages/cli/src/ui/components/ArenaStopDialog.tsx index 24ad2eeb7..da0022aa7 100644 --- a/packages/cli/src/ui/components/ArenaStopDialog.tsx +++ b/packages/cli/src/ui/components/ArenaStopDialog.tsx @@ -14,7 +14,7 @@ import { } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType } from '../types.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'; @@ -38,18 +38,25 @@ export function ArenaStopDialog({ const pushMessage = useCallback( (result: { messageType: 'info' | 'error'; content: string }) => { - addItem( - { - type: - result.messageType === 'info' - ? MessageType.INFO - : MessageType.ERROR, - text: result.content, - }, - Date.now(), - ); + const item: HistoryItemWithoutId = { + type: + result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR, + text: result.content, + }; + addItem(item, Date.now()); + + try { + const chatRecorder = config.getChatRecordingService(); + chatRecorder?.recordSlashCommand({ + phase: 'result', + rawCommand: '/arena stop', + outputHistoryItems: [{ ...item } as Record], + }); + } catch { + // Best-effort recording + } }, - [addItem], + [addItem, config], ); const onStop = useCallback( diff --git a/packages/cli/src/ui/components/messages/ArenaCards.tsx b/packages/cli/src/ui/components/messages/ArenaCards.tsx index ae4be3c68..fe6db8075 100644 --- a/packages/cli/src/ui/components/messages/ArenaCards.tsx +++ b/packages/cli/src/ui/components/messages/ArenaCards.tsx @@ -35,7 +35,7 @@ export const ArenaAgentCard: React.FC = ({ {/* Line 1: Status icon + text + label + duration */} - {icon} {text}: {agent.label} · {duration} + {icon} {agent.label} · {text} · {duration} diff --git a/packages/core/src/agents-collab/arena/ArenaManager.test.ts b/packages/core/src/agents-collab/arena/ArenaManager.test.ts index 88ccce684..0bf2b60ec 100644 --- a/packages/core/src/agents-collab/arena/ArenaManager.test.ts +++ b/packages/core/src/agents-collab/arena/ArenaManager.test.ts @@ -272,11 +272,19 @@ describe('ArenaManager', () => { }); describe('backend initialization', () => { - it('should emit SESSION_WARNING when backend detection returns warning', async () => { + it('should emit SESSION_UPDATE with type warning when backend detection returns warning', async () => { const manager = new ArenaManager(mockConfig as never); - const warnings: Array<{ message: string; sessionId: string }> = []; - manager.getEventEmitter().on(ArenaEventType.SESSION_WARNING, (event) => { - warnings.push({ message: event.message, sessionId: event.sessionId }); + const updates: Array<{ + type: string; + message: string; + sessionId: string; + }> = []; + manager.getEventEmitter().on(ArenaEventType.SESSION_UPDATE, (event) => { + updates.push({ + type: event.type, + message: event.message, + sessionId: event.sessionId, + }); }); hoistedMockDetectBackend.mockResolvedValueOnce({ @@ -287,9 +295,10 @@ describe('ArenaManager', () => { await manager.start(createValidStartOptions()); expect(hoistedMockDetectBackend).toHaveBeenCalledWith(undefined); - expect(warnings).toHaveLength(1); - expect(warnings[0]?.message).toContain('fallback to tmux backend'); - expect(warnings[0]?.sessionId).toMatch(/^arena-/); + const warningUpdate = updates.find((u) => u.type === 'warning'); + expect(warningUpdate).toBeDefined(); + expect(warningUpdate?.message).toContain('fallback to tmux backend'); + expect(warningUpdate?.sessionId).toMatch(/^arena-/); }); it('should emit SESSION_ERROR and mark FAILED when backend init fails', async () => { diff --git a/packages/core/src/agents-collab/arena/ArenaManager.ts b/packages/core/src/agents-collab/arena/ArenaManager.ts index 11a178160..c1f075f08 100644 --- a/packages/core/src/agents-collab/arena/ArenaManager.ts +++ b/packages/core/src/agents-collab/arena/ArenaManager.ts @@ -302,6 +302,7 @@ export class ArenaManager { } // Set up worktrees for all agents + this.emitProgress(`Setting up environment for agents…`); await this.setupWorktrees(); // If cancelled during worktree setup, bail out early @@ -311,6 +312,7 @@ export class ArenaManager { } // Start all agents in parallel via PTY + this.emitProgress('Environment ready. Launching agents…'); this.sessionStatus = ArenaSessionStatus.RUNNING; await this.runAgents(); @@ -474,6 +476,22 @@ export class ArenaManager { return this.worktreeService.getWorktreeDiff(agent.worktree.path); } + // ─── Private: Progress ───────────────────────────────────────── + + /** + * Emit a progress message via SESSION_UPDATE so the UI can display + * setup status. + */ + private emitProgress(message: string): void { + if (!this.sessionId) return; + this.eventEmitter.emit(ArenaEventType.SESSION_UPDATE, { + sessionId: this.sessionId, + type: 'info', + message, + timestamp: Date.now(), + }); + } + // ─── Private: Validation ─────────────────────────────────────── private validateStartOptions(options: ArenaStartOptions): void { @@ -524,8 +542,9 @@ export class ArenaManager { this.backend = backend; if (warning && this.sessionId) { - this.eventEmitter.emit(ArenaEventType.SESSION_WARNING, { + this.eventEmitter.emit(ArenaEventType.SESSION_UPDATE, { sessionId: this.sessionId, + type: 'warning', message: warning, timestamp: Date.now(), }); @@ -534,8 +553,9 @@ export class ArenaManager { // Surface attach hint for external tmux sessions const attachHint = backend.getAttachHint(); if (attachHint && this.sessionId) { - this.eventEmitter.emit(ArenaEventType.SESSION_WARNING, { + this.eventEmitter.emit(ArenaEventType.SESSION_UPDATE, { sessionId: this.sessionId, + type: 'info', message: `To view agent panes, run: ${attachHint}`, timestamp: Date.now(), }); @@ -1045,14 +1065,6 @@ export class ArenaManager { this.updateAgentStatus(agent.agentId, ArenaAgentStatus.RUNNING); } - // Emit stats update event - this.eventEmitter.emit(ArenaEventType.AGENT_STATS_UPDATE, { - sessionId: this.requireConfig().sessionId, - agentId: agent.agentId, - stats: statusFile.stats, - timestamp: Date.now(), - }); - this.callbacks.onAgentStatsUpdate?.(agent.agentId, statusFile.stats); } catch (error: unknown) { // File may not exist yet (agent hasn't written first status) diff --git a/packages/core/src/agents-collab/arena/arena-events.ts b/packages/core/src/agents-collab/arena/arena-events.ts index b7a46e258..1098fcafa 100644 --- a/packages/core/src/agents-collab/arena/arena-events.ts +++ b/packages/core/src/agents-collab/arena/arena-events.ts @@ -8,7 +8,6 @@ import { EventEmitter } from 'events'; import type { ArenaAgentStatus, ArenaModelConfig, - ArenaAgentStats, ArenaAgentResult, ArenaSessionResult, } from './types.js'; @@ -19,6 +18,8 @@ import type { export enum ArenaEventType { /** Arena session started */ SESSION_START = 'session_start', + /** Informational or warning update during session lifecycle */ + SESSION_UPDATE = 'session_update', /** Arena session completed */ SESSION_COMPLETE = 'session_complete', /** Arena session failed */ @@ -27,35 +28,21 @@ export enum ArenaEventType { AGENT_START = 'agent_start', /** Agent status changed */ AGENT_STATUS_CHANGE = 'agent_status_change', - /** Agent streamed text */ - AGENT_STREAM_TEXT = 'agent_stream_text', - /** Agent called a tool */ - AGENT_TOOL_CALL = 'agent_tool_call', - /** Agent tool call completed */ - AGENT_TOOL_RESULT = 'agent_tool_result', - /** Agent stats updated */ - AGENT_STATS_UPDATE = 'agent_stats_update', /** Agent completed */ AGENT_COMPLETE = 'agent_complete', /** Agent error */ AGENT_ERROR = 'agent_error', - /** Non-fatal warning (e.g., backend fallback) */ - SESSION_WARNING = 'session_warning', } export type ArenaEvent = | 'session_start' + | 'session_update' | 'session_complete' | 'session_error' | 'agent_start' | 'agent_status_change' - | 'agent_stream_text' - | 'agent_tool_call' - | 'agent_tool_result' - | 'agent_stats_update' | 'agent_complete' - | 'agent_error' - | 'session_warning'; + | 'agent_error'; /** * Event payload for session start. @@ -97,61 +84,12 @@ export interface ArenaAgentStartEvent { } /** - * Event payload for agent status change. + * Event payload for agent error. */ -export interface ArenaAgentStatusChangeEvent { +export interface ArenaAgentErrorEvent { sessionId: string; agentId: string; - previousStatus: ArenaAgentStatus; - newStatus: ArenaAgentStatus; - timestamp: number; -} - -/** - * Event payload for agent stream text. - */ -export interface ArenaAgentStreamTextEvent { - sessionId: string; - agentId: string; - text: string; - isThought?: boolean; - timestamp: number; -} - -/** - * Event payload for agent tool call. - */ -export interface ArenaAgentToolCallEvent { - sessionId: string; - agentId: string; - callId: string; - toolName: string; - args: Record; - description?: string; - timestamp: number; -} - -/** - * Event payload for agent tool result. - */ -export interface ArenaAgentToolResultEvent { - sessionId: string; - agentId: string; - callId: string; - toolName: string; - success: boolean; - error?: string; - durationMs: number; - timestamp: number; -} - -/** - * Event payload for agent stats update. - */ -export interface ArenaAgentStatsUpdateEvent { - sessionId: string; - agentId: string; - stats: Partial; + error: string; timestamp: number; } @@ -166,20 +104,24 @@ export interface ArenaAgentCompleteEvent { } /** - * Event payload for agent error. + * Event payload for agent status change. */ -export interface ArenaAgentErrorEvent { +export interface ArenaAgentStatusChangeEvent { sessionId: string; agentId: string; - error: string; + previousStatus: ArenaAgentStatus; + newStatus: ArenaAgentStatus; timestamp: number; } /** - * Event payload for session warning (non-fatal). + * Event payload for session update (informational or warning). */ -export interface ArenaSessionWarningEvent { +export type ArenaSessionUpdateType = 'info' | 'warning'; + +export interface ArenaSessionUpdateEvent { sessionId: string; + type: ArenaSessionUpdateType; message: string; timestamp: number; } @@ -189,17 +131,13 @@ export interface ArenaSessionWarningEvent { */ export interface ArenaEventMap { [ArenaEventType.SESSION_START]: ArenaSessionStartEvent; + [ArenaEventType.SESSION_UPDATE]: ArenaSessionUpdateEvent; [ArenaEventType.SESSION_COMPLETE]: ArenaSessionCompleteEvent; [ArenaEventType.SESSION_ERROR]: ArenaSessionErrorEvent; [ArenaEventType.AGENT_START]: ArenaAgentStartEvent; [ArenaEventType.AGENT_STATUS_CHANGE]: ArenaAgentStatusChangeEvent; - [ArenaEventType.AGENT_STREAM_TEXT]: ArenaAgentStreamTextEvent; - [ArenaEventType.AGENT_TOOL_CALL]: ArenaAgentToolCallEvent; - [ArenaEventType.AGENT_TOOL_RESULT]: ArenaAgentToolResultEvent; - [ArenaEventType.AGENT_STATS_UPDATE]: ArenaAgentStatsUpdateEvent; [ArenaEventType.AGENT_COMPLETE]: ArenaAgentCompleteEvent; [ArenaEventType.AGENT_ERROR]: ArenaAgentErrorEvent; - [ArenaEventType.SESSION_WARNING]: ArenaSessionWarningEvent; } /**