diff --git a/apps/server/db/repositories/conversation-repository.ts b/apps/server/db/repositories/conversation-repository.ts index 0545ed1..66fe045 100644 --- a/apps/server/db/repositories/conversation-repository.ts +++ b/apps/server/db/repositories/conversation-repository.ts @@ -20,4 +20,18 @@ export interface ConversationRepository { findById(id: string): Promise; findPendingForAgent(toAgentId: string): Promise; answer(id: string, answer: string): Promise; + + /** + * Count conversations grouped by fromAgentId for a batch of agent IDs. + * Returns only agents that have at least one conversation (count > 0). + * Used by listForRadar to compute messagesCount without N+1 queries. + */ + countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]>; + + /** + * Find all conversations initiated by a given agent, ordered by createdAt ascending. + * Used by conversation.getByFromAgent drilldown procedure. + * Cap at 200 results. + */ + findByFromAgentId(agentId: string): Promise; } diff --git a/apps/server/db/repositories/drizzle/conversation.ts b/apps/server/db/repositories/drizzle/conversation.ts index 6ab11dd..1e359d3 100644 --- a/apps/server/db/repositories/drizzle/conversation.ts +++ b/apps/server/db/repositories/drizzle/conversation.ts @@ -4,7 +4,7 @@ * Implements ConversationRepository interface using Drizzle ORM. */ -import { eq, and, asc } from 'drizzle-orm'; +import { eq, and, asc, count, inArray } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { conversations, type Conversation } from '../../schema.js'; @@ -64,4 +64,26 @@ export class DrizzleConversationRepository implements ConversationRepository { .where(eq(conversations.id, id)); return this.findById(id); } + + async countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]> { + if (agentIds.length === 0) return []; + const rows = await this.db + .select({ + agentId: conversations.fromAgentId, + count: count(), + }) + .from(conversations) + .where(inArray(conversations.fromAgentId, agentIds)) + .groupBy(conversations.fromAgentId); + return rows.map(r => ({ agentId: r.agentId, count: Number(r.count) })); + } + + async findByFromAgentId(agentId: string): Promise { + return this.db + .select() + .from(conversations) + .where(eq(conversations.fromAgentId, agentId)) + .orderBy(asc(conversations.createdAt)) + .limit(200); + } } diff --git a/apps/server/db/repositories/drizzle/log-chunk.ts b/apps/server/db/repositories/drizzle/log-chunk.ts index 244b09b..9d4632b 100644 --- a/apps/server/db/repositories/drizzle/log-chunk.ts +++ b/apps/server/db/repositories/drizzle/log-chunk.ts @@ -4,7 +4,7 @@ * Implements LogChunkRepository interface using Drizzle ORM. */ -import { eq, asc, max } from 'drizzle-orm'; +import { eq, asc, max, inArray } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import type { DrizzleDatabase } from '../../index.js'; import { agentLogChunks } from '../../schema.js'; @@ -41,6 +41,20 @@ export class DrizzleLogChunkRepository implements LogChunkRepository { .orderBy(asc(agentLogChunks.createdAt)); } + async findByAgentIds(agentIds: string[]): Promise<{ agentId: string; content: string; sessionNumber: number; createdAt: Date }[]> { + if (agentIds.length === 0) return []; + return this.db + .select({ + agentId: agentLogChunks.agentId, + content: agentLogChunks.content, + sessionNumber: agentLogChunks.sessionNumber, + createdAt: agentLogChunks.createdAt, + }) + .from(agentLogChunks) + .where(inArray(agentLogChunks.agentId, agentIds)) + .orderBy(asc(agentLogChunks.createdAt)); + } + async deleteByAgentId(agentId: string): Promise { await this.db .delete(agentLogChunks) diff --git a/apps/server/db/repositories/log-chunk-repository.ts b/apps/server/db/repositories/log-chunk-repository.ts index 9f83231..0283a0b 100644 --- a/apps/server/db/repositories/log-chunk-repository.ts +++ b/apps/server/db/repositories/log-chunk-repository.ts @@ -17,6 +17,13 @@ export interface LogChunkRepository { findByAgentId(agentId: string): Promise[]>; + /** + * Batch-fetch chunks for multiple agent IDs in a single query. + * Returns chunks ordered by createdAt ascending. + * agentId field is included so results can be grouped by agent. + */ + findByAgentIds(agentIds: string[]): Promise<{ agentId: string; content: string; sessionNumber: number; createdAt: Date }[]>; + deleteByAgentId(agentId: string): Promise; getSessionCount(agentId: string): Promise; diff --git a/apps/server/test/integration/real-providers/conversation.test.ts b/apps/server/test/integration/real-providers/conversation.test.ts index a3d168e..1d10ffc 100644 --- a/apps/server/test/integration/real-providers/conversation.test.ts +++ b/apps/server/test/integration/real-providers/conversation.test.ts @@ -88,6 +88,24 @@ class InMemoryConversationRepository implements ConversationRepository { return updated; } + async countByFromAgentIds(agentIds: string[]): Promise<{ agentId: string; count: number }[]> { + if (agentIds.length === 0) return []; + const counts = new Map(); + for (const conv of this.store.values()) { + if (agentIds.includes(conv.fromAgentId)) { + counts.set(conv.fromAgentId, (counts.get(conv.fromAgentId) ?? 0) + 1); + } + } + return [...counts.entries()].map(([agentId, count]) => ({ agentId, count })); + } + + async findByFromAgentId(agentId: string): Promise { + return [...this.store.values()] + .filter((c) => c.fromAgentId === agentId) + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .slice(0, 200); + } + /** Test helper — return all conversations */ getAll(): Conversation[] { return [...this.store.values()]; diff --git a/apps/server/test/unit/radar-procedures.test.ts b/apps/server/test/unit/radar-procedures.test.ts new file mode 100644 index 0000000..d7acba6 --- /dev/null +++ b/apps/server/test/unit/radar-procedures.test.ts @@ -0,0 +1,476 @@ +/** + * 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; } +} + +// ============================================================================= +// 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(); + }); +}); diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index bdf6395..644b814 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -11,7 +11,23 @@ import type { ProcedureBuilder } from '../trpc.js'; import type { TRPCContext } from '../context.js'; import type { AgentInfo, AgentResult, PendingQuestions } from '../../agent/types.js'; import type { AgentOutputEvent } from '../../events/types.js'; -import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository } from './_helpers.js'; +import { requireAgentManager, requireLogChunkRepository, requireTaskRepository, requireInitiativeRepository, requireConversationRepository } from './_helpers.js'; + +export type AgentRadarRow = { + id: string; + name: string; + mode: string; + status: string; + initiativeId: string | null; + initiativeName: string | null; + taskId: string | null; + taskName: string | null; + createdAt: string; + questionsCount: number; + messagesCount: number; + subagentsCount: number; + compactionsCount: number; +}; export const spawnAgentInputSchema = z.object({ name: z.string().min(1).optional(), @@ -410,5 +426,177 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { return { content: truncateIfNeeded(raw) }; }), + + listForRadar: publicProcedure + .input(z.object({ + timeRange: z.enum(['1h', '6h', '24h', '7d', 'all']).default('24h'), + status: z.enum(['running', 'completed', 'crashed']).optional(), + initiativeId: z.string().optional(), + mode: z.enum(['execute', 'discuss', 'plan', 'detail', 'refine', 'chat', 'errand']).optional(), + })) + .query(async ({ ctx, input }): Promise => { + const agentManager = requireAgentManager(ctx); + const allAgents = await agentManager.list(); + + // Compute cutoff + const cutoffMap: Record = { + '1h': Date.now() - 3_600_000, + '6h': Date.now() - 21_600_000, + '24h': Date.now() - 86_400_000, + '7d': Date.now() - 604_800_000, + }; + const cutoff = input.timeRange !== 'all' ? cutoffMap[input.timeRange] : null; + + // Filter agents + let filteredAgents = allAgents; + if (cutoff !== null) { + filteredAgents = filteredAgents.filter(a => a.createdAt.getTime() >= cutoff!); + } + if (input.status !== undefined) { + const dbStatus = input.status === 'completed' ? 'stopped' : input.status; + filteredAgents = filteredAgents.filter(a => a.status === dbStatus); + } + if (input.initiativeId !== undefined) { + filteredAgents = filteredAgents.filter(a => a.initiativeId === input.initiativeId); + } + if (input.mode !== undefined) { + filteredAgents = filteredAgents.filter(a => a.mode === input.mode); + } + + const matchingIds = filteredAgents.map(a => a.id); + + // Batch fetch in parallel + const logChunkRepo = requireLogChunkRepository(ctx); + const conversationRepo = requireConversationRepository(ctx); + const initiativeRepo = requireInitiativeRepository(ctx); + const taskRepo = requireTaskRepository(ctx); + + // Collect unique taskIds and initiativeIds for batch lookup + const uniqueTaskIds = [...new Set(filteredAgents.map(a => a.taskId).filter(Boolean) as string[])]; + const uniqueInitiativeIds = [...new Set(filteredAgents.map(a => a.initiativeId).filter(Boolean) as string[])]; + + const [chunks, messageCounts, taskResults, initiativeResults] = await Promise.all([ + logChunkRepo.findByAgentIds(matchingIds), + conversationRepo.countByFromAgentIds(matchingIds), + Promise.all(uniqueTaskIds.map(id => taskRepo.findById(id))), + Promise.all(uniqueInitiativeIds.map(id => initiativeRepo.findById(id))), + ]); + + // Build lookup maps + const taskMap = new Map(taskResults.filter(Boolean).map(t => [t!.id, t!.name])); + const initiativeMap = new Map(initiativeResults.filter(Boolean).map(i => [i!.id, i!.name])); + const messagesMap = new Map(messageCounts.map(m => [m.agentId, m.count])); + + // Group chunks by agentId + const chunksByAgent = new Map(); + for (const chunk of chunks) { + const existing = chunksByAgent.get(chunk.agentId); + if (existing) { + existing.push(chunk); + } else { + chunksByAgent.set(chunk.agentId, [chunk]); + } + } + + // Build result rows + return filteredAgents.map(agent => { + const agentChunks = chunksByAgent.get(agent.id) ?? []; + let questionsCount = 0; + let subagentsCount = 0; + let compactionsCount = 0; + + for (const chunk of agentChunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') { + questionsCount += parsed.input?.questions?.length ?? 0; + } else if (parsed.type === 'tool_use' && parsed.name === 'Agent') { + subagentsCount++; + } else if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') { + compactionsCount++; + } + } catch { /* skip malformed */ } + } + + return { + id: agent.id, + name: agent.name, + mode: agent.mode, + status: agent.status, + initiativeId: agent.initiativeId, + initiativeName: agent.initiativeId ? (initiativeMap.get(agent.initiativeId) ?? null) : null, + taskId: agent.taskId, + taskName: agent.taskId ? (taskMap.get(agent.taskId) ?? null) : null, + createdAt: agent.createdAt.toISOString(), + questionsCount, + messagesCount: messagesMap.get(agent.id) ?? 0, + subagentsCount, + compactionsCount, + }; + }); + }), + + getCompactionEvents: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const logChunkRepo = requireLogChunkRepository(ctx); + const chunks = await logChunkRepo.findByAgentId(input.agentId); + const results: { timestamp: string; sessionNumber: number }[] = []; + for (const chunk of chunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.source === 'compact') { + results.push({ timestamp: chunk.createdAt.toISOString(), sessionNumber: chunk.sessionNumber }); + } + } catch { /* skip malformed */ } + if (results.length >= 200) break; + } + return results; + }), + + getSubagentSpawns: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const logChunkRepo = requireLogChunkRepository(ctx); + const chunks = await logChunkRepo.findByAgentId(input.agentId); + const results: { timestamp: string; description: string; promptPreview: string; fullPrompt: string }[] = []; + for (const chunk of chunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'tool_use' && parsed.name === 'Agent') { + const fullPrompt: string = parsed.input?.prompt ?? ''; + const description: string = parsed.input?.description ?? ''; + results.push({ + timestamp: chunk.createdAt.toISOString(), + description, + promptPreview: fullPrompt.slice(0, 200), + fullPrompt, + }); + } + } catch { /* skip malformed */ } + if (results.length >= 200) break; + } + return results; + }), + + getQuestionsAsked: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const logChunkRepo = requireLogChunkRepository(ctx); + const chunks = await logChunkRepo.findByAgentId(input.agentId); + type QuestionItem = { question: string; header: string; options: { label: string; description: string }[] }; + const results: { timestamp: string; questions: QuestionItem[] }[] = []; + for (const chunk of chunks) { + try { + const parsed = JSON.parse(chunk.content); + if (parsed.type === 'tool_use' && parsed.name === 'AskUserQuestion') { + const questions: QuestionItem[] = parsed.input?.questions ?? []; + results.push({ timestamp: chunk.createdAt.toISOString(), questions }); + } + } catch { /* skip malformed */ } + if (results.length >= 200) break; + } + return results; + }), }; } diff --git a/apps/server/trpc/routers/conversation.ts b/apps/server/trpc/routers/conversation.ts index c5486fd..96f4907 100644 --- a/apps/server/trpc/routers/conversation.ts +++ b/apps/server/trpc/routers/conversation.ts @@ -302,5 +302,31 @@ export function conversationProcedures(publicProcedure: ProcedureBuilder) { cleanup(); } }), + + getByFromAgent: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const repo = requireConversationRepository(ctx); + const agentManager = requireAgentManager(ctx); + + const convs = await repo.findByFromAgentId(input.agentId); + + // Build toAgent name map without N+1 + const toAgentIds = [...new Set(convs.map(c => c.toAgentId))]; + const allAgents = toAgentIds.length > 0 ? await agentManager.list() : []; + const agentNameMap = new Map(allAgents.map(a => [a.id, a.name])); + + return convs.map(c => ({ + id: c.id, + timestamp: c.createdAt.toISOString(), + toAgentName: agentNameMap.get(c.toAgentId) ?? c.toAgentId, + toAgentId: c.toAgentId, + question: c.question, + answer: c.answer ?? null, + status: c.status as 'pending' | 'answered', + taskId: c.taskId ?? null, + phaseId: c.phaseId ?? null, + })); + }), }; } diff --git a/apps/web/src/components/radar/CompactionEventsDialog.tsx b/apps/web/src/components/radar/CompactionEventsDialog.tsx new file mode 100644 index 0000000..c4ed929 --- /dev/null +++ b/apps/web/src/components/radar/CompactionEventsDialog.tsx @@ -0,0 +1,157 @@ +import { useState, useEffect } from 'react' +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { useSubscriptionWithErrorHandling } from '@/hooks' +import type { DrilldownDialogProps } from './types' + +const RELEVANT_EVENTS = ['agent:waiting'] + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +export function CompactionEventsDialog({ + open, + onOpenChange, + agentId, + agentName, + isAgentRunning, +}: DrilldownDialogProps) { + const { data, isLoading, refetch } = trpc.agent.getCompactionEvents.useQuery( + { agentId }, + { enabled: open } + ) + + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + + useEffect(() => { + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setLastRefreshedAt(null) + setSecondsAgo(0) + } + }, [open]) + + return ( + + + + {`Compaction Events — ${agentName}`} + + Each row is a context-window compaction — the model's history was summarized to + free up space. Frequent compactions indicate a long-running agent with large context. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + {data.map((row, i) => ( + + + + + ))} + +
TimestampSession #
+ {formatTimestamp(row.timestamp)} + {row.sessionNumber}
+ + )} +
+ + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )} + +
+ ) +} diff --git a/apps/web/src/components/radar/InterAgentMessagesDialog.tsx b/apps/web/src/components/radar/InterAgentMessagesDialog.tsx new file mode 100644 index 0000000..a302b72 --- /dev/null +++ b/apps/web/src/components/radar/InterAgentMessagesDialog.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect, Fragment } from 'react' +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { Badge } from '@/components/ui/badge' +import { useSubscriptionWithErrorHandling } from '@/hooks' +import type { DrilldownDialogProps } from './types' + +const RELEVANT_EVENTS = ['conversation:created', 'conversation:answered'] + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +export function InterAgentMessagesDialog({ + open, + onOpenChange, + agentId, + agentName, + isAgentRunning, +}: DrilldownDialogProps) { + const [expandedIndex, setExpandedIndex] = useState(null) + + const { data, isLoading, refetch } = trpc.conversation.getByFromAgent.useQuery( + { agentId }, + { enabled: open } + ) + + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.fromAgentId ?? event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + + useEffect(() => { + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setExpandedIndex(null) + setLastRefreshedAt(null) + setSecondsAgo(0) + } + }, [open]) + + return ( + + + + {`Inter-Agent Messages — ${agentName}`} + + Each row is a conversation this agent initiated with another agent. Click a row to see + the full question and answer. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + + {data.map((row, i) => ( + + setExpandedIndex(i === expandedIndex ? null : i)} + > + + + + + {expandedIndex === i && ( + + + + )} + + ))} + +
TimestampTarget AgentStatus
+ {formatTimestamp(row.timestamp)} + {row.toAgentName} + {row.status === 'answered' ? ( + answered + ) : ( + + pending + + )} +
+
+

