feat(arena): add IDLE status for agent follow-up task support

- Introduce AgentStatus.IDLE for agents that finished work but can accept follow-up messages
- Add isSettledStatus() helper to check if agent is settled (IDLE or terminal)
- Update ArenaManager to transition to IDLE after agents finish initial task
- Keep agent tabs visible when session is IDLE so users can continue interacting
- Fix listener cleanup to not detach on IDLE (agents remain alive)
- Update tests to expect 'idle' status after successful completion

This enables the arena collaboration feature where agents can receive
additional tasks after completing their initial work.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-09 21:33:48 +08:00
parent 4a681f435d
commit eaef9efe90
13 changed files with 125 additions and 51 deletions

View file

@ -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);

View file

@ -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 ? (
<KeyboardShortcuts />
) : (

View file

@ -148,11 +148,13 @@ export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
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 (
<Box
@ -266,6 +268,15 @@ export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
<Box height={1} />
{/* Hint */}
{sessionStatus === 'idle' && (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
Switch to an agent tab to continue, or{' '}
<Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
{sessionStatus === 'completed' && (
<Box>
<Text color={theme.text.secondary}>

View file

@ -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:

View file

@ -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);
};
};

View file

@ -67,8 +67,8 @@ export const DefaultAppLayout: React.FC = () => {
<ExitWarning />
</Box>
{/* Tab bar: visible whenever in-process agents exist */}
{hasAgents && <AgentTabBar />}
{/* Tab bar: visible whenever in-process agents exist and input is active */}
{hasAgents && !uiState.dialogsVisible && <AgentTabBar />}
</Box>
);
};

View file

@ -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:

View file

@ -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<boolean>((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<string, ArenaStatusFile> = {};
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;

View file

@ -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',

View file

@ -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

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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