diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index fde381e53..51c696886 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -136,9 +136,7 @@ function buildArenaExecutionInput( const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => ({ modelId: parsedModel.modelId, authType: parsedModel.authType ?? defaultAuthType, - displayName: parsedModel.authType - ? `${parsedModel.authType}:${parsedModel.modelId}` - : parsedModel.modelId, + displayName: parsedModel.modelId, })); return { diff --git a/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx index c60e6ddf5..6ce610887 100644 --- a/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx @@ -49,7 +49,9 @@ export function ArenaStartDialog({ const selectableModelCount = modelItems.filter( (item) => !item.disabled, ).length; - const shouldShowMoreModelsHint = selectableModelCount < 3; + const needsMoreModels = selectableModelCount < 2; + const shouldShowMoreModelsHint = + selectableModelCount >= 2 && selectableModelCount < 3; useKeypress( (key) => { @@ -107,13 +109,28 @@ export function ArenaStartDialog({ )} - {hasDisabledQwenOauth && ( - - - {t( - 'qwen-oauth models are disabled because they are not supported in Arena.', - )} - + {(hasDisabledQwenOauth || needsMoreModels) && ( + + {hasDisabledQwenOauth && ( + + {t('Note: qwen-oauth models are not supported in Arena.')} + + )} + {needsMoreModels && ( + <> + + {t('Arena requires at least 2 models. To add more:')} + + + {t( + ' - Run /auth to set up a Coding Plan (includes multiple models)', + )} + + + {t(' - Or configure modelProviders in settings.json')} + + + )} )} diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index b98b5841b..e0f7554a5 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -50,7 +50,10 @@ vi.mock('../../services/gitWorktreeService.js', () => { }); // Mock the Config class -const createMockConfig = (workingDir: string) => ({ +const createMockConfig = ( + workingDir: string, + arenaSettings: Record = {}, +) => ({ getWorkingDir: () => workingDir, getModel: () => 'test-model', getSessionId: () => 'test-session', @@ -60,7 +63,7 @@ const createMockConfig = (workingDir: string) => ({ getFunctionDeclarationsFiltered: () => [], getTool: () => undefined, }), - getAgentsSettings: () => ({}), + getAgentsSettings: () => ({ arena: arenaSettings }), getUsageStatisticsEnabled: () => false, getTelemetryEnabled: () => false, getTelemetryLogPromptsEnabled: () => false, @@ -74,7 +77,8 @@ describe('ArenaManager', () => { beforeEach(async () => { // Create a temp directory - no need for git repo since we mock GitWorktreeService tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'arena-test-')); - mockConfig = createMockConfig(tempDir); + // Use tempDir as worktreeBaseDir to avoid slow filesystem access in deriveWorktreeDirName + mockConfig = createMockConfig(tempDir, { worktreeBaseDir: tempDir }); mockBackend = createMockBackend(); hoistedMockDetectBackend.mockResolvedValue({ backend: mockBackend }); @@ -362,13 +366,14 @@ describe('ArenaManager', () => { // auto-exit is on by default, so agents terminate quickly. await manager.start(createValidStartOptions()); - const sessionIdBeforeCleanup = manager.getSessionId(); await manager.cleanup(); expect(mockBackend.cleanup).toHaveBeenCalledTimes(1); + // cleanupSession is called with worktreeDirName (short ID), not the full sessionId. + // For 'test-session', the short ID is 'testsess' (first 8 chars with dashes removed). expect(hoistedMockCleanupSession).toHaveBeenCalledWith( - sessionIdBeforeCleanup, + 'testsess', 'arena', ); expect(manager.getBackend()).toBeNull(); @@ -439,8 +444,15 @@ function createValidStartOptions() { } async function waitForMicrotask(): Promise { - await Promise.resolve(); - await Promise.resolve(); + // Use setImmediate (or setTimeout fallback) to yield to the event loop + // and allow other async operations (like the start() method) to progress. + await new Promise((resolve) => { + if (typeof setImmediate === 'function') { + setImmediate(resolve); + } else { + setTimeout(resolve, 0); + } + }); } async function waitForCondition( diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 24d9a0562..172ef632f 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -71,6 +71,8 @@ export class ArenaManager { private cachedResult: ArenaSessionResult | null = null; private sessionId: string | undefined; + /** Short directory name used for worktree paths (derived from sessionId). */ + private worktreeDirName: string | undefined; private sessionStatus: ArenaSessionStatus = ArenaSessionStatus.INITIALIZING; private agents: Map = new Map(); private arenaConfig: ArenaConfig | undefined; @@ -271,6 +273,7 @@ export class ArenaManager { } this.sessionId = this.config.getSessionId(); + this.worktreeDirName = await this.deriveWorktreeDirName(this.sessionId); this.startedAt = Date.now(); this.sessionStatus = ArenaSessionStatus.INITIALIZING; this.masterAbortController = new AbortController(); @@ -357,8 +360,17 @@ export class ArenaManager { return result; } + // Emit worktree info for each agent + const worktreeInfo = Array.from(this.agents.values()) + .map( + (agent, i) => + ` ${i + 1}. ${agent.model.displayName || agent.model.modelId} → ${agent.worktree.path}`, + ) + .join('\n'); + this.emitProgress(`Environment ready. Agent worktrees:\n${worktreeInfo}`); + // Start all agents in parallel via PTY - this.emitProgress('Environment ready. Launching agents…'); + this.emitProgress('Launching agents…'); this.sessionStatus = ArenaSessionStatus.RUNNING; await this.runAgents(); @@ -489,11 +501,12 @@ export class ArenaManager { } // Clean up worktrees - await this.worktreeService.cleanupSession(this.sessionId, 'arena'); + await this.worktreeService.cleanupSession(this.worktreeDirName!, 'arena'); this.agents.clear(); this.cachedResult = null; this.sessionId = undefined; + this.worktreeDirName = undefined; this.arenaConfig = undefined; this.backend = null; this.sessionEndedLogged = false; @@ -531,6 +544,7 @@ export class ArenaManager { this.agents.clear(); this.cachedResult = null; this.sessionId = undefined; + this.worktreeDirName = undefined; this.arenaConfig = undefined; this.backend = null; this.sessionEndedLogged = false; @@ -705,6 +719,28 @@ export class ArenaManager { // ─── Private: Worktree Setup ─────────────────────────────────── + /** + * Derive a short, filesystem-friendly directory name from the full session ID. + * Uses the first 8 hex characters of the UUID. If that path already exists, + * appends a numeric suffix (-2, -3, …) until an unused name is found. + */ + private async deriveWorktreeDirName(sessionId: string): Promise { + const shortId = sessionId.replaceAll('-', '').slice(0, 8); + let candidate = shortId; + let suffix = 2; + + while (true) { + const candidatePath = path.join(this.arenaBaseDir, candidate); + try { + await fs.access(candidatePath); + candidate = `${shortId}-${suffix}`; + suffix++; + } catch { + return candidate; + } + } + } + private async setupWorktrees(): Promise { if (!this.arenaConfig) { throw new Error('Arena config not initialized'); @@ -717,7 +753,7 @@ export class ArenaManager { ); const result = await this.worktreeService.setupWorktrees({ - sessionId: this.arenaConfig.sessionId, + sessionId: this.worktreeDirName!, sourceRepoPath: this.arenaConfig.sourceRepoPath, worktreeNames, branchPrefix: 'arena', @@ -1143,7 +1179,7 @@ export class ArenaManager { throw new Error('Arena config not initialized'); } return GitWorktreeService.getSessionDir( - this.arenaConfig.sessionId, + this.worktreeDirName!, this.arenaBaseDir, ); }