+ Question +

+
+ {row.question} +
+ {row.status === 'answered' ? ( + <> +

+ Answer +

+
+ {row.answer} +
+ + ) : ( +

No answer yet

+ )} +
+
+ + )} +
+ + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )} + +
+ ) +} diff --git a/apps/web/src/components/radar/QuestionsAskedDialog.tsx b/apps/web/src/components/radar/QuestionsAskedDialog.tsx new file mode 100644 index 0000000..97773ac --- /dev/null +++ b/apps/web/src/components/radar/QuestionsAskedDialog.tsx @@ -0,0 +1,201 @@ +import { useState, useEffect, Fragment } from 'react' +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { useSubscriptionWithErrorHandling } from '@/hooks' +import type { DrilldownDialogProps } from './types' + +const RELEVANT_EVENTS = ['agent:waiting'] + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +function truncate(text: string, max: number): string { + return text.length > max ? text.slice(0, max) + '…' : text +} + +export function QuestionsAskedDialog({ + open, + onOpenChange, + agentId, + agentName, + isAgentRunning, +}: DrilldownDialogProps) { + const [expandedIndex, setExpandedIndex] = useState(null) + + const { data, isLoading, refetch } = trpc.agent.getQuestionsAsked.useQuery( + { agentId }, + { enabled: open } + ) + + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + + useEffect(() => { + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setExpandedIndex(null) + setLastRefreshedAt(null) + setSecondsAgo(0) + } + }, [open]) + + return ( + + + + {`Questions Asked — ${agentName}`} + + Each row is a question this agent sent to the user via the AskUserQuestion tool. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + + {data.map((row, i) => { + const n = row.questions.length + const countLabel = `${n} question${n !== 1 ? 's' : ''}` + const firstHeader = truncate(row.questions[0]?.header ?? '', 40) + return ( + + setExpandedIndex(i === expandedIndex ? null : i)} + > + + + + + {expandedIndex === i && ( + + + + )} + + ) + })} + +
Timestamp# QuestionsFirst Question Header
+ {formatTimestamp(row.timestamp)} + {countLabel}{firstHeader}
+
+
    + {row.questions.map((q, qi) => ( +
  1. + + {q.header} + + {q.question} +
      + {q.options.map((opt, oi) => ( +
    • + {`• ${opt.label} — ${opt.description}`} +
    • + ))} +
    +
  2. + ))} +
