/** * Unit tests for Radar tRPC procedures. * * Tests listForRadar, getCompactionEvents, getSubagentSpawns, * getQuestionsAsked, and conversation.getByFromAgent. * * Uses in-memory Drizzle DB + inline MockAgentManager for isolation. */ import { describe, it, expect } from 'vitest'; import { router, publicProcedure, createCallerFactory } from '../../trpc/trpc.js'; import { agentProcedures } from '../../trpc/routers/agent.js'; import { conversationProcedures } from '../../trpc/routers/conversation.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 { DrizzleAgentRepository, DrizzleLogChunkRepository, DrizzleConversationRepository, DrizzleInitiativeRepository, 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(), updatedAt: new Date(), 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 routers // ============================================================================= const agentRouter = router({ ...agentProcedures(publicProcedure) }); const conversationRouter = router({ ...conversationProcedures(publicProcedure) }); const createAgentCaller = createCallerFactory(agentRouter); const createConversationCaller = createCallerFactory(conversationRouter); // ============================================================================= // Helpers // ============================================================================= function makeCtx(agentManager: MockAgentManager): TRPCContext { const db = createTestDatabase(); return { eventBus: { emit: () => {}, on: () => {}, off: () => {} } as unknown as TRPCContext['eventBus'], serverStartedAt: null, processCount: 0, agentManager, logChunkRepository: new DrizzleLogChunkRepository(db), conversationRepository: new DrizzleConversationRepository(db), initiativeRepository: new DrizzleInitiativeRepository(db), taskRepository: new DrizzleTaskRepository(db), // Expose DB-backed agent repo seeder via a non-context helper _agentRepository: new DrizzleAgentRepository(db), } as unknown as TRPCContext & { _agentRepository: DrizzleAgentRepository }; } // Typed helper to access seeder repos function getRepos(ctx: ReturnType) { const c = ctx as unknown as { _agentRepository: DrizzleAgentRepository; logChunkRepository: DrizzleLogChunkRepository; conversationRepository: DrizzleConversationRepository; initiativeRepository: DrizzleInitiativeRepository; taskRepository: DrizzleTaskRepository; }; return { agentRepo: c._agentRepository, logChunkRepo: c.logChunkRepository, convRepo: c.conversationRepository, initiativeRepo: c.initiativeRepository, taskRepo: c.taskRepository, }; } // ============================================================================= // Tests: agent.listForRadar // ============================================================================= describe('agent.listForRadar', () => { it('timeRange=24h — excludes agents older than 24h', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const oldDate = new Date(Date.now() - 48 * 3600_000); const recentDate = new Date(Date.now() - 12 * 3600_000); agents.addAgent({ id: 'agent-old', name: 'old-agent', status: 'stopped', createdAt: oldDate }); agents.addAgent({ id: 'agent-recent', name: 'recent-agent', status: 'running', createdAt: recentDate }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: '24h' }); expect(result.map(r => r.id)).not.toContain('agent-old'); expect(result.map(r => r.id)).toContain('agent-recent'); }); it('status=running filter — only running agents returned', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const now = new Date(); agents.addAgent({ id: 'agent-running', name: 'running-agent', status: 'running', createdAt: now }); agents.addAgent({ id: 'agent-stopped', name: 'stopped-agent', status: 'stopped', createdAt: now }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: 'all', status: 'running' }); expect(result).toHaveLength(1); expect(result[0].id).toBe('agent-running'); expect(result[0].status).toBe('running'); }); it('status=completed filter — maps to stopped in DB', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const now = new Date(); agents.addAgent({ id: 'agent-stopped', name: 'stopped-agent', status: 'stopped', createdAt: now }); agents.addAgent({ id: 'agent-running', name: 'running-agent', status: 'running', createdAt: now }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: 'all', status: 'completed' }); expect(result).toHaveLength(1); expect(result[0].id).toBe('agent-stopped'); }); it('mode=execute filter — only execute agents returned', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const now = new Date(); agents.addAgent({ id: 'agent-exec', name: 'exec-agent', status: 'running', mode: 'execute', createdAt: now }); agents.addAgent({ id: 'agent-plan', name: 'plan-agent', status: 'running', mode: 'plan', createdAt: now }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: 'all', mode: 'execute' }); expect(result).toHaveLength(1); expect(result[0].id).toBe('agent-exec'); }); it('computes messagesCount from conversations table', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { agentRepo, convRepo } = getRepos(ctx); const now = new Date(); // Seed in DB (needed for FK on conversations) const fromAgent = await agentRepo.create({ name: 'from-agent', worktreeId: 'wt-from', status: 'running' }); const toAgent = await agentRepo.create({ name: 'to-agent', worktreeId: 'wt-to', status: 'running' }); // Seed in MockAgentManager for agentManager.list() agents.addAgent({ id: fromAgent.id, name: fromAgent.name, status: 'running', createdAt: now }); agents.addAgent({ id: toAgent.id, name: toAgent.name, status: 'running', createdAt: now }); // Create 3 conversations from fromAgent to toAgent await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q1' }); await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q2' }); await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'Q3' }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: 'all' }); const fromRow = result.find(r => r.id === fromAgent.id); expect(fromRow).toBeDefined(); expect(fromRow!.messagesCount).toBe(3); const toRow = result.find(r => r.id === toAgent.id); expect(toRow!.messagesCount).toBe(0); }); it('computes subagentsCount from log chunks', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { logChunkRepo } = getRepos(ctx); const now = new Date(); agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); await logChunkRepo.insertChunk({ agentId: 'agent-1', agentName: 'agent-one', sessionNumber: 1, content: JSON.stringify({ type: 'tool_use', name: 'Agent', input: { description: 'do stuff', prompt: 'some prompt' } }), }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: 'all' }); const row = result.find(r => r.id === 'agent-1'); expect(row).toBeDefined(); expect(row!.subagentsCount).toBe(1); expect(row!.questionsCount).toBe(0); expect(row!.compactionsCount).toBe(0); }); it('computes compactionsCount from log chunks', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { logChunkRepo } = getRepos(ctx); const now = new Date(); agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); await logChunkRepo.insertChunk({ agentId: 'agent-1', agentName: 'agent-one', sessionNumber: 1, content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }), }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: 'all' }); const row = result.find(r => r.id === 'agent-1'); expect(row).toBeDefined(); expect(row!.compactionsCount).toBe(1); expect(row!.subagentsCount).toBe(0); expect(row!.questionsCount).toBe(0); }); it('computes questionsCount from log chunks — sums questions array length', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { logChunkRepo } = getRepos(ctx); const now = new Date(); agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); await logChunkRepo.insertChunk({ agentId: 'agent-1', agentName: 'agent-one', sessionNumber: 1, content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [ { question: 'First?', header: 'H1', options: [] }, { question: 'Second?', header: 'H2', options: [] }, ], }, }), }); const caller = createAgentCaller(ctx); const result = await caller.listForRadar({ timeRange: 'all' }); const row = result.find(r => r.id === 'agent-1'); expect(row).toBeDefined(); expect(row!.questionsCount).toBe(2); expect(row!.subagentsCount).toBe(0); expect(row!.compactionsCount).toBe(0); }); it('handles malformed JSON in log chunks without throwing', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { logChunkRepo } = getRepos(ctx); const now = new Date(); agents.addAgent({ id: 'agent-1', name: 'agent-one', status: 'running', createdAt: now }); await logChunkRepo.insertChunk({ agentId: 'agent-1', agentName: 'agent-one', sessionNumber: 1, content: 'not valid json {{{', }); const caller = createAgentCaller(ctx); // Should not throw const result = await caller.listForRadar({ timeRange: 'all' }); const row = result.find(r => r.id === 'agent-1'); expect(row).toBeDefined(); expect(row!.questionsCount).toBe(0); expect(row!.subagentsCount).toBe(0); expect(row!.compactionsCount).toBe(0); }); }); // ============================================================================= // Tests: agent.getCompactionEvents // ============================================================================= describe('agent.getCompactionEvents', () => { it('returns compaction events sorted ascending, capped at 200', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { logChunkRepo } = getRepos(ctx); // Seed 201 compaction chunks for (let i = 0; i < 201; i++) { await logChunkRepo.insertChunk({ agentId: 'agent-compact', agentName: 'compact-agent', sessionNumber: i + 1, content: JSON.stringify({ type: 'system', subtype: 'init', source: 'compact' }), }); } const caller = createAgentCaller(ctx); const result = await caller.getCompactionEvents({ agentId: 'agent-compact' }); expect(result).toHaveLength(200); // Each result has correct shape expect(result[0]).toHaveProperty('timestamp'); expect(result[0]).toHaveProperty('sessionNumber'); expect(typeof result[0].timestamp).toBe('string'); expect(typeof result[0].sessionNumber).toBe('number'); // Sorted ascending — sessionNumber of first should be lower than last expect(result[0].sessionNumber).toBeLessThan(result[199].sessionNumber); }); }); // ============================================================================= // Tests: agent.getSubagentSpawns // ============================================================================= describe('agent.getSubagentSpawns', () => { it('returns spawns with correct promptPreview (first 200 chars)', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { logChunkRepo } = getRepos(ctx); const fullPrompt = 'x'.repeat(300); await logChunkRepo.insertChunk({ agentId: 'agent-spawn', agentName: 'spawn-agent', sessionNumber: 1, content: JSON.stringify({ type: 'tool_use', name: 'Agent', input: { description: 'my subagent', prompt: fullPrompt }, }), }); const caller = createAgentCaller(ctx); const result = await caller.getSubagentSpawns({ agentId: 'agent-spawn' }); expect(result).toHaveLength(1); expect(result[0].promptPreview).toHaveLength(200); expect(result[0].fullPrompt).toHaveLength(300); expect(result[0].description).toBe('my subagent'); expect(typeof result[0].timestamp).toBe('string'); }); }); // ============================================================================= // Tests: agent.getQuestionsAsked // ============================================================================= describe('agent.getQuestionsAsked', () => { it('returns questions arrays correctly', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { logChunkRepo } = getRepos(ctx); await logChunkRepo.insertChunk({ agentId: 'agent-q', agentName: 'question-agent', sessionNumber: 1, content: JSON.stringify({ type: 'tool_use', name: 'AskUserQuestion', input: { questions: [ { question: 'Which way?', header: 'Direction', options: [{ label: 'Left', description: 'Go left' }] }, { question: 'How fast?', header: 'Speed', options: [{ label: 'Fast', description: 'Go fast' }] }, ], }, }), }); const caller = createAgentCaller(ctx); const result = await caller.getQuestionsAsked({ agentId: 'agent-q' }); expect(result).toHaveLength(1); expect(result[0].questions).toHaveLength(2); expect(result[0].questions[0].question).toBe('Which way?'); expect(result[0].questions[0].header).toBe('Direction'); expect(result[0].questions[0].options).toHaveLength(1); expect(result[0].questions[0].options[0].label).toBe('Left'); expect(result[0].questions[1].question).toBe('How fast?'); expect(typeof result[0].timestamp).toBe('string'); }); }); // ============================================================================= // Tests: conversation.getByFromAgent // ============================================================================= describe('conversation.getByFromAgent', () => { it('returns conversations with toAgentName resolved', async () => { const agents = new MockAgentManager(); const ctx = makeCtx(agents); const { agentRepo, convRepo } = getRepos(ctx); // Seed agents in DB (FK requirement) const fromAgent = await agentRepo.create({ name: 'from-agent', worktreeId: 'wt-from', status: 'running' }); const toAgent = await agentRepo.create({ name: 'to-agent', worktreeId: 'wt-to', status: 'running' }); // Seed in MockAgentManager for name resolution agents.addAgent({ id: fromAgent.id, name: 'from-agent', status: 'running' }); agents.addAgent({ id: toAgent.id, name: 'to-agent', status: 'running' }); // Create conversation await convRepo.create({ fromAgentId: fromAgent.id, toAgentId: toAgent.id, question: 'What is 2+2?', }); const caller = createConversationCaller(ctx); const result = await caller.getByFromAgent({ agentId: fromAgent.id }); expect(result).toHaveLength(1); expect(result[0].toAgentName).toBe('to-agent'); expect(result[0].toAgentId).toBe(toAgent.id); expect(result[0].question).toBe('What is 2+2?'); expect(result[0].status).toBe('pending'); expect(result[0].answer).toBeNull(); expect(typeof result[0].timestamp).toBe('string'); expect(result[0].taskId).toBeNull(); expect(result[0].phaseId).toBeNull(); }); });