diff --git a/README.md b/README.md index 445b160..1f36637 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,21 @@ models: Each tier can optionally specify `auth_mode` (`auto` | `api_key` | `oauth`) to control whether Flynn uses API keys vs OAuth/token auth for that provider. `use_oauth: true` remains supported as a compatibility alias for `auth_mode: oauth`. +### Agent Backends + +Flynn can run with the built-in native backend or delegate message processing to external CLI backends. + +```yaml +backends: + native: { enabled: true } + codex: { enabled: false, path: /usr/local/bin/codex } + claude_code: { enabled: false, path: /usr/local/bin/claude } + opencode: { enabled: false, path: /usr/local/bin/opencode } + gemini: { enabled: false, path: /usr/local/bin/gemini } +``` + +If multiple external backends are enabled, Flynn selects the first in this order: `codex` -> `claude_code` -> `opencode` -> `gemini`. + ### Native Audio Support Voice messages from channels can be handled in two ways: diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index bd14fe8..59b9d5c 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -196,6 +196,38 @@ 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.opencode.enabled).toBe(false); + expect(result.backends.codex.enabled).toBe(false); + expect(result.backends.gemini.enabled).toBe(false); + }); + + it('accepts explicit external backend configs', () => { + const result = configSchema.parse({ + ...minimalConfig, + backends: { + native: { enabled: false }, + codex: { enabled: true, path: '/usr/local/bin/codex' }, + gemini: { enabled: true, path: '/usr/local/bin/gemini' }, + }, + }); + 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.gemini.enabled).toBe(true); + expect(result.backends.gemini.path).toBe('/usr/local/bin/gemini'); + }); +}); + describe('configSchema — browser', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 00c151c..aefa329 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -195,6 +195,14 @@ const backendsSchema = z.object({ args: z.array(z.string()).default([]), timeout_ms: z.number().min(1_000).max(600_000).default(120_000), }).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), }).default({ enabled: true }), diff --git a/src/daemon/index.test.ts b/src/daemon/index.test.ts new file mode 100644 index 0000000..2abe66a --- /dev/null +++ b/src/daemon/index.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { configSchema } from '../config/schema.js'; +import { createConfiguredExternalBackend } 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' }, + }, + }; + 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' }, + }, + }; + const backend = createConfiguredExternalBackend(cfg); + expect(backend?.name).toBe('gemini'); + }); +});