diff --git a/src/audit/types.ts b/src/audit/types.ts index 69b9fcc..b29f69d 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -223,7 +223,7 @@ export interface BackendFallbackEvent { channel: string; sender: string; from_backend: 'claude_code' | 'opencode' | 'codex' | 'gemini'; - to_backend: 'native'; + to_backend: 'native' | 'claude_code' | 'opencode' | 'codex' | 'gemini'; reason: string; } diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index c70de0a..b3f4da8 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -1197,6 +1197,85 @@ describe('daemon auto-escalate integration', () => { 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 = {