refactor(test): dedupe command config and model test fixtures

This commit is contained in:
Peter Steinberger
2026-02-16 16:32:11 +00:00
parent 130e59a9c0
commit 261f5ee492
6 changed files with 273 additions and 363 deletions

View File

@@ -1,6 +1,18 @@
import { describe, expect, it } from "vitest";
import { buildGatewayAuthConfig } from "./configure.js";
function expectGeneratedTokenFromInput(token: string | undefined, literalToAvoid = "undefined") {
const result = buildGatewayAuthConfig({
mode: "token",
token,
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe(literalToAvoid);
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
}
describe("buildGatewayAuthConfig", () => {
it("preserves allowTailscale when switching to token", () => {
const result = buildGatewayAuthConfig({
@@ -54,68 +66,23 @@ describe("buildGatewayAuthConfig", () => {
});
it("generates random token when token param is undefined", () => {
const result = buildGatewayAuthConfig({
mode: "token",
token: undefined,
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
expectGeneratedTokenFromInput(undefined);
});
it("generates random token when token param is empty string", () => {
const result = buildGatewayAuthConfig({
mode: "token",
token: "",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
expectGeneratedTokenFromInput("");
});
it("generates random token when token param is whitespace only", () => {
const result = buildGatewayAuthConfig({
mode: "token",
token: " ",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
expectGeneratedTokenFromInput(" ");
});
it('generates random token when token param is the literal string "undefined"', () => {
const result = buildGatewayAuthConfig({
mode: "token",
token: "undefined",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("undefined");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
expectGeneratedTokenFromInput("undefined");
});
it('generates random token when token param is the literal string "null"', () => {
const result = buildGatewayAuthConfig({
mode: "token",
token: "null",
});
expect(result?.mode).toBe("token");
expect(result?.token).toBeDefined();
expect(result?.token).not.toBe("null");
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
expectGeneratedTokenFromInput("null", "null");
});
it("builds trusted-proxy config with all options", () => {

View File

@@ -55,81 +55,71 @@ function makeRuntime(): RuntimeEnv {
};
}
async function runTrustedProxyPrompt(textQueue: Array<string | undefined>) {
async function runGatewayPrompt(params: {
selectQueue: string[];
textQueue: Array<string | undefined>;
randomToken?: string;
confirmResult?: boolean;
authConfigFactory?: (input: Record<string, unknown>) => Record<string, unknown>;
}) {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "trusted-proxy", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
mocks.select.mockImplementation(async () => params.selectQueue.shift());
mocks.text.mockImplementation(async () => params.textQueue.shift());
mocks.randomToken.mockReturnValue(params.randomToken ?? "generated-token");
mocks.confirm.mockResolvedValue(params.confirmResult ?? true);
mocks.buildGatewayAuthConfig.mockImplementation((input) =>
params.authConfigFactory ? params.authConfigFactory(input as Record<string, unknown>) : input,
);
const result = await promptGatewayConfig({}, makeRuntime());
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
return { result, call };
}
async function runTrustedProxyPrompt(params: {
textQueue: Array<string | undefined>;
tailscaleMode?: "off" | "serve";
}) {
return runGatewayPrompt({
selectQueue: ["loopback", "trusted-proxy", params.tailscaleMode ?? "off"],
textQueue: params.textQueue,
authConfigFactory: ({ mode, trustedProxy }) => ({ mode, trustedProxy }),
});
}
describe("promptGatewayConfig", () => {
it("generates a token when the prompt returns undefined", async () => {
mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "token", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
const textQueue = ["18789", undefined];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.randomToken.mockReturnValue("generated-token");
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, token, password }) => ({
mode,
token,
password,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "off"],
textQueue: ["18789", undefined],
randomToken: "generated-token",
authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
});
expect(result.token).toBe("generated-token");
});
it("does not set password to literal 'undefined' when prompt returns undefined", async () => {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
// Flow: loopback bind → password auth → tailscale off
const selectQueue = ["loopback", "password", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
// Port prompt → OK, then password prompt → returns undefined (simulating prompter edge case)
const textQueue = ["18789", undefined];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.randomToken.mockReturnValue("unused");
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, token, password }) => ({
mode,
token,
password,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await promptGatewayConfig({}, runtime);
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
const { call } = await runGatewayPrompt({
selectQueue: ["loopback", "password", "off"],
textQueue: ["18789", undefined],
randomToken: "unused",
authConfigFactory: ({ mode, token, password }) => ({ mode, token, password }),
});
expect(call?.password).not.toBe("undefined");
expect(call?.password).toBe("");
});
it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => {
const { result, call } = await runTrustedProxyPrompt([
"18789",
"x-forwarded-user",
"x-forwarded-proto,x-forwarded-host",
"nick@example.com",
"10.0.1.10,192.168.1.5",
]);
const { result, call } = await runTrustedProxyPrompt({
textQueue: [
"18789",
"x-forwarded-user",
"x-forwarded-proto,x-forwarded-host",
"nick@example.com",
"10.0.1.10,192.168.1.5",
],
});
expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({
@@ -142,13 +132,9 @@ describe("promptGatewayConfig", () => {
});
it("handles trusted-proxy with no optional fields", async () => {
const { result, call } = await runTrustedProxyPrompt([
"18789",
"x-remote-user",
"",
"",
"10.0.0.1",
]);
const { result, call } = await runTrustedProxyPrompt({
textQueue: ["18789", "x-remote-user", "", "", "10.0.0.1"],
});
expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({
@@ -160,25 +146,10 @@ describe("promptGatewayConfig", () => {
});
it("forces tailscale off when trusted-proxy is selected", async () => {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "trusted-proxy", "serve"];
mocks.select.mockImplementation(async () => selectQueue.shift());
const textQueue = ["18789", "x-forwarded-user", "", "", "10.0.0.1"];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.confirm.mockResolvedValue(true);
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
const { result } = await runTrustedProxyPrompt({
tailscaleMode: "serve",
textQueue: ["18789", "x-forwarded-user", "", "", "10.0.0.1"],
});
expect(result.config.gateway?.bind).toBe("lan");
expect(result.config.gateway?.tailscale?.mode).toBe("off");
expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false);

View File

@@ -42,20 +42,39 @@ describe("resolveGatewayDevMode", () => {
});
});
function mockNodeGatewayPlanFixture(
params: {
workingDirectory?: string;
version?: string;
supported?: boolean;
warning?: string;
serviceEnvironment?: Record<string, string>;
} = {},
) {
const {
workingDirectory = "/Users/me",
version = "22.0.0",
supported = true,
warning,
serviceEnvironment = { OPENCLAW_PORT: "3000" },
} = params;
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory,
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version,
supported,
});
mocks.renderSystemNodeWarning.mockReturnValue(warning);
mocks.buildServiceEnvironment.mockReturnValue(serviceEnvironment);
}
describe("buildGatewayInstallPlan", () => {
it("uses provided nodePath and returns plan", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
mocks.buildServiceEnvironment.mockReturnValue({ OPENCLAW_PORT: "3000" });
mockNodeGatewayPlanFixture();
const plan = await buildGatewayInstallPlan({
env: {},
@@ -72,18 +91,13 @@ describe("buildGatewayInstallPlan", () => {
it("emits warnings when renderSystemNodeWarning returns one", async () => {
const warn = vi.fn();
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
mockNodeGatewayPlanFixture({
workingDirectory: undefined,
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "18.0.0",
supported: false,
warning: "Node too old",
serviceEnvironment: {},
});
mocks.renderSystemNodeWarning.mockReturnValue("Node too old");
mocks.buildServiceEnvironment.mockReturnValue({});
await buildGatewayInstallPlan({
env: {},
@@ -97,19 +111,11 @@ describe("buildGatewayInstallPlan", () => {
});
it("merges config env vars into the environment", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
OPENCLAW_PORT: "3000",
HOME: "/Users/me",
mockNodeGatewayPlanFixture({
serviceEnvironment: {
OPENCLAW_PORT: "3000",
HOME: "/Users/me",
},
});
const plan = await buildGatewayInstallPlan({
@@ -135,17 +141,7 @@ describe("buildGatewayInstallPlan", () => {
});
it("does not include empty config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({ OPENCLAW_PORT: "3000" });
mockNodeGatewayPlanFixture();
const plan = await buildGatewayInstallPlan({
env: {},
@@ -166,17 +162,7 @@ describe("buildGatewayInstallPlan", () => {
});
it("drops whitespace-only config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({});
mockNodeGatewayPlanFixture({ serviceEnvironment: {} });
const plan = await buildGatewayInstallPlan({
env: {},
@@ -197,19 +183,11 @@ describe("buildGatewayInstallPlan", () => {
});
it("keeps service env values over config env vars", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
HOME: "/Users/service",
OPENCLAW_PORT: "3000",
mockNodeGatewayPlanFixture({
serviceEnvironment: {
HOME: "/Users/service",
OPENCLAW_PORT: "3000",
},
});
const plan = await buildGatewayInstallPlan({

View File

@@ -36,20 +36,29 @@ vi.mock("../agents/model-auth.js", () => ({
getCustomProviderApiKey,
}));
const OPENROUTER_CATALOG = [
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
] as const;
function expectRouterModelFiltering(options: Array<{ value: string }>) {
expect(options.some((opt) => opt.value === "openrouter/auto")).toBe(false);
expect(options.some((opt) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free")).toBe(
true,
);
}
describe("promptDefaultModel", () => {
it("filters internal router models from the selection list", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
]);
loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);
const select = vi.fn(async (params) => {
const first = params.options[0];
@@ -67,10 +76,7 @@ describe("promptDefaultModel", () => {
});
const options = select.mock.calls[0]?.[0]?.options ?? [];
expect(options.some((opt) => opt.value === "openrouter/auto")).toBe(false);
expect(options.some((opt) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free")).toBe(
true,
);
expectRouterModelFiltering(options);
});
it("supports configuring vLLM during onboarding", async () => {
@@ -124,18 +130,7 @@ describe("promptDefaultModel", () => {
describe("promptModelAllowlist", () => {
it("filters internal router models from the selection list", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
]);
loadModelCatalog.mockResolvedValue(OPENROUTER_CATALOG);
const multiselect = vi.fn(async (params) =>
params.options.map((option: { value: string }) => option.value),
@@ -146,12 +141,7 @@ describe("promptModelAllowlist", () => {
await promptModelAllowlist({ config, prompter });
const options = multiselect.mock.calls[0]?.[0]?.options ?? [];
expect(options.some((opt: { value: string }) => opt.value === "openrouter/auto")).toBe(false);
expect(
options.some(
(opt: { value: string }) => opt.value === "openrouter/meta-llama/llama-3.3-70b:free",
),
).toBe(true);
expectRouterModelFiltering(options as Array<{ value: string }>);
});
it("filters to allowed keys when provided", async () => {

View File

@@ -203,11 +203,8 @@ describe("models list/status", () => {
}
async function expectZaiProviderFilter(provider: string) {
setDefaultModel("z.ai/glm-4.7");
setDefaultZaiRegistry();
const runtime = makeRuntime();
const models = [ZAI_MODEL, OPENAI_MODEL];
modelRegistryState.models = models;
modelRegistryState.available = models;
await modelsListCommand({ all: true, provider, json: true }, runtime);
@@ -216,22 +213,67 @@ describe("models list/status", () => {
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
}
function setDefaultZaiRegistry(params: { available?: boolean } = {}) {
const available = params.available ?? true;
setDefaultModel("z.ai/glm-4.7");
modelRegistryState.models = [ZAI_MODEL, OPENAI_MODEL];
modelRegistryState.available = available ? [ZAI_MODEL, OPENAI_MODEL] : [];
}
function setupGoogleAntigravityTemplateCase(params: {
configuredModelId: string;
templateId: string;
templateName: string;
available?: boolean;
}) {
configureGoogleAntigravityModel(params.configuredModelId);
const template = makeGoogleAntigravityTemplate(params.templateId, params.templateName);
modelRegistryState.models = [template];
modelRegistryState.available = params.available ? [template] : [];
return template;
}
async function runGoogleAntigravityListCase(params: {
configuredModelId: string;
templateId: string;
templateName: string;
available?: boolean;
withAuthProfile?: boolean;
}) {
setupGoogleAntigravityTemplateCase(params);
if (params.withAuthProfile) {
enableGoogleAntigravityAuthProfile();
}
const runtime = makeRuntime();
await modelsListCommand({ json: true }, runtime);
return parseJsonLog(runtime);
}
function expectAntigravityModel(
payload: Record<string, unknown>,
params: { key: string; available: boolean; includesTags?: boolean },
) {
const model = (payload.models as Array<Record<string, unknown>>)[0] ?? {};
expect(model.key).toBe(params.key);
expect(model.missing).toBe(false);
expect(model.available).toBe(params.available);
if (params.includesTags) {
expect(model.tags).toContain("default");
expect(model.tags).toContain("configured");
}
}
beforeAll(async () => {
({ modelsListCommand } = await import("./models/list.list-command.js"));
});
it("models list outputs canonical zai key for configured z.ai model", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
setDefaultZaiRegistry();
const runtime = makeRuntime();
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [ZAI_MODEL];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
const payload = parseJsonLog(runtime);
expect(payload.models[0]?.key).toBe("zai/glm-4.7");
});
@@ -262,109 +304,78 @@ describe("models list/status", () => {
});
it("models list marks auth as unavailable when ZAI key is missing", async () => {
loadConfig.mockReturnValue({
agents: { defaults: { model: "z.ai/glm-4.7" } },
});
setDefaultZaiRegistry({ available: false });
const runtime = makeRuntime();
modelRegistryState.models = [ZAI_MODEL];
modelRegistryState.available = [];
await modelsListCommand({ all: true, json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
const payload = parseJsonLog(runtime);
expect(payload.models[0]?.available).toBe(false);
});
it("models list resolves antigravity opus 4.6 thinking from 4.5 template", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
const runtime = makeRuntime();
modelRegistryState.models = [
makeGoogleAntigravityTemplate("claude-opus-4-5-thinking", "Claude Opus 4.5 Thinking"),
];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
const payload = await runGoogleAntigravityListCase({
configuredModelId: "claude-opus-4-6-thinking",
templateId: "claude-opus-4-5-thinking",
templateName: "Claude Opus 4.5 Thinking",
});
expectAntigravityModel(payload, {
key: "google-antigravity/claude-opus-4-6-thinking",
available: false,
includesTags: true,
});
});
it("models list resolves antigravity opus 4.6 (non-thinking) from 4.5 template", async () => {
configureGoogleAntigravityModel("claude-opus-4-6");
const runtime = makeRuntime();
modelRegistryState.models = [
makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5"),
];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.tags).toContain("default");
expect(payload.models[0]?.tags).toContain("configured");
const payload = await runGoogleAntigravityListCase({
configuredModelId: "claude-opus-4-6",
templateId: "claude-opus-4-5",
templateName: "Claude Opus 4.5",
});
expectAntigravityModel(payload, {
key: "google-antigravity/claude-opus-4-6",
available: false,
includesTags: true,
});
});
it("models list marks synthesized antigravity opus 4.6 thinking as available when template is available", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
const runtime = makeRuntime();
const template = makeGoogleAntigravityTemplate(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 Thinking",
);
modelRegistryState.models = [template];
modelRegistryState.available = [template];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
const payload = await runGoogleAntigravityListCase({
configuredModelId: "claude-opus-4-6-thinking",
templateId: "claude-opus-4-5-thinking",
templateName: "Claude Opus 4.5 Thinking",
available: true,
});
expectAntigravityModel(payload, {
key: "google-antigravity/claude-opus-4-6-thinking",
available: true,
});
});
it("models list marks synthesized antigravity opus 4.6 (non-thinking) as available when template is available", async () => {
configureGoogleAntigravityModel("claude-opus-4-6");
const runtime = makeRuntime();
const template = makeGoogleAntigravityTemplate("claude-opus-4-5", "Claude Opus 4.5");
modelRegistryState.models = [template];
modelRegistryState.available = [template];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(true);
const payload = await runGoogleAntigravityListCase({
configuredModelId: "claude-opus-4-6",
templateId: "claude-opus-4-5",
templateName: "Claude Opus 4.5",
available: true,
});
expectAntigravityModel(payload, {
key: "google-antigravity/claude-opus-4-6",
available: true,
});
});
it("models list prefers registry availability over provider auth heuristics", async () => {
configureGoogleAntigravityModel("claude-opus-4-6-thinking");
enableGoogleAntigravityAuthProfile();
const runtime = makeRuntime();
const template = makeGoogleAntigravityTemplate(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 Thinking",
);
modelRegistryState.models = [template];
modelRegistryState.available = [];
await modelsListCommand({ json: true }, runtime);
expect(runtime.log).toHaveBeenCalledTimes(1);
const payload = JSON.parse(String(runtime.log.mock.calls[0]?.[0]));
expect(payload.models[0]?.key).toBe("google-antigravity/claude-opus-4-6-thinking");
expect(payload.models[0]?.missing).toBe(false);
expect(payload.models[0]?.available).toBe(false);
const payload = await runGoogleAntigravityListCase({
configuredModelId: "claude-opus-4-6-thinking",
templateId: "claude-opus-4-5-thinking",
templateName: "Claude Opus 4.5 Thinking",
withAuthProfile: true,
});
expectAntigravityModel(payload, {
key: "google-antigravity/claude-opus-4-6-thinking",
available: false,
});
listProfilesForProvider.mockReturnValue([]);
});

View File

@@ -33,6 +33,22 @@ function makePrompter(): WizardPrompter {
};
}
function expectPrimaryModelChanged(
applied: { changed: boolean; next: OpenClawConfig },
primary: string,
) {
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({ primary });
}
function expectConfigUnchanged(
applied: { changed: boolean; next: OpenClawConfig },
cfg: OpenClawConfig,
) {
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
}
describe("applyDefaultModelChoice", () => {
it("ensures allowlist entry exists when returning an agent override", async () => {
const defaultModel = "vercel-ai-gateway/anthropic/claude-opus-4.6";
@@ -97,10 +113,7 @@ describe("applyGoogleGeminiModelDefault", () => {
it("sets gemini default when model is unset", () => {
const cfg: OpenClawConfig = { agents: { defaults: {} } };
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
expectPrimaryModelChanged(applied, GOOGLE_GEMINI_DEFAULT_MODEL);
});
it("overrides existing model", () => {
@@ -108,10 +121,7 @@ describe("applyGoogleGeminiModelDefault", () => {
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: GOOGLE_GEMINI_DEFAULT_MODEL,
});
expectPrimaryModelChanged(applied, GOOGLE_GEMINI_DEFAULT_MODEL);
});
it("no-ops when already gemini default", () => {
@@ -119,8 +129,7 @@ describe("applyGoogleGeminiModelDefault", () => {
agents: { defaults: { model: GOOGLE_GEMINI_DEFAULT_MODEL } },
};
const applied = applyGoogleGeminiModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
expectConfigUnchanged(applied, cfg);
});
});
@@ -162,10 +171,7 @@ describe("applyOpenAICodexModelDefault", () => {
it("sets openai-codex default when model is unset", () => {
const cfg: OpenClawConfig = { agents: { defaults: {} } };
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
expectPrimaryModelChanged(applied, OPENAI_CODEX_DEFAULT_MODEL);
});
it("sets openai-codex default when model is openai/*", () => {
@@ -173,10 +179,7 @@ describe("applyOpenAICodexModelDefault", () => {
agents: { defaults: { model: OPENAI_DEFAULT_MODEL } },
};
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENAI_CODEX_DEFAULT_MODEL,
});
expectPrimaryModelChanged(applied, OPENAI_CODEX_DEFAULT_MODEL);
});
it("does not override openai-codex/*", () => {
@@ -184,8 +187,7 @@ describe("applyOpenAICodexModelDefault", () => {
agents: { defaults: { model: OPENAI_CODEX_DEFAULT_MODEL } },
};
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
expectConfigUnchanged(applied, cfg);
});
it("does not override non-openai models", () => {
@@ -193,8 +195,7 @@ describe("applyOpenAICodexModelDefault", () => {
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
};
const applied = applyOpenAICodexModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
expectConfigUnchanged(applied, cfg);
});
});
@@ -202,10 +203,7 @@ describe("applyOpencodeZenModelDefault", () => {
it("sets opencode default when model is unset", () => {
const cfg: OpenClawConfig = { agents: { defaults: {} } };
const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENCODE_ZEN_DEFAULT_MODEL,
});
expectPrimaryModelChanged(applied, OPENCODE_ZEN_DEFAULT_MODEL);
});
it("overrides existing model", () => {
@@ -213,10 +211,7 @@ describe("applyOpencodeZenModelDefault", () => {
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
} as OpenClawConfig;
const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(true);
expect(applied.next.agents?.defaults?.model).toEqual({
primary: OPENCODE_ZEN_DEFAULT_MODEL,
});
expectPrimaryModelChanged(applied, OPENCODE_ZEN_DEFAULT_MODEL);
});
it("no-ops when already opencode-zen default", () => {
@@ -224,8 +219,7 @@ describe("applyOpencodeZenModelDefault", () => {
agents: { defaults: { model: OPENCODE_ZEN_DEFAULT_MODEL } },
} as OpenClawConfig;
const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
expectConfigUnchanged(applied, cfg);
});
it("no-ops when already legacy opencode-zen default", () => {
@@ -233,8 +227,7 @@ describe("applyOpencodeZenModelDefault", () => {
agents: { defaults: { model: "opencode-zen/claude-opus-4-5" } },
} as OpenClawConfig;
const applied = applyOpencodeZenModelDefault(cfg);
expect(applied.changed).toBe(false);
expect(applied.next).toEqual(cfg);
expectConfigUnchanged(applied, cfg);
});
it("preserves fallbacks when setting primary", () => {