diff --git a/server/src/__tests__/agent-routing-service.test.ts b/server/src/__tests__/agent-routing-service.test.ts new file mode 100644 index 00000000..dd2a95d3 --- /dev/null +++ b/server/src/__tests__/agent-routing-service.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AgentRoutingService } from '../services/agent-routing-service'; +import type { AgentRoutingConfig, AppConfig, Task } from '@veritas-kanban/shared'; + +// Mock ConfigService +const mockGetConfig = vi.fn(); +const mockSaveConfig = vi.fn(); + +vi.mock('../services/config-service.js', () => { + return { + ConfigService: class MockConfigService { + getConfig = mockGetConfig; + saveConfig = mockSaveConfig; + }, + }; +}); + +const BASE_CONFIG: AppConfig = { + repos: [], + agents: [ + { type: 'claude-code', name: 'Claude Code', command: 'claude', args: [], enabled: true }, + { type: 'amp', name: 'Amp', command: 'amp', args: [], enabled: true }, + { type: 'copilot', name: 'GitHub Copilot', command: 'copilot', args: [], enabled: true }, + { type: 'gemini', name: 'Gemini CLI', command: 'gemini', args: [], enabled: false }, + ], + defaultAgent: 'claude-code', + agentRouting: { + enabled: true, + rules: [ + { + id: 'code-high', + name: 'High-priority code', + match: { type: 'code', priority: 'high' }, + agent: 'claude-code', + model: 'opus', + fallback: 'amp', + enabled: true, + }, + { + id: 'code-default', + name: 'Code tasks', + match: { type: 'code' }, + agent: 'claude-code', + model: 'sonnet', + fallback: 'copilot', + enabled: true, + }, + { + id: 'docs', + name: 'Documentation', + match: { type: 'docs' }, + agent: 'claude-code', + model: 'haiku', + enabled: true, + }, + { + id: 'disabled-rule', + name: 'Disabled rule', + match: { type: 'feature' }, + agent: 'amp', + enabled: false, + }, + ], + defaultAgent: 'claude-code', + defaultModel: 'sonnet', + fallbackOnFailure: true, + maxRetries: 1, + }, +}; + +describe('AgentRoutingService', () => { + let service: AgentRoutingService; + + beforeEach(() => { + mockGetConfig.mockResolvedValue(structuredClone(BASE_CONFIG)); + mockSaveConfig.mockResolvedValue(undefined); + service = new AgentRoutingService(); + }); + + describe('resolveAgent', () => { + it('matches high-priority code task to first rule', async () => { + const result = await service.resolveAgent({ + type: 'code', + priority: 'high', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('opus'); + expect(result.fallback).toBe('amp'); + expect(result.rule).toBe('code-high'); + expect(result.reason).toContain('High-priority code'); + }); + + it('matches medium-priority code task to second rule', async () => { + const result = await service.resolveAgent({ + type: 'code', + priority: 'medium', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('sonnet'); + expect(result.fallback).toBe('copilot'); + expect(result.rule).toBe('code-default'); + }); + + it('matches docs to docs rule', async () => { + const result = await service.resolveAgent({ + type: 'docs', + priority: 'low', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('haiku'); + expect(result.rule).toBe('docs'); + }); + + it('skips disabled rules', async () => { + const result = await service.resolveAgent({ + type: 'feature', + priority: 'medium', + }); + + // disabled-rule matches feature but is disabled, so falls through to default + expect(result.rule).toBeUndefined(); + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('sonnet'); + expect(result.reason).toContain('No routing rules matched'); + }); + + it('falls back to default when no rules match', async () => { + const result = await service.resolveAgent({ + type: 'design', + priority: 'low', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('sonnet'); + expect(result.rule).toBeUndefined(); + expect(result.reason).toContain('No routing rules matched'); + }); + + it('returns default agent when routing is disabled', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.enabled = false; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'code', + priority: 'high', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.reason).toContain('Routing disabled'); + }); + + it('skips rules where agent is disabled', async () => { + const config = structuredClone(BASE_CONFIG); + // Disable claude-code so the first two rules are skipped + config.agents[0].enabled = false; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'code', + priority: 'high', + }); + + // Both code rules point to claude-code which is disabled — falls to default + // But default is also claude-code (disabled), so it returns the config default anyway + expect(result.reason).toContain('No routing rules matched'); + }); + + it('matches array criteria', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.rules = [ + { + id: 'multi-type', + name: 'Multiple types', + match: { type: ['bug', 'hotfix'], priority: ['high', 'medium'] }, + agent: 'amp', + enabled: true, + }, + ]; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'bug', + priority: 'medium', + }); + expect(result.agent).toBe('amp'); + expect(result.rule).toBe('multi-type'); + }); + + it('matches minSubtasks criteria', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.rules = [ + { + id: 'complex', + name: 'Complex tasks', + match: { minSubtasks: 5 }, + agent: 'amp', + enabled: true, + }, + ]; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'feature', + priority: 'medium', + subtasks: Array.from({ length: 6 }, (_, i) => ({ + id: `s${i}`, + title: `Sub ${i}`, + completed: false, + created: new Date().toISOString(), + })), + }); + expect(result.agent).toBe('amp'); + expect(result.rule).toBe('complex'); + }); + + it('does NOT match when subtasks below threshold', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.rules = [ + { + id: 'complex', + name: 'Complex tasks', + match: { minSubtasks: 5 }, + agent: 'amp', + enabled: true, + }, + ]; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'feature', + priority: 'medium', + subtasks: [ + { id: 's1', title: 'Sub 1', completed: false, created: new Date().toISOString() }, + ], + }); + expect(result.rule).toBeUndefined(); // No match + }); + }); + + describe('getFallback', () => { + it('returns fallback agent from matched rule', async () => { + const result = await service.getFallback({ type: 'code', priority: 'high' }, 'claude-code'); + + expect(result).not.toBeNull(); + expect(result!.agent).toBe('amp'); + expect(result!.reason).toContain('Fallback'); + }); + + it('returns null when fallback is disabled', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.fallbackOnFailure = false; + mockGetConfig.mockResolvedValue(config); + + const result = await service.getFallback({ type: 'code', priority: 'high' }, 'claude-code'); + expect(result).toBeNull(); + }); + + it('returns default agent as fallback when no specific fallback', async () => { + const result = await service.getFallback( + { type: 'docs', priority: 'low' }, + 'claude-code' // docs rule has no fallback, and default is claude-code (same) + ); + // claude-code === failedAgent, so no fallback + expect(result).toBeNull(); + }); + + it('returns default agent when it differs from failed', async () => { + const result = await service.getFallback( + { type: 'docs', priority: 'low' }, + 'amp' // Failed agent is amp, default is claude-code → valid fallback + ); + expect(result).not.toBeNull(); + expect(result!.agent).toBe('claude-code'); + }); + }); + + describe('updateRoutingConfig', () => { + it('saves valid config', async () => { + const newConfig: AgentRoutingConfig = { + enabled: true, + rules: [], + defaultAgent: 'amp', + fallbackOnFailure: false, + maxRetries: 0, + }; + + await service.updateRoutingConfig(newConfig); + expect(mockSaveConfig).toHaveBeenCalled(); + }); + + it('rejects duplicate rule IDs', async () => { + const newConfig: AgentRoutingConfig = { + enabled: true, + rules: [ + { id: 'dup', name: 'A', match: {}, agent: 'amp', enabled: true }, + { id: 'dup', name: 'B', match: {}, agent: 'amp', enabled: true }, + ], + defaultAgent: 'amp', + fallbackOnFailure: false, + maxRetries: 0, + }; + + await expect(service.updateRoutingConfig(newConfig)).rejects.toThrow('unique'); + }); + + it('rejects maxRetries > 3', async () => { + const newConfig: AgentRoutingConfig = { + enabled: true, + rules: [], + defaultAgent: 'amp', + fallbackOnFailure: false, + maxRetries: 5, + }; + + await expect(service.updateRoutingConfig(newConfig)).rejects.toThrow('maxRetries'); + }); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index e947f559..f5569c5a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -526,6 +526,8 @@ setHealthWss(wss); // Track subscriptions: taskId -> Set of WebSocket clients const agentSubscriptions = new Map>(); +// Track chat subscriptions: sessionId -> Set of WebSocket clients +const chatSubscriptions = new Map>(); // ---- Heartbeat: server pings every WS_HEARTBEAT_INTERVAL_MS ---- const heartbeatInterval = setInterval(() => { @@ -598,11 +600,47 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => { ); let subscribedTaskId: string | null = null; + let subscribedChatSession: string | null = null; ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); + // Handle subscription to chat session + if (message.type === 'chat:subscribe' && message.sessionId) { + // Unsubscribe from previous chat session + if (subscribedChatSession) { + const subs = chatSubscriptions.get(subscribedChatSession); + if (subs) { + subs.delete(ws); + if (subs.size === 0) { + chatSubscriptions.delete(subscribedChatSession); + } + } + } + + // Subscribe to new chat session + const sessionId: string = message.sessionId; + subscribedChatSession = sessionId; + if (!chatSubscriptions.has(sessionId)) { + chatSubscriptions.set(sessionId, new Set()); + } + chatSubscriptions.get(sessionId)!.add(ws); + + // Send confirmation + ws.send( + JSON.stringify({ + type: 'chat:subscribed', + sessionId, + }) + ); + + log.debug( + { sessionId, clients: chatSubscriptions.get(sessionId)!.size }, + 'Chat subscription added' + ); + } + // Handle subscription to agent output if (message.type === 'subscribe' && message.taskId) { // Unsubscribe from previous task @@ -706,7 +744,7 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => { ws.heartbeatTimer = undefined; } - // Clean up subscriptions + // Clean up agent subscriptions if (subscribedTaskId) { const subs = agentSubscriptions.get(subscribedTaskId); if (subs) { @@ -716,11 +754,22 @@ wss.on('connection', (ws: HeartbeatWebSocket, req) => { } } } + + // Clean up chat subscriptions + if (subscribedChatSession) { + const subs = chatSubscriptions.get(subscribedChatSession); + if (subs) { + subs.delete(ws); + if (subs.size === 0) { + chatSubscriptions.delete(subscribedChatSession); + } + } + } }); }); // Export for use in other modules -export { wss }; +export { wss, chatSubscriptions }; // Graceful shutdown handler async function gracefulShutdown(signal: string) { diff --git a/server/src/routes/agent-routing.ts b/server/src/routes/agent-routing.ts new file mode 100644 index 00000000..42dcbb95 --- /dev/null +++ b/server/src/routes/agent-routing.ts @@ -0,0 +1,147 @@ +/** + * Agent Routing API Routes + * + * POST /api/agents/route — Resolve the best agent for a task + * GET /api/agents/routing — Get current routing configuration + * PUT /api/agents/routing — Update routing configuration + */ + +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { getAgentRoutingService } from '../services/agent-routing-service.js'; +import { getTaskService } from '../services/task-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; + +const router: RouterType = Router(); + +// ─── Validation Schemas ────────────────────────────────────────── + +const routeByTaskIdSchema = z.object({ + taskId: z.string().min(1), +}); + +const routeByMetadataSchema = z.object({ + type: z.string().optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + project: z.string().optional(), + subtaskCount: z.number().int().nonnegative().optional(), +}); + +const routingMatchSchema = z.object({ + type: z.union([z.string(), z.array(z.string())]).optional(), + priority: z + .union([z.enum(['low', 'medium', 'high']), z.array(z.enum(['low', 'medium', 'high']))]) + .optional(), + project: z.union([z.string(), z.array(z.string())]).optional(), + minSubtasks: z.number().int().nonnegative().optional(), +}); + +const routingRuleSchema = z.object({ + id: z.string().min(1).max(50), + name: z.string().min(1).max(200), + match: routingMatchSchema, + agent: z.string().min(1).max(50), + model: z.string().max(50).optional(), + fallback: z.string().max(50).optional(), + enabled: z.boolean(), +}); + +const routingConfigSchema = z.object({ + enabled: z.boolean(), + rules: z.array(routingRuleSchema), + defaultAgent: z.string().min(1).max(50), + defaultModel: z.string().max(50).optional(), + fallbackOnFailure: z.boolean(), + maxRetries: z.number().int().min(0).max(3), +}); + +// ─── Routes ────────────────────────────────────────────────────── + +/** + * POST /api/agents/route + * + * Resolve the best agent for a task. Accepts either: + * - { taskId: "..." } to look up an existing task + * - { type, priority, project, subtaskCount } for ad-hoc routing + */ +router.post( + '/route', + asyncHandler(async (req, res) => { + const routing = getAgentRoutingService(); + + // Try taskId first + const taskIdParse = routeByTaskIdSchema.safeParse(req.body); + if (taskIdParse.success) { + const taskService = getTaskService(); + const task = await taskService.getTask(taskIdParse.data.taskId); + if (!task) { + throw new NotFoundError('Task not found'); + } + const result = await routing.resolveAgent(task); + return res.json(result); + } + + // Fall back to metadata + const metaParse = routeByMetadataSchema.safeParse(req.body); + if (metaParse.success) { + const { type, priority, project, subtaskCount } = metaParse.data; + const result = await routing.resolveAgent({ + type: type || 'feature', + priority: priority || 'medium', + project, + subtasks: subtaskCount + ? Array.from({ length: subtaskCount }, (_, i) => ({ + id: `stub_${i}`, + title: '', + completed: false, + created: new Date().toISOString(), + })) + : undefined, + }); + return res.json(result); + } + + throw new ValidationError('Provide either { taskId } or { type, priority, ... }'); + }) +); + +/** + * GET /api/agents/routing + * + * Get the current routing configuration. + */ +router.get( + '/routing', + asyncHandler(async (_req, res) => { + const routing = getAgentRoutingService(); + const config = await routing.getRoutingConfig(); + res.json(config); + }) +); + +/** + * PUT /api/agents/routing + * + * Replace the entire routing configuration. + */ +router.put( + '/routing', + asyncHandler(async (req, res) => { + let parsed; + try { + parsed = routingConfigSchema.parse(req.body); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError('Invalid routing config', error.errors); + } + throw error; + } + + const routing = getAgentRoutingService(); + const updated = await routing.updateRoutingConfig(parsed); + res.json(updated); + }) +); + +export { router as agentRoutingRoutes }; diff --git a/server/src/routes/chat.ts b/server/src/routes/chat.ts new file mode 100644 index 00000000..1c754915 --- /dev/null +++ b/server/src/routes/chat.ts @@ -0,0 +1,228 @@ +/** + * Chat Routes + * + * Built-in chat interface for conversing with agents. + */ + +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { getChatService } from '../services/chat-service.js'; +import { sendGatewayChat, loadGatewayToken } from '../services/gateway-chat-client.js'; +import { broadcastChatMessage } from '../services/broadcast-service.js'; +import type { ChatSendInput } from '@veritas-kanban/shared'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; +import { createLogger } from '../lib/logger.js'; + +const log = createLogger('chat'); + +// Load gateway token on startup +loadGatewayToken().catch(() => {}); + +const router: RouterType = Router(); +const chatService = getChatService(); + +// Validation schemas +const chatSendSchema = z.object({ + sessionId: z.string().optional(), + taskId: z.string().optional(), + message: z.string().min(1, 'Message cannot be empty'), + agent: z.string().optional(), + model: z.string().optional(), + mode: z.enum(['ask', 'build']).optional(), +}); + +/** + * POST /api/chat/send + * Send a message to the chat interface + * + * Returns the user message echo immediately. + * Agent response will stream via WebSocket (chat:message events). + */ +router.post( + '/send', + asyncHandler(async (req, res) => { + // Validate input + const validatedInput = chatSendSchema.parse(req.body); + const input = validatedInput as ChatSendInput; + + let session; + let sessionId: string; + + // Get or create session + if (input.sessionId) { + session = await chatService.getSession(input.sessionId); + if (!session && input.taskId) { + // Session was deleted — recreate for task-scoped chats + log.info( + { sessionId: input.sessionId, taskId: input.taskId }, + 'Recreating deleted task chat session' + ); + session = await chatService.createSession({ + taskId: input.taskId, + agent: input.agent || 'veritas', + mode: input.mode || 'ask', + }); + } else if (!session) { + throw new NotFoundError(`Session ${input.sessionId} not found`); + } + sessionId = input.sessionId; + } else if (input.taskId) { + // Task-scoped session + session = await chatService.getSessionForTask(input.taskId); + if (!session) { + // Create new task-scoped session + session = await chatService.createSession({ + taskId: input.taskId, + agent: input.agent || 'veritas', + mode: input.mode || 'ask', + }); + } + sessionId = session.id; + } else { + // Create new board-level session + session = await chatService.createSession({ + agent: input.agent || 'veritas', + mode: input.mode || 'ask', + }); + sessionId = session.id; + } + + // Add user message + const userMessage = await chatService.addMessage(sessionId, { + role: 'user', + content: input.message, + }); + + log.info({ sessionId, messageId: userMessage.id, taskId: session.taskId }, 'Chat message sent'); + + // Return immediately - agent response will arrive async + res.status(200).json({ + sessionId, + messageId: userMessage.id, + message: 'Message sent — agent response incoming', + }); + + // Trigger async AI response via Clawdbot Gateway + const gatewaySessionKey = `kanban-chat-${sessionId}`; + + sendGatewayChat(input.message, gatewaySessionKey, { + onDelta: (text) => { + // Broadcast streaming chunk to kanban WebSocket clients + broadcastChatMessage(sessionId, { + type: 'chat:delta', + sessionId, + text, + }); + }, + onFinal: async (response) => { + try { + // Save the assistant response to the session + const assistantMessage = await chatService.addMessage(sessionId, { + role: 'assistant', + content: response.text, + agent: session.agent, + }); + + log.info({ sessionId, messageId: assistantMessage.id }, 'Assistant response saved'); + + // Broadcast final message to kanban WebSocket clients + broadcastChatMessage(sessionId, { + type: 'chat:message', + sessionId, + message: assistantMessage, + }); + } catch (err: any) { + log.error({ err: err.message, sessionId }, 'Failed to save assistant response'); + } + }, + onError: async (error) => { + log.error({ error, sessionId }, 'Gateway chat error'); + + // Save error as system message + try { + await chatService.addMessage(sessionId, { + role: 'system', + content: `Error: ${error}`, + }); + + broadcastChatMessage(sessionId, { + type: 'chat:error', + sessionId, + error, + }); + } catch (err: any) { + log.error({ err: err.message }, 'Failed to save error message'); + } + }, + }).catch((err) => { + log.error({ err: err.message, sessionId }, 'Gateway chat failed'); + }); + }) +); + +/** + * GET /api/chat/sessions + * List all board-level chat sessions + */ +router.get( + '/sessions', + asyncHandler(async (_req, res) => { + const sessions = await chatService.listSessions(); + res.json(sessions); + }) +); + +/** + * GET /api/chat/sessions/:id + * Get a specific chat session with messages + */ +router.get( + '/sessions/:id', + asyncHandler(async (req, res) => { + const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const session = await chatService.getSession(sessionId); + + if (!session) { + throw new NotFoundError(`Session ${sessionId} not found`); + } + + res.json(session); + }) +); + +/** + * GET /api/chat/sessions/:id/history + * Get messages for a session (messages only, no metadata) + */ +router.get( + '/sessions/:id/history', + asyncHandler(async (req, res) => { + const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const session = await chatService.getSession(sessionId); + + if (!session) { + throw new NotFoundError(`Session ${sessionId} not found`); + } + + res.json(session.messages); + }) +); + +/** + * DELETE /api/chat/sessions/:id + * Delete a chat session + */ +router.delete( + '/sessions/:id', + asyncHandler(async (req, res) => { + const sessionId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + await chatService.deleteSession(sessionId); + + log.info({ sessionId }, 'Chat session deleted'); + + res.status(204).send(); + }) +); + +export { router as chatRoutes }; diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index c377842b..1c5652f4 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -26,6 +26,7 @@ const createTaskSchema = z.object({ priority: z.enum(['low', 'medium', 'high']).optional().default('medium'), project: z.string().optional(), sprint: z.string().optional(), + agent: z.string().max(50).optional(), // "auto" | agent type slug }); const gitSchema = z @@ -103,6 +104,7 @@ const updateTaskSchema = z.object({ priority: z.enum(['low', 'medium', 'high']).optional(), project: z.string().optional(), sprint: z.string().optional(), + agent: z.string().max(50).optional(), git: gitSchema, attempt: attemptSchema, reviewComments: z.array(reviewCommentSchema).optional(), diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts index 5ffe4c60..4dd01089 100644 --- a/server/src/routes/v1/index.ts +++ b/server/src/routes/v1/index.ts @@ -27,7 +27,9 @@ import attachmentRoutes from '../attachments.js'; // Feature routes import { configRoutes } from '../config.js'; +import { chatRoutes } from '../chat.js'; import { agentRoutes } from '../agents.js'; +import { agentRoutingRoutes } from '../agent-routing.js'; import { diffRoutes } from '../diff.js'; import { automationRoutes } from '../automation.js'; import { summaryRoutes } from '../summary.js'; @@ -85,6 +87,8 @@ v1Router.use( // ── Feature routes ─────────────────────────────────────────── v1Router.use('/config', configRoutes); +v1Router.use('/chat', chatRoutes); // Chat interface - must be before agent routes +v1Router.use('/agents', agentRoutingRoutes); // Must be before agentRoutes (/:taskId would match "route"/"routing") v1Router.use('/agents', agentRoutes); v1Router.use('/diff', diffRoutes); v1Router.use('/automation', automationRoutes); diff --git a/server/src/services/agent-routing-service.ts b/server/src/services/agent-routing-service.ts new file mode 100644 index 00000000..af116e7a --- /dev/null +++ b/server/src/services/agent-routing-service.ts @@ -0,0 +1,224 @@ +/** + * AgentRoutingService - Task-aware agent/model routing + * + * Matches task metadata (type, priority, project, complexity) against + * user-configured routing rules to select the optimal agent and model. + * + * Rules are evaluated in order — first match wins. + * Falls back to the configured default when no rules match. + */ + +import { ConfigService } from './config-service.js'; +import { + DEFAULT_ROUTING_CONFIG, + type AgentRoutingConfig, + type RoutingRule, + type RoutingResult, + type RoutingMatchCriteria, +} from '@veritas-kanban/shared'; +import type { Task, AgentType, TaskPriority } from '@veritas-kanban/shared'; +import { createLogger } from '../lib/logger.js'; + +const log = createLogger('agent-routing'); + +export class AgentRoutingService { + private configService: ConfigService; + + constructor(configService?: ConfigService) { + this.configService = configService || new ConfigService(); + } + + /** + * Resolve the best agent for a given task. + * + * @param task - Full task object (or partial with type/priority/project/subtasks) + * @returns RoutingResult with the selected agent, optional model, fallback, and reasoning + */ + async resolveAgent( + task: Pick + ): Promise { + const config = await this.configService.getConfig(); + const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG; + + // If routing is disabled, return the global default + if (!routing.enabled) { + return { + agent: routing.defaultAgent || config.defaultAgent, + model: routing.defaultModel, + reason: 'Routing disabled — using default agent', + }; + } + + // Evaluate rules in order (first match wins) + for (const rule of routing.rules) { + if (!rule.enabled) continue; + + if (this.matchesRule(task, rule.match)) { + // Verify the agent is actually configured and enabled + const agentConfig = config.agents.find((a) => a.type === rule.agent); + if (!agentConfig?.enabled) { + log.warn(`Rule "${rule.name}" matched but agent "${rule.agent}" is disabled — skipping`); + continue; + } + + log.info( + `Task [type=${task.type}, priority=${task.priority}] matched rule "${rule.name}" → ${rule.agent}${rule.model ? ` (${rule.model})` : ''}` + ); + return { + agent: rule.agent, + model: rule.model, + fallback: rule.fallback, + rule: rule.id, + reason: `Matched rule: ${rule.name}`, + }; + } + } + + // No rule matched — use defaults + log.info( + `Task [type=${task.type}, priority=${task.priority}] — no rules matched, using default: ${routing.defaultAgent}` + ); + return { + agent: routing.defaultAgent || config.defaultAgent, + model: routing.defaultModel, + reason: 'No routing rules matched — using default agent', + }; + } + + /** + * Get the fallback agent for a given primary agent. + * Used when an agent fails and `fallbackOnFailure` is enabled. + */ + async getFallback( + task: Pick, + failedAgent: AgentType + ): Promise { + const config = await this.configService.getConfig(); + const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG; + + if (!routing.fallbackOnFailure) { + return null; + } + + // Find the rule that originally matched (to get its fallback) + for (const rule of routing.rules) { + if (!rule.enabled) continue; + if (rule.agent !== failedAgent) continue; + if (!rule.fallback) continue; + if (!this.matchesRule(task, rule.match)) continue; + + const fallbackConfig = config.agents.find((a) => a.type === rule.fallback); + if (!fallbackConfig?.enabled) { + log.warn(`Fallback agent "${rule.fallback}" for rule "${rule.name}" is disabled`); + continue; + } + + log.info(`Falling back from ${failedAgent} → ${rule.fallback} (rule: ${rule.name})`); + return { + agent: rule.fallback, + rule: rule.id, + reason: `Fallback: ${failedAgent} failed → ${rule.fallback} (rule: ${rule.name})`, + }; + } + + // No specific fallback found — try default if it's different from failed + const defaultAgent = routing.defaultAgent || config.defaultAgent; + if (defaultAgent !== failedAgent) { + const defaultConfig = config.agents.find((a) => a.type === defaultAgent); + if (defaultConfig?.enabled) { + return { + agent: defaultAgent, + model: routing.defaultModel, + reason: `Fallback: ${failedAgent} failed → default agent (${defaultAgent})`, + }; + } + } + + return null; + } + + /** + * Get the current routing config (for UI display). + */ + async getRoutingConfig(): Promise { + const config = await this.configService.getConfig(); + return config.agentRouting || DEFAULT_ROUTING_CONFIG; + } + + /** + * Update routing config. + */ + async updateRoutingConfig(routing: AgentRoutingConfig): Promise { + // Validate rule IDs are unique + const ids = routing.rules.map((r) => r.id); + const uniqueIds = new Set(ids); + if (ids.length !== uniqueIds.size) { + throw new Error('Routing rule IDs must be unique'); + } + + // Validate maxRetries range + if (routing.maxRetries < 0 || routing.maxRetries > 3) { + throw new Error('maxRetries must be between 0 and 3'); + } + + const config = await this.configService.getConfig(); + await this.configService.saveConfig({ ...config, agentRouting: routing }); + return routing; + } + + // ─── Private helpers ─────────────────────────────────────────── + + /** + * Check if a task matches a rule's criteria. + * All specified criteria must match (AND logic). + * Unspecified criteria are ignored (wildcard). + */ + private matchesRule( + task: Pick, + match: RoutingMatchCriteria + ): boolean { + // Type check + if (match.type !== undefined) { + if (!this.matchesValue(task.type, match.type)) return false; + } + + // Priority check + if (match.priority !== undefined) { + if (!this.matchesValue(task.priority, match.priority)) return false; + } + + // Project check + if (match.project !== undefined) { + if (!task.project) return false; + if (!this.matchesValue(task.project, match.project)) return false; + } + + // Complexity (subtask count) + if (match.minSubtasks !== undefined) { + const subtaskCount = task.subtasks?.length ?? 0; + if (subtaskCount < match.minSubtasks) return false; + } + + return true; + } + + /** + * Check if a value matches a single value or array of acceptable values. + */ + private matchesValue(actual: T, expected: T | T[]): boolean { + if (Array.isArray(expected)) { + return expected.includes(actual); + } + return actual === expected; + } +} + +// Singleton +let _instance: AgentRoutingService | null = null; + +export function getAgentRoutingService(): AgentRoutingService { + if (!_instance) { + _instance = new AgentRoutingService(); + } + return _instance; +} diff --git a/server/src/services/broadcast-service.ts b/server/src/services/broadcast-service.ts index f642e7e5..114914b6 100644 --- a/server/src/services/broadcast-service.ts +++ b/server/src/services/broadcast-service.ts @@ -11,7 +11,13 @@ export function initBroadcast(wss: WebSocketServer): void { wssRef = wss; } -export type TaskChangeType = 'created' | 'updated' | 'deleted' | 'archived' | 'restored' | 'reordered'; +export type TaskChangeType = + | 'created' + | 'updated' + | 'deleted' + | 'archived' + | 'restored' + | 'reordered'; export interface TaskChangeEvent { type: 'task:changed'; @@ -42,7 +48,31 @@ export function broadcastTaskChange(changeType: TaskChangeType, taskId?: string) const payload = JSON.stringify(message); wssRef.clients.forEach((client: WebSocket) => { - if (client.readyState === 1) { // WebSocket.OPEN = 1 + if (client.readyState === 1) { + // WebSocket.OPEN = 1 + client.send(payload); + } + }); +} + +export interface ChatBroadcastEvent { + type: 'chat:delta' | 'chat:message' | 'chat:error'; + sessionId: string; + text?: string; + message?: unknown; + error?: string; +} + +/** + * Broadcast a chat message/event to all connected WebSocket clients. + */ +export function broadcastChatMessage(sessionId: string, event: ChatBroadcastEvent): void { + if (!wssRef) return; + + const payload = JSON.stringify(event); + + wssRef.clients.forEach((client: WebSocket) => { + if (client.readyState === 1) { client.send(payload); } }); @@ -63,7 +93,8 @@ export function broadcastTelemetryEvent(event: AnyTelemetryEvent): void { const payload = JSON.stringify(message); wssRef.clients.forEach((client: WebSocket) => { - if (client.readyState === 1) { // WebSocket.OPEN = 1 + if (client.readyState === 1) { + // WebSocket.OPEN = 1 client.send(payload); } }); diff --git a/server/src/services/chat-service.ts b/server/src/services/chat-service.ts new file mode 100644 index 00000000..a2127be4 --- /dev/null +++ b/server/src/services/chat-service.ts @@ -0,0 +1,326 @@ +/** + * Chat Service + * + * Manages chat sessions stored as markdown files with YAML frontmatter. + * - Task-scoped sessions: .veritas-kanban/chats/task_{taskId}.md + * - Board-level sessions: .veritas-kanban/chats/sessions/{sessionId}.md + */ + +import fs from 'fs/promises'; +import path from 'path'; +import matter from 'gray-matter'; +import { nanoid } from 'nanoid'; +import type { ChatSession, ChatMessage } from '@veritas-kanban/shared'; +import { withFileLock } from './file-lock.js'; +import { createLogger } from '../lib/logger.js'; + +const log = createLogger('chat-service'); + +// Default paths - resolve to .veritas-kanban/chats/ +const DEFAULT_PROJECT_ROOT = path.resolve(process.cwd(), '..'); +const DEFAULT_CHATS_DIR = path.join(DEFAULT_PROJECT_ROOT, '.veritas-kanban', 'chats'); +const DEFAULT_SESSIONS_DIR = path.join(DEFAULT_CHATS_DIR, 'sessions'); + +export interface ChatServiceOptions { + chatsDir?: string; +} + +export class ChatService { + private chatsDir: string; + private sessionsDir: string; + + constructor(options: ChatServiceOptions = {}) { + this.chatsDir = options.chatsDir || DEFAULT_CHATS_DIR; + this.sessionsDir = path.join(this.chatsDir, 'sessions'); + this.ensureDirectories(); + } + + private async ensureDirectories(): Promise { + await fs.mkdir(this.chatsDir, { recursive: true }); + await fs.mkdir(this.sessionsDir, { recursive: true }); + } + + /** + * Generate a new session ID + */ + private generateSessionId(): string { + return `chat_${nanoid(12)}`; + } + + /** + * Generate a new message ID + */ + private generateMessageId(): string { + return `msg_${nanoid(10)}`; + } + + /** + * Get file path for a session + */ + private getSessionPath(sessionId: string, taskId?: string): string { + if (taskId) { + return path.join(this.chatsDir, `task_${taskId}.md`); + } + return path.join(this.sessionsDir, `${sessionId}.md`); + } + + /** + * Parse a session from markdown file + */ + private parseSession(filePath: string, content: string): ChatSession { + const { data, content: markdown } = matter(content); + + // Parse messages from markdown (simple format: role + content blocks) + const messages: ChatMessage[] = []; + const messageBlocks = markdown.split(/\n---\n/); + + for (const block of messageBlocks) { + if (!block.trim()) continue; + + const lines = block.trim().split('\n'); + const metaLine = lines[0]; + const messageContent = lines.slice(1).join('\n').trim(); + + // Parse meta line: **id** | role | timestamp | [agent] | [model] + const match = metaLine.match( + /^\*\*(.+?)\*\*\s*\|\s*(\w+)\s*\|\s*(.+?)(?:\s*\|\s*(.+?))?(?:\s*\|\s*(.+?))?$/ + ); + + if (match) { + const [, id, role, timestamp, agent, model] = match; + messages.push({ + id, + role: role as 'user' | 'assistant' | 'system', + content: messageContent, + timestamp, + agent: agent || undefined, + model: model || undefined, + }); + } + } + + return { + id: data.id, + taskId: data.taskId, + title: data.title, + messages, + agent: data.agent, + model: data.model, + mode: data.mode || 'ask', + created: data.created, + updated: data.updated, + }; + } + + /** + * Serialize a session to markdown with YAML frontmatter + */ + private serializeSession(session: ChatSession): string { + const frontmatter = { + id: session.id, + taskId: session.taskId, + title: session.title, + agent: session.agent, + model: session.model, + mode: session.mode, + created: session.created, + updated: session.updated, + }; + + // Remove undefined values + Object.keys(frontmatter).forEach((key) => { + if (frontmatter[key as keyof typeof frontmatter] === undefined) { + delete frontmatter[key as keyof typeof frontmatter]; + } + }); + + // Serialize messages as markdown blocks + const messageBlocks = session.messages.map((msg) => { + const meta = [`**${msg.id}**`, msg.role, msg.timestamp, msg.agent || '', msg.model || ''] + .filter(Boolean) + .join(' | '); + + return `${meta}\n\n${msg.content}`; + }); + + const markdown = messageBlocks.join('\n\n---\n\n'); + + return matter.stringify(markdown, frontmatter); + } + + /** + * Get a session by ID + */ + async getSession(sessionId: string): Promise { + // Try to find the session file (could be task-scoped or board-level) + // First check if it's a task-scoped session + const taskMatch = sessionId.match(/^task_(.+)$/); + if (taskMatch) { + const taskId = taskMatch[1]; + const filePath = this.getSessionPath(sessionId, taskId); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + return this.parseSession(filePath, content); + } catch (err: any) { + if (err.code === 'ENOENT') return null; + throw err; + } + } + + // Board-level session + const filePath = this.getSessionPath(sessionId); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + return this.parseSession(filePath, content); + } catch (err: any) { + if (err.code === 'ENOENT') return null; + throw err; + } + } + + /** + * Get the session for a specific task + */ + async getSessionForTask(taskId: string): Promise { + const filePath = this.getSessionPath(`task_${taskId}`, taskId); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + return this.parseSession(filePath, content); + } catch (err: any) { + if (err.code === 'ENOENT') return null; + throw err; + } + } + + /** + * List all sessions (board-level only) + */ + async listSessions(): Promise { + try { + const files = await fs.readdir(this.sessionsDir); + const sessions: ChatSession[] = []; + + for (const file of files) { + if (!file.endsWith('.md')) continue; + + const filePath = path.join(this.sessionsDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + sessions.push(this.parseSession(filePath, content)); + } + + // Sort by updated time (newest first) + sessions.sort((a, b) => new Date(b.updated).getTime() - new Date(a.updated).getTime()); + + return sessions; + } catch (err: any) { + if (err.code === 'ENOENT') return []; + throw err; + } + } + + /** + * Create a new session + */ + async createSession(input: { + taskId?: string; + agent: string; + mode?: 'ask' | 'build'; + }): Promise { + const sessionId = input.taskId ? `task_${input.taskId}` : this.generateSessionId(); + const now = new Date().toISOString(); + + const session: ChatSession = { + id: sessionId, + taskId: input.taskId, + title: input.taskId ? `Task ${input.taskId}` : 'New Conversation', + messages: [], + agent: input.agent, + mode: input.mode || 'ask', + created: now, + updated: now, + }; + + const filePath = this.getSessionPath(sessionId, input.taskId); + const content = this.serializeSession(session); + + await withFileLock(filePath, async () => { + await fs.writeFile(filePath, content, 'utf-8'); + }); + + log.info({ sessionId, taskId: input.taskId }, 'Created chat session'); + + return session; + } + + /** + * Add a message to a session + */ + async addMessage( + sessionId: string, + message: Omit + ): Promise { + const session = await this.getSession(sessionId); + + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + + const newMessage: ChatMessage = { + id: this.generateMessageId(), + timestamp: new Date().toISOString(), + ...message, + }; + + session.messages.push(newMessage); + session.updated = newMessage.timestamp; + + const filePath = this.getSessionPath(sessionId, session.taskId); + const content = this.serializeSession(session); + + await withFileLock(filePath, async () => { + await fs.writeFile(filePath, content, 'utf-8'); + }); + + log.debug({ sessionId, messageId: newMessage.id, role: newMessage.role }, 'Added message'); + + return newMessage; + } + + /** + * Delete a session + */ + async deleteSession(sessionId: string): Promise { + const session = await this.getSession(sessionId); + + if (!session) { + // Already gone — treat as success + log.info({ sessionId }, 'Chat session already deleted or never existed'); + return; + } + + const filePath = this.getSessionPath(sessionId, session.taskId); + + await withFileLock(filePath, async () => { + try { + await fs.unlink(filePath); + } catch (err: any) { + if (err.code !== 'ENOENT') throw err; + } + }); + + log.info({ sessionId }, 'Deleted chat session'); + } +} + +// Singleton instance +let chatService: ChatService | null = null; + +export function getChatService(): ChatService { + if (!chatService) { + chatService = new ChatService(); + } + return chatService; +} diff --git a/server/src/services/clawdbot-agent-service.ts b/server/src/services/clawdbot-agent-service.ts index bac67a6b..40513bdf 100644 --- a/server/src/services/clawdbot-agent-service.ts +++ b/server/src/services/clawdbot-agent-service.ts @@ -16,6 +16,7 @@ import fs from 'fs/promises'; import path from 'path'; import { ConfigService } from './config-service.js'; import { TaskService } from './task-service.js'; +import { getAgentRoutingService } from './agent-routing-service.js'; import { getBreaker } from './circuit-registry.js'; import type { Task, AgentType, TaskAttempt, AttemptStatus } from '@veritas-kanban/shared'; import { createLogger } from '../lib/logger.js'; @@ -95,9 +96,22 @@ export class ClawdbotAgentService { throw new Error('An agent is already running for this task'); } - // Get agent config + // Get agent config — use routing engine when agent is "auto" or not specified const config = await this.configService.getConfig(); - const agent = agentType || config.defaultAgent; + let agent: AgentType; + let routingReason: string | undefined; + + if (!agentType || agentType === 'auto') { + const routing = getAgentRoutingService(); + const result = await routing.resolveAgent(task); + agent = result.agent; + routingReason = result.reason; + log.info( + `[ClawdbotAgent] Routing resolved agent for task ${taskId}: ${agent} (${routingReason})` + ); + } else { + agent = agentType; + } // Create attempt const attemptId = `attempt_${nanoid(8)}`; diff --git a/server/src/services/gateway-chat-client.ts b/server/src/services/gateway-chat-client.ts new file mode 100644 index 00000000..b0b99881 --- /dev/null +++ b/server/src/services/gateway-chat-client.ts @@ -0,0 +1,270 @@ +/** + * Gateway Chat Client + * + * Connects to the Clawdbot Gateway WebSocket to proxy chat messages. + * Handles authentication, message sending, and response collection. + */ + +import WebSocket from 'ws'; +import { randomUUID } from 'crypto'; +import { createLogger } from '../lib/logger.js'; + +const log = createLogger('gateway-chat'); + +const GATEWAY_URL = process.env.CLAWDBOT_GATEWAY || 'http://127.0.0.1:18789'; +const PROTOCOL_VERSION = 3; +const CONNECT_TIMEOUT_MS = 10_000; +const RESPONSE_TIMEOUT_MS = 120_000; // 2 minutes for AI response + +// Cached token — populated lazily +let cachedToken: string | null = null; + +function getToken(): string { + if (cachedToken) return cachedToken; + return process.env.CLAWDBOT_GATEWAY_TOKEN || ''; +} + +interface ChatResponse { + text: string; + usage?: Record; + error?: string; +} + +interface StreamCallbacks { + onDelta?: (text: string) => void; + onFinal?: (response: ChatResponse) => void; + onError?: (error: string) => void; +} + +/** + * Send a message to the Clawdbot Gateway and collect the response. + * Opens a temporary WebSocket connection for each request. + */ +export async function sendGatewayChat( + message: string, + sessionKey: string, + callbacks?: StreamCallbacks +): Promise { + const wsUrl = GATEWAY_URL.replace(/^http/, 'ws'); + + return new Promise((resolve, reject) => { + let connected = false; + let responseText = ''; + let responseUsage: Record | undefined; + let connectTimer: ReturnType; + let responseTimer: ReturnType; + + const ws = new WebSocket(wsUrl); + + const cleanup = () => { + clearTimeout(connectTimer); + clearTimeout(responseTimer); + try { + ws.close(); + } catch { + /* ignore */ + } + }; + + connectTimer = setTimeout(() => { + if (!connected) { + cleanup(); + const err = 'Gateway connection timeout'; + callbacks?.onError?.(err); + reject(new Error(err)); + } + }, CONNECT_TIMEOUT_MS); + + ws.on('error', (err) => { + log.error({ err: err.message }, 'Gateway WebSocket error'); + cleanup(); + const errMsg = `Gateway connection failed: ${err.message}`; + callbacks?.onError?.(errMsg); + reject(new Error(errMsg)); + }); + + ws.on('message', (data) => { + let msg: any; + try { + msg = JSON.parse(data.toString()); + } catch { + return; + } + + // Step 1: Handle challenge → send connect + if (msg.type === 'event' && msg.event === 'connect.challenge') { + ws.send( + JSON.stringify({ + type: 'req', + id: randomUUID(), + method: 'connect', + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: 'gateway-client', + version: '1.0.0', + platform: 'node', + mode: 'backend', + }, + auth: { token: getToken() }, + }, + }) + ); + return; + } + + // Step 2: Handle connect response → send chat.send + if (msg.type === 'res' && msg.ok && msg.payload?.type === 'hello-ok') { + connected = true; + clearTimeout(connectTimer); + + log.info({ sessionKey }, 'Connected to gateway, sending chat message'); + + // Start response timeout + responseTimer = setTimeout(() => { + cleanup(); + const err = 'Gateway response timeout'; + callbacks?.onError?.(err); + reject(new Error(err)); + }, RESPONSE_TIMEOUT_MS); + + ws.send( + JSON.stringify({ + type: 'req', + id: randomUUID(), + method: 'chat.send', + params: { + sessionKey, + message, + idempotencyKey: randomUUID(), + }, + }) + ); + return; + } + + // Handle chat.send ack + if (msg.type === 'res' && msg.ok && msg.payload?.runId) { + log.debug({ runId: msg.payload.runId }, 'Chat run started'); + return; + } + + // Handle errors + if (msg.type === 'res' && !msg.ok) { + cleanup(); + const errMsg = msg.error?.message || 'Unknown gateway error'; + log.error({ error: msg.error }, 'Gateway error'); + callbacks?.onError?.(errMsg); + reject(new Error(errMsg)); + return; + } + + // Step 3: Handle streaming chat events + if (msg.type === 'event' && msg.event === 'chat') { + const payload = msg.payload || {}; + + if (payload.state === 'delta') { + // Gateway sends full accumulated text in each delta, not incremental chunks + const content = payload.message?.content; + if (Array.isArray(content)) { + let fullText = ''; + for (const block of content) { + if (block.type === 'text' && block.text) { + fullText += block.text; + } + } + // Calculate the new chunk (what was added since last delta) + const newChunk = fullText.slice(responseText.length); + responseText = fullText; + if (newChunk) { + callbacks?.onDelta?.(newChunk); + } + } + } + + if (payload.state === 'final') { + // Extract final text if we didn't get it from deltas + if (!responseText && payload.message?.content) { + const content = payload.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (typeof content === 'string') { + responseText = content; + } + } + + responseUsage = payload.usage; + + const response: ChatResponse = { + text: responseText, + usage: responseUsage, + }; + + log.info({ sessionKey, textLength: responseText.length }, 'Chat response complete'); + cleanup(); + callbacks?.onFinal?.(response); + resolve(response); + return; + } + + if (payload.state === 'error') { + cleanup(); + const errMsg = payload.errorMessage || 'Chat error'; + callbacks?.onError?.(errMsg); + reject(new Error(errMsg)); + return; + } + + if (payload.state === 'aborted') { + cleanup(); + const response: ChatResponse = { + text: responseText || '(response aborted)', + }; + callbacks?.onFinal?.(response); + resolve(response); + return; + } + } + }); + + ws.on('close', () => { + if (!connected) { + reject(new Error('Gateway WebSocket closed before connecting')); + } + }); + }); +} + +/** + * Load the gateway token from config file if not in env + */ +export async function loadGatewayToken(): Promise { + if (cachedToken) return cachedToken; + if (process.env.CLAWDBOT_GATEWAY_TOKEN) { + cachedToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + return cachedToken; + } + + try { + const fs = await import('fs/promises'); + const path = await import('path'); + const configPath = path.join(process.env.HOME || '', '.clawdbot', 'clawdbot.json'); + const raw = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(raw); + const token = config?.gateway?.auth?.token; + if (token) { + cachedToken = token; + process.env.CLAWDBOT_GATEWAY_TOKEN = token; + return token; + } + } catch (err: any) { + log.warn({ err: err.message }, 'Failed to load gateway token from config'); + } + + return ''; +} diff --git a/server/src/services/task-service.ts b/server/src/services/task-service.ts index 43fcd9a5..af8b4b61 100644 --- a/server/src/services/task-service.ts +++ b/server/src/services/task-service.ts @@ -391,6 +391,7 @@ export class TaskService { priority: input.priority || 'medium', project: input.project, sprint: input.sprint, + agent: input.agent, // Pre-assigned agent (or "auto" for routing) subtasks: input.subtasks, // Include subtasks from template blockedBy: input.blockedBy, // Include dependencies from blueprint created: now, diff --git a/shared/src/types/chat.types.ts b/shared/src/types/chat.types.ts new file mode 100644 index 00000000..12c70fd6 --- /dev/null +++ b/shared/src/types/chat.types.ts @@ -0,0 +1,41 @@ +/** + * Chat Interface Types + * + * Built-in chat interface for conversing with agents about tasks or the board. + */ + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: string; + agent?: string; // Which agent responded + model?: string; // Which model was used + toolCalls?: Array<{ + // Collapsible tool-use blocks + name: string; + input: string; + output?: string; + }>; +} + +export interface ChatSession { + id: string; + taskId?: string; // Task-scoped (undefined = board-level) + title: string; + messages: ChatMessage[]; + agent: string; // Current agent for this session + model?: string; + mode: 'ask' | 'build'; // Ask = read-only, Build = can mutate + created: string; + updated: string; +} + +export interface ChatSendInput { + sessionId?: string; // Existing session (omit for new) + taskId?: string; // Task context + message: string; + agent?: string; // Override agent + model?: string; // Override model + mode?: 'ask' | 'build'; +} diff --git a/shared/src/types/config.types.ts b/shared/src/types/config.types.ts index e5fbeab1..5c6eccfc 100644 --- a/shared/src/types/config.types.ts +++ b/shared/src/types/config.types.ts @@ -4,8 +4,8 @@ import type { AgentType, TaskPriority } from './task.types.js'; import type { TelemetryConfig } from './telemetry.types.js'; export interface DevServerConfig { - command: string; // e.g., "pnpm dev" or "npm run dev" - port?: number; // Expected port (auto-detected if not specified) + command: string; // e.g., "pnpm dev" or "npm run dev" + port?: number; // Expected port (auto-detected if not specified) readyPattern?: string; // Regex pattern to detect when server is ready } @@ -24,10 +24,106 @@ export interface AgentConfig { enabled: boolean; } +// ============ Agent Routing Types ============ + +/** Criteria for matching a task to a routing rule */ +export interface RoutingMatchCriteria { + type?: string | string[]; // Task type(s) — e.g. "code", "bug", "docs" + priority?: TaskPriority | TaskPriority[]; // Task priority(ies) + project?: string | string[]; // Project slug(s) + /** Min subtask count to consider "complex" */ + minSubtasks?: number; +} + +/** A single routing rule: match criteria → agent selection */ +export interface RoutingRule { + id: string; // Unique rule ID for CRUD + name: string; // Human-readable name + match: RoutingMatchCriteria; // Conditions to match + agent: AgentType; // Primary agent to use + model?: string; // Optional model override (e.g. "opus", "sonnet") + fallback?: AgentType; // Fallback agent if primary fails + enabled: boolean; // Can disable without deleting +} + +/** Top-level routing configuration */ +export interface AgentRoutingConfig { + enabled: boolean; // Master toggle for routing engine + rules: RoutingRule[]; // Ordered list — first match wins + defaultAgent: AgentType; // Fallback when no rules match + defaultModel?: string; // Default model for the default agent + fallbackOnFailure: boolean; // Auto-retry with fallback on failure + maxRetries: number; // Max retries before giving up (0-3) +} + +/** Result from the routing engine */ +export interface RoutingResult { + agent: AgentType; + model?: string; + fallback?: AgentType; + rule?: string; // ID of matched rule (undefined = default) + reason: string; // Human-readable explanation +} + +/** Default routing config */ +export const DEFAULT_ROUTING_CONFIG: AgentRoutingConfig = { + enabled: true, + rules: [ + { + id: 'code-high', + name: 'High-priority code → Claude Code (Opus)', + match: { type: 'code', priority: 'high' }, + agent: 'claude-code', + model: 'opus', + fallback: 'amp', + enabled: true, + }, + { + id: 'code-default', + name: 'Code tasks → Claude Code (Sonnet)', + match: { type: 'code' }, + agent: 'claude-code', + model: 'sonnet', + fallback: 'copilot', + enabled: true, + }, + { + id: 'bug-high', + name: 'High-priority bugs → Claude Code (Opus)', + match: { type: 'bug', priority: 'high' }, + agent: 'claude-code', + model: 'opus', + fallback: 'amp', + enabled: true, + }, + { + id: 'docs', + name: 'Documentation → Claude Code (Haiku)', + match: { type: 'docs' }, + agent: 'claude-code', + model: 'haiku', + enabled: true, + }, + { + id: 'review', + name: 'Code review → Claude Code (Opus)', + match: { type: 'review' }, + agent: 'claude-code', + model: 'opus', + enabled: true, + }, + ], + defaultAgent: 'claude-code', + defaultModel: 'sonnet', + fallbackOnFailure: true, + maxRetries: 1, +}; + export interface AppConfig { repos: RepoConfig[]; agents: AgentConfig[]; defaultAgent: AgentType; + agentRouting?: AgentRoutingConfig; telemetry?: TelemetryConfig; features?: FeatureSettings; } @@ -52,16 +148,16 @@ export interface TaskBehaviorSettings { enableSubtaskAutoComplete: boolean; enableDependencies: boolean; enableAttachments: boolean; - attachmentMaxFileSize: number; // bytes + attachmentMaxFileSize: number; // bytes attachmentMaxPerTask: number; - attachmentMaxTotalSize: number; // bytes + attachmentMaxTotalSize: number; // bytes enableComments: boolean; defaultPriority: TaskPriority; } /** Agent & git settings */ export interface AgentBehaviorSettings { - timeoutMinutes: number; // 5-480 + timeoutMinutes: number; // 5-480 autoCommitOnComplete: boolean; autoCleanupWorktrees: boolean; enablePreview: boolean; @@ -70,7 +166,7 @@ export interface AgentBehaviorSettings { /** Telemetry & activity settings */ export interface TelemetryFeatureSettings { enabled: boolean; - retentionDays: number; // 7-365 + retentionDays: number; // 7-365 enableTraces: boolean; enableActivityTracking: boolean; } @@ -81,8 +177,8 @@ export interface NotificationSettings { onTaskComplete: boolean; onAgentFailure: boolean; onReviewNeeded: boolean; - channel: string; // Teams channel ID - webhookUrl?: string; // Optional: Teams webhook URL for immediate delivery + channel: string; // Teams channel ID + webhookUrl?: string; // Optional: Teams webhook URL for immediate delivery } /** Archive settings */ @@ -94,9 +190,9 @@ export interface ArchiveSettings { /** Budget tracking settings */ export interface BudgetSettings { enabled: boolean; - monthlyTokenLimit: number; // Monthly token budget (0 = no limit) - monthlyCostLimit: number; // Monthly cost budget in dollars (0 = no limit) - warningThreshold: number; // Percentage threshold for warning (0-100, default 80) + monthlyTokenLimit: number; // Monthly token budget (0 = no limit) + monthlyCostLimit: number; // Monthly cost budget in dollars (0 = no limit) + warningThreshold: number; // Percentage threshold for warning (0-100, default 80) } /** All feature settings combined */ @@ -127,9 +223,9 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = { enableSubtaskAutoComplete: true, enableDependencies: true, enableAttachments: true, - attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB + attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB attachmentMaxPerTask: 20, - attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB + attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB enableComments: true, defaultPriority: 'medium', }, @@ -158,8 +254,8 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = { }, budget: { enabled: true, - monthlyTokenLimit: 0, // 0 = no limit - monthlyCostLimit: 0, // 0 = no limit (dollars) - warningThreshold: 80, // Warn at 80% of budget + monthlyTokenLimit: 0, // 0 = no limit + monthlyCostLimit: 0, // 0 = no limit (dollars) + warningThreshold: 80, // Warn at 80% of budget }, }; diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 4b9ae523..1a61843d 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -6,3 +6,4 @@ export * from './telemetry.types.js'; export * from './template.types.js'; export * from './websocket.types.js'; export * from './managed-list.types.js'; +export * from './chat.types.js'; diff --git a/shared/src/types/task.types.ts b/shared/src/types/task.types.ts index 9fe09776..23c9fea2 100644 --- a/shared/src/types/task.types.ts +++ b/shared/src/types/task.types.ts @@ -125,6 +125,9 @@ export interface Task { created: string; updated: string; + // Agent assignment — "auto" uses routing engine, or a specific agent slug + agent?: AgentType | 'auto'; + // Code task specific git?: TaskGit; @@ -196,6 +199,7 @@ export interface CreateTaskInput { priority?: TaskPriority; project?: string; sprint?: string; + agent?: AgentType | 'auto'; // Pre-assign an agent (or "auto" for routing engine) subtasks?: Subtask[]; // Can be provided when creating from a template blockedBy?: string[]; // Can be provided when creating from a blueprint } @@ -208,6 +212,7 @@ export interface UpdateTaskInput { priority?: TaskPriority; project?: string; sprint?: string; + agent?: AgentType | 'auto'; git?: Partial; attempt?: TaskAttempt; reviewComments?: ReviewComment[]; @@ -247,6 +252,7 @@ export interface TaskSummary { type: TaskType; project?: string; sprint?: string; + agent?: AgentType | 'auto'; created: string; updated: string; subtasks?: Subtask[]; diff --git a/shared/src/types/websocket.types.ts b/shared/src/types/websocket.types.ts index 043b0bd1..73fdfec4 100644 --- a/shared/src/types/websocket.types.ts +++ b/shared/src/types/websocket.types.ts @@ -1,12 +1,15 @@ // WebSocket Message Types import type { AttemptStatus } from './task.types.js'; +import type { ChatMessage } from './chat.types.js'; -export type WSMessageType = +export type WSMessageType = | 'agent:output' | 'agent:status' | 'agent:complete' | 'task:updated' + | 'chat:message' + | 'chat:subscribed' | 'error'; export interface WSMessage { @@ -32,3 +35,11 @@ export interface AgentStatusMessage extends WSMessage { exitCode?: number; }; } + +export interface ChatMessageEvent extends WSMessage { + type: 'chat:message'; + data: { + sessionId: string; + message: ChatMessage; + }; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 7600d224..8c38490d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { AuthGuard } from './components/auth'; import { ErrorBoundary } from './components/shared/ErrorBoundary'; import { SkipToContent } from './components/shared/SkipToContent'; import { LiveAnnouncerProvider } from './components/shared/LiveAnnouncer'; +import { FloatingChat } from './components/chat/FloatingChat'; // Main app content (only rendered when authenticated) function AppContent() { @@ -38,6 +39,7 @@ function AppContent() { + diff --git a/web/src/__tests__/KanbanBoard.test.tsx b/web/src/__tests__/KanbanBoard.test.tsx index c54f1416..76430383 100644 --- a/web/src/__tests__/KanbanBoard.test.tsx +++ b/web/src/__tests__/KanbanBoard.test.tsx @@ -116,7 +116,7 @@ vi.mock('@/components/board/FilterBar', () => ({ FilterBar: () => null, filterTasks: (tasks: Task[]) => tasks, filtersToSearchParams: () => new URLSearchParams(), - searchParamsToFilters: () => ({ search: '', project: null, type: null }), + searchParamsToFilters: () => ({ search: '', project: null, type: null, agent: null }), })); vi.mock('@/components/board/BulkActionsBar', () => ({ diff --git a/web/src/components/board/FilterBar.tsx b/web/src/components/board/FilterBar.tsx index 52ac5299..2c9a181f 100644 --- a/web/src/components/board/FilterBar.tsx +++ b/web/src/components/board/FilterBar.tsx @@ -12,11 +12,13 @@ import { Badge } from '@/components/ui/badge'; import type { Task, TaskType } from '@veritas-kanban/shared'; import { useTaskTypes, getTypeIcon } from '@/hooks/useTaskTypes'; import { useProjects } from '@/hooks/useProjects'; +import { useConfig } from '@/hooks/useConfig'; export interface FilterState { search: string; project: string | null; type: TaskType | null; + agent: string | null; } interface FilterBarProps { @@ -28,12 +30,16 @@ interface FilterBarProps { export function FilterBar({ filters, onFiltersChange }: FilterBarProps) { const { data: taskTypes = [], isLoading: typesLoading } = useTaskTypes(); const { data: projects = [], isLoading: projectsLoading } = useProjects(); + const { data: config } = useConfig(); + const agents = config?.agents || []; // Count active filters - const activeFilterCount = [filters.search, filters.project, filters.type].filter(Boolean).length; + const activeFilterCount = [filters.search, filters.project, filters.type, filters.agent].filter( + Boolean + ).length; const clearAllFilters = () => { - onFiltersChange({ search: '', project: null, type: null }); + onFiltersChange({ search: '', project: null, type: null, agent: null }); }; const updateSearch = (value: string) => { @@ -118,6 +124,28 @@ export function FilterBar({ filters, onFiltersChange }: FilterBarProps) { + {/* Agent filter */} + + {/* Active filter indicator & clear */} {activeFilterCount > 0 && (
@@ -146,6 +174,7 @@ export function filtersToSearchParams(filters: FilterState): URLSearchParams { if (filters.search) params.set('q', filters.search); if (filters.project) params.set('project', filters.project); if (filters.type) params.set('type', filters.type); + if (filters.agent) params.set('agent', filters.agent); return params; } @@ -154,6 +183,7 @@ export function searchParamsToFilters(params: URLSearchParams): FilterState { search: params.get('q') || '', project: params.get('project') || null, type: (params.get('type') as TaskType) || null, + agent: params.get('agent') || null, }; } @@ -179,6 +209,17 @@ export function filterTasks(tasks: Task[], filters: FilterState): Task[] { return false; } + // Agent filter + if (filters.agent) { + if (filters.agent === 'unassigned') { + if (task.agent) return false; + } else if (filters.agent === 'auto') { + if (task.agent !== 'auto') return false; + } else { + if (task.agent !== filters.agent) return false; + } + } + return true; }); } diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index 7db5adb3..213b7dac 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -47,7 +47,7 @@ export function KanbanBoard() { if (typeof window !== 'undefined') { return searchParamsToFilters(new URLSearchParams(window.location.search)); } - return { search: '', project: null, type: null }; + return { search: '', project: null, type: null, agent: null }; }); const { selectedTaskId, setTasks, setOnOpenTask, setOnMoveTask } = useKeyboard(); diff --git a/web/src/components/chat/ChatPanel.tsx b/web/src/components/chat/ChatPanel.tsx new file mode 100644 index 00000000..f7847c5f --- /dev/null +++ b/web/src/components/chat/ChatPanel.tsx @@ -0,0 +1,433 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + MessageSquare, + Send, + ChevronDown, + ChevronRight, + Loader2, + Bot, + User, + Trash2, + Download, +} from 'lucide-react'; +import { + useChatSession, + useSendChatMessage, + useDeleteChatSession, + useChatStream, + useChatSessions, +} from '@/hooks/useChat'; +import { useTask } from '@/hooks/useTasks'; +import type { ChatMessage } from '@veritas-kanban/shared'; + +interface ChatPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + taskId?: string; +} + +export function ChatPanel({ open, onOpenChange, taskId }: ChatPanelProps) { + const [message, setMessage] = useState(''); + const [mode, setMode] = useState<'ask' | 'build'>('ask'); + const [currentSessionId, setCurrentSessionId] = useState(); + const { data: task } = useTask(taskId || ''); + const { data: sessions = [] } = useChatSessions(); + const { data: session } = useChatSession(currentSessionId); + const { mutate: sendChatMessage, isPending } = useSendChatMessage(); + const { mutate: deleteChatSession } = useDeleteChatSession(); + const { streamingMessage } = useChatStream(currentSessionId); + + const scrollAreaRef = useRef(null); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + if (shouldAutoScroll && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [session?.messages, streamingMessage, shouldAutoScroll]); + + // Detect manual scroll-up to pause auto-scroll + const handleScroll = (e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + const isAtBottom = Math.abs(target.scrollHeight - target.scrollTop - target.clientHeight) < 50; + setShouldAutoScroll(isAtBottom); + }; + + // Filter sessions by taskId if scoped + const filteredSessions = useMemo(() => { + if (!taskId) { + return sessions.filter((s) => !s.taskId); + } + return sessions.filter((s) => s.taskId === taskId); + }, [sessions, taskId]); + + // Handle sending a message + const handleSend = () => { + if (!message.trim() || isPending) return; + + sendChatMessage( + { + sessionId: currentSessionId, + taskId, + message: message.trim(), + mode, + }, + { + onSuccess: (response) => { + setCurrentSessionId(response.sessionId); + setMessage(''); + setShouldAutoScroll(true); + // Re-focus the input so user can keep typing + requestAnimationFrame(() => inputRef.current?.focus()); + }, + } + ); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + // Load session on mount — task-scoped sessions use a deterministic ID + useEffect(() => { + if (taskId && !currentSessionId) { + setCurrentSessionId(`task_${taskId}`); + } else if (!taskId && !currentSessionId && filteredSessions.length > 0) { + setCurrentSessionId(filteredSessions[0].id); + } + }, [filteredSessions, currentSessionId, taskId]); + + return ( + + + {/* Header action buttons — positioned next to Sheet's built-in X */} + {currentSessionId && session?.messages && session.messages.length > 0 && ( + + )} + {currentSessionId && session?.messages && session.messages.length > 0 && ( + + + + + + + Clear chat history? + + This will permanently delete all messages in this chat. This action cannot be + undone. + + + + Cancel + { + if (currentSessionId) { + deleteChatSession(currentSessionId, { + onSuccess: () => { + setCurrentSessionId(undefined); + if (taskId) { + setTimeout(() => setCurrentSessionId(`task_${taskId}`), 100); + } + }, + }); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Clear History + + + + + )} + + + + + {taskId ? 'Task Chat' : 'Board Chat'} + + {taskId && task && ( +
+ + Task: {task.title} +
+ )} +
+ + {/* Messages */} + +
+ {session?.messages.map((msg) => ( + + ))} + {streamingMessage && ( + + )} + {(!session || session.messages.length === 0) && !streamingMessage && ( +
+ +

+ {taskId ? 'Start a conversation about this task' : 'Start a new chat session'} +

+
+ )} +
+
+ + + {/* Input Area */} +
+
+ setMessage(e.target.value)} + onKeyDown={handleKeyPress} + placeholder="Type a message..." + disabled={isPending} + className="flex-1" + autoFocus + /> + +
+ + {/* Mode Toggle */} +
+ Mode: + + + + {mode === 'ask' ? '· Read-only queries' : '· Changes, files, commands'} + +
+
+ + + ); +} + +interface ChatMessageBubbleProps { + message: ChatMessage | { id: string; role: string; content: string; timestamp: string }; + isStreaming?: boolean; +} + +function ChatMessageBubble({ message, isStreaming }: ChatMessageBubbleProps) { + const [expandedTools, setExpandedTools] = useState>(new Set()); + const isUser = message.role === 'user'; + const isSystem = message.role === 'system'; + + const toggleTool = (index: number) => { + setExpandedTools((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + if (isSystem) { + return ( +
{message.content}
+ ); + } + + return ( +
+ {!isUser && ( +
+ +
+ )} +
+
+ + {isStreaming && } +
+ + {/* Tool calls */} + {'toolCalls' in message && message.toolCalls && message.toolCalls.length > 0 && ( +
+ {message.toolCalls.map((tool, idx) => ( +
+ + {expandedTools.has(idx) && ( +
+
+
Input:
+
+                        {tool.input}
+                      
+
+ {tool.output && ( +
+
Output:
+
+                          {tool.output}
+                        
+
+ )} +
+ )} +
+ ))} +
+ )} + + {/* Timestamp */} +
+ {new Date(message.timestamp).toLocaleTimeString()} +
+
+ {isUser && ( +
+ +
+ )} +
+ ); +} + +/** + * Simple markdown renderer + * Handles code blocks and basic formatting + */ +function MarkdownContent({ content }: { content: string }) { + // Split content by code blocks + const parts = content.split(/(```[\s\S]*?```|`[^`]+`)/g); + + return ( +
+ {parts.map((part, idx) => { + // Multi-line code block + if (part.startsWith('```')) { + const lines = part.split('\n'); + const language = lines[0].replace('```', '').trim(); + const code = lines.slice(1, -1).join('\n'); + + return ( +
+              {language && 
{language}
} + {code} +
+ ); + } + + // Inline code + if (part.startsWith('`') && part.endsWith('`')) { + return ( + + {part.slice(1, -1)} + + ); + } + + // Regular text + return ( + + {part} + + ); + })} +
+ ); +} diff --git a/web/src/components/chat/FloatingChat.tsx b/web/src/components/chat/FloatingChat.tsx new file mode 100644 index 00000000..a2bbc1ab --- /dev/null +++ b/web/src/components/chat/FloatingChat.tsx @@ -0,0 +1,69 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { MessageSquare } from 'lucide-react'; +import { ChatPanel } from './ChatPanel'; +import { chatEventTarget } from '@/hooks/useTaskSync'; +import { cn } from '@/lib/utils'; + +/** + * Floating chat bubble — bottom-right corner. + * Opens a board-level ChatPanel (no taskId). + * Pulses when a new response arrives while closed. + */ +export function FloatingChat() { + const [open, setOpen] = useState(false); + const [hasUnread, setHasUnread] = useState(false); + + // Listen for incoming chat messages when panel is closed + useEffect(() => { + const handler = (e: Event) => { + const msg = (e as CustomEvent).detail; + // Only pulse for board-level chat messages (no taskId in sessionId) + if ( + !open && + (msg.type === 'chat:message' || msg.type === 'chat:delta') && + msg.sessionId && + !msg.sessionId.includes('task_') + ) { + setHasUnread(true); + } + }; + + chatEventTarget.addEventListener('chat', handler); + return () => chatEventTarget.removeEventListener('chat', handler); + }, [open]); + + // Clear unread when opening + const handleOpen = () => { + setOpen(true); + setHasUnread(false); + }; + + return ( + <> + {/* Floating button */} + + + {/* Chat panel — board-level (no taskId) */} + + + ); +} diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 8ab2ae69..55e6a281 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,9 +1,10 @@ -import { Plus, Settings, Keyboard, Activity, Archive } from 'lucide-react'; +import { Plus, Settings, Keyboard, Activity, Archive, MessageSquare } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CreateTaskDialog } from '@/components/task/CreateTaskDialog'; import { SettingsDialog } from '@/components/settings/SettingsDialog'; import { ActivitySidebar } from './ActivitySidebar'; import { ArchiveSidebar } from './ArchiveSidebar'; +import { ChatPanel } from '@/components/chat/ChatPanel'; import { UserMenu } from './UserMenu'; import { AgentStatusIndicator } from '@/components/shared/AgentStatusIndicator'; import { WebSocketIndicator } from '@/components/shared/WebSocketIndicator'; @@ -16,18 +17,20 @@ export function Header() { const [settingsTab, setSettingsTab] = useState(); const [activityOpen, setActivityOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); - const { setOpenCreateDialog, openHelpDialog } = useKeyboard(); + const [chatOpen, setChatOpen] = useState(false); + const { setOpenCreateDialog, setOpenChatPanel, openHelpDialog } = useKeyboard(); const openSecuritySettings = useCallback(() => { setSettingsTab('security'); setSettingsOpen(true); }, []); - // Register the create dialog opener with keyboard context (ref, no useEffect needed) + // Register the create dialog and chat panel openers with keyboard context (refs, no useEffect needed) setOpenCreateDialog(() => setCreateOpen(true)); + setOpenChatPanel(() => setChatOpen(true)); return ( -
+
); } diff --git a/web/src/components/layout/KeyboardShortcutsDialog.tsx b/web/src/components/layout/KeyboardShortcutsDialog.tsx index b431d752..58d5837b 100644 --- a/web/src/components/layout/KeyboardShortcutsDialog.tsx +++ b/web/src/components/layout/KeyboardShortcutsDialog.tsx @@ -20,6 +20,7 @@ const shortcuts: { category: string; items: Shortcut[] }[] = [ category: 'Actions', items: [ { keys: ['c'], description: 'Create new task' }, + { keys: ['⌘⇧C'], description: 'Open agent chat' }, { keys: ['1'], description: 'Move to To Do' }, { keys: ['2'], description: 'Move to In Progress' }, { keys: ['3'], description: 'Move to Review' }, diff --git a/web/src/components/settings/tabs/AgentsTab.tsx b/web/src/components/settings/tabs/AgentsTab.tsx index 534c1235..53c56778 100644 --- a/web/src/components/settings/tabs/AgentsTab.tsx +++ b/web/src/components/settings/tabs/AgentsTab.tsx @@ -1,8 +1,16 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { AlertDialog, AlertDialogAction, @@ -16,9 +24,26 @@ import { } from '@/components/ui/alert-dialog'; import { useConfig, useUpdateAgents } from '@/hooks/useConfig'; import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings'; -import { Bot, Plus, Pencil, Trash2, Check, X } from 'lucide-react'; -import type { AgentConfig, AgentType } from '@veritas-kanban/shared'; -import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared'; +import { useRoutingConfig, useUpdateRoutingConfig } from '@/hooks/useRouting'; +import { + Bot, + Plus, + Pencil, + Trash2, + Check, + X, + Route, + ChevronDown, + ChevronUp, + Loader2, +} from 'lucide-react'; +import type { + AgentConfig, + AgentType, + RoutingRule, + AgentRoutingConfig, +} from '@veritas-kanban/shared'; +import { DEFAULT_FEATURE_SETTINGS, DEFAULT_ROUTING_CONFIG } from '@veritas-kanban/shared'; import { cn } from '@/lib/utils'; import { ToggleRow, NumberRow, SectionHeader, SaveIndicator } from '../shared'; @@ -160,6 +185,9 @@ export function AgentsTab() { />
+ + {/* Agent Routing Rules */} + ); } @@ -268,6 +296,599 @@ interface AgentFormProps { onCancel: () => void; } +// ============ Routing Rules Section ============ + +interface RoutingRulesSectionProps { + agents: AgentConfig[]; +} + +function RoutingRulesSection({ agents }: RoutingRulesSectionProps) { + const { data: routingConfig, isLoading } = useRoutingConfig(); + const updateRouting = useUpdateRoutingConfig(); + const [editingRuleId, setEditingRuleId] = useState(null); + const [showAddRule, setShowAddRule] = useState(false); + const [expanded, setExpanded] = useState(true); + + const config = routingConfig || DEFAULT_ROUTING_CONFIG; + const enabledAgents = agents.filter((a) => a.enabled); + + const saveConfig = useCallback( + (updated: AgentRoutingConfig) => { + updateRouting.mutate(updated); + }, + [updateRouting] + ); + + const handleToggleEnabled = () => { + saveConfig({ ...config, enabled: !config.enabled }); + }; + + const handleToggleRule = (ruleId: string) => { + const updated = { + ...config, + rules: config.rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)), + }; + saveConfig(updated); + }; + + const handleAddRule = (rule: RoutingRule) => { + saveConfig({ ...config, rules: [...config.rules, rule] }); + setShowAddRule(false); + }; + + const handleEditRule = (originalId: string, updated: RoutingRule) => { + saveConfig({ + ...config, + rules: config.rules.map((r) => (r.id === originalId ? updated : r)), + }); + setEditingRuleId(null); + }; + + const handleRemoveRule = (ruleId: string) => { + saveConfig({ + ...config, + rules: config.rules.filter((r) => r.id !== ruleId), + }); + }; + + const handleMoveRule = (ruleId: string, direction: 'up' | 'down') => { + const idx = config.rules.findIndex((r) => r.id === ruleId); + if (idx < 0) return; + const newIdx = direction === 'up' ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= config.rules.length) return; + const newRules = [...config.rules]; + [newRules[idx], newRules[newIdx]] = [newRules[newIdx], newRules[idx]]; + saveConfig({ ...config, rules: newRules }); + }; + + const handleDefaultAgentChange = (agent: string) => { + saveConfig({ ...config, defaultAgent: agent as AgentType }); + }; + + const handleDefaultModelChange = (model: string) => { + saveConfig({ ...config, defaultModel: model || undefined }); + }; + + const handleFallbackToggle = () => { + saveConfig({ ...config, fallbackOnFailure: !config.fallbackOnFailure }); + }; + + const handleMaxRetriesChange = (value: number) => { + saveConfig({ ...config, maxRetries: Math.min(3, Math.max(0, value)) }); + }; + + const resetRouting = () => { + saveConfig(DEFAULT_ROUTING_CONFIG); + }; + + if (isLoading) { + return ( +
+ Loading routing config... +
+ ); + } + + return ( +
+
+ +
+ {updateRouting.isPending && } + +
+
+ + {expanded && ( +
+ {/* Rules list */} +
+
+

+ Rules (first match wins) +

+ {!showAddRule && ( + + )} +
+ + {showAddRule && ( + r.id)} + onSubmit={handleAddRule} + onCancel={() => setShowAddRule(false)} + /> + )} + + {config.rules.length === 0 ? ( +
+ No routing rules — all tasks use the default agent. +
+ ) : ( +
+ {config.rules.map((rule, idx) => + editingRuleId === rule.id ? ( + r.id !== rule.id).map((r) => r.id)} + onSubmit={(updated) => handleEditRule(rule.id, updated)} + onCancel={() => setEditingRuleId(null)} + /> + ) : ( + handleToggleRule(rule.id)} + onEdit={() => setEditingRuleId(rule.id)} + onRemove={() => handleRemoveRule(rule.id)} + onMoveUp={() => handleMoveRule(rule.id, 'up')} + onMoveDown={() => handleMoveRule(rule.id, 'down')} + /> + ) + )} +
+ )} +
+ + {/* Default & Fallback settings */} +
+
+

