Files
flynn/src/gateway/handlers/handlers.test.ts
T
William Valentin b39010d602 fix(tests): resolve 4 post-phase test failures
- platformClients.integration: iOS/Android push tests lacked
  setStatus() call before listNodes(), so platform filter excluded
  nodes. Added publishHeartbeat() to set platform on connection state.
- server.test: agent.send now emits run_state events before done (Phase
  1). Added sendAndWaitForDone() helper and updated test to find done
  event rather than assuming index 0.
- handlers.test: updated agent.send/cancel assertions to use find()
  and pass send arg to agent.cancel, consistent with run_state events.
- httpBody: req.destroy() closed socket before 413 response could be
  sent. Removed socket destruction from body reader; 413 responses now
  send Connection: close so Node closes the connection cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 11:55:14 -08:00

2136 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof createSessionHandlers>[0]['sessionManager'] {
return value as Parameters<typeof createSessionHandlers>[0]['sessionManager'];
}
function asToolRegistry(value: unknown): Parameters<typeof createToolHandlers>[0]['toolRegistry'] {
return value as Parameters<typeof createToolHandlers>[0]['toolRegistry'];
}
function asToolExecutor(value: unknown): Parameters<typeof createToolHandlers>[0]['toolExecutor'] {
return value as Parameters<typeof createToolHandlers>[0]['toolExecutor'];
}
function asSessionBridge(value: unknown): Parameters<typeof createAgentHandlers>[0]['sessionBridge'] {
return value as Parameters<typeof createAgentHandlers>[0]['sessionBridge'];
}
function asHistorySessionManager(value: unknown): Parameters<typeof createHistoryHandlers>[0]['sessionManager'] {
return value as Parameters<typeof createHistoryHandlers>[0]['sessionManager'];
}
function asConfigValue(value: unknown): Parameters<typeof createConfigHandlers>[0]['config'] {
return value as Parameters<typeof createConfigHandlers>[0]['config'];
}
function asRedactInput(value: unknown): Parameters<typeof redactConfig>[0] {
return value as Parameters<typeof redactConfig>[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<string, unknown>)[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<string, unknown>;
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<LocalBackendStatus[]> => 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<LocalBackendControlResult> => 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<LocalBackendControlResult> => ({
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<DockerDependencyStatus[]> => ([
{
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<DockerDependencyControlResult> => ({
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<ObservabilitySource[]> => ([
{
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<ServiceLogSnapshot> => 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<void>((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<void>((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<void>,
});
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<void>,
});
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<Parameters<typeof createConfigHandlers>[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<typeof createPairingHandlers>;
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');
});
});