diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index cc9debe..c70de0a 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -1196,6 +1196,183 @@ describe('daemon auto-escalate integration', () => { expect(processSpy).toHaveBeenCalled(); expect(reply).toHaveBeenCalledWith(expect.objectContaining({ text: 'native fallback response' })); }); + + 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', () => {