Files
agent-workflow/src/workflow-model-discovery.test.ts
T

255 lines
9.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { extractModelIds, extractReasonixModels, discoverExecutorModels } from "./workflow-model-discovery.js";
import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js";
describe("workflow-model-discovery helpers", () => {
it("extracts model ids from data[] shape", () => {
expect(extractModelIds({
data: [
{ id: "claude-sonnet-4" },
{ id: "claude-opus-4" },
{ name: "ignored" },
],
})).toEqual(["claude-sonnet-4", "claude-opus-4"]);
});
it("extracts model ids from models[] shape", () => {
expect(extractModelIds({
models: [
{ id: "model-a" },
"model-b",
],
})).toEqual(["model-a", "model-b"]);
});
it("extracts concrete Reasonix provider/model ids", () => {
expect(extractReasonixModels({
providers: [
{
name: "custom-opencode-ai",
models: ["deepseek-v4-flash", "deepseek-v4-pro"],
},
{ name: "anthropic", models: [] },
],
})).toEqual([
"custom-opencode-ai/deepseek-v4-flash",
"custom-opencode-ai/deepseek-v4-pro",
"anthropic",
]);
});
it("supports object model entries and legacy top-level models", () => {
expect(extractReasonixModels({
providers: [{ name: "gateway", models: [{ id: "model-a" }, { name: "model-b" }] }],
})).toEqual(["gateway/model-a", "gateway/model-b"]);
expect(extractReasonixModels({
models: [{ provider: "gateway", id: "model-c" }, "provider-only"],
})).toEqual(["gateway/model-c", "provider-only"]);
});
});
describe("saveGlobalExecutorModels", () => {
let tmpHome: string;
let originalHome: string | undefined;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-global-config-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it("atomically updates only executor models and preserves unrelated fields", () => {
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
version: "1",
defaultExecutor: "reasonix",
maxCycles: 7,
executors: {
reasonix: { binary: "/usr/local/bin/reasonix", model: "old-model", profile: "fast" },
claude: { binary: "claude" },
},
}, null, 2));
saveGlobalExecutorModels({
reasonix: "new-reasonix",
claude: "claude-sonnet-4",
agy: null,
});
const loaded = loadGlobalWorkflowConfig()!;
expect(loaded.defaultExecutor).toBe("reasonix");
expect(loaded.maxCycles).toBe(7);
expect(loaded.executors?.reasonix).toEqual({
binary: "/usr/local/bin/reasonix",
model: "new-reasonix",
profile: "fast",
});
expect(loaded.executors?.claude).toEqual({
binary: "claude",
model: "claude-sonnet-4",
});
expect(loaded.executors?.agy).toBeUndefined();
});
it("null clears a model override without dropping other executor fields", () => {
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
version: "1",
executors: {
agy: { binary: "agy", model: "to-remove", agent: "code-assistant" },
},
}));
saveGlobalExecutorModels({ agy: null });
const loaded = loadGlobalWorkflowConfig()!;
expect(loaded.executors?.agy).toEqual({ binary: "agy", agent: "code-assistant" });
});
});
describe("discoverExecutorModels isolation", () => {
let tmpHome: string;
let originalHome: string | undefined;
let originalFetch: typeof globalThis.fetch | undefined;
const ANTHROPIC_VARS = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
] as const;
const savedVars: Record<string, string | undefined> = {};
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-discovery-"));
originalHome = process.env.HOME;
process.env.HOME = tmpHome;
originalFetch = globalThis.fetch;
for (const key of ANTHROPIC_VARS) {
savedVars[key] = process.env[key];
delete process.env[key];
}
const binDir = path.join(tmpHome, "bin");
fs.mkdirSync(binDir, { recursive: true });
const agyBin = path.join(binDir, "agy");
const reasonixBin = path.join(binDir, "reasonix");
fs.writeFileSync(agyBin, "#!/bin/sh\nprintf '%s\\n' 'Test Agy Model'\n", { mode: 0o755 });
fs.writeFileSync(reasonixBin, `#!/bin/sh
printf '%s\\n' '{"providers":[{"name":"test-provider","models":["test-model"]}]}'
`, { mode: 0o755 });
const configDir = path.join(tmpHome, ".agent-workflow");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
version: "1",
executors: {
agy: { binary: agyBin },
reasonix: { binary: reasonixBin },
},
}));
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
for (const key of ANTHROPIC_VARS) {
if (savedVars[key] === undefined) delete process.env[key];
else process.env[key] = savedVars[key];
}
if (originalFetch) globalThis.fetch = originalFetch;
else delete (globalThis as { fetch?: typeof fetch }).fetch;
fs.rmSync(tmpHome, { recursive: true, force: true });
vi.restoreAllMocks();
});
function writeClaudeSettings(env: Record<string, string>): void {
const claudeDir = path.join(tmpHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ env }));
}
it("keeps Claude discovery isolated and never returns credentials", async () => {
process.env.ANTHROPIC_API_KEY = "secret-key-value";
process.env.ANTHROPIC_BASE_URL = "https://user:pass@api.example.com/v1?token=abc#frag";
globalThis.fetch = vi.fn(async () => {
throw new Error("network down secret-key-value");
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("error");
expect(result.claude.error).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("secret-key-value");
expect(JSON.stringify(result)).not.toContain("user:pass");
expect(result.agy).toMatchObject({ status: "ok", models: ["Test Agy Model"] });
expect(result.reasonix).toMatchObject({
status: "ok",
models: ["test-provider/test-model"],
});
});
it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => {
// Process env is fully cleared in beforeEach; settings.json is the only source.
writeClaudeSettings({
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
ANTHROPIC_DEFAULT_SONNET_MODEL: "gateway-sonnet",
ANTHROPIC_DEFAULT_OPUS_MODEL: "gateway-opus",
});
let capturedUrl = "";
let capturedAuth: string | undefined;
let capturedApiKey: string | undefined;
globalThis.fetch = vi.fn(async (input: any, init: any) => {
capturedUrl = String(input);
const headers = new Headers(init?.headers);
capturedAuth = headers.get("authorization") ?? undefined;
capturedApiKey = headers.get("x-api-key") ?? undefined;
return {
ok: true,
status: 200,
json: async () => ({ data: [{ id: "gateway-model-1" }, { id: "gateway-model-2" }] }),
};
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("ok");
expect(capturedUrl.startsWith("https://gateway.internal.example")).toBe(true);
// Auth token must use the Authorization: Bearer header, not x-api-key.
expect(capturedAuth).toBe("Bearer token-from-settings");
expect(capturedApiKey == null).toBe(true);
expect(result.claude.models).toContain("gateway-model-1");
// Configured default model candidates are surfaced.
expect(result.claude.defaults).toContain("gateway-sonnet");
expect(result.claude.defaults).toContain("gateway-opus");
// The resolved token must never appear anywhere in the browser-visible result.
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
it("redacts a settings.json auth token from browser-visible errors", async () => {
writeClaudeSettings({
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
});
globalThis.fetch = vi.fn(async () => {
throw new Error("upstream failed for token-from-settings");
}) as unknown as typeof fetch;
const result = await discoverExecutorModels();
expect(result.claude.status).toBe("error");
expect(result.claude.error).toBeTruthy();
expect(JSON.stringify(result)).not.toContain("token-from-settings");
});
});