Files
fathah--hermes-desktop/tests/config-model-block.test.ts
2026-07-13 13:36:53 +08:00

494 lines
17 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { join } from "path";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
// Regression tests for issue #242: a `default:` (or `provider:`, `base_url:`)
// key under a *sibling* block such as `personalities:` was being picked up
// as the model's value because the readers/writers used loose regexes
// against the whole file. Both `getModelConfig` and `setModelConfig`
// should now scope strictly to the top-level `model:` block.
const TEST_DIR = join(tmpdir(), `hermes-test-model-block-${Date.now()}`);
async function importConfigWithHome(
home: string,
): Promise<typeof import("../src/main/config")> {
vi.resetModules();
process.env.HERMES_HOME = home;
return await import("../src/main/config");
}
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
delete process.env.HERMES_HOME;
vi.resetModules();
rmSync(TEST_DIR, { recursive: true, force: true });
});
describe("getModelConfig — scoped to model: block", () => {
it("returns the value from model.default, not personalities.default (issue #242)", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
' default: "gpt-5.5"',
' provider: "openai-codex"',
' base_url: "https://chatgpt.com/backend-api/codex"',
"personalities:",
" default: You give clear, accurate, and actionable responses.",
" coding: You are an expert coding assistant.",
"",
].join("\n"),
);
const { getModelConfig } = await importConfigWithHome(TEST_DIR);
const mc = getModelConfig();
expect(mc.model).toBe("gpt-5.5");
expect(mc.provider).toBe("openai-codex");
expect(mc.baseUrl).toBe("https://chatgpt.com/backend-api/codex");
});
it("ignores `default:` inside nested blocks even when it appears first in the file", async () => {
// personalities: comes BEFORE model:, so the old regex (which picked
// the first occurrence anywhere) would have returned the personality
// description as the model name. The block-scoped reader must not.
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"agent:",
" personalities:",
" default: You give clear, accurate, and actionable responses.",
"personalities:",
" default: You are an expert coding assistant.",
"model:",
' default: "claude-sonnet-4"',
' provider: "anthropic"',
"",
].join("\n"),
);
const { getModelConfig } = await importConfigWithHome(TEST_DIR);
const mc = getModelConfig();
expect(mc.model).toBe("claude-sonnet-4");
expect(mc.provider).toBe("anthropic");
});
it("returns defaults when the model: block is absent", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"personalities:",
" default: Just a personality.",
"agent:",
" provider: should-not-be-picked",
"",
].join("\n"),
);
const { getModelConfig } = await importConfigWithHome(TEST_DIR);
const mc = getModelConfig();
// Defaults from getModelConfig() when nothing relevant is found.
expect(mc.provider).toBe("auto");
expect(mc.model).toBe("");
expect(mc.baseUrl).toBe("");
});
it("returns defaults when the config file doesn't exist", async () => {
const { getModelConfig } = await importConfigWithHome(TEST_DIR);
const mc = getModelConfig();
expect(mc).toEqual({ provider: "auto", model: "", baseUrl: "" });
});
it("ignores grandchildren (deeper nesting under model:) — only direct siblings count", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
' default: "real-model"',
" fallback:",
' default: "decoy-model"', // 4-space indent: nested, must not be picked
' provider: "decoy-provider"',
"",
].join("\n"),
);
const { getModelConfig } = await importConfigWithHome(TEST_DIR);
const mc = getModelConfig();
expect(mc.model).toBe("real-model");
});
it("ignores `provider:` set under unrelated blocks (e.g. auxiliary.vision)", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"auxiliary:",
" vision:",
" provider: auto",
"model:",
' default: "gpt-4o"',
' provider: "openai"',
"",
].join("\n"),
);
const { getModelConfig } = await importConfigWithHome(TEST_DIR);
expect(
(await importConfigWithHome(TEST_DIR)).getModelConfig().provider,
).toBe("openai");
void getModelConfig;
});
it("handles single-quoted, double-quoted, and bare values", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
" default: bare-value",
" provider: 'single-quoted'",
' base_url: "https://example.com"',
"",
].join("\n"),
);
const { getModelConfig } = await importConfigWithHome(TEST_DIR);
const mc = getModelConfig();
expect(mc.model).toBe("bare-value");
expect(mc.provider).toBe("single-quoted");
expect(mc.baseUrl).toBe("https://example.com");
});
});
describe("setModelConfig — scoped to model: block", () => {
it("updates model.default in place without touching personalities.default", async () => {
const before = [
"model:",
' default: "gpt-3.5"',
' provider: "openai"',
"personalities:",
" default: You give clear, accurate, and actionable responses.",
" coding: You are an expert coding assistant.",
"",
].join("\n");
writeFileSync(join(TEST_DIR, "config.yaml"), before);
const { setModelConfig } = await importConfigWithHome(TEST_DIR);
setModelConfig("openai", "gpt-4o", "", undefined);
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
expect(after).toContain('default: "gpt-4o"');
// The personality description must remain intact.
expect(after).toContain(
"default: You give clear, accurate, and actionable responses.",
);
});
it("inserts the model.default key at the block's existing indent when it was missing", async () => {
const before = [
"model:",
' provider: "openai"',
"personalities:",
" default: keep me intact",
"",
].join("\n");
writeFileSync(join(TEST_DIR, "config.yaml"), before);
const { setModelConfig, getModelConfig } =
await importConfigWithHome(TEST_DIR);
setModelConfig("openai", "gpt-4o", "https://api.openai.com/v1");
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
// The new keys land inside the model: block, indented like its existing children.
expect(after).toMatch(/^model:\n(?: {2}[a-zA-Z_]+: .*\n)+/m);
expect(after).toContain(' default: "gpt-4o"');
expect(after).toContain(' base_url: "https://api.openai.com/v1"');
expect(after).toContain("default: keep me intact");
// Round-trip through the reader to confirm structure is preserved.
const mc = getModelConfig();
expect(mc).toEqual({
provider: "openai",
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
});
it("creates the model: block if it doesn't exist yet", async () => {
const before = ["personalities:", " default: keep me intact", ""].join(
"\n",
);
writeFileSync(join(TEST_DIR, "config.yaml"), before);
const { setModelConfig, getModelConfig } =
await importConfigWithHome(TEST_DIR);
setModelConfig("anthropic", "claude-sonnet", "");
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
// Model block exists with both keys correctly indented under it.
// Insertion order isn't load-bearing — we round-trip through the
// reader below to confirm the values are correctly scoped.
expect(after).toMatch(/^model:\n(?: {2}[a-zA-Z_]+: .*\n)+/m);
expect(after).toContain(' provider: "anthropic"');
expect(after).toContain(' default: "claude-sonnet"');
// Personality description must remain intact.
expect(after).toContain("default: keep me intact");
const mc = getModelConfig();
expect(mc.provider).toBe("anthropic");
expect(mc.model).toBe("claude-sonnet");
});
it("invalidates cached reads after a write", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
["model:", ' default: "gpt-3.5"', ' provider: "openai"', ""].join("\n"),
);
const { getModelConfig, setModelConfig } =
await importConfigWithHome(TEST_DIR);
expect(getModelConfig().model).toBe("gpt-3.5");
setModelConfig("openai", "gpt-4o", "");
expect(getModelConfig().model).toBe("gpt-4o");
});
// Regression tests for #228: previously setModelConfig early-returned
// when config.yaml didn't exist, so users on a fresh / custom
// HERMES_HOME had their model selection silently dropped — the desktop
// appeared to save, but the Python gateway saw an empty model and
// returned 404s. The fix bootstraps an empty config.yaml when missing
// and threads through upsertBlockChild as usual.
it("creates config.yaml from scratch when it doesn't exist (issue #228)", async () => {
// Note: no writeFileSync — the file genuinely does not exist.
const configFile = join(TEST_DIR, "config.yaml");
expect(existsSync(configFile)).toBe(false);
const { setModelConfig, getModelConfig } =
await importConfigWithHome(TEST_DIR);
setModelConfig("gemini", "gemini-2.5-flash", "");
expect(existsSync(configFile)).toBe(true);
const after = readFileSync(configFile, "utf-8");
// The file should start with `model:` (no stray leading blank line)
// and contain both keys.
expect(after).toMatch(/^model:\n/);
expect(after).toContain(' provider: "gemini"');
expect(after).toContain(' default: "gemini-2.5-flash"');
// Round-trip confirms the values are scoped to model: and parse back.
const mc = getModelConfig();
expect(mc).toEqual({
provider: "gemini",
model: "gemini-2.5-flash",
baseUrl: "",
});
});
it("creates config.yaml with all three model fields when base_url is given", async () => {
const configFile = join(TEST_DIR, "config.yaml");
expect(existsSync(configFile)).toBe(false);
const { setModelConfig, getModelConfig } =
await importConfigWithHome(TEST_DIR);
setModelConfig("custom", "qwen-coder-30b", "http://localhost:1234/v1");
expect(existsSync(configFile)).toBe(true);
const mc = getModelConfig();
expect(mc).toEqual({
provider: "custom",
model: "qwen-coder-30b",
baseUrl: "http://localhost:1234/v1",
});
});
it("creates the file under the profile directory for a named profile", async () => {
const profileDir = join(TEST_DIR, "profiles", "work");
const configFile = join(profileDir, "config.yaml");
// Neither the profile dir nor the file exists yet — safeWriteFile
// should create both.
expect(existsSync(configFile)).toBe(false);
const { setModelConfig, getModelConfig } =
await importConfigWithHome(TEST_DIR);
setModelConfig("anthropic", "claude-sonnet-4", "", "work");
expect(existsSync(configFile)).toBe(true);
const mc = getModelConfig("work");
expect(mc.provider).toBe("anthropic");
expect(mc.model).toBe("claude-sonnet-4");
});
});
describe("setModelConfig — context_length override", () => {
it("writes model.context_length when a positive value is passed", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
["model:", ' default: "qwen-max"', ' provider: "qwen"', ""].join("\n"),
);
const { setModelConfig, getModelContextLengthOverride } =
await importConfigWithHome(TEST_DIR);
setModelConfig("qwen", "qwen-max", "", undefined, 65536);
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
// Written unquoted so YAML parses it as a number, not a string.
expect(after).toContain("context_length: 65536");
expect(after).not.toContain('context_length: "65536"');
expect(getModelContextLengthOverride()).toEqual({
model: "qwen-max",
contextLength: 65536,
});
});
it("leaves an existing context_length untouched when passed undefined", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
' default: "qwen-max"',
' provider: "qwen"',
' context_length: "65536"',
"",
].join("\n"),
);
const { setModelConfig, getModelContextLengthOverride } =
await importConfigWithHome(TEST_DIR);
// No 5th arg — the override must survive a plain provider/model write.
setModelConfig("qwen", "qwen-max", "");
expect(getModelContextLengthOverride()?.contextLength).toBe(65536);
});
it("removes model.context_length when null (or non-positive) is passed", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
' default: "qwen-max"',
' provider: "qwen"',
' context_length: "65536"',
' base_url: "https://example.com"',
"",
].join("\n"),
);
const { setModelConfig, getModelContextLengthOverride } =
await importConfigWithHome(TEST_DIR);
setModelConfig("qwen", "qwen-max", "https://example.com", undefined, null);
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
expect(after).not.toContain("context_length");
// Sibling keys must remain intact after the line removal.
expect(after).toContain('default: "qwen-max"');
expect(after).toContain('base_url: "https://example.com"');
expect(getModelContextLengthOverride()).toBeNull();
});
it("getModelContextLengthOverride returns null for absent/invalid values", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
["model:", ' default: "m"', ' context_length: "0"', ""].join("\n"),
);
const { getModelContextLengthOverride } =
await importConfigWithHome(TEST_DIR);
expect(getModelContextLengthOverride()).toBeNull();
});
});
// Regression for the cross-provider switch bug: switching between an
// Anthropic-compatible and an OpenAI-compatible custom endpoint left a stale
// `model.api_mode` behind, so the new endpoint was hit over the wrong protocol
// (e.g. /v1/messages against a chat/completions server → 404 / lost
// connection). setModelConfig must rewrite or clear api_mode on every switch.
describe("setModelConfig — api_mode override", () => {
it("writes model.api_mode when a non-empty mode is passed", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
' default: "claude-sonnet-4"',
' provider: "custom"',
' base_url: "https://example.com/anthropic"',
"",
].join("\n"),
);
const { setModelConfig } = await importConfigWithHome(TEST_DIR);
setModelConfig(
"custom",
"claude-sonnet-4",
"https://example.com/anthropic",
undefined,
undefined,
"anthropic_messages",
);
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
expect(after).toContain('api_mode: "anthropic_messages"');
});
it("leaves an existing api_mode untouched when passed undefined", async () => {
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
' default: "claude-sonnet-4"',
' provider: "custom"',
' api_mode: "anthropic_messages"',
"",
].join("\n"),
);
const { setModelConfig } = await importConfigWithHome(TEST_DIR);
// No 6th arg — a plain provider/model write must not disturb api_mode.
setModelConfig("custom", "claude-sonnet-4", "");
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
expect(after).toContain('api_mode: "anthropic_messages"');
});
it("clears a stale api_mode when null is passed (the switch-provider fix)", async () => {
// Start on an Anthropic-compatible endpoint with an explicit mode...
writeFileSync(
join(TEST_DIR, "config.yaml"),
[
"model:",
' default: "claude-sonnet-4"',
' provider: "custom"',
' base_url: "https://example.com/anthropic"',
' api_mode: "anthropic_messages"',
"",
].join("\n"),
);
const { setModelConfig } = await importConfigWithHome(TEST_DIR);
// ...then switch to an OpenAI-compatible endpoint that has no explicit
// mode (apiMode = null). The stale anthropic_messages must be removed so
// the agent re-detects chat_completions from the new base_url.
setModelConfig(
"custom",
"gpt-4o",
"https://api.openai-compatible.example/v1",
undefined,
undefined,
null,
);
const after = readFileSync(join(TEST_DIR, "config.yaml"), "utf-8");
expect(after).not.toContain("api_mode");
// Sibling keys must survive the line removal.
expect(after).toContain('default: "gpt-4o"');
expect(after).toContain(
'base_url: "https://api.openai-compatible.example/v1"',
);
});
});