import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createSystemHandlers } from './system.js'; import type { TokenUsageEntry } from './system.js'; import { createSessionHandlers } from './sessions.js'; import { createToolHandlers } from './tools.js'; import { createAgentHandlers } from './agent.js'; import { createIntentHandlers } from './intents.js'; import { createRoutingHandlers } from './routing.js'; import { createHistoryHandlers } from './history.js'; import { createCanvasHandlers } from './canvas.js'; import { createConfigHandlers, redactConfig } from './config.js'; import { createPairingHandlers } from './pairing.js'; import type { LocalBackendStatus, LocalBackendControlResult } from './localBackends.js'; import type { DockerDependencyStatus, DockerDependencyControlResult } from './dockerDependencies.js'; import type { ObservabilitySource, ObservabilitySeriesSnapshot, ServiceLogSnapshot } from './observability.js'; import { PairingManager } from '../../channels/pairing.js'; import { LaneQueue } from '../lane-queue.js'; import { CanvasStore } from '../canvas-store.js'; import { ErrorCode } from '../protocol.js'; import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js'; import { ComponentRegistry } from '../../intents/index.js'; import { RoutingPolicy } from '../../routing/index.js'; function asSessionHandlerSessionManager(value: unknown): Parameters[0]['sessionManager'] { return value as Parameters[0]['sessionManager']; } function asToolRegistry(value: unknown): Parameters[0]['toolRegistry'] { return value as Parameters[0]['toolRegistry']; } function asToolExecutor(value: unknown): Parameters[0]['toolExecutor'] { return value as Parameters[0]['toolExecutor']; } function asSessionBridge(value: unknown): Parameters[0]['sessionBridge'] { return value as Parameters[0]['sessionBridge']; } function asHistorySessionManager(value: unknown): Parameters[0]['sessionManager'] { return value as Parameters[0]['sessionManager']; } function asConfigValue(value: unknown): Parameters[0]['config'] { return value as Parameters[0]['config']; } function asRedactInput(value: unknown): Parameters[0] { return value as Parameters[0]; } function getPath(value: unknown, ...path: string[]): unknown { let current: unknown = value; for (const key of path) { if (!current || typeof current !== 'object') { return undefined; } current = (current as Record)[key]; } return current; } describe('system handlers', () => { const deps = { startTime: Date.now() - 60_000, version: '0.1.0', getSessionCount: () => 3, getToolCount: () => 6, getConnectionCount: () => 2, }; const handlers = createSystemHandlers(deps); it('system.health returns status info', async () => { const req: GatewayRequest = { id: 1, method: 'system.health' }; const result = await handlers['system.health'](req) as GatewayResponse; expect(result.id).toBe(1); const r = result.result as Record; expect(r.status).toBe('ok'); expect(r.version).toBe('0.1.0'); expect(r.sessions).toBe(3); expect(r.tools).toBe(6); expect(r.connections).toBe(2); expect(typeof r.uptime).toBe('number'); expect(r.uptime).toBeGreaterThanOrEqual(59); }); it('system.services returns empty list when getServices is not provided', async () => { const req: GatewayRequest = { id: 2, method: 'system.services' }; const result = await handlers['system.services'](req) as GatewayResponse; expect(result.id).toBe(2); expect(getPath(result.result, 'services')).toEqual([]); }); it('system.services returns services from getServices callback', async () => { const handlers = createSystemHandlers({ ...deps, getServices: () => [ { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' }, { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 }, ], }); const req: GatewayRequest = { id: 3, method: 'system.services' }; const result = await handlers['system.services'](req) as GatewayResponse; expect(getPath(result.result, 'services')).toEqual([ { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' }, { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 }, ]); }); it('system.modelCatalog returns empty providers when callback is not provided', async () => { const req: GatewayRequest = { id: 33, method: 'system.modelCatalog' }; const result = await handlers['system.modelCatalog'](req) as GatewayResponse; expect(getPath(result.result, 'providers')).toEqual([]); }); it('system.modelCatalog returns provider models from callback', async () => { const getModelCatalog = vi.fn(async () => [ { provider: 'openai', models: ['gpt-4o-mini', 'gpt-4.1'], source: 'api' as const, fetchedAt: 123, }, ]); const handlers = createSystemHandlers({ ...deps, getModelCatalog, }); const req: GatewayRequest = { id: 34, method: 'system.modelCatalog', params: { provider: 'openai', forceRefresh: true }, }; const result = await handlers['system.modelCatalog'](req) as GatewayResponse; expect(getModelCatalog).toHaveBeenCalledWith({ provider: 'openai', forceRefresh: true }); expect(getPath(result.result, 'providers')).toEqual([ { provider: 'openai', models: ['gpt-4o-mini', 'gpt-4.1'], source: 'api', fetchedAt: 123, }, ]); }); it('system.localBackends returns empty list when callback is not provided', async () => { const req: GatewayRequest = { id: 35, method: 'system.localBackends' }; const result = await handlers['system.localBackends'](req) as GatewayResponse; expect(getPath(result.result, 'backends')).toEqual([]); }); it('system.localBackends returns backend statuses from callback', async () => { const localBackends: LocalBackendStatus[] = [ { id: 'ollama', provider: 'ollama', name: 'Ollama', unit: 'ollama.service', configured: true, loadState: 'loaded', activeState: 'active', subState: 'running', unitFileState: 'enabled', description: 'Ollama Service', pid: 1234, result: 'success', statusText: 'active (running)', availableActions: ['restart', 'stop'], }, ]; const getLocalBackends = vi.fn(async (): Promise => localBackends); const handlers = createSystemHandlers({ ...deps, getLocalBackends, }); const req: GatewayRequest = { id: 36, method: 'system.localBackends' }; const result = await handlers['system.localBackends'](req) as GatewayResponse; expect(getLocalBackends).toHaveBeenCalledTimes(1); expect(getPath(result.result, 'backends')).toHaveLength(1); expect(getPath(result.result, 'backends', '0', 'id')).toBe('ollama'); }); it('system.localBackendControl validates required params', async () => { const handlers = createSystemHandlers({ ...deps, controlLocalBackend: vi.fn(), }); const missingBackend = await handlers['system.localBackendControl']({ id: 37, method: 'system.localBackendControl', params: { action: 'restart' }, }) as GatewayError; expect(missingBackend.error.code).toBe(ErrorCode.InvalidRequest); const missingAction = await handlers['system.localBackendControl']({ id: 38, method: 'system.localBackendControl', params: { backend: 'ollama' }, }) as GatewayError; expect(missingAction.error.code).toBe(ErrorCode.InvalidRequest); const badAction = await handlers['system.localBackendControl']({ id: 39, method: 'system.localBackendControl', params: { backend: 'ollama', action: 'reload' }, }) as GatewayError; expect(badAction.error.code).toBe(ErrorCode.InvalidRequest); }); it('system.localBackendControl forwards action to callback', async () => { const controlResult: LocalBackendControlResult = { backend: 'ollama', action: 'restart', status: { id: 'ollama', provider: 'ollama', name: 'Ollama', unit: 'ollama.service', configured: true, loadState: 'loaded', activeState: 'active', subState: 'running', unitFileState: 'enabled', description: 'Ollama Service', pid: 321, result: 'success', statusText: 'active (running)', availableActions: ['restart', 'stop'], }, }; const controlLocalBackend = vi.fn(async (): Promise => controlResult); const handlers = createSystemHandlers({ ...deps, controlLocalBackend, }); const req: GatewayRequest = { id: 40, method: 'system.localBackendControl', params: { backend: 'ollama', action: 'restart' }, }; const result = await handlers['system.localBackendControl'](req) as GatewayResponse; expect(controlLocalBackend).toHaveBeenCalledWith('ollama', 'restart'); expect(getPath(result.result, 'status', 'activeState')).toBe('active'); }); it('system.localBackendControl accepts update action', async () => { const controlLocalBackend = vi.fn(async (): Promise => ({ backend: 'ollama', action: 'update', status: { id: 'ollama', provider: 'ollama', name: 'Ollama', unit: 'ollama.service', configured: true, loadState: 'loaded', activeState: 'active', subState: 'running', unitFileState: 'enabled', description: 'Ollama Service', pid: 321, result: 'success', statusText: 'active (running)', availableActions: ['restart', 'stop', 'update'], }, message: 'Updated 2 model(s).', updatedModels: ['llama3.2', 'nomic-embed-text'], })); const handlers = createSystemHandlers({ ...deps, controlLocalBackend, }); const req: GatewayRequest = { id: 41, method: 'system.localBackendControl', params: { backend: 'ollama', action: 'update' }, }; const result = await handlers['system.localBackendControl'](req) as GatewayResponse; expect(controlLocalBackend).toHaveBeenCalledWith('ollama', 'update'); expect(getPath(result.result, 'action')).toBe('update'); expect(getPath(result.result, 'updatedModels')).toEqual(['llama3.2', 'nomic-embed-text']); }); it('system.dockerDependencies returns empty list when callback is not provided', async () => { const req: GatewayRequest = { id: 42, method: 'system.dockerDependencies' }; const result = await handlers['system.dockerDependencies'](req) as GatewayResponse; expect(getPath(result.result, 'dependencies')).toEqual([]); }); it('system.dockerDependencies returns dependency statuses from callback', async () => { const getDockerDependencies = vi.fn(async (): Promise => ([ { id: 'whisper', name: 'Whisper (whisper.cpp)', service: 'whisper-server', configured: true, state: 'running', health: 'healthy', statusText: 'Up 10 minutes (healthy)', containerName: 'flynn-whisper-server-1', availableActions: ['restart', 'stop', 'update'], }, ])); const handlers = createSystemHandlers({ ...deps, getDockerDependencies, }); const req: GatewayRequest = { id: 43, method: 'system.dockerDependencies' }; const result = await handlers['system.dockerDependencies'](req) as GatewayResponse; expect(getDockerDependencies).toHaveBeenCalledTimes(1); expect(getPath(result.result, 'dependencies', '0', 'id')).toBe('whisper'); expect(getPath(result.result, 'dependencies', '0', 'state')).toBe('running'); }); it('system.dockerDependencyControl validates required params', async () => { const handlers = createSystemHandlers({ ...deps, controlDockerDependency: vi.fn(), }); const missingDependency = await handlers['system.dockerDependencyControl']({ id: 44, method: 'system.dockerDependencyControl', params: { action: 'restart' }, }) as GatewayError; expect(missingDependency.error.code).toBe(ErrorCode.InvalidRequest); const missingAction = await handlers['system.dockerDependencyControl']({ id: 45, method: 'system.dockerDependencyControl', params: { dependency: 'whisper' }, }) as GatewayError; expect(missingAction.error.code).toBe(ErrorCode.InvalidRequest); const badAction = await handlers['system.dockerDependencyControl']({ id: 46, method: 'system.dockerDependencyControl', params: { dependency: 'whisper', action: 'reload' }, }) as GatewayError; expect(badAction.error.code).toBe(ErrorCode.InvalidRequest); }); it('system.dockerDependencyControl forwards action to callback', async () => { const controlDockerDependency = vi.fn(async (): Promise => ({ dependency: 'whisper' as const, action: 'restart' as const, status: { id: 'whisper' as const, name: 'Whisper (whisper.cpp)', service: 'whisper-server', configured: true, state: 'running', health: 'healthy', statusText: 'running (healthy)', containerName: 'whisper-server', availableActions: ['restart', 'stop', 'update'], }, message: 'Restarted whisper-server container.', })); const handlers = createSystemHandlers({ ...deps, controlDockerDependency, }); const req: GatewayRequest = { id: 47, method: 'system.dockerDependencyControl', params: { dependency: 'whisper', action: 'restart' }, }; const result = await handlers['system.dockerDependencyControl'](req) as GatewayResponse; expect(controlDockerDependency).toHaveBeenCalledWith('whisper', 'restart'); expect(getPath(result.result, 'status', 'state')).toBe('running'); expect(getPath(result.result, 'action')).toBe('restart'); }); it('system.observabilitySources returns empty list when callback is not provided', async () => { const req: GatewayRequest = { id: 48, method: 'system.observabilitySources' }; const result = await handlers['system.observabilitySources'](req) as GatewayResponse; expect(getPath(result.result, 'sources')).toEqual([]); }); it('system.observabilitySources returns source list from callback', async () => { const getObservabilitySources = vi.fn(async (): Promise => ([ { id: 'systemd:flynn', name: 'Flynn daemon', kind: 'systemd_system', runtime: 'systemd_system', status: 'running', graphCapable: true, logCapable: true, }, ])); const handlers = createSystemHandlers({ ...deps, getObservabilitySources, }); const req: GatewayRequest = { id: 49, method: 'system.observabilitySources' }; const result = await handlers['system.observabilitySources'](req) as GatewayResponse; expect(getObservabilitySources).toHaveBeenCalledTimes(1); expect(getPath(result.result, 'sources', '0', 'id')).toBe('systemd:flynn'); }); it('system.observabilitySeries validates sourceIds parameter', async () => { const handlers = createSystemHandlers({ ...deps, getObservabilitySeries: vi.fn(), }); const result = await handlers['system.observabilitySeries']({ id: 50, method: 'system.observabilitySeries', params: { sourceIds: 'not-an-array' as unknown as string[] }, }) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); it('system.observabilitySeries forwards query to callback', async () => { const snapshot: ObservabilitySeriesSnapshot = { generatedAt: 123, windowMinutes: 60, bucketSeconds: 30, series: [ { sourceId: 'systemd:flynn', points: [{ ts: 100, stateCode: 3, healthCode: 2, errorCount: 0, restartCount: 1 }], }, ], }; const getObservabilitySeries = vi.fn(async () => snapshot); const handlers = createSystemHandlers({ ...deps, getObservabilitySeries, }); const req: GatewayRequest = { id: 51, method: 'system.observabilitySeries', params: { windowMinutes: 120, bucketSeconds: 60, sourceIds: ['systemd:flynn'] }, }; const result = await handlers['system.observabilitySeries'](req) as GatewayResponse; expect(getObservabilitySeries).toHaveBeenCalledWith({ windowMinutes: 120, bucketSeconds: 60, sourceIds: ['systemd:flynn'], }); expect(getPath(result.result, 'series', '0', 'points', '0', 'restartCount')).toBe(1); }); it('system.serviceLogs validates required sourceId', async () => { const handlers = createSystemHandlers({ ...deps, getServiceLogs: vi.fn(), }); const result = await handlers['system.serviceLogs']({ id: 52, method: 'system.serviceLogs', params: { lines: 100 }, }) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); it('system.serviceLogs forwards request to callback', async () => { const snapshot: ServiceLogSnapshot = { sourceId: 'docker:whisper', fetchedAt: 123, redacted: false, truncated: false, lines: [{ ts: 100, level: 'warn', text: 'queue depth high' }], }; const getServiceLogs = vi.fn(async (): Promise => snapshot); const handlers = createSystemHandlers({ ...deps, getServiceLogs, }); const req: GatewayRequest = { id: 53, method: 'system.serviceLogs', params: { sourceId: 'docker:whisper', lines: 50, sinceSeconds: 600 }, }; const result = await handlers['system.serviceLogs'](req) as GatewayResponse; expect(getServiceLogs).toHaveBeenCalledWith({ sourceId: 'docker:whisper', lines: 50, sinceSeconds: 600, }); expect(getPath(result.result, 'lines', '0', 'text')).toBe('queue depth high'); }); it('system.presence returns empty result when getPresence is not provided', async () => { const req: GatewayRequest = { id: 4, method: 'system.presence' }; const result = await handlers['system.presence'](req) as GatewayResponse; expect(result.id).toBe(4); expect(getPath(result.result, 'presence')).toEqual([]); expect(getPath(result.result, 'summary')).toEqual({ total: 0, online: 0, offline: 0 }); }); it('system.presence returns filtered presence entries', async () => { const handlers = createSystemHandlers({ ...deps, getPresence: ({ channel, status, limit } = {}) => { const all = [ { channel: 'telegram', senderId: '1', senderName: 'alice', firstSeenAt: 1000, lastSeenAt: 2000, messageCount: 3, status: 'online' as const, }, { channel: 'discord', senderId: '2', senderName: 'bob', firstSeenAt: 1000, lastSeenAt: 1500, messageCount: 1, status: 'offline' as const, }, ]; return all .filter((entry) => !channel || entry.channel === channel) .filter((entry) => !status || entry.status === status) .slice(0, limit ?? 100); }, }); const req: GatewayRequest = { id: 5, method: 'system.presence', params: { channel: 'telegram', status: 'online', limit: 10 }, }; const result = await handlers['system.presence'](req) as GatewayResponse; const presence = getPath(result.result, 'presence') as Array<{ channel: string }>; expect(presence).toHaveLength(1); expect(presence[0]?.channel).toBe('telegram'); expect(getPath(result.result, 'summary')).toEqual({ total: 1, online: 1, offline: 0 }); }); it('system.location returns empty result when getNodeLocations is not provided', async () => { const req: GatewayRequest = { id: 6, method: 'system.location' }; const result = await handlers['system.location'](req) as GatewayResponse; expect(result.id).toBe(6); expect(getPath(result.result, 'locations')).toEqual([]); expect(getPath(result.result, 'summary')).toEqual({ total: 0 }); }); it('system.location returns filtered node locations', async () => { const handlers = createSystemHandlers({ ...deps, getNodeLocations: ({ role, nodeId, limit } = {}) => { const all = [ { nodeId: 'node-1', role: 'companion', connectionId: 'c1', location: { latitude: 37.7, longitude: -122.4, source: 'gps' as const, capturedAt: 1000, receivedAt: 1005, }, }, { nodeId: 'node-2', role: 'observer', connectionId: 'c2', location: { latitude: 40.7, longitude: -74.0, source: 'network' as const, capturedAt: 900, receivedAt: 905, }, }, ]; return all .filter((entry) => !role || entry.role === role) .filter((entry) => !nodeId || entry.nodeId === nodeId) .slice(0, limit ?? 100); }, }); const req: GatewayRequest = { id: 7, method: 'system.location', params: { role: 'companion', limit: 1 }, }; const result = await handlers['system.location'](req) as GatewayResponse; const locations = getPath(result.result, 'locations') as Array<{ nodeId: string }>; expect(locations).toHaveLength(1); expect(locations[0]?.nodeId).toBe('node-1'); expect(getPath(result.result, 'summary')).toEqual({ total: 1 }); }); it('system.nodes returns empty result when getNodes is not provided', async () => { const req: GatewayRequest = { id: 8, method: 'system.nodes' }; const result = await handlers['system.nodes'](req) as GatewayResponse; expect(result.id).toBe(8); expect(getPath(result.result, 'nodes')).toEqual([]); expect(getPath(result.result, 'summary')).toEqual({ total: 0 }); }); it('system.nodes returns filtered registered node snapshots', async () => { const handlers = createSystemHandlers({ ...deps, getNodes: ({ role, platform, limit } = {}) => { const all = [ { connectionId: 'c1', nodeId: 'companion-mac', role: 'companion', identity: 'will@example.com', protocolVersion: 1, capabilities: ['ui.canvas'], registeredAt: 100, status: { platform: 'macos' as const, appVersion: '0.3.0', powerSource: 'ac' as const, reportedAt: 120 }, }, { connectionId: 'c2', nodeId: 'observer-linux', role: 'observer', protocolVersion: 1, capabilities: [], registeredAt: 90, status: { platform: 'linux' as const, powerSource: 'unknown' as const, reportedAt: 95 }, }, ]; return all .filter((entry) => !role || entry.role === role) .filter((entry) => !platform || entry.status?.platform === platform) .slice(0, limit ?? 100); }, }); const req: GatewayRequest = { id: 9, method: 'system.nodes', params: { role: 'companion', platform: 'macos', limit: 1 }, }; const result = await handlers['system.nodes'](req) as GatewayResponse; const nodes = getPath(result.result, 'nodes') as Array<{ nodeId: string }>; expect(nodes).toHaveLength(1); expect(nodes[0]?.nodeId).toBe('companion-mac'); expect(getPath(result.result, 'summary')).toEqual({ total: 1 }); }); }); describe('system.tokenUsage handler', () => { it('returns empty sessions when no getTokenUsage provided', async () => { const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, }); const req: GatewayRequest = { id: 1, method: 'system.tokenUsage' }; const result = await handlers['system.tokenUsage'](req) as GatewayResponse; expect(result.id).toBe(1); const r = result.result as { sessions: unknown[] }; expect(r.sessions).toEqual([]); }); it('returns session usage data from getTokenUsage callback', async () => { const mockUsage: TokenUsageEntry[] = [ { sessionId: 'telegram:user1', primary: { inputTokens: 1000, outputTokens: 500, calls: 3 }, delegation: { fast: { inputTokens: 200, outputTokens: 100, calls: 1 } }, total: { inputTokens: 1200, outputTokens: 600, calls: 4, estimatedCost: 0.0234 }, }, { sessionId: 'ws:abc-123', primary: { inputTokens: 50, outputTokens: 25, calls: 1 }, delegation: {}, total: { inputTokens: 50, outputTokens: 25, calls: 1, estimatedCost: 0 }, }, ]; const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 2, getToolCount: () => 0, getConnectionCount: () => 1, getTokenUsage: () => mockUsage, }); const req: GatewayRequest = { id: 2, method: 'system.tokenUsage' }; const result = await handlers['system.tokenUsage'](req) as GatewayResponse; expect(result.id).toBe(2); const r = result.result as { sessions: typeof mockUsage }; expect(r.sessions).toHaveLength(2); expect(r.sessions[0].sessionId).toBe('telegram:user1'); expect(r.sessions[0].total.inputTokens).toBe(1200); expect(r.sessions[0].total.estimatedCost).toBe(0.0234); expect(r.sessions[0].delegation.fast.inputTokens).toBe(200); expect(r.sessions[1].sessionId).toBe('ws:abc-123'); expect(r.sessions[1].total.calls).toBe(1); }); }); describe('system.contextUsage handler', () => { it('returns empty sessions when no getContextUsage provided', async () => { const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, }); const req: GatewayRequest = { id: 21, method: 'system.contextUsage' }; const result = await handlers['system.contextUsage'](req) as GatewayResponse; expect(result.id).toBe(21); const r = result.result as { sessions: unknown[] }; expect(r.sessions).toEqual([]); }); it('returns session context budget data from getContextUsage callback', async () => { const mockUsage = [ { sessionId: 'telegram:user1', budget: { estimatedTokens: 120000, contextWindow: 200000, remainingTokens: 80000, usagePct: 60, thresholdPct: 80, thresholdTokens: 160000, shouldCompact: false, }, }, ]; const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 1, getToolCount: () => 0, getConnectionCount: () => 1, getContextUsage: () => mockUsage, }); const req: GatewayRequest = { id: 22, method: 'system.contextUsage' }; const result = await handlers['system.contextUsage'](req) as GatewayResponse; const r = result.result as { sessions: typeof mockUsage }; expect(r.sessions).toHaveLength(1); expect(r.sessions[0].sessionId).toBe('telegram:user1'); expect(r.sessions[0].budget.usagePct).toBe(60); expect(r.sessions[0].budget.shouldCompact).toBe(false); }); }); describe('system.sessionAnalytics handler', () => { it('returns empty analytics when callback is not provided', async () => { const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, }); const req: GatewayRequest = { id: 3, method: 'system.sessionAnalytics' }; const result = await handlers['system.sessionAnalytics'](req) as GatewayResponse; expect(result.id).toBe(3); const r = result.result as { daily: unknown[]; topSessions: unknown[]; topTools: unknown[]; topTopics: unknown[]; averageMessagesPerSession: number; totalSessions: number; totalMessages: number; }; expect(r.daily).toEqual([]); expect(r.topSessions).toEqual([]); expect(r.topTools).toEqual([]); expect(r.topTopics).toEqual([]); expect(r.averageMessagesPerSession).toBe(0); expect(r.totalSessions).toBe(0); expect(r.totalMessages).toBe(0); }); it('returns analytics from callback', async () => { const getSessionAnalytics = vi.fn(() => ({ daily: [{ day: '2026-02-16', sessions: 2, messages: 8 }], topSessions: [{ sessionId: 'telegram:1', messages: 5, lastActivity: 1708080000 }], topTools: [{ toolName: 'web.search', executions: 4 }], topTopics: [{ topic: 'kubernetes', occurrences: 3 }], averageMessagesPerSession: 4, totalSessions: 2, totalMessages: 8, })); const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 2, getToolCount: () => 0, getConnectionCount: () => 1, getSessionAnalytics, }); const req: GatewayRequest = { id: 4, method: 'system.sessionAnalytics', params: { days: 7, topLimit: 5 }, }; const result = await handlers['system.sessionAnalytics'](req) as GatewayResponse; expect(getSessionAnalytics).toHaveBeenCalledWith({ days: 7, topLimit: 5 }); expect(getPath(result.result, 'totalSessions')).toBe(2); expect(getPath(result.result, 'daily')).toEqual([{ day: '2026-02-16', sessions: 2, messages: 8 }]); expect(getPath(result.result, 'topTools')).toEqual([{ toolName: 'web.search', executions: 4 }]); expect(getPath(result.result, 'topTopics')).toEqual([{ topic: 'kubernetes', occurrences: 3 }]); }); }); describe('session handlers', () => { const mockHistory = [ { role: 'user' as const, content: 'hello' }, { role: 'assistant' as const, content: 'hi' }, ]; const mockSession = { id: 'ws:test', addMessage: vi.fn(), getHistory: vi.fn(() => mockHistory), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockSessionManager = { listSessions: vi.fn(() => ['ws:test']), getSession: vi.fn(() => mockSession), getSessionConfig: vi.fn((_frontend: string, _userId: string, _key: string) => undefined), transferSession: vi.fn(), closeSession: vi.fn(), }; const handlers = createSessionHandlers({ sessionManager: asSessionHandlerSessionManager(mockSessionManager), }); beforeEach(() => { vi.clearAllMocks(); mockSessionManager.listSessions.mockReturnValue(['ws:test']); mockSessionManager.getSession.mockReturnValue(mockSession); mockSession.getHistory.mockReturnValue(mockHistory); }); it('sessions.list returns session list with message counts and metadata', async () => { const req: GatewayRequest = { id: 1, method: 'sessions.list' }; const result = await handlers['sessions.list'](req) as GatewayResponse; expect(result.id).toBe(1); const r = result.result as { sessions: Array<{ id: string; messageCount: number; frontend: string; userId: string }>; total: number }; expect(r.sessions).toHaveLength(1); expect(r.sessions[0].id).toBe('ws:test'); expect(r.sessions[0].frontend).toBe('ws'); expect(r.sessions[0].userId).toBe('test'); expect(r.sessions[0].messageCount).toBe(2); expect(r.total).toBe(1); }); it('sessions.list supports persisted inclusion, frontend filter, and paging', async () => { mockSessionManager.listSessions.mockReturnValue(['ws:a', 'ws:b', 'telegram:c']); const req: GatewayRequest = { id: 10, method: 'sessions.list', params: { includePersisted: true, frontend: 'ws', limit: 1, offset: 1 }, }; const result = await handlers['sessions.list'](req) as GatewayResponse; const payload = result.result as { sessions: Array<{ id: string }>; total: number }; expect(mockSessionManager.listSessions).toHaveBeenCalledWith({ includePersisted: true, frontend: 'ws' }); expect(payload.total).toBe(3); expect(payload.sessions).toHaveLength(1); expect(payload.sessions[0].id).toBe('ws:b'); }); it('sessions.list rejects invalid pagination and filters', async () => { const badLimit = await handlers['sessions.list']({ id: 11, method: 'sessions.list', params: { limit: -1 }, }) as GatewayError; expect(badLimit.error.code).toBe(ErrorCode.InvalidRequest); const badFrontend = await handlers['sessions.list']({ id: 12, method: 'sessions.list', params: { frontend: '' }, }) as GatewayError; expect(badFrontend.error.code).toBe(ErrorCode.InvalidRequest); }); it('sessions.history returns messages with pagination', async () => { const req: GatewayRequest = { id: 2, method: 'sessions.history', params: { sessionId: 'ws:test', limit: 1, offset: 0 } }; const result = await handlers['sessions.history'](req) as GatewayResponse; const r = result.result as { messages: unknown[]; total: number }; expect(r.messages).toHaveLength(1); expect(r.total).toBe(2); }); it('sessions.history requires sessionId', async () => { const req: GatewayRequest = { id: 3, method: 'sessions.history', params: {} }; const result = await handlers['sessions.history'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); it('sessions.history rejects invalid pagination values', async () => { const badOffset = await handlers['sessions.history']({ id: 13, method: 'sessions.history', params: { sessionId: 'ws:test', offset: -1 }, }) as GatewayError; expect(badOffset.error.code).toBe(ErrorCode.InvalidRequest); }); it('sessions.create creates a new session', async () => { const req: GatewayRequest = { id: 4, method: 'sessions.create', params: { sessionId: 'ws:new' } }; const result = await handlers['sessions.create'](req) as GatewayResponse; const r = result.result as { sessionId: string }; expect(r.sessionId).toBe('ws:new'); expect(mockSessionManager.getSession).toHaveBeenCalledWith('ws', 'new'); }); it('sessions.create auto-generates session ID', async () => { const req: GatewayRequest = { id: 5, method: 'sessions.create' }; const result = await handlers['sessions.create'](req) as GatewayResponse; const r = result.result as { sessionId: string }; expect(r.sessionId).toMatch(/^ws:\d+$/); }); }); describe('canvas handlers', () => { it('supports put/get/list/delete/clear lifecycle', async () => { const handlers = createCanvasHandlers({ store: new CanvasStore() }); const putReq: GatewayRequest = { id: 1, method: 'canvas.put', params: { sessionId: 'ws:abc', artifactId: 'card-1', type: 'note', title: 'Draft', content: { text: 'hello' }, }, }; const putRes = await handlers['canvas.put'](putReq) as GatewayResponse; expect(getPath(putRes.result, 'artifact', 'id')).toBe('card-1'); const getRes = await handlers['canvas.get']({ id: 2, method: 'canvas.get', params: { sessionId: 'ws:abc', artifactId: 'card-1' }, }) as GatewayResponse; expect(getPath(getRes.result, 'artifact', 'title')).toBe('Draft'); const listRes = await handlers['canvas.list']({ id: 3, method: 'canvas.list', params: { sessionId: 'ws:abc' }, }) as GatewayResponse; expect((getPath(listRes.result, 'artifacts') as unknown[]).length).toBe(1); const delRes = await handlers['canvas.delete']({ id: 4, method: 'canvas.delete', params: { sessionId: 'ws:abc', artifactId: 'card-1' }, }) as GatewayResponse; expect(getPath(delRes.result, 'deleted')).toBe(true); await handlers['canvas.put']({ id: 5, method: 'canvas.put', params: { sessionId: 'ws:abc', artifactId: 'card-2', type: 'note', content: 'a' }, }); await handlers['canvas.put']({ id: 6, method: 'canvas.put', params: { sessionId: 'ws:abc', artifactId: 'card-3', type: 'note', content: 'b' }, }); const clearRes = await handlers['canvas.clear']({ id: 7, method: 'canvas.clear', params: { sessionId: 'ws:abc' }, }) as GatewayResponse; expect(getPath(clearRes.result, 'cleared')).toBe(2); }); it('validates required params', async () => { const handlers = createCanvasHandlers({ store: new CanvasStore() }); const missingSession = await handlers['canvas.list']({ id: 8, method: 'canvas.list', params: {}, }) as GatewayError; expect(missingSession.error.code).toBe(ErrorCode.InvalidRequest); const missingContent = await handlers['canvas.put']({ id: 9, method: 'canvas.put', params: { sessionId: 'ws:abc', type: 'note' }, }) as GatewayError; expect(missingContent.error.code).toBe(ErrorCode.InvalidRequest); }); }); describe('tool handlers', () => { const mockTool = { name: 'test.tool', description: 'A test tool', inputSchema: { type: 'object' as const, properties: {} }, execute: vi.fn(), }; const mockRegistry = { list: vi.fn(() => [mockTool]), filteredList: vi.fn(() => [mockTool]), get: vi.fn((name: string) => (name === 'test.tool' ? mockTool : undefined)), register: vi.fn(), toAnthropicFormat: vi.fn(), toOpenAIFormat: vi.fn(), }; const mockExecutor = { execute: vi.fn(async () => ({ success: true, output: 'done' })), }; const handlers = createToolHandlers({ toolRegistry: asToolRegistry(mockRegistry), toolExecutor: asToolExecutor(mockExecutor), }); beforeEach(() => { vi.clearAllMocks(); mockRegistry.list.mockReturnValue([mockTool]); mockRegistry.filteredList.mockReturnValue([mockTool]); mockRegistry.get.mockImplementation((name: string) => (name === 'test.tool' ? mockTool : undefined)); mockExecutor.execute.mockResolvedValue({ success: true, output: 'done' }); }); it('tools.list returns tool definitions', async () => { const req: GatewayRequest = { id: 1, method: 'tools.list' }; const result = await handlers['tools.list'](req) as GatewayResponse; const r = result.result as { tools: Array<{ name: string }> }; expect(r.tools).toHaveLength(1); expect(r.tools[0].name).toBe('test.tool'); }); it('tools.invoke executes a tool', async () => { const req: GatewayRequest = { id: 2, method: 'tools.invoke', params: { tool: 'test.tool', args: {} } }; const result = await handlers['tools.invoke'](req) as GatewayResponse; expect(result.result).toEqual({ success: true, output: 'done' }); expect(mockExecutor.execute).toHaveBeenCalledWith('test.tool', {}); }); it('tools.invoke errors on missing tool name', async () => { const req: GatewayRequest = { id: 3, method: 'tools.invoke', params: {} }; const result = await handlers['tools.invoke'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); it('tools.invoke errors on unknown tool', async () => { const req: GatewayRequest = { id: 4, method: 'tools.invoke', params: { tool: 'unknown' } }; const result = await handlers['tools.invoke'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.ToolNotFound); }); }); describe('agent handlers', () => { const mockAgent = { process: vi.fn(async () => 'response text'), consumeContextAlert: vi.fn(() => undefined), getContextBudget: vi.fn(() => ({ estimatedTokens: 0, contextWindow: 128000, remainingTokens: 128000, usagePct: 0, thresholdPct: 80, thresholdTokens: 102400, shouldCompact: false, })), setOnToolUse: vi.fn(), }; const mockBridge = { getAgent: vi.fn(() => mockAgent), getSessionId: vi.fn(() => 'ws:conn-1'), isBusy: vi.fn(() => false), cancel: vi.fn(() => false), setBusy: vi.fn(), setOnToolUse: vi.fn(), }; const laneQueue = new LaneQueue(); const handlers = createAgentHandlers({ sessionBridge: asSessionBridge(mockBridge), laneQueue, }); beforeEach(() => { vi.clearAllMocks(); mockBridge.isBusy.mockReturnValue(false); mockBridge.cancel.mockReturnValue(false); mockBridge.getAgent.mockReturnValue(mockAgent); mockAgent.process.mockResolvedValue('response text'); }); it('agent.send processes message and sends done event', async () => { const req: GatewayRequest = { id: 1, method: 'agent.send', params: { message: 'hello', connectionId: 'conn-1' } }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('hello', undefined); const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done') as GatewayEvent | undefined; expect(doneEvent).toBeTruthy(); if (!doneEvent) { throw new Error('done event not emitted'); } expect(doneEvent.event).toBe('done'); expect(getPath(doneEvent.data, 'content')).toBe('response text'); }); it('agent.send passes attachments to agent.process', async () => { const attachments = [ { mimeType: 'image/png', data: 'iVBOR...', filename: 'screenshot.png' }, { mimeType: 'application/pdf', url: 'https://example.com/doc.pdf' }, ]; const req: GatewayRequest = { id: 10, method: 'agent.send', params: { message: 'describe this', connectionId: 'conn-1', attachments }, }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('describe this', [ { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: 'screenshot.png' }, { mimeType: 'application/pdf', data: undefined, url: 'https://example.com/doc.pdf', filename: undefined }, ]); const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done') as GatewayEvent | undefined; expect(doneEvent).toBeTruthy(); if (!doneEvent) { throw new Error('done event not emitted'); } expect(doneEvent.event).toBe('done'); }); it('agent.send works with empty attachments array', async () => { const req: GatewayRequest = { id: 11, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1', attachments: [] }, }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('hi', []); const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done'); expect(doneEvent).toBeTruthy(); }); it('agent.send accepts attachment-only requests', async () => { const req: GatewayRequest = { id: 12, method: 'agent.send', params: { connectionId: 'conn-1', attachments: [{ mimeType: 'image/png', data: 'iVBOR...' }], }, }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('', [ { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: undefined }, ]); const doneEvent = sent.find((msg) => (msg as GatewayEvent).event === 'done'); expect(doneEvent).toBeTruthy(); }); it('agent.send requires message or attachments', async () => { const req: GatewayRequest = { id: 2, method: 'agent.send', params: { connectionId: 'conn-1' } }; const send = vi.fn(); const result = await handlers['agent.send'](req, send) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); expect(result.error.message).toContain('message'); }); it('agent.send queues concurrent requests instead of rejecting', async () => { // Simulate the first request blocking let resolveFirst!: () => void; const firstBlocks = new Promise((r) => { resolveFirst = r; }); let callCount = 0; mockAgent.process.mockImplementation(async () => { callCount++; if (callCount === 1) { await firstBlocks; return 'first response'; } return 'second response'; }); const req1: GatewayRequest = { id: 3, method: 'agent.send', params: { message: 'first', connectionId: 'conn-1' } }; const req2: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'second', connectionId: 'conn-1' } }; const sent1: OutboundMessage[] = []; const sent2: OutboundMessage[] = []; const p1 = handlers['agent.send'](req1, vi.fn((msg: OutboundMessage) => sent1.push(msg))); const p2 = handlers['agent.send'](req2, vi.fn((msg: OutboundMessage) => sent2.push(msg))); // Release the first request resolveFirst(); await Promise.all([p1, p2]); // Both should have completed — no AgentBusy error expect(sent1.find((msg) => (msg as GatewayEvent).event === 'done')).toBeTruthy(); expect(sent2.find((msg) => (msg as GatewayEvent).event === 'done')).toBeTruthy(); expect(mockAgent.process).toHaveBeenCalledTimes(2); }); it('agent.send handles errors gracefully', async () => { mockAgent.process.mockRejectedValue(new Error('model failed')); const req: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); const errorEvent = sent.find((msg) => (msg as GatewayEvent).event === 'error') as GatewayEvent | undefined; expect(errorEvent).toBeTruthy(); if (!errorEvent) { throw new Error('error event not emitted'); } expect(errorEvent.event).toBe('error'); expect(getPath(errorEvent.data, 'message')).toBe('model failed'); }); it('agent.send sets and cleans up tool use callback', async () => { const req: GatewayRequest = { id: 5, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } }; const send = vi.fn(); await handlers['agent.send'](req, send); // setOnToolUse called twice: once to set callback, once to clear it expect(mockBridge.setOnToolUse).toHaveBeenCalledTimes(2); expect(mockBridge.setOnToolUse).toHaveBeenLastCalledWith('conn-1', undefined); }); it('agent.send sets busy state correctly', async () => { const req: GatewayRequest = { id: 6, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } }; const send = vi.fn(); await handlers['agent.send'](req, send); expect(mockBridge.setBusy).toHaveBeenCalledWith('conn-1', true); expect(mockBridge.setBusy).toHaveBeenCalledWith('conn-1', false); }); it('agent.cancel returns cancelled state', async () => { mockBridge.cancel.mockReturnValue(true); const req: GatewayRequest = { id: 7, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; const result = await handlers['agent.cancel'](req, vi.fn()) as GatewayResponse; expect(getPath(result.result, 'cancelled')).toBe(true); expect(getPath(result.result, 'message')).toContain('Cancellation requested'); expect(mockBridge.cancel).toHaveBeenCalledWith('conn-1'); }); it('agent.cancel returns not-cancelled when no active operation exists', async () => { mockBridge.cancel.mockReturnValue(false); const req: GatewayRequest = { id: 8, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; const result = await handlers['agent.cancel'](req, vi.fn()) as GatewayResponse; expect(getPath(result.result, 'cancelled')).toBe(false); expect(getPath(result.result, 'message')).toContain('No active operation'); }); }); describe('intent handlers', () => { it('intents.list returns configured rules', async () => { const registry = new ComponentRegistry({ matchThreshold: 0.6 }); registry.register({ name: 'deploy-route', patterns: ['deploy *'], target: { type: 'agent', name: 'coder' }, priority: 5, enabled: true, }); const handlers = createIntentHandlers({ intentRegistry: registry, enabled: true, }); const req: GatewayRequest = { id: 10, method: 'intents.list' }; const result = await handlers['intents.list'](req) as GatewayResponse; const payload = result.result as { enabled: boolean; rules: Array<{ name: string }> }; expect(payload.enabled).toBe(true); expect(payload.rules).toHaveLength(1); expect(payload.rules[0].name).toBe('deploy-route'); }); it('intents.match returns best rule match', async () => { const registry = new ComponentRegistry({ matchThreshold: 0.5 }); registry.register({ name: 'deploy-route', patterns: ['deploy *'], target: { type: 'agent', name: 'coder' }, priority: 5, enabled: true, }); const handlers = createIntentHandlers({ intentRegistry: registry, enabled: true, }); const req: GatewayRequest = { id: 11, method: 'intents.match', params: { input: 'deploy backend service' }, }; const result = await handlers['intents.match'](req) as GatewayResponse; const payload = result.result as { match: { rule: { name: string } } }; expect(payload.match.rule.name).toBe('deploy-route'); }); }); describe('routing handlers', () => { it('routing.decide returns match and policy decision', async () => { const registry = new ComponentRegistry({ matchThreshold: 0.5 }); registry.register({ name: 'deploy-route', patterns: ['deploy *'], target: { type: 'agent', name: 'coder' }, priority: 5, enabled: true, }); const policy = new RoutingPolicy({ enabled: true, fastPathThreshold: 0.7, llmThreshold: 0.3, defaultPath: 'llm', }); const handlers = createRoutingHandlers({ intentRegistry: registry, routingPolicy: policy, }); const req: GatewayRequest = { id: 12, method: 'routing.decide', params: { input: 'deploy service' }, }; const result = await handlers['routing.decide'](req) as GatewayResponse; const payload = result.result as { match: { rule: { name: string } }; decision: { path: string }; }; expect(payload.match.rule.name).toBe('deploy-route'); expect(payload.decision.path).toBe('fast'); }); }); describe('history handlers', () => { it('history.search returns ranked results', async () => { const historySessionManager = asHistorySessionManager({ searchHistory: () => [{ sessionId: 'ws:test', messageId: 1, role: 'user', content: 'deploy', score: 0.9, createdAt: 123 }], reindexHistory: () => 0, }); const handlers = createHistoryHandlers({ sessionManager: historySessionManager, }); const req: GatewayRequest = { id: 13, method: 'history.search', params: { query: 'deploy' } }; const result = await handlers['history.search'](req) as GatewayResponse; const payload = result.result as { results: Array<{ sessionId: string }> }; expect(payload.results[0].sessionId).toBe('ws:test'); }); it('history.reindex returns count', async () => { const historySessionManager = asHistorySessionManager({ searchHistory: () => [], reindexHistory: () => 42, }); const handlers = createHistoryHandlers({ sessionManager: historySessionManager, }); const req: GatewayRequest = { id: 14, method: 'history.reindex' }; const result = await handlers['history.reindex'](req) as GatewayResponse; expect((result.result as { reindexed: number }).reindexed).toBe(42); }); }); describe('system.restart handler', () => { it('returns restarting:true and calls restart callback', async () => { const restartFn = vi.fn(async () => {}); const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, restart: restartFn, }); const req: GatewayRequest = { id: 1, method: 'system.restart' }; const result = await handlers['system.restart'](req) as GatewayResponse; expect(result.id).toBe(1); expect(getPath(result.result, 'restarting')).toBe(true); // Restart is called asynchronously via queueMicrotask await new Promise((resolve) => queueMicrotask(resolve)); expect(restartFn).toHaveBeenCalledOnce(); }); it('returns error when restart is not available', async () => { const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, }); const req: GatewayRequest = { id: 2, method: 'system.restart' }; const result = await handlers['system.restart'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InternalError); expect(result.error.message).toContain('not available'); }); }); describe('config handlers', () => { function makeConfig() { return { telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] }, server: { tailscale: {}, localhost: true, port: 18800, queue: { mode: 'collect' as const, cap: 50, overflow: 'drop_old' as const, debounce_ms: 0, summarize_overflow: true, }, nodes: { enabled: false, allowed_roles: ['companion'], feature_gates: {}, location: { enabled: false, }, push: { enabled: false, }, }, }, models: { default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' }, fallback_chain: [], }, backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } }, hooks: { confirm: ['shell.exec'], log: [], silent: [] }, mcp: { servers: [] }, }; } it('config.get returns redacted config', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 1, method: 'config.get' }; const result = await handlers['config.get'](req) as GatewayResponse; expect(getPath(result.result, 'telegram', 'bot_token')).toBe('***'); expect(getPath(result.result, 'models', 'default', 'api_key')).toBe('***'); // Non-secret values are preserved expect(getPath(result.result, 'server', 'port')).toBe(18800); expect(getPath(result.result, 'hooks', 'confirm')).toEqual(['shell.exec']); }); it('config.patch applies valid patches', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 2, method: 'config.patch', params: { patches: { 'hooks.confirm': ['shell.exec', 'file.write'], 'hooks.log': ['file.read'], 'server.queue.mode': 'followup', 'server.queue.debounce_ms': 100, 'server.nodes.location.enabled': true, 'server.nodes.push.enabled': true, 'automation.delivery_mode': 'announce', 'automation.daily_briefing.enabled': true, 'automation.daily_briefing.output.channel': 'telegram', 'automation.daily_briefing.output.peer': '12345', 'automation.daily_briefing.model_tier': 'fast', 'memory.daily_log.enabled': true, 'memory.proactive_extract.enabled': true, 'memory.proactive_extract.min_tool_calls': 2, 'tts.enabled': true, 'tts.enabled_channels': ['telegram', 'discord'], }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual([ 'hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms', 'server.nodes.location.enabled', 'server.nodes.push.enabled', 'automation.delivery_mode', 'automation.daily_briefing.enabled', 'automation.daily_briefing.output.channel', 'automation.daily_briefing.output.peer', 'automation.daily_briefing.model_tier', 'memory.daily_log.enabled', 'memory.proactive_extract.enabled', 'memory.proactive_extract.min_tool_calls', 'tts.enabled', 'tts.enabled_channels', ]); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(false); // Verify the config was actually mutated expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']); expect(config.hooks.log).toEqual(['file.read']); expect(config.server.queue.mode).toBe('followup'); expect(config.server.queue.debounce_ms).toBe(100); expect(config.server.nodes.location.enabled).toBe(true); expect(config.server.nodes.push.enabled).toBe(true); expect(getPath(config, 'automation', 'delivery_mode')).toBe('announce'); expect(getPath(config, 'automation', 'daily_briefing', 'enabled')).toBe(true); expect(getPath(config, 'automation', 'daily_briefing', 'output', 'channel')).toBe('telegram'); expect(getPath(config, 'automation', 'daily_briefing', 'output', 'peer')).toBe('12345'); expect(getPath(config, 'automation', 'daily_briefing', 'model_tier')).toBe('fast'); expect(getPath(config, 'memory', 'daily_log', 'enabled')).toBe(true); expect(getPath(config, 'memory', 'proactive_extract', 'enabled')).toBe(true); expect(getPath(config, 'memory', 'proactive_extract', 'min_tool_calls')).toBe(2); expect(getPath(config, 'tts', 'enabled')).toBe(true); expect(getPath(config, 'tts', 'enabled_channels')).toEqual(['telegram', 'discord']); }); it('config.patch applies councils model and routing patches', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 22, method: 'config.patch', params: { patches: { 'councils.enabled': true, 'councils.defaults.max_rounds': 3, 'councils.groups.D.model_tier': 'complex', 'councils.groups.P.model_tier': 'fast', 'councils.meta_model_tier': 'default', 'councils.groups.D.arbiter_agent': 'd_arbiter', 'councils.groups.D.freethinker_agent': 'd_ft', 'councils.groups.P.arbiter_agent': 'p_arbiter', 'councils.groups.P.freethinker_agent': 'p_ft', 'councils.meta_arbiter_agent': 'meta_arbiter', 'councils.scaffold_path': 'docs/councils/ai-council-production-scaffold.json', }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual([ 'councils.enabled', 'councils.defaults.max_rounds', 'councils.groups.D.model_tier', 'councils.groups.P.model_tier', 'councils.meta_model_tier', 'councils.groups.D.arbiter_agent', 'councils.groups.D.freethinker_agent', 'councils.groups.P.arbiter_agent', 'councils.groups.P.freethinker_agent', 'councils.meta_arbiter_agent', 'councils.scaffold_path', ]); expect(r.rejected).toEqual([]); expect(getPath(config, 'councils', 'enabled')).toBe(true); expect(getPath(config, 'councils', 'defaults', 'max_rounds')).toBe(3); expect(getPath(config, 'councils', 'groups', 'P', 'model_tier')).toBe('fast'); expect(getPath(config, 'councils', 'meta_model_tier')).toBe('default'); expect(getPath(config, 'councils', 'meta_arbiter_agent')).toBe('meta_arbiter'); }); it('config.patch rejects unknown keys', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 3, method: 'config.patch', params: { patches: { 'telegram.bot_token': 'hacked', 'hooks.confirm': [], }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual(['hooks.confirm']); expect(r.rejected).toEqual(['telegram.bot_token']); expect(r.persisted).toBe(false); }); it('config.patch rejects invalid value types', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 4, method: 'config.patch', params: { patches: { 'hooks.confirm': 'not-an-array', 'server.queue.cap': 0, 'memory.proactive_extract.min_tool_calls': 99, 'tts.enabled_channels': [1, 2, 3], 'automation.daily_briefing.model_tier': 'ultra', }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual([]); expect(r.rejected).toEqual([ 'hooks.confirm', 'server.queue.cap', 'memory.proactive_extract.min_tool_calls', 'tts.enabled_channels', 'automation.daily_briefing.model_tier', ]); expect(r.persisted).toBe(false); }); it('config.patch applies service configuration keys for heartbeat and service toggles', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 41, method: 'config.patch', params: { patches: { 'automation.heartbeat.enabled': true, 'automation.heartbeat.interval': '1m', 'automation.heartbeat.notify_cooldown': '10m', 'automation.heartbeat.failure_threshold': 3, 'automation.heartbeat.disk_threshold_mb': 250, 'automation.heartbeat.process_memory_threshold_mb': 2048, 'automation.heartbeat.backup_failure_threshold': 2, 'automation.heartbeat.provider_error_rate_threshold': 0.4, 'automation.heartbeat.provider_error_min_calls': 8, 'automation.heartbeat.checks': ['gateway', 'model', 'disk'], 'automation.gmail.enabled': true, 'automation.gcal.enabled': true, 'backup.enabled': true, 'audio.enabled': true, 'sandbox.enabled': true, }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual([ 'automation.heartbeat.enabled', 'automation.heartbeat.interval', 'automation.heartbeat.notify_cooldown', 'automation.heartbeat.failure_threshold', 'automation.heartbeat.disk_threshold_mb', 'automation.heartbeat.process_memory_threshold_mb', 'automation.heartbeat.backup_failure_threshold', 'automation.heartbeat.provider_error_rate_threshold', 'automation.heartbeat.provider_error_min_calls', 'automation.heartbeat.checks', 'automation.gmail.enabled', 'automation.gcal.enabled', 'backup.enabled', 'audio.enabled', 'sandbox.enabled', ]); expect(r.rejected).toEqual([]); expect(getPath(config, 'automation', 'heartbeat', 'enabled')).toBe(true); expect(getPath(config, 'automation', 'heartbeat', 'interval')).toBe('1m'); expect(getPath(config, 'automation', 'heartbeat', 'notify_cooldown')).toBe('10m'); expect(getPath(config, 'automation', 'heartbeat', 'failure_threshold')).toBe(3); expect(getPath(config, 'automation', 'heartbeat', 'disk_threshold_mb')).toBe(250); expect(getPath(config, 'automation', 'heartbeat', 'process_memory_threshold_mb')).toBe(2048); expect(getPath(config, 'automation', 'heartbeat', 'backup_failure_threshold')).toBe(2); expect(getPath(config, 'automation', 'heartbeat', 'provider_error_rate_threshold')).toBe(0.4); expect(getPath(config, 'automation', 'heartbeat', 'provider_error_min_calls')).toBe(8); expect(getPath(config, 'automation', 'heartbeat', 'checks')).toEqual(['gateway', 'model', 'disk']); expect(getPath(config, 'automation', 'gmail', 'enabled')).toBe(true); expect(getPath(config, 'automation', 'gcal', 'enabled')).toBe(true); expect(getPath(config, 'backup', 'enabled')).toBe(true); expect(getPath(config, 'audio', 'enabled')).toBe(true); expect(getPath(config, 'sandbox', 'enabled')).toBe(true); }); it('config.patch persists changes when persistence callback is provided', async () => { const config = makeConfig(); const persist = vi.fn(); const handlers = createConfigHandlers({ config: asConfigValue(config), persistConfig: persist as () => Promise, }); const req: GatewayRequest = { id: 6, method: 'config.patch', params: { patches: { 'hooks.confirm': ['shell.exec', 'file.write'] } }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual(['hooks.confirm']); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(true); expect(persist).toHaveBeenCalledTimes(1); expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']); }); it('config.patch does not mutate runtime config when persistence fails', async () => { const config = makeConfig(); const before = [...config.hooks.confirm]; const persist = vi.fn().mockRejectedValue(new Error('disk full')); const handlers = createConfigHandlers({ config: asConfigValue(config), persistConfig: persist as () => Promise, }); const req: GatewayRequest = { id: 7, method: 'config.patch', params: { patches: { 'hooks.confirm': ['file.write'] } }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean; persistError?: string }; expect(r.applied).toEqual([]); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(false); expect(r.persistError).toContain('disk full'); expect(config.hooks.confirm).toEqual(before); }); it('config.patch requires patches object', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 5, method: 'config.patch', params: {} }; const result = await handlers['config.patch'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); it('config.patch updates tier provider/model defaults and syncs runtime model router', async () => { const config = makeConfig(); const modelRouter = { setClient: vi.fn(), setTierStrict: vi.fn(), } as unknown as NonNullable[0]['modelRouter']>; const handlers = createConfigHandlers({ config: asConfigValue(config), modelRouter, }); const req: GatewayRequest = { id: 8, method: 'config.patch', params: { patches: { 'models.default.provider': 'synthetic', 'models.default.model': 'synthetic-default', 'models.fast.provider': 'synthetic', 'models.fast.model': 'synthetic-fast', }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual([ 'models.default.provider', 'models.default.model', 'models.fast.provider', 'models.fast.model', ]); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(false); expect(getPath(config, 'models', 'default', 'provider')).toBe('synthetic'); expect(getPath(config, 'models', 'default', 'model')).toBe('synthetic-default'); expect(getPath(config, 'models', 'fast', 'provider')).toBe('synthetic'); expect(getPath(config, 'models', 'fast', 'model')).toBe('synthetic-fast'); expect(modelRouter.setClient).toHaveBeenCalledWith('default', expect.any(Object), 'synthetic/synthetic-default'); expect(modelRouter.setClient).toHaveBeenCalledWith('fast', expect.any(Object), 'synthetic/synthetic-fast'); expect(modelRouter.setTierStrict).toHaveBeenCalledWith('default', false); expect(modelRouter.setTierStrict).toHaveBeenCalledWith('fast', false); }); }); describe('redactConfig – comprehensive credential redaction', () => { /** * Build a full config object with secrets in every possible location. * Optional sections (discord, slack, etc.) are included to test redaction. */ function makeFullConfig() { return { telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true }, discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true }, slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false }, matrix: { homeserver_url: 'https://matrix.example.org', access_token: 'mx-secret', allowed_room_ids: ['!room1:example.org'], require_mention: true }, mattermost: { server_url: 'https://mattermost.example.org', bot_token: 'mm-secret', allowed_channel_ids: [], require_mention: true, mention_name: 'flynn', poll_interval_ms: 3000 }, server: { tailscale: {}, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true }, models: { default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def', fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' }, }, fast: { provider: 'openai' as const, model: 'gpt-4o-mini', api_key: 'sk-fast', fallback: { provider: 'gemini' as const, model: 'gemini-flash', api_key: 'sk-fast-fb' }, }, complex: { provider: 'anthropic' as const, model: 'claude-opus', auth_token: 'at-complex' }, local: { provider: 'ollama' as const, model: 'llama3' }, fallback_chain: [], local_providers: { ollama: { provider: 'ollama' as const, model: 'llama3', api_key: 'lp-key', auth_token: 'lp-token', fallback: { provider: 'llamacpp' as const, model: 'llama', api_key: 'lp-fb-key' }, }, }, thinking: { anthropic: { budgetTokens: 4096 }, openai: { reasoningEffort: 'medium' as const }, gemini: { budgetTokens: 4096 } }, }, web_search: { provider: 'brave' as const, api_key: 'brave-key', endpoint: 'https://api.brave.com', max_results: 5 }, audio: { transcription_endpoint: 'https://api.openai.com', transcription_api_key: 'audio-key', transcription_model: 'whisper-1' }, memory: { enabled: true, auto_extract: true, max_context_tokens: 2000, embedding: { enabled: true, provider: 'openai' as const, model: 'text-embedding-3-small', api_key: 'embed-key', dimensions: 1536, chunk_size: 512, chunk_overlap: 50, top_k: 5, hybrid_weight: 0.7 }, }, automation: { cron: [], webhooks: [ { name: 'github', secret: 'wh-secret-1', message: '{{body}}', output: { channel: 'telegram', peer: '123' }, enabled: true }, { name: 'gitlab', secret: 'wh-secret-2', message: '{{body}}', output: { channel: 'telegram', peer: '456' }, enabled: true }, { name: 'no-secret', message: '{{body}}', output: { channel: 'telegram', peer: '789' }, enabled: true }, ], gmail: { enabled: true, credentials_file: '/path/to/creds.json', token_file: '/path/to/token.json', watch_labels: ['INBOX'], poll_interval: '300s', output: { channel: 'telegram', peer: '123' }, message: 'new email' }, heartbeat: { enabled: false, interval: '5m', checks: ['gateway'], failure_threshold: 2, disk_threshold_mb: 100 }, }, mcp: { servers: [ { name: 'my-server', command: 'node', args: ['server.js'], env: { API_KEY: 'mcp-api-key', DATABASE_URL: 'postgres://secret@host/db' } }, { name: 'no-env', command: 'python', args: ['app.py'] }, ], }, hooks: { confirm: ['shell.exec'], log: [], silent: [] }, backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } }, }; } it('redacts telegram.bot_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'telegram', 'bot_token')).toBe('***'); }); it('redacts discord.bot_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'discord', 'bot_token')).toBe('***'); }); it('redacts slack.bot_token, app_token, and signing_secret', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'slack', 'bot_token')).toBe('***'); expect(getPath(result, 'slack', 'app_token')).toBe('***'); expect(getPath(result, 'slack', 'signing_secret')).toBe('***'); }); it('redacts matrix.access_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'matrix', 'access_token')).toBe('***'); }); it('redacts mattermost.bot_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'mattermost', 'bot_token')).toBe('***'); }); it('redacts server.token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'server', 'token')).toBe('***'); }); it('redacts model api_key and auth_token for all tiers', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'models', 'default', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'default', 'auth_token')).toBe('***'); expect(getPath(result, 'models', 'fast', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'complex', 'auth_token')).toBe('***'); // local has no keys — should remain unchanged expect(getPath(result, 'models', 'local', 'api_key')).toBeUndefined(); }); it('redacts model fallback api_key and auth_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'models', 'default', 'fallback', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'default', 'fallback', 'auth_token')).toBe('***'); expect(getPath(result, 'models', 'fast', 'fallback', 'api_key')).toBe('***'); }); it('redacts local_providers api_key, auth_token, and their fallbacks', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'models', 'local_providers', 'ollama', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'local_providers', 'ollama', 'auth_token')).toBe('***'); expect(getPath(result, 'models', 'local_providers', 'ollama', 'fallback', 'api_key')).toBe('***'); }); it('redacts web_search.api_key', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'web_search', 'api_key')).toBe('***'); }); it('redacts audio.transcription_api_key', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'audio', 'transcription_api_key')).toBe('***'); }); it('redacts memory.embedding.api_key', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'memory', 'embedding', 'api_key')).toBe('***'); }); it('redacts automation webhook secrets', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'automation', 'webhooks', '0', 'secret')).toBe('***'); expect(getPath(result, 'automation', 'webhooks', '1', 'secret')).toBe('***'); // Webhook without a secret should remain unaffected expect(getPath(result, 'automation', 'webhooks', '2', 'secret')).toBeUndefined(); }); it('redacts automation gmail credentials_file and token_file', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'automation', 'gmail', 'credentials_file')).toBe('***'); expect(getPath(result, 'automation', 'gmail', 'token_file')).toBe('***'); }); it('redacts all MCP server env vars', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'mcp', 'servers', '0', 'env', 'API_KEY')).toBe('***'); expect(getPath(result, 'mcp', 'servers', '0', 'env', 'DATABASE_URL')).toBe('***'); // Server without env should be unaffected expect(getPath(result, 'mcp', 'servers', '1', 'env')).toBeUndefined(); }); it('preserves non-secret fields', () => { const result = redactConfig(asRedactInput(makeFullConfig())); // telegram expect(getPath(result, 'telegram', 'allowed_chat_ids')).toEqual([1]); expect(getPath(result, 'telegram', 'require_mention')).toBe(true); // discord expect(getPath(result, 'discord', 'allowed_guild_ids')).toEqual(['g1']); // slack expect(getPath(result, 'slack', 'allowed_channel_ids')).toEqual([]); // server expect(getPath(result, 'server', 'port')).toBe(18800); expect(getPath(result, 'server', 'tailscale')).toBeDefined(); // models expect(getPath(result, 'models', 'default', 'provider')).toBe('anthropic'); expect(getPath(result, 'models', 'default', 'model')).toBe('claude'); expect(getPath(result, 'models', 'fallback_chain')).toEqual([]); // web_search expect(getPath(result, 'web_search', 'provider')).toBe('brave'); expect(getPath(result, 'web_search', 'max_results')).toBe(5); // audio expect(getPath(result, 'audio', 'transcription_model')).toBe('whisper-1'); // memory expect(getPath(result, 'memory', 'embedding', 'model')).toBe('text-embedding-3-small'); // hooks expect(getPath(result, 'hooks', 'confirm')).toEqual(['shell.exec']); // mcp expect(getPath(result, 'mcp', 'servers', '0', 'name')).toBe('my-server'); expect(getPath(result, 'mcp', 'servers', '0', 'command')).toBe('node'); }); it('handles missing optional sections gracefully', () => { const minimal = { telegram: { bot_token: 'tok', allowed_chat_ids: [1] }, models: { default: { provider: 'anthropic' as const, model: 'claude' }, fallback_chain: [] }, server: { port: 18800 }, hooks: { confirm: [], log: [], silent: [] }, }; // Should not throw even when discord, slack, automation, mcp, etc. are absent const result = redactConfig(asRedactInput(minimal)); expect(getPath(result, 'telegram', 'bot_token')).toBe('***'); expect(result.discord).toBeUndefined(); expect(result.slack).toBeUndefined(); expect(result.automation).toBeUndefined(); }); it('does not mutate the original config object', () => { const config = makeFullConfig(); redactConfig(asRedactInput(config)); // Original secrets should still be intact expect(config.telegram.bot_token).toBe('tg-secret'); expect(config.models.default.api_key).toBe('sk-def'); expect(config.server.token).toBe('bearer-secret'); }); }); describe('pairing handlers', () => { let pm: PairingManager; let handlers: ReturnType; beforeEach(() => { pm = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }); handlers = createPairingHandlers({ pairingManager: pm }); }); it('pairing.generate returns a code and expiry', async () => { const req: GatewayRequest = { id: 1, method: 'pairing.generate', params: { label: 'for alice' } }; const result = await handlers['pairing.generate'](req) as GatewayResponse; const r = result.result as { code: string; expiresAt: number }; expect(r.code).toHaveLength(6); expect(r.expiresAt).toBeGreaterThan(Date.now()); }); it('pairing.generate works without label', async () => { const req: GatewayRequest = { id: 2, method: 'pairing.generate' }; const result = await handlers['pairing.generate'](req) as GatewayResponse; const r = result.result as { code: string; expiresAt: number }; expect(r.code).toHaveLength(6); }); it('pairing.list returns pending codes and approved senders', async () => { // Generate a code first pm.generateCode('test'); // Approve a sender const code = pm.generateCode('for bob'); pm.validateCode('telegram', '12345', code); const req: GatewayRequest = { id: 3, method: 'pairing.list' }; const result = await handlers['pairing.list'](req) as GatewayResponse; const r = result.result as { pending: unknown[]; approved: unknown[] }; expect(r.pending).toHaveLength(1); // one code remaining (the other was consumed) expect(r.approved).toHaveLength(1); }); it('pairing.revoke removes an approved sender', async () => { // Approve a sender const code = pm.generateCode(); pm.validateCode('discord', 'chan-1', code); expect(pm.isApproved('discord', 'chan-1')).toBe(true); const req: GatewayRequest = { id: 4, method: 'pairing.revoke', params: { channel: 'discord', senderId: 'chan-1' } }; const result = await handlers['pairing.revoke'](req) as GatewayResponse; const r = result.result as { revoked: boolean }; expect(r.revoked).toBe(true); expect(pm.isApproved('discord', 'chan-1')).toBe(false); }); it('pairing.revoke returns false for unknown sender', async () => { const req: GatewayRequest = { id: 5, method: 'pairing.revoke', params: { channel: 'telegram', senderId: 'unknown' } }; const result = await handlers['pairing.revoke'](req) as GatewayResponse; const r = result.result as { revoked: boolean }; expect(r.revoked).toBe(false); }); it('pairing.revoke requires channel and senderId', async () => { const req: GatewayRequest = { id: 6, method: 'pairing.revoke', params: {} }; const result = await handlers['pairing.revoke'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); expect(result.error.message).toContain('channel'); }); });