/** * Unit tests for getHeadquartersDashboard tRPC procedure. * * Uses in-memory Drizzle DB + inline MockAgentManager for isolation. */ import { describe, it, expect, vi } from 'vitest'; import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js'; import { headquartersProcedures } from '../../trpc/routers/headquarters.js'; import type { TRPCContext } from '../../trpc/context.js'; import type { AgentManager, AgentInfo, PendingQuestions } from '../../agent/types.js'; import { createTestDatabase } from '../../db/repositories/drizzle/test-helpers.js'; import { DrizzleInitiativeRepository, DrizzlePhaseRepository, DrizzleTaskRepository, } from '../../db/repositories/drizzle/index.js'; // ============================================================================= // MockAgentManager // ============================================================================= class MockAgentManager implements AgentManager { private agents: AgentInfo[] = []; private questions: Map = new Map(); addAgent(info: Partial & Pick): void { this.agents.push({ taskId: null, initiativeId: null, sessionId: null, worktreeId: info.id, mode: 'execute', provider: 'claude', accountId: null, createdAt: new Date('2025-01-01T00:00:00Z'), updatedAt: new Date('2025-01-01T00:00:00Z'), userDismissedAt: null, exitCode: null, prompt: null, ...info, }); } setQuestions(agentId: string, questions: PendingQuestions): void { this.questions.set(agentId, questions); } async list(): Promise { return [...this.agents]; } async getPendingQuestions(agentId: string): Promise { return this.questions.get(agentId) ?? null; } async spawn(): Promise { throw new Error('Not implemented'); } async stop(): Promise { throw new Error('Not implemented'); } async get(): Promise { return null; } async getByName(): Promise { return null; } async resume(): Promise { throw new Error('Not implemented'); } async getResult() { return null; } async delete(): Promise { throw new Error('Not implemented'); } async dismiss(): Promise { throw new Error('Not implemented'); } async resumeForConversation(): Promise { return false; } async sendUserMessage(): Promise { throw new Error('Not implemented'); } } // ============================================================================= // Test router // ============================================================================= const testRouter = router({ ...headquartersProcedures(publicProcedure), }); const createCaller = createCallerFactory(testRouter); // ============================================================================= // Helpers // ============================================================================= function makeCtx(agentManager: MockAgentManager, overrides?: Partial): TRPCContext { const db = createTestDatabase(); return { eventBus: {} as TRPCContext['eventBus'], serverStartedAt: null, processCount: 0, agentManager, initiativeRepository: new DrizzleInitiativeRepository(db), phaseRepository: new DrizzlePhaseRepository(db), taskRepository: new DrizzleTaskRepository(db), ...overrides, }; } // ============================================================================= // Tests // ============================================================================= describe('getHeadquartersDashboard', () => { it('empty state — no initiatives, no agents → all arrays empty', async () => { const agents = new MockAgentManager(); const caller = createCaller(makeCtx(agents)); const result = await caller.getHeadquartersDashboard(); expect(result.waitingForInput).toEqual([]); expect(result.pendingReviewInitiatives).toEqual([]); expect(result.pendingReviewPhases).toEqual([]); expect(result.planningInitiatives).toEqual([]); expect(result.resolvingConflicts).toEqual([]); expect(result.blockedPhases).toEqual([]); }); it('waitingForInput — agent with waiting_for_input status appears', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' }); agents.addAgent({ id: 'agent-1', name: 'jolly-agent', status: 'waiting_for_input', initiativeId: initiative.id, userDismissedAt: null, updatedAt: new Date('2025-06-01T12:00:00Z'), }); agents.setQuestions('agent-1', { questions: [{ id: 'q1', question: 'Which approach?' }], }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.waitingForInput).toHaveLength(1); const item = result.waitingForInput[0]; expect(item.agentId).toBe('agent-1'); expect(item.agentName).toBe('jolly-agent'); expect(item.initiativeId).toBe(initiative.id); expect(item.initiativeName).toBe('My Initiative'); expect(item.questionText).toBe('Which approach?'); }); it('waitingForInput — dismissed agent is excluded', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' }); agents.addAgent({ id: 'agent-1', name: 'dismissed-agent', status: 'waiting_for_input', initiativeId: initiative.id, userDismissedAt: new Date(), }); agents.setQuestions('agent-1', { questions: [{ id: 'q1', question: 'Which approach?' }], }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.waitingForInput).toEqual([]); }); it('pendingReviewInitiatives — initiative with pending_review status appears', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'Review Me', status: 'pending_review' }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.pendingReviewInitiatives).toHaveLength(1); expect(result.pendingReviewInitiatives[0].initiativeId).toBe(initiative.id); expect(result.pendingReviewInitiatives[0].initiativeName).toBe('Review Me'); }); it('pendingReviewPhases — phase with pending_review status appears', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const phaseRepo = ctx.phaseRepository!; const initiative = await initiativeRepo.create({ name: 'My Initiative', status: 'active' }); const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending_review', }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.pendingReviewPhases).toHaveLength(1); const item = result.pendingReviewPhases[0]; expect(item.initiativeId).toBe(initiative.id); expect(item.initiativeName).toBe('My Initiative'); expect(item.phaseId).toBe(phase.id); expect(item.phaseName).toBe('Phase 1'); }); it('planningInitiatives — all phases pending and no running agents', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const phaseRepo = ctx.phaseRepository!; const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' }); const phase1 = await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending', }); await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 2', status: 'pending', }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.planningInitiatives).toHaveLength(1); const item = result.planningInitiatives[0]; expect(item.initiativeId).toBe(initiative.id); expect(item.initiativeName).toBe('Planning Init'); expect(item.pendingPhaseCount).toBe(2); // since = oldest phase createdAt expect(item.since).toBe(phase1.createdAt.toISOString()); }); it('planningInitiatives — excluded when a running agent exists for the initiative', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const phaseRepo = ctx.phaseRepository!; const initiative = await initiativeRepo.create({ name: 'Planning Init', status: 'active' }); await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' }); agents.addAgent({ id: 'agent-running', name: 'busy-agent', status: 'running', initiativeId: initiative.id, userDismissedAt: null, }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.planningInitiatives).toEqual([]); }); it('planningInitiatives — excluded when a phase is not pending', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const phaseRepo = ctx.phaseRepository!; const initiative = await initiativeRepo.create({ name: 'Mixed Init', status: 'active' }); await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 1', status: 'pending' }); await phaseRepo.create({ initiativeId: initiative.id, name: 'Phase 2', status: 'in_progress' }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.planningInitiatives).toEqual([]); }); it('blockedPhases — phase with blocked status appears', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const phaseRepo = ctx.phaseRepository!; const initiative = await initiativeRepo.create({ name: 'Blocked Init', status: 'active' }); const phase = await phaseRepo.create({ initiativeId: initiative.id, name: 'Stuck Phase', status: 'blocked', }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.blockedPhases).toHaveLength(1); const item = result.blockedPhases[0]; expect(item.initiativeId).toBe(initiative.id); expect(item.initiativeName).toBe('Blocked Init'); expect(item.phaseId).toBe(phase.id); expect(item.phaseName).toBe('Stuck Phase'); expect(item.lastMessage).toBeNull(); }); it('resolvingConflicts — running conflict agent appears', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); agents.addAgent({ id: 'agent-conflict', name: 'conflict-1234567890', status: 'running', initiativeId: initiative.id, userDismissedAt: null, updatedAt: new Date('2025-06-01T12:00:00Z'), }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.resolvingConflicts).toHaveLength(1); const item = result.resolvingConflicts[0]; expect(item.initiativeId).toBe(initiative.id); expect(item.initiativeName).toBe('Conflicting Init'); expect(item.agentId).toBe('agent-conflict'); expect(item.agentName).toBe('conflict-1234567890'); expect(item.agentStatus).toBe('running'); }); it('resolvingConflicts — waiting_for_input conflict agent appears', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); agents.addAgent({ id: 'agent-conflict', name: 'conflict-1234567890', status: 'waiting_for_input', initiativeId: initiative.id, userDismissedAt: null, updatedAt: new Date('2025-06-01T12:00:00Z'), }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.resolvingConflicts).toHaveLength(1); expect(result.resolvingConflicts[0].agentStatus).toBe('waiting_for_input'); }); it('resolvingConflicts — dismissed conflict agent is excluded', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); agents.addAgent({ id: 'agent-conflict', name: 'conflict-1234567890', status: 'running', initiativeId: initiative.id, userDismissedAt: new Date(), }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.resolvingConflicts).toEqual([]); }); it('resolvingConflicts — idle conflict agent is excluded', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'Conflicting Init', status: 'active' }); agents.addAgent({ id: 'agent-conflict', name: 'conflict-1234567890', status: 'idle', initiativeId: initiative.id, userDismissedAt: null, }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.resolvingConflicts).toEqual([]); }); it('resolvingConflicts — non-conflict agent is excluded', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const initiativeRepo = ctx.initiativeRepository!; const initiative = await initiativeRepo.create({ name: 'Some Init', status: 'active' }); agents.addAgent({ id: 'agent-regular', name: 'regular-agent', status: 'running', initiativeId: initiative.id, userDismissedAt: null, }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.resolvingConflicts).toEqual([]); }); it('ordering — waitingForInput sorted oldest first', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); agents.addAgent({ id: 'agent-newer', name: 'newer-agent', status: 'waiting_for_input', userDismissedAt: null, updatedAt: new Date('2025-06-02T00:00:00Z'), }); agents.addAgent({ id: 'agent-older', name: 'older-agent', status: 'waiting_for_input', userDismissedAt: null, updatedAt: new Date('2025-06-01T00:00:00Z'), }); const caller = createCaller(ctx); const result = await caller.getHeadquartersDashboard(); expect(result.waitingForInput).toHaveLength(2); expect(result.waitingForInput[0].agentId).toBe('agent-older'); expect(result.waitingForInput[1].agentId).toBe('agent-newer'); }); });