Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions src/__tests__/backend-registry-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Backend registry parity tests.
*
* Verifies that ALL four backends (Claude SDK, Kilo, OpenCode, Codex)
* register themselves into the registry with the same QueryBackend
* surface — so the dispatcher can swap backends without leaking
* backend-specific behaviour upstream.
*
* Each backend factory's `init(config, ctx)` returns a `QueryBackend`
* whose required + optional methods Talon's core relies on. This file
* doesn't actually CALL `init` (it would spawn real subprocesses);
* instead it verifies registry presence + factory shape.
*/

import { describe, it, expect, beforeAll } from "vitest";

import {
clearBackends,
getBackend,
listBackends,
hasBackend,
} from "../backend/registry.js";

const ALL_BACKENDS = ["claude", "kilo", "opencode", "codex"] as const;

beforeAll(async () => {
// Reset registry for a clean import. Each factory module's
// side-effect import re-registers it.
clearBackends();
await import("../backend/claude-sdk/factory.js");
await import("../backend/kilo/factory.js");
await import("../backend/opencode/factory.js");
await import("../backend/codex/factory.js");
});

describe("backend registry parity — all four backends present", () => {
it("registers Claude, Kilo, OpenCode, and Codex", () => {
for (const id of ALL_BACKENDS) {
expect(hasBackend(id), `expected backend "${id}" registered`).toBe(true);
}
});

it("listBackends returns them sorted by id", () => {
const ids = listBackends().map((b) => b.id);
expect(ids).toContain("claude");
expect(ids).toContain("codex");
expect(ids).toContain("kilo");
expect(ids).toContain("opencode");
// Sorted property: ids should equal their sorted-copy
const sorted = [...ids].sort();
expect(ids).toEqual(sorted);
});

it("every backend has a non-empty label", () => {
for (const id of ALL_BACKENDS) {
const factory = getBackend(id);
expect(factory, `factory for ${id}`).toBeDefined();
expect(factory!.label.length).toBeGreaterThan(0);
}
});

it("every backend factory has an init function", () => {
for (const id of ALL_BACKENDS) {
const factory = getBackend(id);
expect(typeof factory!.init).toBe("function");
}
});

it("expected labels", () => {
expect(getBackend("claude")?.label).toBe("Anthropic");
expect(getBackend("kilo")?.label).toBe("Kilo");
expect(getBackend("opencode")?.label).toBe("OpenCode");
expect(getBackend("codex")?.label).toBe("Codex");
});
});

describe("backend registry parity — duplicate registration is rejected", () => {
it("re-registering an existing id throws", async () => {
const { registerBackend } = await import("../backend/registry.js");
expect(() =>
registerBackend({
id: "claude",
label: "Duplicate",
init: async () => ({ backend: {} as never }),
}),
).toThrow(/already registered/);
});
});
158 changes: 158 additions & 0 deletions src/__tests__/codex-models.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* Codex model catalog tests.
*/

import { describe, it, expect } from "vitest";

import {
CODEX_MODELS,
resolveModel,
getModelInfo,
getSettingsPresentation,
getProviders,
getProviderModels,
formatModelError,
listModels,
} from "../backend/codex/models.js";

describe("codex / model catalog", () => {
it("exposes at least the gpt-5-codex flagship", () => {
expect(CODEX_MODELS.some((m) => m.id === "gpt-5-codex")).toBe(true);
});

it("every model carries the openai provider", () => {
for (const m of CODEX_MODELS) {
expect(m.provider).toBe("openai");
expect(m.providerName).toBe("OpenAI");
expect(m.selectable).toBe(true);
}
});
});