+ Defaults +

+ +
+
+
+
+ +

Used when no rules match

+
+ +
+
+
+ +

+ Model override for the default agent +

+
+ handleDefaultModelChange(e.target.value)} + placeholder="e.g., sonnet" + className="w-[180px] h-8 text-sm" + /> +
+ + +
+
+
+ )} +
+ ); +} + +// ============ Routing Rule Item (display mode) ============ + +interface RoutingRuleItemProps { + rule: RoutingRule; + agents: AgentConfig[]; + isFirst: boolean; + isLast: boolean; + onToggle: () => void; + onEdit: () => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +function RoutingRuleItem({ + rule, + agents, + isFirst, + isLast, + onToggle, + onEdit, + onRemove, + onMoveUp, + onMoveDown, +}: RoutingRuleItemProps) { + const agentName = agents.find((a) => a.type === rule.agent)?.name || rule.agent; + const fallbackName = rule.fallback + ? agents.find((a) => a.type === rule.fallback)?.name || rule.fallback + : null; + + const matchLabels: string[] = []; + if (rule.match.type) { + const types = Array.isArray(rule.match.type) ? rule.match.type : [rule.match.type]; + matchLabels.push(`type: ${types.join(', ')}`); + } + if (rule.match.priority) { + const priorities = Array.isArray(rule.match.priority) + ? rule.match.priority + : [rule.match.priority]; + matchLabels.push(`priority: ${priorities.join(', ')}`); + } + if (rule.match.project) { + const projects = Array.isArray(rule.match.project) ? rule.match.project : [rule.match.project]; + matchLabels.push(`project: ${projects.join(', ')}`); + } + if (rule.match.minSubtasks) { + matchLabels.push(`≥${rule.match.minSubtasks} subtasks`); + } + + return ( +
+ {/* Reorder buttons */} +
+ + +
+ + {/* Rule info */} +
+
+ {rule.name} +
+
+ {matchLabels.map((label, i) => ( + + {label} + + ))} + → + + {agentName} + {rule.model ? ` (${rule.model})` : ''} + + {fallbackName && ( + <> + fallback: + + {fallbackName} + + + )} +
+
+ + {/* Actions */} +
+ + + +
+
+ ); +} + +// ============ Routing Rule Form (add/edit mode) ============ + +interface RoutingRuleFormProps { + rule?: RoutingRule; + agents: AgentConfig[]; + existingIds: string[]; + onSubmit: (rule: RoutingRule) => void; + onCancel: () => void; +} + +function RoutingRuleForm({ rule, agents, existingIds, onSubmit, onCancel }: RoutingRuleFormProps) { + const isEditing = !!rule; + const [name, setName] = useState(rule?.name || ''); + const [id, setId] = useState(rule?.id || ''); + const [matchType, setMatchType] = useState( + rule?.match.type + ? Array.isArray(rule.match.type) + ? rule.match.type.join(', ') + : rule.match.type + : '' + ); + const [matchPriority, setMatchPriority] = useState( + rule?.match.priority + ? Array.isArray(rule.match.priority) + ? rule.match.priority.join(', ') + : rule.match.priority + : '' + ); + const [matchProject, setMatchProject] = useState( + rule?.match.project + ? Array.isArray(rule.match.project) + ? rule.match.project.join(', ') + : rule.match.project + : '' + ); + const [minSubtasks, setMinSubtasks] = useState(rule?.match.minSubtasks?.toString() || ''); + const [agent, setAgent] = useState(rule?.agent || agents[0]?.type || ''); + const [model, setModel] = useState(rule?.model || ''); + const [fallback, setFallback] = useState(rule?.fallback || ''); + + const autoId = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const effectiveId = id || autoId; + const isDuplicate = !isEditing && existingIds.includes(effectiveId); + const isValid = name.trim() && effectiveId && agent && !isDuplicate; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!isValid) return; + + const parseList = (val: string): string | string[] | undefined => { + if (!val.trim()) return undefined; + const items = val + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return items.length === 1 ? items[0] : items.length > 0 ? items : undefined; + }; + + onSubmit({ + id: isEditing ? rule.id : effectiveId, + name: name.trim(), + match: { + type: parseList(matchType), + priority: parseList(matchPriority) as any, + project: parseList(matchProject), + minSubtasks: minSubtasks ? parseInt(minSubtasks, 10) : undefined, + }, + agent: agent as AgentType, + model: model.trim() || undefined, + fallback: (fallback.trim() || undefined) as AgentType | undefined, + enabled: rule?.enabled ?? true, + }); + }; + + return ( +
+
+ + {isEditing ? `Edit Rule: ${rule.name}` : 'Add Routing Rule'} +
+ +
+
+
+ + setName(e.target.value)} + placeholder="e.g., High-priority bugs" + /> +
+
+ + setId(e.target.value)} + placeholder="auto from name" + disabled={isEditing} + className={cn(isDuplicate && 'border-red-500')} + /> +
+
+ + {/* Match criteria */} +
+
+ + setMatchType(e.target.value)} + placeholder="e.g., code, bug" + className="font-mono text-sm" + /> +
+
+ + setMatchPriority(e.target.value)} + placeholder="e.g., high" + className="font-mono text-sm" + /> +
+
+ +
+
+ + setMatchProject(e.target.value)} + placeholder="e.g., rubicon" + className="font-mono text-sm" + /> +
+
+ + setMinSubtasks(e.target.value)} + placeholder="e.g., 5" + className="font-mono text-sm" + min="0" + /> +
+
+ + {/* Agent selection */} +
+
+ + +
+
+ + setModel(e.target.value)} + placeholder="e.g., opus" + className="font-mono text-sm" + /> +
+
+ + +
+
+
+ +
+ + +
+
+ ); +} + +// ============ Agent Form (add/edit mode) ============ + function AgentForm({ agent, existingTypes, onSubmit, onCancel }: AgentFormProps) { const isEditing = !!agent; const [name, setName] = useState(agent?.name || ''); diff --git a/web/src/components/shared/AgentStatusIndicator.tsx b/web/src/components/shared/AgentStatusIndicator.tsx index f98b4ef1..2dbe85b7 100644 --- a/web/src/components/shared/AgentStatusIndicator.tsx +++ b/web/src/components/shared/AgentStatusIndicator.tsx @@ -309,10 +309,7 @@ export function AgentStatusIndicator({ if (isLoading && !data) { return (
-