+
+
+ + )} +
+ + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )} + +
+ ) +} diff --git a/apps/web/src/components/radar/SubagentSpawnsDialog.tsx b/apps/web/src/components/radar/SubagentSpawnsDialog.tsx new file mode 100644 index 0000000..ca56aa3 --- /dev/null +++ b/apps/web/src/components/radar/SubagentSpawnsDialog.tsx @@ -0,0 +1,185 @@ +import { useState, useEffect, Fragment } from 'react' +import { trpc } from '@/lib/trpc' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Skeleton } from '@/components/ui/skeleton' +import { useSubscriptionWithErrorHandling } from '@/hooks' +import type { DrilldownDialogProps } from './types' + +const RELEVANT_EVENTS = ['agent:waiting'] + +function formatTimestamp(ts: string): string { + const date = new Date(ts) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + let relative: string + if (diffSecs < 60) { + relative = `${diffSecs}s ago` + } else if (diffMins < 60) { + relative = `${diffMins}m ago` + } else if (diffHours < 24) { + relative = `${diffHours}h ago` + } else { + relative = `${diffDays}d ago` + } + + const absolute = date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + + return `${relative} · ${absolute}` +} + +export function SubagentSpawnsDialog({ + open, + onOpenChange, + agentId, + agentName, + isAgentRunning, +}: DrilldownDialogProps) { + const [expandedIndex, setExpandedIndex] = useState(null) + + const { data, isLoading, refetch } = trpc.agent.getSubagentSpawns.useQuery( + { agentId }, + { enabled: open } + ) + + const [lastRefreshedAt, setLastRefreshedAt] = useState(null) + const [secondsAgo, setSecondsAgo] = useState(0) + + useSubscriptionWithErrorHandling( + () => trpc.onEvent.useSubscription(undefined), + { + enabled: open && !!isAgentRunning, + onData: (event: any) => { + const eventType: string = event?.data?.type ?? event?.type ?? '' + const eventAgentId: string = event?.data?.agentId ?? event?.agentId ?? '' + if (RELEVANT_EVENTS.some(e => eventType.startsWith(e)) && eventAgentId === agentId) { + void refetch().then(() => { + setLastRefreshedAt(new Date()) + setSecondsAgo(0) + }) + } + }, + } + ) + + useEffect(() => { + if (!open || !isAgentRunning || !lastRefreshedAt) return + const interval = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - lastRefreshedAt.getTime()) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [open, isAgentRunning, lastRefreshedAt]) + + useEffect(() => { + if (!open) { + setExpandedIndex(null) + setLastRefreshedAt(null) + setSecondsAgo(0) + } + }, [open]) + + return ( + + + + {`Subagent Spawns — ${agentName}`} + + Each row is an Agent tool call — a subagent spawned by this agent. The description and + first 200 characters of the prompt are shown. + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((i) => ( +
+
+ + + +
+ {i < 2 &&
} +
+ ))} +
+ ) : !data || data.length === 0 ? ( +

No data found

+ ) : ( + <> + {data.length >= 200 && ( +

Showing first 200 instances.

+ )} + + + + + + + + + + {data.map((row, i) => ( + + setExpandedIndex(i === expandedIndex ? null : i)} + > + + + + + {expandedIndex === i && ( + + + + )} + + ))} + +
TimestampDescriptionPrompt Preview
+ {formatTimestamp(row.timestamp)} + {row.description} + {row.promptPreview} + {row.fullPrompt.length > row.promptPreview.length && ( + + )} +
+
+
{row.fullPrompt}
+
+
+ + )} +
+ + {isAgentRunning && lastRefreshedAt && ( +

+ Last refreshed: {secondsAgo === 0 ? 'just now' : `${secondsAgo}s ago`} +

+ )} + +
+ ) +} diff --git a/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx b/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx new file mode 100644 index 0000000..8352c1e --- /dev/null +++ b/apps/web/src/components/radar/__tests__/CompactionEventsDialog.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, act, waitFor } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + getCompactionEvents: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + onEvent: { + useSubscription: vi.fn(), + }, + }, +})) + +import { CompactionEventsDialog } from '../CompactionEventsDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('CompactionEventsDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Compaction Events/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows correctly', () => { + mockUseQueryReturn = { + data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 3 }], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('3')).toBeInTheDocument() + // Timestamp includes year 2026 + expect(screen.getByText(/2026/)).toBeInTheDocument() + expect(screen.queryByText('Showing first 200 instances.')).toBeNull() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }), + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) + + it('renders dialog title and subtitle', () => { + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } + render() + expect(screen.getByText(/Compaction Events — test-agent/)).toBeInTheDocument() + expect(screen.getByText(/context-window compaction/)).toBeInTheDocument() + }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }] }) + mockUseQueryReturn = { + data: [{ timestamp: '2026-03-06T10:00:00.000Z', sessionNumber: 1 }], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) +}) diff --git a/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx b/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx new file mode 100644 index 0000000..4ffb10a --- /dev/null +++ b/apps/web/src/components/radar/__tests__/InterAgentMessagesDialog.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + conversation: { + getByFromAgent: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + onEvent: { + useSubscription: vi.fn(), + }, + }, +})) + +import { InterAgentMessagesDialog } from '../InterAgentMessagesDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('InterAgentMessagesDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Inter-Agent Messages/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows for answered conversation', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: 'It is src/api/index.ts', + status: 'answered', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('target-agent')).toBeInTheDocument() + expect(screen.getByText('answered')).toBeInTheDocument() + expect(screen.queryByText('What is the export path?')).toBeNull() + }) + + it('expands answered row to show question and answer', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: 'It is src/api/index.ts', + status: 'answered', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + + fireEvent.click(screen.getByText('target-agent').closest('tr')!) + expect(screen.getByText('What is the export path?')).toBeInTheDocument() + expect(screen.getByText('It is src/api/index.ts')).toBeInTheDocument() + expect(screen.queryByText('No answer yet')).toBeNull() + }) + + it('expands pending row to show question and "No answer yet"', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c2', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: null, + status: 'pending', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + + fireEvent.click(screen.getByText('target-agent').closest('tr')!) + expect(screen.getByText('What is the export path?')).toBeInTheDocument() + expect(screen.getByText('No answer yet')).toBeInTheDocument() + expect(screen.queryByText('It is src/api/index.ts')).toBeNull() + }) + + it('collapses row when clicked again', () => { + mockUseQueryReturn = { + data: [ + { + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: 'It is src/api/index.ts', + status: 'answered', + taskId: null, + phaseId: null, + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + + const row = screen.getByText('target-agent').closest('tr')! + fireEvent.click(row) + expect(screen.getByText('What is the export path?')).toBeInTheDocument() + fireEvent.click(row) + expect(screen.queryByText('What is the export path?')).toBeNull() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ + id: 'c1', + timestamp: '2026-03-06T10:00:00.000Z', + toAgentName: 'target-agent', + toAgentId: 'agent-2', + question: 'What is the export path?', + answer: null, + status: 'pending', + taskId: null, + phaseId: null, + }), + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [] }) + mockUseQueryReturn = { + data: [], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'conversation:created', fromAgentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) +}) diff --git a/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx b/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx new file mode 100644 index 0000000..4a7e89d --- /dev/null +++ b/apps/web/src/components/radar/__tests__/QuestionsAskedDialog.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + getQuestionsAsked: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + onEvent: { + useSubscription: vi.fn(), + }, + }, +})) + +import { QuestionsAskedDialog } from '../QuestionsAskedDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('QuestionsAskedDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Questions Asked/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows correctly', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] }, + { question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] }, + ], + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('2 questions')).toBeInTheDocument() + expect(screen.getByText('Method')).toBeInTheDocument() + expect(screen.queryByText('Pick a method')).toBeNull() + }) + + it('expands row to show all sub-questions on click', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] }, + { question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] }, + ], + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + + fireEvent.click(screen.getByText('2 questions').closest('tr')!) + expect(screen.getByText('Pick a method')).toBeInTheDocument() + expect(screen.getByText('Pick a strategy')).toBeInTheDocument() + expect(screen.getByText('• A — Option A')).toBeInTheDocument() + }) + + it('collapses row when clicked again', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [{ label: 'A', description: 'Option A' }] }, + { question: 'Pick a strategy', header: 'Strategy', options: [{ label: 'B', description: 'Option B' }] }, + ], + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + + const row = screen.getByText('2 questions').closest('tr')! + fireEvent.click(row) + expect(screen.getByText('Pick a method')).toBeInTheDocument() + fireEvent.click(row) + expect(screen.queryByText('Pick a method')).toBeNull() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Pick a method', header: 'Method', options: [] }, + ], + }), + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) + + it('shows singular "1 question" for single-question rows', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + questions: [ + { question: 'Only one', header: 'Single', options: [] }, + ], + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('1 question')).toBeInTheDocument() + expect(screen.queryByText('1 questions')).toBeNull() + }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [] }) + mockUseQueryReturn = { + data: [], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) +}) diff --git a/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx b/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx new file mode 100644 index 0000000..916f840 --- /dev/null +++ b/apps/web/src/components/radar/__tests__/SubagentSpawnsDialog.test.tsx @@ -0,0 +1,198 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +const mockUseSubscriptionWithErrorHandling = vi.hoisted(() => vi.fn()) +vi.mock('@/hooks', () => ({ + useSubscriptionWithErrorHandling: mockUseSubscriptionWithErrorHandling, +})) + +let mockUseQueryReturn: { data: unknown; isLoading: boolean; refetch?: () => Promise } = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), +} + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + getSubagentSpawns: { + useQuery: vi.fn((_args: unknown, _opts: unknown) => mockUseQueryReturn), + }, + }, + onEvent: { + useSubscription: vi.fn(), + }, + }, +})) + +import { SubagentSpawnsDialog } from '../SubagentSpawnsDialog' + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + agentId: 'agent-123', + agentName: 'test-agent', +} + +describe('SubagentSpawnsDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQueryReturn = { + data: undefined, + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + mockUseSubscriptionWithErrorHandling.mockReturnValue({ + isConnected: false, + isConnecting: false, + error: null, + reconnectAttempts: 0, + lastEventId: null, + reconnect: vi.fn(), + reset: vi.fn(), + }) + }) + + it('does not render dialog content when open=false', () => { + render() + expect(screen.queryByText(/Subagent Spawns/)).toBeNull() + }) + + it('shows skeleton rows when loading', () => { + mockUseQueryReturn = { data: undefined, isLoading: true, refetch: vi.fn().mockResolvedValue({}) } + render() + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(3) + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows "No data found" when data is empty', () => { + mockUseQueryReturn = { data: [], isLoading: false, refetch: vi.fn().mockResolvedValue({}) } + render() + expect(screen.getByText('No data found')).toBeInTheDocument() + }) + + it('renders data rows correctly', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + description: 'my task', + promptPreview: 'hello', + fullPrompt: 'hello world full', + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('my task')).toBeInTheDocument() + expect(screen.getByText('hello')).toBeInTheDocument() + expect(screen.queryByText('hello world full')).toBeNull() + }) + + it('expands and collapses row on click', () => { + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + description: 'my task', + promptPreview: 'hello', + fullPrompt: 'hello world full', + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + + // Click the row — should expand + fireEvent.click(screen.getByText('my task').closest('tr')!) + expect(screen.getByText('hello world full')).toBeInTheDocument() + + // Click again — should collapse + fireEvent.click(screen.getByText('my task').closest('tr')!) + expect(screen.queryByText('hello world full')).toBeNull() + }) + + it('shows ellipsis suffix when fullPrompt is longer than promptPreview', () => { + const fullPrompt = 'A'.repeat(201) + const promptPreview = fullPrompt.slice(0, 200) + mockUseQueryReturn = { + data: [ + { + timestamp: '2026-03-06T10:00:00.000Z', + description: 'truncated task', + promptPreview, + fullPrompt, + }, + ], + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('…')).toBeInTheDocument() + }) + + it('shows 200-instance note when data length is 200', () => { + mockUseQueryReturn = { + data: Array(200).fill({ + timestamp: '2026-03-06T10:00:00.000Z', + description: 'task', + promptPreview: 'prompt', + fullPrompt: 'full prompt', + }), + isLoading: false, + refetch: vi.fn().mockResolvedValue({}), + } + render() + expect(screen.getByText('Showing first 200 instances.')).toBeInTheDocument() + }) + + describe('isAgentRunning behavior', () => { + it('shows "Last refreshed: just now" after SSE-triggered refetch when isAgentRunning=true', async () => { + let capturedOnData: ((event: any) => void) | undefined + mockUseSubscriptionWithErrorHandling.mockImplementation((_getter: any, opts: any) => { + capturedOnData = opts.onData + return { isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0, lastEventId: null, reconnect: vi.fn(), reset: vi.fn() } + }) + + const mockRefetch = vi.fn().mockResolvedValue({ data: [] }) + mockUseQueryReturn = { + data: [], + isLoading: false, + refetch: mockRefetch, + } + + render() + + await act(async () => { + capturedOnData?.({ data: { type: 'agent:waiting', agentId: 'agent-1' } }) + }) + + await waitFor(() => { + expect(screen.getByText(/Last refreshed: just now/)).toBeInTheDocument() + }) + }) + + it('does not show "Last refreshed" when isAgentRunning=false', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('does not show "Last refreshed" when isAgentRunning is not provided', () => { + render() + expect(screen.queryByText(/Last refreshed/)).not.toBeInTheDocument() + }) + + it('subscription is enabled only when open=true and isAgentRunning=true', () => { + render() + expect(mockUseSubscriptionWithErrorHandling).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ enabled: false }) + ) + }) + }) +}) diff --git a/apps/web/src/components/radar/types.ts b/apps/web/src/components/radar/types.ts new file mode 100644 index 0000000..e7dd307 --- /dev/null +++ b/apps/web/src/components/radar/types.ts @@ -0,0 +1,7 @@ +export interface DrilldownDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + agentId: string + agentName: string + isAgentRunning?: boolean +} diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index f6ed563..7dbc2eb 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -10,6 +10,7 @@ const navItems = [ { label: 'HQ', to: '/hq', badgeKey: null }, { label: 'Initiatives', to: '/initiatives', badgeKey: null }, { label: 'Agents', to: '/agents', badgeKey: 'running' as const }, + { label: 'Radar', to: '/radar', badgeKey: null }, { label: 'Inbox', to: '/inbox', badgeKey: 'questions' as const }, { label: 'Settings', to: '/settings', badgeKey: null }, ] as const diff --git a/apps/web/src/routes/__tests__/radar.test.tsx b/apps/web/src/routes/__tests__/radar.test.tsx new file mode 100644 index 0000000..01f110e --- /dev/null +++ b/apps/web/src/routes/__tests__/radar.test.tsx @@ -0,0 +1,451 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +vi.mock('@/components/radar/CompactionEventsDialog', () => ({ + CompactionEventsDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) => + open ? ( +
+ +
+ ) : null, +})) +vi.mock('@/components/radar/SubagentSpawnsDialog', () => ({ + SubagentSpawnsDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) => + open ? ( +
+ +
+ ) : null, +})) +vi.mock('@/components/radar/QuestionsAskedDialog', () => ({ + QuestionsAskedDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) => + open ? ( +
+ +
+ ) : null, +})) +vi.mock('@/components/radar/InterAgentMessagesDialog', () => ({ + InterAgentMessagesDialog: ({ open, agentId, agentName, isAgentRunning, onOpenChange }: any) => + open ? ( +
+ +
+ ) : null, +})) + +type AgentRadarRow = { + id: string + name: string + mode: string + status: string + initiativeId: string | null + initiativeName: string | null + taskId: string | null + taskName: string | null + createdAt: string + questionsCount: number + messagesCount: number + subagentsCount: number + compactionsCount: number +} + +// --- Hoisted mocks --- +const mockListForRadarUseQuery = vi.hoisted(() => vi.fn()) +const mockListInitiativesUseQuery = vi.hoisted(() => vi.fn()) +const mockListAgentsUseQuery = vi.hoisted(() => vi.fn()) +const mockNavigate = vi.hoisted(() => vi.fn()) +const mockUseSearch = vi.hoisted(() => + vi.fn().mockReturnValue({ + timeRange: '24h', + status: 'all', + initiativeId: undefined, + mode: 'all', + }) +) + +vi.mock('@/lib/trpc', () => ({ + trpc: { + agent: { + listForRadar: { useQuery: mockListForRadarUseQuery }, + }, + listInitiatives: { useQuery: mockListInitiativesUseQuery }, + listAgents: { useQuery: mockListAgentsUseQuery }, + }, +})) + +vi.mock('@/hooks', () => ({ + useLiveUpdates: vi.fn(), + LiveUpdateRule: undefined, +})) + +vi.mock('@/components/ThemeToggle', () => ({ + ThemeToggle: () => null, +})) + +vi.mock('@/components/HealthDot', () => ({ + HealthDot: () => null, +})) + +vi.mock('@/components/NavBadge', () => ({ + NavBadge: () => null, +})) + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: () => () => ({ component: null }), + useNavigate: () => mockNavigate, + useSearch: mockUseSearch, + Link: ({ + to, + search, + children, + }: { + to: string + search?: Record + children: React.ReactNode | ((props: { isActive: boolean }) => React.ReactNode) + }) => { + const params = search ? new URLSearchParams(search).toString() : '' + const href = params ? `${to}?${params}` : to + const content = typeof children === 'function' ? children({ isActive: false }) : children + return {content} + }, +})) + +// Import after mocks +import { RadarPage } from '../radar' +import { AppLayout } from '../../layouts/AppLayout' + +function makeAgent(overrides?: Partial): AgentRadarRow { + return { + id: 'agent-1', + name: 'jolly-penguin', + mode: 'execute', + status: 'running', + initiativeId: null, + initiativeName: null, + taskId: null, + taskName: null, + createdAt: new Date(Date.now() - 3600_000).toISOString(), + questionsCount: 0, + messagesCount: 0, + subagentsCount: 0, + compactionsCount: 0, + ...overrides, + } +} + +describe('RadarPage', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListInitiativesUseQuery.mockReturnValue({ data: [], isLoading: false }) + mockUseSearch.mockReturnValue({ + timeRange: '24h', + status: 'all', + initiativeId: undefined, + mode: 'all', + }) + }) + + it('renders "Radar" heading', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + expect(screen.getByRole('heading', { name: /radar/i })).toBeInTheDocument() + }) + + it('renders 4 summary stat cards with correct aggregated values', () => { + // Use distinct totals; scope number checks to stat card containers to avoid table collisions + const agents = [ + makeAgent({ id: 'a1', questionsCount: 3, messagesCount: 10, subagentsCount: 2, compactionsCount: 1 }), + makeAgent({ id: 'a2', questionsCount: 4, messagesCount: 5, subagentsCount: 1, compactionsCount: 3 }), + ] + mockListForRadarUseQuery.mockReturnValue({ data: agents, isLoading: false }) + render() + + // Verify labels exist + expect(screen.getByText('Total Questions Asked')).toBeInTheDocument() + expect(screen.getByText('Total Inter-Agent Messages')).toBeInTheDocument() + expect(screen.getByText('Total Subagent Spawns')).toBeInTheDocument() + expect(screen.getByText('Total Compaction Events')).toBeInTheDocument() + + // Verify aggregated totals by scoping to the stat card's container + // Total Questions: 3+4=7 + const questionsContainer = screen.getByText('Total Questions Asked').parentElement! + expect(questionsContainer.querySelector('.text-3xl')).toHaveTextContent('7') + // Total Messages: 10+5=15 + const messagesContainer = screen.getByText('Total Inter-Agent Messages').parentElement! + expect(messagesContainer.querySelector('.text-3xl')).toHaveTextContent('15') + }) + + it('table renders one row per agent', () => { + const agents = [ + makeAgent({ id: 'a1', name: 'agent-one' }), + makeAgent({ id: 'a2', name: 'agent-two' }), + makeAgent({ id: 'a3', name: 'agent-three' }), + ] + mockListForRadarUseQuery.mockReturnValue({ data: agents, isLoading: false }) + render() + + const tbody = document.querySelector('tbody')! + const rows = within(tbody).getAllByRole('row') + expect(rows).toHaveLength(3) + }) + + it('default sort: newest first', () => { + const older = makeAgent({ id: 'a1', name: 'older-agent', createdAt: new Date(Date.now() - 7200_000).toISOString() }) + const newer = makeAgent({ id: 'a2', name: 'newer-agent', createdAt: new Date(Date.now() - 1800_000).toISOString() }) + mockListForRadarUseQuery.mockReturnValue({ data: [older, newer], isLoading: false }) + render() + + const tbody = document.querySelector('tbody')! + const rows = within(tbody).getAllByRole('row') + expect(rows[0]).toHaveTextContent('newer-agent') + expect(rows[1]).toHaveTextContent('older-agent') + }) + + it('clicking "Started" column header sorts ascending, clicking again sorts descending', () => { + const older = makeAgent({ id: 'a1', name: 'older-agent', createdAt: new Date(Date.now() - 7200_000).toISOString() }) + const newer = makeAgent({ id: 'a2', name: 'newer-agent', createdAt: new Date(Date.now() - 1800_000).toISOString() }) + mockListForRadarUseQuery.mockReturnValue({ data: [older, newer], isLoading: false }) + render() + + // First click: default is desc (newest first), clicking toggles to asc (oldest first) + fireEvent.click(screen.getByRole('columnheader', { name: /started/i })) + const rowsAsc = within(document.querySelector('tbody')!).getAllByRole('row') + expect(rowsAsc[0]).toHaveTextContent('older-agent') + expect(rowsAsc[1]).toHaveTextContent('newer-agent') + + // Second click: re-query header (text content changed to '▲'), toggle back to desc + fireEvent.click(screen.getByRole('columnheader', { name: /started/i })) + const rowsDesc = within(document.querySelector('tbody')!).getAllByRole('row') + expect(rowsDesc[0]).toHaveTextContent('newer-agent') + expect(rowsDesc[1]).toHaveTextContent('older-agent') + }) + + it('agent name cell renders a Link to /agents with selected param', () => { + const agent = makeAgent({ id: 'agent-xyz', name: 'test-agent' }) + mockListForRadarUseQuery.mockReturnValue({ data: [agent], isLoading: false }) + render() + + const link = screen.getByRole('link', { name: 'test-agent' }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', expect.stringContaining('/agents')) + expect(link).toHaveAttribute('href', expect.stringContaining('agent-xyz')) + }) + + it('non-zero metric cell has cursor-pointer class; zero cell does not', () => { + mockListForRadarUseQuery.mockReturnValue({ + data: [makeAgent({ id: 'a1', questionsCount: 5 })], + isLoading: false, + }) + render() + + // Find all cells with text "5" — the non-zero questions cell + const tbody = document.querySelector('tbody')! + const cells = tbody.querySelectorAll('td') + + // Find the cell containing "5" (questionsCount) + const nonZeroCell = Array.from(cells).find(cell => cell.textContent === '5') + expect(nonZeroCell).toBeTruthy() + expect(nonZeroCell!.className).toContain('cursor-pointer') + + // Find a zero cell (messagesCount=0) + const zeroCell = Array.from(cells).find(cell => cell.textContent === '0' && !cell.className.includes('cursor-pointer')) + expect(zeroCell).toBeTruthy() + }) + + it('selecting mode filter calls navigate with mode param', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + + const selects = screen.getAllByRole('combobox') + // Mode select is the 4th select (timeRange, status, initiative, mode) + const modeSelect = selects[3] + fireEvent.change(modeSelect, { target: { value: 'execute' } }) + + expect(mockNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + search: expect.any(Function), + }) + ) + + // Call the search function to verify the result + const call = mockNavigate.mock.calls[0][0] + const result = call.search({ timeRange: '24h', status: 'all', initiativeId: undefined, mode: 'all' }) + expect(result).toMatchObject({ mode: 'execute' }) + }) + + it('selecting status filter calls navigate with status param', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + + const selects = screen.getAllByRole('combobox') + // Status select is the 2nd select + const statusSelect = selects[1] + fireEvent.change(statusSelect, { target: { value: 'running' } }) + + expect(mockNavigate).toHaveBeenCalledWith( + expect.objectContaining({ + search: expect.any(Function), + }) + ) + + const call = mockNavigate.mock.calls[0][0] + const result = call.search({ timeRange: '24h', status: 'all', initiativeId: undefined, mode: 'all' }) + expect(result).toMatchObject({ status: 'running' }) + }) + + it('empty state shown when agents returns []', () => { + mockListForRadarUseQuery.mockReturnValue({ data: [], isLoading: false }) + render() + + expect(screen.getByText('No agent activity in this time period')).toBeInTheDocument() + }) + + it('loading skeleton shown when isLoading is true', () => { + mockListForRadarUseQuery.mockReturnValue({ data: undefined, isLoading: true }) + render() + + const skeletons = document.querySelectorAll('.animate-pulse') + expect(skeletons.length).toBeGreaterThanOrEqual(5) + }) + + describe('dialog integration', () => { + const alphaAgent = { + id: 'agent-alpha-id', + name: 'agent-alpha', + mode: 'execute', + status: 'running', + initiativeId: null, + initiativeName: null, + taskId: null, + taskName: null, + createdAt: new Date(Date.now() - 3600_000).toISOString(), + questionsCount: 3, + messagesCount: 2, + subagentsCount: 1, + compactionsCount: 4, + } + const betaAgent = { + id: 'agent-beta-id', + name: 'agent-beta', + mode: 'execute', + status: 'stopped', + initiativeId: null, + initiativeName: null, + taskId: null, + taskName: null, + createdAt: new Date(Date.now() - 7200_000).toISOString(), + questionsCount: 0, + messagesCount: 0, + subagentsCount: 0, + compactionsCount: 0, + } + + beforeEach(() => { + mockListForRadarUseQuery.mockReturnValue({ data: [alphaAgent, betaAgent], isLoading: false }) + }) + + it('clicking a non-zero Compactions cell opens CompactionEventsDialog with correct agentId and agentName', async () => { + render() + const cell = screen.getByTestId(`cell-compactions-agent-alpha-id`) + fireEvent.click(cell) + const dialog = await screen.findByTestId('compaction-dialog') + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha') + expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id') + }) + + it('clicking a non-zero Subagents cell opens SubagentSpawnsDialog', async () => { + render() + const cell = screen.getByTestId(`cell-subagents-agent-alpha-id`) + fireEvent.click(cell) + const dialog = await screen.findByTestId('subagents-dialog') + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha') + expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id') + }) + + it('clicking a non-zero Questions cell opens QuestionsAskedDialog', async () => { + render() + const cell = screen.getByTestId(`cell-questions-agent-alpha-id`) + fireEvent.click(cell) + const dialog = await screen.findByTestId('questions-dialog') + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha') + expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id') + }) + + it('clicking a non-zero Messages cell opens InterAgentMessagesDialog', async () => { + render() + const cell = screen.getByTestId(`cell-messages-agent-alpha-id`) + fireEvent.click(cell) + const dialog = await screen.findByTestId('messages-dialog') + expect(dialog).toBeInTheDocument() + expect(dialog).toHaveAttribute('data-agent-name', 'agent-alpha') + expect(dialog).toHaveAttribute('data-agent-id', 'agent-alpha-id') + }) + + it('dialog closes when onOpenChange(false) fires', async () => { + render() + const cell = screen.getByTestId(`cell-compactions-agent-alpha-id`) + fireEvent.click(cell) + const dialog = await screen.findByTestId('compaction-dialog') + expect(dialog).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'close' })) + expect(screen.queryByTestId('compaction-dialog')).not.toBeInTheDocument() + }) + + it('zero metric cells do not open any dialog when clicked', () => { + render() + // agent-beta has all zeros — click each zero metric cell + fireEvent.click(screen.getByTestId('cell-compactions-agent-beta-id')) + fireEvent.click(screen.getByTestId('cell-subagents-agent-beta-id')) + fireEvent.click(screen.getByTestId('cell-questions-agent-beta-id')) + fireEvent.click(screen.getByTestId('cell-messages-agent-beta-id')) + expect(screen.queryByTestId('compaction-dialog')).not.toBeInTheDocument() + expect(screen.queryByTestId('subagents-dialog')).not.toBeInTheDocument() + expect(screen.queryByTestId('questions-dialog')).not.toBeInTheDocument() + expect(screen.queryByTestId('messages-dialog')).not.toBeInTheDocument() + }) + + it('passes isAgentRunning=true when the agent status is "running"', async () => { + render() + const cell = screen.getByTestId(`cell-compactions-agent-alpha-id`) + fireEvent.click(cell) + const dialog = await screen.findByTestId('compaction-dialog') + expect(dialog).toHaveAttribute('data-is-running', 'true') + }) + + it('passes isAgentRunning=false when the agent status is "stopped"', async () => { + // Use a stopped agent with non-zero compactions + const stoppedAgent = { ...betaAgent, id: 'stopped-agent-id', name: 'stopped-agent', compactionsCount: 2, status: 'stopped' } + mockListForRadarUseQuery.mockReturnValue({ data: [stoppedAgent], isLoading: false }) + render() + const cell = screen.getByTestId('cell-compactions-stopped-agent-id') + fireEvent.click(cell) + const dialog = await screen.findByTestId('compaction-dialog') + expect(dialog).toHaveAttribute('data-is-running', 'false') + }) + }) +}) + +describe('AppLayout - Radar nav item', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('"Radar" appears in AppLayout nav', () => { + mockListAgentsUseQuery.mockReturnValue({ data: [], isLoading: false }) + render( + +
content
+
+ ) + + const radarLink = screen.getByRole('link', { name: 'Radar' }) + expect(radarLink).toBeInTheDocument() + expect(radarLink).toHaveAttribute('href', expect.stringContaining('/radar')) + }) +}) diff --git a/apps/web/src/routes/radar.tsx b/apps/web/src/routes/radar.tsx new file mode 100644 index 0000000..7e68b3b --- /dev/null +++ b/apps/web/src/routes/radar.tsx @@ -0,0 +1,388 @@ +import { useState, useMemo } from 'react' +import { createFileRoute, useNavigate, useSearch, Link } from '@tanstack/react-router' +import { trpc } from '@/lib/trpc' +import { useLiveUpdates } from '@/hooks' +import type { LiveUpdateRule } from '@/hooks' +import { Card, CardContent } from '@/components/ui/card' +import { CompactionEventsDialog } from '@/components/radar/CompactionEventsDialog' +import { SubagentSpawnsDialog } from '@/components/radar/SubagentSpawnsDialog' +import { QuestionsAskedDialog } from '@/components/radar/QuestionsAskedDialog' +import { InterAgentMessagesDialog } from '@/components/radar/InterAgentMessagesDialog' + +type TimeRange = '1h' | '6h' | '24h' | '7d' | 'all' +type StatusFilter = 'all' | 'running' | 'completed' | 'crashed' +type ModeFilter = 'all' | 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat' | 'errand' +type SortColumn = + | 'name' + | 'mode' + | 'status' + | 'initiative' + | 'task' + | 'started' + | 'questions' + | 'messages' + | 'subagents' + | 'compactions' + +const VALID_TIME_RANGES: TimeRange[] = ['1h', '6h', '24h', '7d', 'all'] +const VALID_STATUSES: StatusFilter[] = ['all', 'running', 'completed', 'crashed'] +const VALID_MODES: ModeFilter[] = [ + 'all', + 'execute', + 'discuss', + 'plan', + 'detail', + 'refine', + 'chat', + 'errand', +] + +export const Route = createFileRoute('/radar')({ + component: RadarPage, + validateSearch: (search: Record) => ({ + timeRange: VALID_TIME_RANGES.includes(search.timeRange as TimeRange) + ? (search.timeRange as TimeRange) + : '24h', + status: VALID_STATUSES.includes(search.status as StatusFilter) + ? (search.status as StatusFilter) + : 'all', + initiativeId: typeof search.initiativeId === 'string' ? search.initiativeId : undefined, + mode: VALID_MODES.includes(search.mode as ModeFilter) ? (search.mode as ModeFilter) : 'all', + }), +}) + +const RADAR_LIVE_UPDATE_RULES: LiveUpdateRule[] = [ + { prefix: 'agent:waiting', invalidate: ['agent'] }, + { prefix: 'conversation:created', invalidate: ['agent'] }, + { prefix: 'agent:stopped', invalidate: ['agent'] }, + { prefix: 'agent:crashed', invalidate: ['agent'] }, +] + +export function RadarPage() { + const { timeRange, status, initiativeId, mode } = useSearch({ from: '/radar' }) as { + timeRange: TimeRange + status: StatusFilter + initiativeId: string | undefined + mode: ModeFilter + } + const navigate = useNavigate() + + useLiveUpdates(RADAR_LIVE_UPDATE_RULES) + + const { data: agents = [], isLoading } = trpc.agent.listForRadar.useQuery({ + timeRange, + status: status === 'all' ? undefined : status, + initiativeId: initiativeId ?? undefined, + mode: mode === 'all' ? undefined : mode, + }) + + const { data: initiatives = [] } = trpc.listInitiatives.useQuery() + + type DrilldownType = 'questions' | 'messages' | 'subagents' | 'compactions' + + const [drilldown, setDrilldown] = useState<{ + type: DrilldownType + agentId: string + agentName: string + } | null>(null) + + const [sortState, setSortState] = useState<{ column: SortColumn; direction: 'asc' | 'desc' }>({ + column: 'started', + direction: 'desc', + }) + + function handleSort(column: SortColumn) { + setSortState((prev) => + prev.column === column + ? { column, direction: prev.direction === 'asc' ? 'desc' : 'asc' } + : { column, direction: 'asc' }, + ) + } + + const sortedAgents = useMemo(() => { + return [...agents].sort((a, b) => { + let cmp = 0 + switch (sortState.column) { + case 'name': + cmp = a.name.localeCompare(b.name) + break + case 'mode': + cmp = a.mode.localeCompare(b.mode) + break + case 'status': + cmp = a.status.localeCompare(b.status) + break + case 'initiative': + cmp = (a.initiativeName ?? '').localeCompare(b.initiativeName ?? '') + break + case 'task': + cmp = (a.taskName ?? '').localeCompare(b.taskName ?? '') + break + case 'started': + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + break + case 'questions': + cmp = a.questionsCount - b.questionsCount + break + case 'messages': + cmp = a.messagesCount - b.messagesCount + break + case 'subagents': + cmp = a.subagentsCount - b.subagentsCount + break + case 'compactions': + cmp = a.compactionsCount - b.compactionsCount + break + } + return sortState.direction === 'asc' ? cmp : -cmp + }) + }, [agents, sortState]) + + const totalQuestions = agents.reduce((sum, a) => sum + a.questionsCount, 0) + const totalMessages = agents.reduce((sum, a) => sum + a.messagesCount, 0) + const totalSubagents = agents.reduce((sum, a) => sum + a.subagentsCount, 0) + const totalCompactions = agents.reduce((sum, a) => sum + a.compactionsCount, 0) + + function sortIndicator(column: SortColumn) { + if (sortState.column !== column) return null + return sortState.direction === 'asc' ? ' ▲' : ' ▼' + } + + function SortableTh({ + column, + label, + className, + }: { + column: SortColumn + label: string + className?: string + }) { + return ( + handleSort(column)} + > + {label} + {sortIndicator(column)} + + ) + } + + const isAgentRunning = drilldown + ? agents.find((a) => a.id === drilldown.agentId)?.status === 'running' + : false + + return ( +
+

