diff --git a/src/backends/external.test.ts b/src/backends/external.test.ts index 97cda3a..f46897c 100644 --- a/src/backends/external.test.ts +++ b/src/backends/external.test.ts @@ -15,19 +15,6 @@ vi.mock('child_process', () => ({ const mockExecFile = vi.mocked(execFile); -function makeFailChild(stderrText = 'failed'): FakeChild { - const child = new EventEmitter() as FakeChild; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); - child.stdin = { end: vi.fn() }; - child.kill = vi.fn(); - setImmediate(() => { - child.stderr.emit('data', Buffer.from(stderrText)); - child.emit('close', 1); - }); - return child; -} - describe('ExternalCliBackend', () => { beforeEach(() => { vi.clearAllMocks(); @@ -117,25 +104,4 @@ describe('ExternalCliBackend', () => { expect(opencode.name).toBe('opencode'); expect(gemini.name).toBe('gemini'); }); - - it('retries failed backend calls when retries are configured', async () => { - spawnMock - .mockImplementationOnce(() => makeFailChild('transient')) - .mockImplementationOnce(() => makeChild('recovered')); - const backend = new ExternalCliBackend({ - name: 'codex', - command: 'codex', - retries: 1, - retryDelayMs: 0, - }); - - const result = await backend.process({ - systemPrompt: 'sys', - history: [], - message: 'retry me', - }); - - expect(result).toBe('recovered'); - expect(spawnMock).toHaveBeenCalledTimes(2); - }); }); diff --git a/src/channels/line/adapter.ts b/src/channels/line/adapter.ts index 1cd1898..e0baed2 100644 --- a/src/channels/line/adapter.ts +++ b/src/channels/line/adapter.ts @@ -169,19 +169,6 @@ export class LineAdapter implements ChannelAdapter { } finally { await rm(tempDir, { recursive: true, force: true }); } - - if (message.attachments && message.attachments.length > 0) { - for (const attachment of message.attachments) { - if (attachment.url) { - const line = attachment.filename ? `${attachment.filename}: ${attachment.url}` : attachment.url; - await this.sendPush(peerId, line); - continue; - } - if (attachment.data) { - console.warn(`LINE: skipping attachment data (${attachment.mimeType}) — upload not implemented`); - } - } - } } async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { diff --git a/src/channels/signal/adapter.ts b/src/channels/signal/adapter.ts index 0d75ec0..fac44c5 100644 --- a/src/channels/signal/adapter.ts +++ b/src/channels/signal/adapter.ts @@ -388,27 +388,3 @@ function sanitizeFilename(filename?: string): string { function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } - -function sanitizeFilename(name?: string): string { - if (!name) { - return ''; - } - return name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100); -} - -function extensionFromMimeType(mimeType?: string): string { - if (!mimeType) { - return ''; - } - const simple = mimeType.split('/')[1]?.trim().toLowerCase(); - if (!simple) { - return ''; - } - if (simple.includes('jpeg')) { - return '.jpg'; - } - if (simple.includes('plain')) { - return '.txt'; - } - return `.${simple.replace(/[^a-z0-9]/g, '')}`; -} diff --git a/src/channels/zalo/adapter.ts b/src/channels/zalo/adapter.ts index 754781d..080be27 100644 --- a/src/channels/zalo/adapter.ts +++ b/src/channels/zalo/adapter.ts @@ -159,19 +159,6 @@ export class ZaloAdapter implements ChannelAdapter { } finally { await rm(tempDir, { recursive: true, force: true }); } - - if (message.attachments && message.attachments.length > 0) { - for (const attachment of message.attachments) { - if (attachment.url) { - const line = attachment.filename ? `${attachment.filename}: ${attachment.url}` : attachment.url; - await this.sendText(peerId, line); - continue; - } - if (attachment.data) { - console.warn(`Zalo: skipping attachment data (${attachment.mimeType}) — upload not implemented`); - } - } - } } async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 70613a7..bd14fe8 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -196,64 +196,6 @@ describe('configSchema — server', () => { }); }); -describe('configSchema — backends', () => { - const minimalConfig = { - telegram: { bot_token: 'test', allowed_chat_ids: [1] }, - models: { default: { provider: 'anthropic', model: 'claude-3' } }, - }; - - it('defaults backend flags to native enabled and externals disabled', () => { - const result = configSchema.parse(minimalConfig); - expect(result.backends.native.enabled).toBe(true); - expect(result.backends.claude_code.enabled).toBe(false); - expect(result.backends.claude_code.args).toEqual([]); - expect(result.backends.claude_code.timeout_ms).toBe(120000); - expect(result.backends.claude_code.retries).toBe(0); - expect(result.backends.claude_code.retry_delay_ms).toBe(300); - expect(result.backends.opencode.enabled).toBe(false); - expect(result.backends.opencode.args).toEqual([]); - expect(result.backends.opencode.timeout_ms).toBe(120000); - expect(result.backends.opencode.retries).toBe(0); - expect(result.backends.opencode.retry_delay_ms).toBe(300); - expect(result.backends.codex.enabled).toBe(false); - expect(result.backends.codex.args).toEqual([]); - expect(result.backends.codex.timeout_ms).toBe(120000); - expect(result.backends.codex.retries).toBe(0); - expect(result.backends.codex.retry_delay_ms).toBe(300); - expect(result.backends.gemini.enabled).toBe(false); - expect(result.backends.gemini.args).toEqual([]); - expect(result.backends.gemini.timeout_ms).toBe(120000); - expect(result.backends.gemini.retries).toBe(0); - expect(result.backends.gemini.retry_delay_ms).toBe(300); - }); - - it('accepts explicit external backend configs', () => { - const result = configSchema.parse({ - ...minimalConfig, - backends: { - default: 'codex', - native: { enabled: false }, - codex: { enabled: true, path: '/usr/local/bin/codex', args: ['run'], timeout_ms: 300000, retries: 2, retry_delay_ms: 1000 }, - gemini: { enabled: true, path: '/usr/local/bin/gemini', args: ['chat'], timeout_ms: 60000, retries: 1, retry_delay_ms: 500 }, - }, - }); - expect(result.backends.default).toBe('codex'); - expect(result.backends.native.enabled).toBe(false); - expect(result.backends.codex.enabled).toBe(true); - expect(result.backends.codex.path).toBe('/usr/local/bin/codex'); - expect(result.backends.codex.args).toEqual(['run']); - expect(result.backends.codex.timeout_ms).toBe(300000); - expect(result.backends.codex.retries).toBe(2); - expect(result.backends.codex.retry_delay_ms).toBe(1000); - expect(result.backends.gemini.enabled).toBe(true); - expect(result.backends.gemini.path).toBe('/usr/local/bin/gemini'); - expect(result.backends.gemini.args).toEqual(['chat']); - expect(result.backends.gemini.timeout_ms).toBe(60000); - expect(result.backends.gemini.retries).toBe(1); - expect(result.backends.gemini.retry_delay_ms).toBe(500); - }); -}); - describe('configSchema — browser', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, @@ -430,19 +372,6 @@ describe('configSchema — agent_configs', () => { expect(result.agent_configs.assistant.tool_profile).toBe('messaging'); expect(result.agent_configs.coder.sandbox).toBe(true); }); - - it('rejects invalid backend name in agent config', () => { - expect(() => configSchema.parse({ - ...minimalConfig, - agent_configs: { - assistant: { - system_prompt: 'You are helpful.', - model_tier: 'default', - backend: 'unknown_backend', - }, - }, - })).toThrow(); - }); }); describe('configSchema — backends', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 4e4f7b5..00c151c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -176,40 +176,24 @@ const backendsSchema = z.object({ path: z.string().optional(), args: z.array(z.string()).default([]), timeout_ms: z.number().min(1_000).max(600_000).default(120_000), - retries: z.number().min(0).max(5).default(0), - retry_delay_ms: z.number().min(0).max(30_000).default(300), }).default({ enabled: false }), opencode: z.object({ enabled: z.boolean().default(false), path: z.string().optional(), args: z.array(z.string()).default([]), timeout_ms: z.number().min(1_000).max(600_000).default(120_000), - retries: z.number().min(0).max(5).default(0), - retry_delay_ms: z.number().min(0).max(30_000).default(300), }).default({ enabled: false }), codex: z.object({ enabled: z.boolean().default(false), path: z.string().optional(), args: z.array(z.string()).default([]), timeout_ms: z.number().min(1_000).max(600_000).default(120_000), - retries: z.number().min(0).max(5).default(0), - retry_delay_ms: z.number().min(0).max(30_000).default(300), }).default({ enabled: false }), gemini: z.object({ enabled: z.boolean().default(false), path: z.string().optional(), args: z.array(z.string()).default([]), timeout_ms: z.number().min(1_000).max(600_000).default(120_000), - retries: z.number().min(0).max(5).default(0), - retry_delay_ms: z.number().min(0).max(30_000).default(300), - }).default({ enabled: false }), - codex: z.object({ - enabled: z.boolean().default(false), - path: z.string().optional(), - }).default({ enabled: false }), - gemini: z.object({ - enabled: z.boolean().default(false), - path: z.string().optional(), }).default({ enabled: false }), native: z.object({ enabled: z.boolean().default(true), diff --git a/src/daemon/index.test.ts b/src/daemon/index.test.ts deleted file mode 100644 index dacba5c..0000000 --- a/src/daemon/index.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { configSchema } from '../config/schema.js'; -import { createConfiguredExternalBackend, createConfiguredExternalBackends, validateBackendConfig } from './index.js'; - -describe('createConfiguredExternalBackend', () => { - const base = configSchema.parse({ - telegram: { bot_token: 'test', allowed_chat_ids: [1] }, - models: { default: { provider: 'anthropic', model: 'claude-3' } }, - }); - - it('returns undefined when no external backend is enabled', () => { - const backend = createConfiguredExternalBackend(base); - expect(backend).toBeUndefined(); - }); - - it('selects codex when enabled', () => { - const cfg = { - ...base, - backends: { - ...base.backends, - codex: { enabled: true, path: '/usr/bin/codex', args: [], timeout_ms: 120000, retries: 0, retry_delay_ms: 300 }, - }, - }; - const backend = createConfiguredExternalBackend(cfg); - expect(backend?.name).toBe('codex'); - }); - - it('selects gemini when enabled and higher-priority backends are disabled', () => { - const cfg = { - ...base, - backends: { - ...base.backends, - gemini: { enabled: true, path: '/usr/bin/gemini', args: [], timeout_ms: 120000, retries: 0, retry_delay_ms: 300 }, - }, - }; - const backend = createConfiguredExternalBackend(cfg); - expect(backend?.name).toBe('gemini'); - }); - - it('returns all enabled external backends and the default priority selection', () => { - const cfg = { - ...base, - backends: { - ...base.backends, - codex: { enabled: true, path: '/usr/bin/codex', args: [], timeout_ms: 120000, retries: 0, retry_delay_ms: 300 }, - gemini: { enabled: true, path: '/usr/bin/gemini', args: [], timeout_ms: 120000, retries: 0, retry_delay_ms: 300 }, - }, - }; - const configured = createConfiguredExternalBackends(cfg); - expect(configured.defaultName).toBe('codex'); - expect(configured.backends.codex?.name).toBe('codex'); - expect(configured.backends.gemini?.name).toBe('gemini'); - }); - - it('honors backends.default when that backend is enabled', () => { - const cfg = { - ...base, - backends: { - ...base.backends, - default: 'gemini' as const, - codex: { enabled: true, path: '/usr/bin/codex', args: [], timeout_ms: 120000, retries: 0, retry_delay_ms: 300 }, - gemini: { enabled: true, path: '/usr/bin/gemini', args: [], timeout_ms: 120000, retries: 0, retry_delay_ms: 300 }, - }, - }; - const configured = createConfiguredExternalBackends(cfg); - expect(configured.defaultName).toBe('gemini'); - expect(configured.backends.codex?.name).toBe('codex'); - expect(configured.backends.gemini?.name).toBe('gemini'); - }); -}); - -describe('validateBackendConfig', () => { - const base = configSchema.parse({ - telegram: { bot_token: 'test', allowed_chat_ids: [1] }, - models: { default: { provider: 'anthropic', model: 'claude-3' } }, - }); - - it('throws when no backend is enabled', () => { - const cfg = { - ...base, - backends: { - ...base.backends, - native: { enabled: false }, - codex: { ...base.backends.codex, enabled: false }, - claude_code: { ...base.backends.claude_code, enabled: false }, - opencode: { ...base.backends.opencode, enabled: false }, - gemini: { ...base.backends.gemini, enabled: false }, - }, - }; - expect(() => validateBackendConfig(cfg)).toThrow('No backend enabled'); - }); - - it('throws when backends.default points to a disabled backend', () => { - const cfg = { - ...base, - backends: { - ...base.backends, - default: 'gemini' as const, - codex: { ...base.backends.codex, enabled: true }, - gemini: { ...base.backends.gemini, enabled: false }, - }, - }; - expect(() => validateBackendConfig(cfg)).toThrow('backends.default=gemini is not enabled'); - }); -}); diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 70114fa..3c51f90 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -110,18 +110,7 @@ export interface StartDaemonOptions { configPath?: string; } -function validateUnsupportedConfig(config: Config): void { - if (config.backends.claude_code.enabled) { - throw new Error('backends.claude_code is not implemented yet. Set backends.claude_code.enabled=false.'); - } - if (config.backends.opencode.enabled) { - throw new Error('backends.opencode is not implemented yet. Set backends.opencode.enabled=false.'); - } -} - export async function startDaemon(config: Config, options?: StartDaemonOptions): Promise { - validateUnsupportedConfig(config); - // ── Log level ── setLogLevel(config.log_level); diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 7f190a4..907cb3c 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -423,78 +423,6 @@ describe('daemon command fast-path integration', () => { expect(session.setConfig).toHaveBeenCalledWith('queue.mode', 'followup'); }); - it('includes external backend in status fast-path output', async () => { - const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process'); - const session = { - id: 'telegram:user-status-backend', - addMessage: vi.fn(), - getHistory: vi.fn(() => []), - clear: vi.fn(), - replaceHistory: vi.fn(), - getConfig: vi.fn(() => undefined), - setConfig: vi.fn(), - deleteConfig: vi.fn(), - }; - - const commandRegistry = new CommandRegistry(); - registerBuiltinCommands(commandRegistry); - - const router = createMessageRouter({ - sessionManager: { - getSession: vi.fn(() => session), - } as unknown as MessageRouterDeps['sessionManager'], - modelRouter: { - getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], - getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), - getLabel: (tier: string) => tier, - } as unknown as MessageRouterDeps['modelRouter'], - systemPrompt: 'test prompt', - toolRegistry: { - clone() { return this; }, - register: vi.fn(), - } as unknown as MessageRouterDeps['toolRegistry'], - toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], - config: { - agents: { - primary_tier: 'default', - delegation: { - compaction: 'fast', - memory_extraction: 'fast', - classification: 'fast', - tool_summarisation: 'fast', - complex_reasoning: 'complex', - }, - max_delegation_depth: 3, - max_iterations: 10, - }, - compaction: { enabled: false }, - models: { default: { provider: 'anthropic', model: 'claude' } }, - } as unknown as MessageRouterDeps['config'], - commandRegistry, - externalBackends: { - codex: { - name: 'codex', - process: vi.fn(async () => 'unused'), - }, - } as unknown as MessageRouterDeps['externalBackends'], - defaultExternalBackendName: 'codex', - }); - - const reply = vi.fn(async (_message: OutboundMessage) => {}); - await router.handler({ - id: 'm-status-backend', - channel: 'telegram', - senderId: 'user-status-backend', - text: '/status', - timestamp: Date.now(), - metadata: { isCommand: true, command: 'status' }, - } as MessageRouterInput, reply); - - expect(processSpy).not.toHaveBeenCalled(); - const outbound = reply.mock.calls[0]?.[0] as OutboundMessage | undefined; - expect(outbound?.text).toContain('Backend: codex'); - }); - it('uses intent match to override agent target', async () => { const session = { id: 'telegram:user-2', @@ -1193,337 +1121,6 @@ describe('daemon auto-escalate integration', () => { expect(setModelTierSpy).toHaveBeenCalledWith('complex'); expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'complex-tier response' })); }); - - it('falls back to native processing when external backend fails', async () => { - const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process') - .mockResolvedValue('native fallback response'); - const history: Array<{ role: 'user' | 'assistant'; content: string }> = []; - const session = { - id: 'telegram:external-fail', - addMessage: vi.fn((msg: { role: 'user' | 'assistant'; content: string }) => { - history.push(msg); - return msg; - }), - getHistory: vi.fn(() => [...history]), - clear: vi.fn(), - replaceHistory: vi.fn(), - getConfig: vi.fn(() => undefined), - setConfig: vi.fn(), - deleteConfig: vi.fn(), - }; - - const externalBackend = { - name: 'codex', - process: vi.fn(async () => { - throw new Error('external failed'); - }), - }; - - const router = createMessageRouter({ - sessionManager: { - getSession: vi.fn(() => session), - } as unknown as MessageRouterDeps['sessionManager'], - modelRouter: { - getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], - getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), - getLabel: (tier: string) => tier, - } as unknown as MessageRouterDeps['modelRouter'], - systemPrompt: 'test prompt', - toolRegistry: { - clone() { return this; }, - register: vi.fn(), - } as unknown as MessageRouterDeps['toolRegistry'], - toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], - config: { - agents: { - primary_tier: 'default', - delegation: { - compaction: 'fast', - memory_extraction: 'fast', - classification: 'fast', - tool_summarisation: 'fast', - complex_reasoning: 'complex', - }, - max_delegation_depth: 3, - max_iterations: 10, - auto_escalate: false, - }, - compaction: { enabled: false }, - models: { default: { provider: 'anthropic', model: 'claude' } }, - } as unknown as MessageRouterDeps['config'], - externalBackends: { codex: externalBackend } as unknown as MessageRouterDeps['externalBackends'], - defaultExternalBackendName: 'codex', - }); - - const reply = vi.fn(async (_message: OutboundMessage) => {}); - await router.handler({ - id: 'm-external-fail', - channel: 'telegram', - senderId: 'external-fail', - text: 'hello fallback', - timestamp: Date.now(), - } as MessageRouterInput, reply); - - expect(externalBackend.process).toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalled(); - expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'native fallback response' })); - }); - - it('fails over to another enabled external backend before native fallback', async () => { - const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process') - .mockResolvedValue('native should not be used'); - const session = { - id: 'telegram:external-failover', - addMessage: vi.fn(), - getHistory: vi.fn(() => []), - clear: vi.fn(), - replaceHistory: vi.fn(), - getConfig: vi.fn(() => undefined), - setConfig: vi.fn(), - deleteConfig: vi.fn(), - }; - - const codexBackend = { - name: 'codex', - process: vi.fn(async () => { - throw new Error('codex failed'); - }), - }; - const geminiBackend = { - name: 'gemini', - process: vi.fn(async () => 'gemini recovered'), - }; - - const router = createMessageRouter({ - sessionManager: { - getSession: vi.fn(() => session), - } as unknown as MessageRouterDeps['sessionManager'], - modelRouter: { - getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], - getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), - getLabel: (tier: string) => tier, - } as unknown as MessageRouterDeps['modelRouter'], - systemPrompt: 'test prompt', - toolRegistry: { - clone() { return this; }, - register: vi.fn(), - } as unknown as MessageRouterDeps['toolRegistry'], - toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], - config: { - agents: { - primary_tier: 'default', - delegation: { - compaction: 'fast', - memory_extraction: 'fast', - classification: 'fast', - tool_summarisation: 'fast', - complex_reasoning: 'complex', - }, - max_delegation_depth: 3, - max_iterations: 10, - auto_escalate: false, - }, - compaction: { enabled: false }, - models: { default: { provider: 'anthropic', model: 'claude' } }, - } as unknown as MessageRouterDeps['config'], - externalBackends: { - codex: codexBackend, - gemini: geminiBackend, - } as unknown as MessageRouterDeps['externalBackends'], - defaultExternalBackendName: 'codex', - }); - - const reply = vi.fn(async (_message: OutboundMessage) => {}); - await router.handler({ - id: 'm-external-failover', - channel: 'telegram', - senderId: 'external-failover', - text: 'hello failover', - timestamp: Date.now(), - } as MessageRouterInput, reply); - - expect(codexBackend.process).toHaveBeenCalled(); - expect(geminiBackend.process).toHaveBeenCalled(); - expect(processSpy).not.toHaveBeenCalled(); - expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'gemini recovered' })); - }); - - it('uses per-agent backend override instead of default external backend', async () => { - const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process'); - const session = { - id: 'telegram:agent-backend-override', - addMessage: vi.fn(), - getHistory: vi.fn(() => []), - clear: vi.fn(), - replaceHistory: vi.fn(), - getConfig: vi.fn(() => undefined), - setConfig: vi.fn(), - deleteConfig: vi.fn(), - }; - - const codexBackend = { - name: 'codex', - process: vi.fn(async () => 'codex response'), - }; - const geminiBackend = { - name: 'gemini', - process: vi.fn(async () => 'gemini response'), - }; - - const agentConfigRegistry = new AgentConfigRegistry(); - agentConfigRegistry.loadFromConfig({ - coder: { - model_tier: 'complex', - backend: 'gemini', - sandbox: false, - }, - }); - const agentRouter = new AgentRouter({ - channels: { telegram: 'coder' }, - senders: {}, - }); - - const router = createMessageRouter({ - sessionManager: { - getSession: vi.fn(() => session), - } as unknown as MessageRouterDeps['sessionManager'], - modelRouter: { - getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], - getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), - getLabel: (tier: string) => tier, - } as unknown as MessageRouterDeps['modelRouter'], - systemPrompt: 'test prompt', - toolRegistry: { - clone() { return this; }, - register: vi.fn(), - } as unknown as MessageRouterDeps['toolRegistry'], - toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], - config: { - agents: { - primary_tier: 'default', - delegation: { - compaction: 'fast', - memory_extraction: 'fast', - classification: 'fast', - tool_summarisation: 'fast', - complex_reasoning: 'complex', - }, - max_delegation_depth: 3, - max_iterations: 10, - auto_escalate: false, - }, - compaction: { enabled: false }, - models: { default: { provider: 'anthropic', model: 'claude' } }, - } as unknown as MessageRouterDeps['config'], - agentConfigRegistry, - agentRouter, - externalBackends: { - codex: codexBackend, - gemini: geminiBackend, - } as unknown as MessageRouterDeps['externalBackends'], - defaultExternalBackendName: 'codex', - }); - - const reply = vi.fn(async (_message: OutboundMessage) => {}); - await router.handler({ - id: 'm-agent-backend-override', - channel: 'telegram', - senderId: 'user-agent-override', - text: 'route to gemini backend', - timestamp: Date.now(), - } as MessageRouterInput, reply); - - expect(geminiBackend.process).toHaveBeenCalled(); - expect(codexBackend.process).not.toHaveBeenCalled(); - expect(processSpy).not.toHaveBeenCalled(); - expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'gemini response' })); - }); - - it('falls back to native when per-agent backend is configured but unavailable', async () => { - const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process') - .mockResolvedValue('native response (missing backend)'); - const session = { - id: 'telegram:missing-agent-backend', - addMessage: vi.fn(), - getHistory: vi.fn(() => []), - clear: vi.fn(), - replaceHistory: vi.fn(), - getConfig: vi.fn(() => undefined), - setConfig: vi.fn(), - deleteConfig: vi.fn(), - }; - - const agentConfigRegistry = new AgentConfigRegistry(); - agentConfigRegistry.loadFromConfig({ - coder: { - model_tier: 'complex', - backend: 'gemini', - sandbox: false, - }, - }); - const agentRouter = new AgentRouter({ - channels: { telegram: 'coder' }, - senders: {}, - }); - - const codexBackend = { - name: 'codex', - process: vi.fn(async () => 'codex response'), - }; - - const router = createMessageRouter({ - sessionManager: { - getSession: vi.fn(() => session), - } as unknown as MessageRouterDeps['sessionManager'], - modelRouter: { - getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], - getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), - getLabel: (tier: string) => tier, - } as unknown as MessageRouterDeps['modelRouter'], - systemPrompt: 'test prompt', - toolRegistry: { - clone() { return this; }, - register: vi.fn(), - } as unknown as MessageRouterDeps['toolRegistry'], - toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], - config: { - agents: { - primary_tier: 'default', - delegation: { - compaction: 'fast', - memory_extraction: 'fast', - classification: 'fast', - tool_summarisation: 'fast', - complex_reasoning: 'complex', - }, - max_delegation_depth: 3, - max_iterations: 10, - auto_escalate: false, - }, - compaction: { enabled: false }, - models: { default: { provider: 'anthropic', model: 'claude' } }, - } as unknown as MessageRouterDeps['config'], - agentConfigRegistry, - agentRouter, - externalBackends: { - codex: codexBackend, - } as unknown as MessageRouterDeps['externalBackends'], - defaultExternalBackendName: 'codex', - }); - - const reply = vi.fn(async (_message: OutboundMessage) => {}); - await router.handler({ - id: 'm-missing-agent-backend', - channel: 'telegram', - senderId: 'user-missing-backend', - text: 'fall back to native', - timestamp: Date.now(), - } as MessageRouterInput, reply); - - expect(codexBackend.process).not.toHaveBeenCalled(); - expect(processSpy).toHaveBeenCalled(); - expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'native response (missing backend)' })); - }); }); describe('daemon talk mode (voice wake) integration', () => {