feat(arena): improve cancellation handling and simplify to in-process mode

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

- Track user-initiated cancellation separately from failures

- Cancel round immediately when user denies a tool call

- Add message queue to handle input during streaming

- Add info messages during Arena operations (apply, stop, cleanup)

- Disable tmux/iTerm2 backends (only in-process mode supported)

- Polish UI: green tool count, updated warning prefix

This improves the Arena UX by providing clearer feedback and

properly handling user cancellations without treating them as failures.
This commit is contained in:
tanzhenxin 2026-03-12 16:57:44 +08:00
parent 3233d16b5c
commit 4ee94715df
13 changed files with 153 additions and 65 deletions

View file

@ -1193,12 +1193,12 @@ const SETTINGS_SCHEMA = {
requiresRestart: false, requiresRestart: false,
default: undefined as string | undefined, default: undefined as string | undefined,
description: description:
'Display mode for multi-agent sessions. "tmux" uses tmux panes, "iterm2" uses iTerm2 tabs, "in-process" runs in the current terminal.', 'Display mode for multi-agent sessions. Currently only "in-process" is supported.',
showInDialog: false, showInDialog: false,
options: [ options: [
{ value: 'in-process', label: 'In-process' }, { value: 'in-process', label: 'In-process' },
{ value: 'tmux', label: 'tmux' }, // { value: 'tmux', label: 'tmux' },
{ value: 'iterm2', label: 'iTerm2' }, // { value: 'iterm2', label: 'iTerm2' },
], ],
}, },
arena: { arena: {

View file

@ -249,10 +249,7 @@ function executeArenaCommand(
} else if (event.type === 'info') { } else if (event.type === 'info') {
addAndRecordArenaMessage(MessageType.INFO, event.message); addAndRecordArenaMessage(MessageType.INFO, event.message);
} else { } else {
addAndRecordArenaMessage( addAndRecordArenaMessage(MessageType.WARNING, event.message);
MessageType.WARNING,
`Arena warning: ${event.message}`,
);
} }
}; };

View file

@ -18,9 +18,10 @@
*/ */
import { Box, Text, useStdin } from 'ink'; import { Box, Text, useStdin } from 'ink';
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
AgentStatus, AgentStatus,
isTerminalStatus,
ApprovalMode, ApprovalMode,
APPROVAL_MODES, APPROVAL_MODES,
} from '@qwen-code/qwen-code-core'; } from '@qwen-code/qwen-code-core';
@ -38,6 +39,7 @@ import { useTextBuffer } from '../shared/text-buffer.js';
import { calculatePromptWidths } from '../../utils/layoutUtils.js'; import { calculatePromptWidths } from '../../utils/layoutUtils.js';
import { BaseTextInput } from '../BaseTextInput.js'; import { BaseTextInput } from '../BaseTextInput.js';
import { LoadingIndicator } from '../LoadingIndicator.js'; import { LoadingIndicator } from '../LoadingIndicator.js';
import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js';
import { AgentFooter } from './AgentFooter.js'; import { AgentFooter } from './AgentFooter.js';
import { keyMatchers, Command } from '../../keyMatchers.js'; import { keyMatchers, Command } from '../../keyMatchers.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
@ -182,13 +184,35 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
[buffer, agentTabBarFocused, setAgentTabBarFocused], [buffer, agentTabBarFocused, setAgentTabBarFocused],
); );
// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
const [messageQueue, setMessageQueue] = useState<string[]>([]);
// When agent becomes idle (and not terminal), flush queued messages.
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
messageQueue.length > 0 &&
status !== undefined &&
!isTerminalStatus(status)
) {
const combined = messageQueue.join('\n');
setMessageQueue([]);
interactiveAgent?.enqueueMessage(combined);
}
}, [streamingState, messageQueue, interactiveAgent, status]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(text: string) => { (text: string) => {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed || !interactiveAgent) return; if (!trimmed || !interactiveAgent) return;
if (streamingState === StreamingState.Idle) {
interactiveAgent.enqueueMessage(trimmed); interactiveAgent.enqueueMessage(trimmed);
} else {
setMessageQueue((prev) => [...prev, trimmed]);
}
}, },
[interactiveAgent], [interactiveAgent, streamingState],
); );
// ── Render ── // ── Render ──
@ -255,6 +279,8 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
</Box> </Box>
)} )}
<QueuedMessageDisplay messageQueue={messageQueue} />
{/* Input prompt — always visible, like the main Composer */} {/* Input prompt — always visible, like the main Composer */}
<BaseTextInput <BaseTextInput
buffer={buffer} buffer={buffer}

