2162 lines
83 KiB
TypeScript
2162 lines
83 KiB
TypeScript
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,
|
||
'audio.talk_mode.enabled': true,
|
||
'audio.talk_mode.wake_phrase': 'ok flynn',
|
||
'audio.talk_mode.timeout_ms': 180000,
|
||
'audio.talk_mode.allow_manual_toggle': false,
|
||
'tts.enabled': true,
|
||
'tts.enabled_channels': ['telegram', 'discord'],
|
||
'tts.fallback.max_attempts': 2,
|
||
'tts.fallback.failure_cooldown_ms': 90000,
|
||
},
|
||
},
|
||
};
|
||
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',
|
||
'audio.talk_mode.enabled',
|
||
'audio.talk_mode.wake_phrase',
|
||
'audio.talk_mode.timeout_ms',
|
||
'audio.talk_mode.allow_manual_toggle',
|
||
'tts.enabled',
|
||
'tts.enabled_channels',
|
||
'tts.fallback.max_attempts',
|
||
'tts.fallback.failure_cooldown_ms',
|
||
]);
|
||
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, 'audio', 'talk_mode', 'enabled')).toBe(true);
|
||
expect(getPath(config, 'audio', 'talk_mode', 'wake_phrase')).toBe('ok flynn');
|
||
expect(getPath(config, 'audio', 'talk_mode', 'timeout_ms')).toBe(180000);
|
||
expect(getPath(config, 'audio', 'talk_mode', 'allow_manual_toggle')).toBe(false);
|
||
expect(getPath(config, 'tts', 'enabled')).toBe(true);
|
||
expect(getPath(config, 'tts', 'enabled_channels')).toEqual(['telegram', 'discord']);
|
||
expect(getPath(config, 'tts', 'fallback', 'max_attempts')).toBe(2);
|
||
expect(getPath(config, 'tts', 'fallback', 'failure_cooldown_ms')).toBe(90000);
|
||
});
|
||
|
||
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,
|
||
'audio.talk_mode.wake_phrase': '',
|
||
'audio.talk_mode.timeout_ms': 99999999,
|
||
'tts.enabled_channels': [1, 2, 3],
|
||
'tts.fallback.max_attempts': 0,
|
||
'tts.fallback.failure_cooldown_ms': 0,
|
||
'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',
|
||
'audio.talk_mode.wake_phrase',
|
||
'audio.talk_mode.timeout_ms',
|
||
'tts.enabled_channels',
|
||
'tts.fallback.max_attempts',
|
||
'tts.fallback.failure_cooldown_ms',
|
||
'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');
|
||
});
|
||
});
|