Files
Codewalkers/apps/server/test/unit/headquarters.test.ts
Lukas May 28521e1c20 chore: merge main into cw/small-change-flow
Integrates main branch changes (headquarters dashboard, task retry count,
agent prompt persistence, remote sync improvements) with the initiative's
errand agent feature. Both features coexist in the merged result.

Key resolutions:
- Schema: take main's errands table (nullable projectId, no conflictFiles,
  with errandsRelations); migrate to 0035_faulty_human_fly
- Router: keep both errandProcedures and headquartersProcedures
- Errand prompt: take main's simpler version (no question-asking flow)
- Manager: take main's status check (running|idle only, no waiting_for_input)
- Tests: update to match removed conflictFiles field and undefined vs null
2026-03-06 16:48:12 +01:00

321 lines
12 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; }
}
// =============================================================================
// 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.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('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');
});
});