Radar

+ + {/* Summary stat cards */} +
+ + +

{totalQuestions}

+

Total Questions Asked

+
+
+ + +

{totalMessages}

+

Total Inter-Agent Messages

+
+
+ + +

{totalSubagents}

+

Total Subagent Spawns

+
+
+ + +

{totalCompactions}

+

Total Compaction Events

+
+
+
+ + {/* Filter bar */} +
+ + + + + + + +
+ + {/* Empty state */} + {!isLoading && agents.length === 0 && ( +

No agent activity in this time period

+ )} + + {/* Agent activity table */} + {(isLoading || agents.length > 0) && ( + + + + + + + + + + + + + + + + + {isLoading + ? Array.from({ length: 5 }).map((_, i) => ( + + + + )) + : sortedAgents.map((agent) => ( + + + + + + + + + + + + + ))} + +
+
+
+ + {agent.name} + + {agent.mode}{agent.status}{agent.initiativeName ?? '—'}{agent.taskName ?? '—'} + {new Date(agent.createdAt).toLocaleString()} + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.questionsCount > 0 + ? () => setDrilldown({ type: 'questions', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.questionsCount} + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.messagesCount > 0 + ? () => setDrilldown({ type: 'messages', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.messagesCount} + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.subagentsCount > 0 + ? () => setDrilldown({ type: 'subagents', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.subagentsCount} + 0 ? 'cursor-pointer hover:bg-muted/50 text-right px-3 py-2' : 'text-muted-foreground text-right px-3 py-2'} + onClick={agent.compactionsCount > 0 + ? () => setDrilldown({ type: 'compactions', agentId: agent.id, agentName: agent.name }) + : undefined} + > + {agent.compactionsCount} +
+ )} + + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + /> + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + /> + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + /> + { if (!open) setDrilldown(null) }} + agentId={drilldown?.agentId ?? ''} + agentName={drilldown?.agentName ?? ''} + isAgentRunning={isAgentRunning} + /> +
+ ) +} diff --git a/docs/database.md b/docs/database.md index 2a6e994..3d7ccd5 100644 --- a/docs/database.md +++ b/docs/database.md @@ -245,8 +245,8 @@ Index: `(phaseId)`. | ProjectRepository | + junction ops: setInitiativeProjects (diff-based), findProjectsByInitiativeId | | AccountRepository | + findNextAvailable (round-robin), markExhausted, clearExpiredExhaustion | | ProposalRepository | + findByAgentIdAndStatus, updateManyByAgentId, countByAgentIdAndStatus | -| LogChunkRepository | insertChunk, findByAgentId, deleteByAgentId, getSessionCount | -| ConversationRepository | create, findById, findPendingForAgent, answer | +| LogChunkRepository | insertChunk, findByAgentId, findByAgentIds (batch), deleteByAgentId, getSessionCount | +| ConversationRepository | create, findById, findPendingForAgent, answer, countByFromAgentIds (batch), findByFromAgentId | | ChatSessionRepository | createSession, findActiveSession, findActiveSessionByAgentId, updateSession, createMessage, findMessagesBySessionId | | ReviewCommentRepository | create, findByPhaseId, resolve, unresolve, delete | | ErrandRepository | create, findById, findAll (filter by projectId/status), update, delete | diff --git a/docs/server-api.md b/docs/server-api.md index 350fa02..cd2f17a 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -69,6 +69,10 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getActiveRefineAgent | query | Active refine agent for initiative | | getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | listWaitingAgents | query | Agents waiting for input | +| listForRadar | query | Radar page: per-agent metrics (questionsCount, messagesCount, subagentsCount, compactionsCount) with time/status/mode/initiative filters | +| getCompactionEvents | query | Compaction events for one agent: `{agentId}` → `{timestamp, sessionNumber}[]` (cap 200) | +| getSubagentSpawns | query | Subagent spawn events for one agent: `{agentId}` → `{timestamp, description, promptPreview, fullPrompt}[]` (cap 200) | +| getQuestionsAsked | query | AskUserQuestion tool calls for one agent: `{agentId}` → `{timestamp, questions[]}[]` (cap 200) | | onAgentOutput | subscription | Live raw JSONL output stream via EventBus | ### Tasks @@ -254,6 +258,7 @@ Inter-agent communication for parallel agents. | `getPendingConversations` | query | Poll for incoming questions: `{agentId}` → Conversation[] | | `getConversation` | query | Get conversation by ID: `{id}` → Conversation | | `answerConversation` | mutation | Answer a conversation: `{id, answer}` → Conversation | +| `getByFromAgent` | query | Radar drilldown: all conversations sent by agent: `{agentId}` → `{id, timestamp, toAgentName, toAgentId, question, answer, status, taskId, phaseId}[]` (cap 200) | Target resolution: `toAgentId` → direct; `taskId` → find running agent by task; `phaseId` → find running agent by any task in phase.