- Register errandProcedures in appRouter (was defined but never spread) - Fix nullable projectId guard in errand delete/abandon procedures - Add sendUserMessage stub to MockAgentManager in headquarters and radar-procedures tests (AgentManager interface gained this method) - Add missing qualityReview field to Initiative fixture in file-io test (schema gained this column from the quality-review phase) - Cast conflictFiles access in CLI errand resolve command Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
/**
|
|
* 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<string, PendingQuestions> = new Map();
|
|
|
|
addAgent(info: Partial<AgentInfo> & Pick<AgentInfo, 'id' | 'name' | 'status'>): 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<AgentInfo[]> {
|
|
return [...this.agents];
|
|
}
|
|
|
|
async getPendingQuestions(agentId: string): Promise<PendingQuestions | null> {
|
|
return this.questions.get(agentId) ?? null;
|
|
}
|
|
|
|
async spawn(): Promise<AgentInfo> { throw new Error('Not implemented'); }
|
|
async stop(): Promise<void> { throw new Error('Not implemented'); }
|
|
async get(): Promise<AgentInfo | null> { return null; }
|
|
async getByName(): Promise<AgentInfo | null> { return null; }
|
|
async resume(): Promise<void> { throw new Error('Not implemented'); }
|
|
async getResult() { return null; }
|
|
async delete(): Promise<void> { throw new Error('Not implemented'); }
|
|
async dismiss(): Promise<void> { throw new Error('Not implemented'); }
|
|
async resumeForConversation(): Promise<boolean> { return false; }
|
|
async sendUserMessage(): Promise<void> { throw new Error('Not implemented'); }
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test router
|
|
// =============================================================================
|
|
|
|
const testRouter = router({
|
|
...headquartersProcedures(publicProcedure),
|
|
});
|
|
|
|
const createCaller = createCallerFactory(testRouter);
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
function makeCtx(agentManager: MockAgentManager, overrides?: Partial<TRPCContext>): 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');
|
|
});
|
|
});
|