describe("codex / resolveModel", () => {
it("returns exact match for a known model id", () => {
const result = resolveModel("gpt-5-codex");
expect(result.kind).toBe("exact");
if (result.kind === "exact") {
expect(result.model.id).toBe("gpt-5-codex");
expect(result.storedValue).toBe("gpt-5-codex");
}
});

it("returns missing for an empty query", () => {
expect(resolveModel("").kind).toBe("missing");
expect(resolveModel(" ").kind).toBe("missing");
});

it("returns missing for an unrecognised query", () => {
expect(resolveModel("nonsense-model-1.0").kind).toBe("missing");
});

it("returns ambiguous for a prefix that matches multiple", () => {
// `gpt-5` matches gpt-5, gpt-5-codex, gpt-5-mini → ambiguous
const result = resolveModel("gpt-5");
// Exact match on "gpt-5" wins via the first-pass exact filter
expect(result.kind).toBe("exact");
});

it("returns ambiguous when only prefix matches multiple", () => {
// `gpt` (no exact match) matches all gpt-5* models → ambiguous
const result = resolveModel("gpt");
expect(result.kind).toBe("ambiguous");
if (result.kind === "ambiguous") {
expect(result.matches.length).toBeGreaterThan(1);
}
});
});

describe("codex / getModelInfo", () => {
it("returns the model for a known id", () => {
expect(getModelInfo("gpt-5-codex")?.id).toBe("gpt-5-codex");
});

it("returns undefined for unknown ids", () => {
expect(getModelInfo("not-real")).toBeUndefined();
});
});

describe("codex / getSettingsPresentation", () => {
it("returns one button per model with active marker on the current one", () => {
const { modelButtons, modelDetails } = getSettingsPresentation("gpt-5");
expect(modelButtons).toHaveLength(CODEX_MODELS.length);
expect(modelDetails).toHaveLength(CODEX_MODELS.length);

const active = modelButtons.find((b) => b.callback_data.endsWith("gpt-5"));
const others = modelButtons.filter(
(b) => !b.callback_data.endsWith("gpt-5"),
);
expect(active?.text).toMatch(/^●/);
for (const b of others) {
expect(b.text).not.toMatch(/^●/);
}
});

it("uses the supplied callbackPrefix", () => {
const { modelButtons } = getSettingsPresentation("gpt-5", "custom:prefix:");
for (const b of modelButtons) {
expect(b.callback_data.startsWith("custom:prefix:")).toBe(true);
}
});
});

describe("codex / getProviders + getProviderModels", () => {
it("returns OpenAI as the sole provider", () => {
const providers = getProviders();
expect(providers).toHaveLength(1);
expect(providers[0].id).toBe("openai");
expect(providers[0].modelCount).toBe(CODEX_MODELS.length);
});

it("returns paginated models for openai provider", () => {
const result = getProviderModels("openai", 1, 2);
expect(result.models).toHaveLength(2);
expect(result.total).toBe(CODEX_MODELS.length);
});

it("returns empty for unknown provider", () => {
expect(getProviderModels("anthropic", 1, 50)).toEqual({
models: [],
total: 0,
});
});
});

describe("codex / formatModelError", () => {
it("describes ambiguous matches with backtick-quoted ids", () => {
const msg = formatModelError("gpt", {
kind: "ambiguous",
matches: CODEX_MODELS.filter((m) => m.id.startsWith("gpt-5")),
});
expect(msg).toContain("Multiple Codex models match");
expect(msg).toContain("`gpt-5-codex`");
});

it("describes a missing query with the full catalog", () => {
const msg = formatModelError("xyz", { kind: "missing" });
expect(msg).toContain("No Codex model matches");
expect(msg).toContain("gpt-5-codex");
});
});

describe("codex / listModels", () => {
it("returns all by default", () => {
const { models, total } = listModels();
expect(total).toBe(CODEX_MODELS.length);
expect(models).toEqual(CODEX_MODELS);
});

it("returns nothing for `free` filter — no free Codex models", () => {
const { models, total } = listModels("free");
expect(models).toEqual([]);
expect(total).toBe(0);
});

it("returns all for the `all` filter", () => {
const { total } = listModels("all");
expect(total).toBe(CODEX_MODELS.length);
});
});
Loading
Loading