feat: add pluggable multi-CLI backend system

Implement BackendAdapter interface with four CLI backends:
- ClaudeCodeBackend (extracted from AgentRuntime)
- CodexBackend (OpenAI Codex CLI)
- GeminiBackend (Google Gemini CLI)
- OpenCodeBackend (OpenCode CLI)

Add BackendRegistry for resolution/creation via AGENT_BACKEND env var.
Refactor AgentRuntime to delegate to BackendAdapter instead of
hardcoding Claude CLI. Update GatewayConfig with new env vars
(AGENT_BACKEND, BACKEND_CLI_PATH, BACKEND_MODEL, BACKEND_MAX_TURNS).

Includes 10 property-based test files and unit tests for edge cases.
This commit is contained in:
2026-02-22 23:41:30 -05:00
parent f2247ea3ac
commit 453389f55c
25 changed files with 3262 additions and 195 deletions

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import fc from "fast-check";
import { resolveBackendName, createBackend } from "../../src/backends/registry.js";
import { ClaudeCodeBackend } from "../../src/backends/claude-backend.js";
import { CodexBackend } from "../../src/backends/codex-backend.js";
import { GeminiBackend } from "../../src/backends/gemini-backend.js";
import { OpenCodeBackend } from "../../src/backends/opencode-backend.js";
import type { BackendAdapterConfig, BackendName } from "../../src/backends/types.js";
// Feature: multi-cli-backend, Property 7: Backend name resolution
// **Validates: Requirements 6.1, 6.2, 6.3, 6.5**
const VALID_NAMES: BackendName[] = ["claude", "codex", "gemini", "opencode"];
/** Arbitrary that produces one of the four valid backend names */
const validBackendName = fc.constantFrom(...VALID_NAMES);
/** Arbitrary that produces strings which are NOT valid backend names and NOT undefined */
const invalidBackendName = fc
.string({ minLength: 1, maxLength: 100 })
.filter((s) => !VALID_NAMES.includes(s as BackendName));
describe("Property 7: Backend name resolution", () => {
it("returns the corresponding BackendName for any valid backend name string", () => {
fc.assert(
fc.property(validBackendName, (name) => {
const result = resolveBackendName(name);
return result === name;
}),
{ numRuns: 100 },
);
});
it("returns 'claude' when input is undefined", () => {
expect(resolveBackendName(undefined)).toBe("claude");
});
it("throws a descriptive error for any invalid string value", () => {
fc.assert(
fc.property(invalidBackendName, (name) => {
try {
resolveBackendName(name);
return false; // Should have thrown
} catch (err) {
const message = (err as Error).message;
// Error must mention the invalid value and list valid options
return (
message.includes(name) &&
VALID_NAMES.every((valid) => message.includes(valid))
);
}
}),
{ numRuns: 100 },
);
});
it("createBackend returns the correct implementation for each valid name", () => {
const config: BackendAdapterConfig = {
cliPath: "/usr/bin/test",
workingDir: "/tmp",
queryTimeoutMs: 30000,
allowedTools: [],
maxTurns: 25,
};
const expectedTypes: Record<BackendName, new (cfg: BackendAdapterConfig) => unknown> = {
claude: ClaudeCodeBackend,
codex: CodexBackend,
gemini: GeminiBackend,
opencode: OpenCodeBackend,
};
fc.assert(
fc.property(validBackendName, (name) => {
const backend = createBackend(name, config);
return (
backend instanceof expectedTypes[name] &&
backend.name() === name
);
}),
{ numRuns: 100 },
);
});
});