Files
flynn/src/gateway/handlers/handlers.test.ts
T
2026-02-16 12:30:55 -08:00

1228 lines
48 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 { createConfigHandlers, redactConfig } from './config.js';
import { createPairingHandlers } from './pairing.js';
import { PairingManager } from '../../channels/pairing.js';
import { LaneQueue } from '../lane-queue.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.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 });
});
});
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('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),
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', 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 }> };
expect(r.sessions).toHaveLength(1);
expect(r.sessions[0].id).toBe('ws:test');
expect(r.sessions[0].messageCount).toBe(2);
});
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.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('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'),
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);
expect(sent).toHaveLength(1);
const doneEvent = sent[0] as GatewayEvent;
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[0] as GatewayEvent;
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', []);
expect(sent).toHaveLength(1);
});
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 },
]);
expect(sent).toHaveLength(1);
expect((sent[0] as GatewayEvent).event).toBe('done');
});
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).toHaveLength(1);
expect((sent1[0] as GatewayEvent).event).toBe('done');
expect(sent2).toHaveLength(1);
expect((sent2[0] as GatewayEvent).event).toBe('done');
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[0] as GatewayEvent;
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) 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) 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,
},
},
},
models: {
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
fallback_chain: ['anthropic'],
},
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,
},
},
};
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']);
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);
});
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,
},
},
};
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']);
expect(r.persisted).toBe(false);
});
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);
});
});
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: ['anthropic'],
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(['anthropic']);
// 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');
});
});