feat: add channel adapter abstraction with Telegram and WebChat adapters
Implement Phase 3 channel adapters that decouple message sources from the agent via a uniform ChannelAdapter interface and ChannelRegistry. - Add ChannelAdapter/InboundMessage/OutboundMessage types - Add ChannelRegistry for adapter lifecycle and message routing - Add TelegramAdapter (grammy bot, auth middleware, confirmations, chunking) - Add WebChatAdapter (thin shim over GatewayServer) - Refactor daemon to use ChannelRegistry with per-channel-per-user agents - Add config.get/config.patch gateway handlers (Phase 2 loose end) - Add system.restart gateway handler (Phase 2 loose end) - Add implementation plans and design docs Tests: 225 passing (33 new channel adapter + gateway handler tests)
This commit is contained in:
@@ -3,6 +3,7 @@ import { createSystemHandlers } from './system.js';
|
||||
import { createSessionHandlers } from './sessions.js';
|
||||
import { createToolHandlers } from './tools.js';
|
||||
import { createAgentHandlers } from './agent.js';
|
||||
import { createConfigHandlers } from './config.js';
|
||||
import { ErrorCode } from '../protocol.js';
|
||||
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js';
|
||||
|
||||
@@ -269,3 +270,144 @@ describe('agent handlers', () => {
|
||||
expect((result.result as any).cancelled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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((result.result as any).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_only: true, localhost: true, port: 18800 },
|
||||
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: config as any });
|
||||
const req: GatewayRequest = { id: 1, method: 'config.get' };
|
||||
const result = await handlers['config.get'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as Record<string, any>;
|
||||
expect(r.telegram.bot_token).toBe('***');
|
||||
expect(r.models.default.api_key).toBe('***');
|
||||
// Non-secret values are preserved
|
||||
expect(r.server.port).toBe(18800);
|
||||
expect(r.hooks.confirm).toEqual(['shell.exec']);
|
||||
});
|
||||
|
||||
it('config.patch applies valid patches', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
const req: GatewayRequest = {
|
||||
id: 2,
|
||||
method: 'config.patch',
|
||||
params: {
|
||||
patches: {
|
||||
'hooks.confirm': ['shell.exec', 'file.write'],
|
||||
'hooks.log': ['file.read'],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await handlers['config.patch'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as { applied: string[]; rejected: string[] };
|
||||
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log']);
|
||||
expect(r.rejected).toEqual([]);
|
||||
// Verify the config was actually mutated
|
||||
expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']);
|
||||
expect(config.hooks.log).toEqual(['file.read']);
|
||||
});
|
||||
|
||||
it('config.patch rejects unknown keys', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
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[] };
|
||||
expect(r.applied).toEqual(['hooks.confirm']);
|
||||
expect(r.rejected).toEqual(['telegram.bot_token']);
|
||||
});
|
||||
|
||||
it('config.patch rejects invalid value types', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
const req: GatewayRequest = {
|
||||
id: 4,
|
||||
method: 'config.patch',
|
||||
params: {
|
||||
patches: {
|
||||
'hooks.confirm': 'not-an-array',
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await handlers['config.patch'](req) as GatewayResponse;
|
||||
|
||||
const r = result.result as { applied: string[]; rejected: string[] };
|
||||
expect(r.applied).toEqual([]);
|
||||
expect(r.rejected).toEqual(['hooks.confirm']);
|
||||
});
|
||||
|
||||
it('config.patch requires patches object', async () => {
|
||||
const config = makeConfig();
|
||||
const handlers = createConfigHandlers({ config: config as any });
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user