View file

@ -74,6 +74,10 @@ export function ArenaSelectDialog({
mgr.getAgentStates().find((item) => item.agentId === agentId); mgr.getAgentStates().find((item) => item.agentId === agentId);
const label = agent?.model.modelId || agentId; const label = agent?.model.modelId || agentId;
pushMessage({
messageType: 'info',
content: `Applying changes from ${label}`,
});
const result = await mgr.applyAgentResult(agentId); const result = await mgr.applyAgentResult(agentId);
if (!result.success) { if (!result.success) {
pushMessage({ pushMessage({
@ -111,6 +115,10 @@ export function ArenaSelectDialog({
} }
try { try {
pushMessage({
messageType: 'info',
content: 'Discarding Arena results and cleaning up…',
});
await config.cleanupArenaRuntime(true); await config.cleanupArenaRuntime(true);
pushMessage({ pushMessage({
messageType: 'info', messageType: 'info',

View file

@ -264,7 +264,11 @@ export function ArenaStatusDialog({
<Text color={theme.status.error}>{failedToolCalls}</Text> <Text color={theme.status.error}>{failedToolCalls}</Text>
</Text> </Text>
) : ( ) : (
<Text color={theme.text.primary}> <Text
color={
toolCalls > 0 ? theme.status.success : theme.text.primary
}
>
{pad(String(toolCalls), colTools - 1, 'right')} {pad(String(toolCalls), colTools - 1, 'right')}
</Text> </Text>
)} )}

View file

@ -80,9 +80,17 @@ export function ArenaStopDialog({
sessionStatus === ArenaSessionStatus.RUNNING || sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING sessionStatus === ArenaSessionStatus.INITIALIZING
) { ) {
pushMessage({
messageType: 'info',
content: 'Stopping Arena agents…',
});
await mgr.cancel(); await mgr.cancel();
} }
await mgr.waitForSettled(); await mgr.waitForSettled();
pushMessage({
messageType: 'info',
content: 'Cleaning up Arena resources…',
});
if (action === 'preserve') { if (action === 'preserve') {
await mgr.cleanupRuntime(); await mgr.cleanupRuntime();

View file

@ -75,7 +75,7 @@ export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => ( export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage <StatusMessage
text={text} text={text}
prefix="" prefix=""
prefixColor={theme.status.warning} prefixColor={theme.status.warning}
textColor={theme.status.warning} textColor={theme.status.warning}
/> />

View file

@ -124,7 +124,8 @@ export function useAgentStreamingState(
}, [status, hasPendingApprovals]); }, [status, hasPendingApprovals]);
const isInputActive = const isInputActive =
streamingState === StreamingState.Idle && (streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding) &&
status !== undefined && status !== undefined &&
!isTerminalStatus(status); !isTerminalStatus(status);

View file

@ -1105,7 +1105,11 @@ export class ArenaManager {
return incoming; return incoming;
} }
private updateAgentStatus(agentId: string, newStatus: AgentStatus): void { private updateAgentStatus(
agentId: string,
newStatus: AgentStatus,
options?: { roundCancelledByUser?: boolean },
): void {
const agent = this.agents.get(agentId); const agent = this.agents.get(agentId);
if (!agent) { if (!agent) {
return; return;
@ -1130,8 +1134,12 @@ export class ArenaManager {
previousStatus === AgentStatus.RUNNING && previousStatus === AgentStatus.RUNNING &&
newStatus === AgentStatus.IDLE newStatus === AgentStatus.IDLE
) { ) {
if (options?.roundCancelledByUser) {
this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning');
} else {
this.emitProgress(`Agent ${label} finished initial task.`, 'success'); this.emitProgress(`Agent ${label} finished initial task.`, 'success');
} }
}
// Emit progress messages for follow-up transitions (only after // Emit progress messages for follow-up transitions (only after
// the initial task — the session is IDLE once all agents first settle). // the initial task — the session is IDLE once all agents first settle).
@ -1145,7 +1153,14 @@ export class ArenaManager {
previousStatus === AgentStatus.RUNNING && previousStatus === AgentStatus.RUNNING &&
newStatus === AgentStatus.IDLE newStatus === AgentStatus.IDLE
) { ) {
this.emitProgress(`Agent ${label} finished follow-up task.`, 'success'); if (options?.roundCancelledByUser) {
this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning');
} else {
this.emitProgress(
`Agent ${label} finished follow-up task.`,
'success',
);
}
} }
} }
@ -1317,7 +1332,10 @@ export class ArenaManager {
agent.syncStats = syncStats; agent.syncStats = syncStats;
const applyStatus = (incoming: AgentStatus) => { const applyStatus = (
incoming: AgentStatus,
options?: { roundCancelledByUser?: boolean },
) => {
const resolved = this.resolveTransition(agent.status, incoming); const resolved = this.resolveTransition(agent.status, incoming);
if (!resolved) return; if (!resolved) return;
if (resolved === AgentStatus.FAILED) { if (resolved === AgentStatus.FAILED) {
@ -1327,14 +1345,16 @@ export class ArenaManager {
if (isSettledStatus(resolved)) { if (isSettledStatus(resolved)) {
agent.stats.durationMs = Date.now() - agent.startedAt; agent.stats.durationMs = Date.now() - agent.startedAt;
} }
this.updateAgentStatus(agent.agentId, resolved); this.updateAgentStatus(agent.agentId, resolved, options);
}; };
// Sync stats before mapping so counters are up-to-date even when // Sync stats before mapping so counters are up-to-date even when
// the provider omits usage_metadata events. // the provider omits usage_metadata events.
const onStatusChange = (event: AgentStatusChangeEvent) => { const onStatusChange = (event: AgentStatusChangeEvent) => {
syncStats(); syncStats();
applyStatus(event.newStatus); applyStatus(event.newStatus, {
roundCancelledByUser: event.roundCancelledByUser,
});
// Write status files so external consumers get a consistent // Write status files so external consumers get a consistent
// file-based view regardless of backend mode. // file-based view regardless of backend mode.
this.flushInProcessStatusFiles().catch((err) => this.flushInProcessStatusFiles().catch((err) =>

View file

@ -6,10 +6,10 @@
import { createDebugLogger } from '../../utils/debugLogger.js'; import { createDebugLogger } from '../../utils/debugLogger.js';
import type { Config } from '../../config/config.js'; import type { Config } from '../../config/config.js';
import { TmuxBackend } from './TmuxBackend.js'; // import { TmuxBackend } from './TmuxBackend.js';
import { InProcessBackend } from './InProcessBackend.js'; import { InProcessBackend } from './InProcessBackend.js';
import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js'; import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js';
import { isTmuxAvailable } from './tmux-commands.js'; // import { isTmuxAvailable } from './tmux-commands.js';
const debugLogger = createDebugLogger('BACKEND_DETECT'); const debugLogger = createDebugLogger('BACKEND_DETECT');
@ -35,44 +35,54 @@ export async function detectBackend(
preference: DisplayMode | undefined, preference: DisplayMode | undefined,
runtimeContext: Config, runtimeContext: Config,
): Promise<DetectBackendResult> { ): Promise<DetectBackendResult> {
// 1. User explicit preference // Currently only in-process mode is supported. Other backends (tmux,
if (preference === DISPLAY_MODE.IN_PROCESS) { // iterm2) are kept in the codebase but not wired up as entry points.
debugLogger.info('Using InProcessBackend (user preference)'); const warning =
return { backend: new InProcessBackend(runtimeContext) }; preference && preference !== DISPLAY_MODE.IN_PROCESS
} ? `Display mode "${preference}" is not currently supported. Using in-process mode instead.`
: undefined;
debugLogger.info('Using InProcessBackend');
return { backend: new InProcessBackend(runtimeContext), warning };
if (preference === DISPLAY_MODE.ITERM2) { // --- Disabled backends (kept for future use) ---
throw new Error( // // 1. User explicit preference
`Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`, // if (preference === DISPLAY_MODE.IN_PROCESS) {
); // debugLogger.info('Using InProcessBackend (user preference)');
} // return { backend: new InProcessBackend(runtimeContext) };
// }
if (preference === DISPLAY_MODE.TMUX) { //
debugLogger.info('Using TmuxBackend (user preference)'); // if (preference === DISPLAY_MODE.ITERM2) {
return { backend: new TmuxBackend() }; // throw new Error(
} // `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`,
// );
// 2. Auto-detect // }
if (process.env['TMUX']) { //
debugLogger.info('Detected $TMUX — attempting TmuxBackend'); // if (preference === DISPLAY_MODE.TMUX) {
return { backend: new TmuxBackend() }; // debugLogger.info('Using TmuxBackend (user preference)');
} // return { backend: new TmuxBackend() };
// }
// Other terminals (including iTerm2): use tmux external session mode if available. //
if (isTmuxAvailable()) { // // 2. Auto-detect
debugLogger.info( // if (process.env['TMUX']) {
'tmux is available — using TmuxBackend external session mode', // debugLogger.info('Detected $TMUX — attempting TmuxBackend');
); // return { backend: new TmuxBackend() };
return { backend: new TmuxBackend() }; // }
} //
// // Other terminals (including iTerm2): use tmux external session mode if available.
// Fallback: use InProcessBackend // if (isTmuxAvailable()) {
debugLogger.info( // debugLogger.info(
'No PTY backend available — falling back to InProcessBackend', // 'tmux is available — using TmuxBackend external session mode',
); // );
return { // return { backend: new TmuxBackend() };
backend: new InProcessBackend(runtimeContext), // }
warning: //
'tmux is not available. Using in-process mode (no split-pane terminal view).', // // Fallback: use InProcessBackend
}; // debugLogger.info(
// 'No PTY backend available — falling back to InProcessBackend',
// );
// return {
// backend: new InProcessBackend(runtimeContext),
// warning:
// 'tmux is not available. Using in-process mode (no split-pane terminal view).',
// };
} }

View file

@ -176,6 +176,8 @@ export interface AgentStatusChangeEvent {
agentId: string; agentId: string;
previousStatus: AgentStatus; previousStatus: AgentStatus;
newStatus: AgentStatus; newStatus: AgentStatus;
/** True when the transition to IDLE was caused by user cancelling the round. */
roundCancelledByUser?: boolean;
timestamp: number; timestamp: number;
} }

View file

@ -234,7 +234,7 @@ describe('AgentInteractive', () => {
resolveLoop!(); resolveLoop!();
await vi.waitFor(() => { await vi.waitFor(() => {
expect(agent.getStatus()).toBe('failed'); expect(agent.getStatus()).toBe('idle');
}); });
await agent.shutdown(); await agent.shutdown();

View file

@ -25,9 +25,10 @@ import type { AgentCore } from './agent-core.js';
import type { ContextState } from './agent-headless.js'; import type { ContextState } from './agent-headless.js';
import type { GeminiChat } from '../../core/geminiChat.js'; import type { GeminiChat } from '../../core/geminiChat.js';
import type { FunctionDeclaration } from '@google/genai'; import type { FunctionDeclaration } from '@google/genai';
import type { import {
ToolCallConfirmationDetails, ToolConfirmationOutcome,
ToolResultDisplay, type ToolCallConfirmationDetails,
type ToolResultDisplay,
} from '../../tools/tools.js'; } from '../../tools/tools.js';
import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js'; import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js';
import { import {
@ -64,6 +65,7 @@ export class AgentInteractive {
private chat: GeminiChat | undefined; private chat: GeminiChat | undefined;
private toolsList: FunctionDeclaration[] = []; private toolsList: FunctionDeclaration[] = [];
private processing = false; private processing = false;
private roundCancelledByUser = false;
// Pending tool approval requests. Keyed by callId. // Pending tool approval requests. Keyed by callId.
// Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when // Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when
@ -161,6 +163,7 @@ export class AgentInteractive {
this.setStatus(AgentStatus.RUNNING); this.setStatus(AgentStatus.RUNNING);
this.lastRoundError = undefined; this.lastRoundError = undefined;
this.roundCancelledByUser = false;
this.roundAbortController = new AbortController(); this.roundAbortController = new AbortController();
// Propagate master abort to round // Propagate master abort to round
@ -199,6 +202,8 @@ export class AgentInteractive {
this.lastRoundError = `Terminated: ${result.terminateMode}`; this.lastRoundError = `Terminated: ${result.terminateMode}`;
} }
} catch (err) { } catch (err) {
// User-initiated cancellation already logged by cancelCurrentRound().
if (this.roundCancelledByUser) return;
// Agent survives round errors — log and settle status in runLoop. // Agent survives round errors — log and settle status in runLoop.
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
this.lastRoundError = errorMessage; this.lastRoundError = errorMessage;
@ -220,6 +225,7 @@ export class AgentInteractive {
* Adds a visible "cancelled" info message and clears pending approvals. * Adds a visible "cancelled" info message and clears pending approvals.
*/ */
cancelCurrentRound(): void { cancelCurrentRound(): void {
this.roundCancelledByUser = true;
this.roundAbortController?.abort(); this.roundAbortController?.abort();
this.pendingApprovals.clear(); this.pendingApprovals.clear();
this.addMessage('info', 'Agent round cancelled.', { this.addMessage('info', 'Agent round cancelled.', {
@ -344,7 +350,7 @@ export class AgentInteractive {
* On error FAILED (terminal). * On error FAILED (terminal).
*/ */
private settleRoundStatus(): void { private settleRoundStatus(): void {
if (this.lastRoundError) { if (this.lastRoundError && !this.roundCancelledByUser) {
this.setStatus(AgentStatus.FAILED); this.setStatus(AgentStatus.FAILED);
} else { } else {
this.setStatus(AgentStatus.IDLE); this.setStatus(AgentStatus.IDLE);
@ -361,6 +367,7 @@ export class AgentInteractive {
agentId: this.config.agentId, agentId: this.config.agentId,
previousStatus, previousStatus,
newStatus, newStatus,
roundCancelledByUser: this.roundCancelledByUser || undefined,
timestamp: Date.now(), timestamp: Date.now(),
}); });
} }
@ -462,6 +469,11 @@ export class AgentInteractive {
timestamp: Date.now(), timestamp: Date.now(),
} as AgentToolOutputUpdateEvent); } as AgentToolOutputUpdateEvent);
await event.respond(outcome, payload); await event.respond(outcome, payload);
// When the user denies a tool, cancel the round immediately
// so the agent doesn't waste a turn "acknowledging" the denial.
if (outcome === ToolConfirmationOutcome.Cancel) {
this.cancelCurrentRound();
}
}, },
} as ToolCallConfirmationDetails; } as ToolCallConfirmationDetails;