// Migrate Hermes tests cover model.apply plugin behavior. import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { resolvePreferredOpenClawTmpDir, tempWorkspace, type TempWorkspace, } from "openclaw/plugin-sdk/temp-path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { HERMES_REASON_DEFAULT_MODEL_CONFIGURED, HERMES_REASON_MODEL_PROVIDER_CONFLICT, } from "./items.js"; import { buildHermesMigrationProvider } from "./provider.js"; import { makeConfigRuntime, makeContext, writeFile } from "./test/provider-helpers.js"; let testWorkspace: TempWorkspace; function defaultModelItem(status: "migrated" | "conflict") { return { id: "config:default-model", kind: "config", action: "update", target: "agents.defaults.model", status, ...(status === "conflict" ? { reason: HERMES_REASON_DEFAULT_MODEL_CONFIGURED } : {}), details: { model: "openai/gpt-5.4" }, }; } describe("Hermes migration model apply", () => { beforeEach(async () => { testWorkspace = await tempWorkspace({ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-migrate-hermes-", }); }); afterEach(async () => { await testWorkspace.cleanup(); }); it("updates only the primary model when applying over object-form model config", async () => { const root = testWorkspace.dir; const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); await writeFile( path.join(source, "config.yaml"), "model:\n provider: openai\n model: gpt-5.4\n", ); const existingConfig = { agents: { defaults: { workspace: workspaceDir, model: { primary: "anthropic/claude-sonnet-4.6", fallbacks: ["openrouter/anthropic/claude-opus-4.6"], timeoutMs: 120_000, }, }, }, } as OpenClawConfig; let writtenConfig: OpenClawConfig | undefined; const provider = buildHermesMigrationProvider({ runtime: makeConfigRuntime(existingConfig, (next) => { writtenConfig = next; }), }); const result = await provider.apply( makeContext({ source, stateDir, workspaceDir, overwrite: true, model: existingConfig.agents?.defaults?.model, reportDir, }), ); expect(result.items).toEqual([defaultModelItem("migrated")]); expect(writtenConfig?.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.4", fallbacks: ["openrouter/anthropic/claude-opus-4.6"], timeoutMs: 120_000, }); }); it("updates the default-agent model override when applying with overwrite", async () => { const root = testWorkspace.dir; const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); await writeFile( path.join(source, "config.yaml"), "model:\n provider: openai\n model: gpt-5.4\n", ); const existingConfig = { agents: { defaults: { workspace: workspaceDir, model: { primary: "google/gemini-3-pro", fallbacks: ["openai/gpt-5.4"], }, }, list: [ { id: "main", default: true, model: { primary: "anthropic/claude-sonnet-4.6", fallbacks: ["openrouter/anthropic/claude-opus-4.6"], }, }, ], }, } as OpenClawConfig; let writtenConfig: OpenClawConfig | undefined; const provider = buildHermesMigrationProvider({ runtime: makeConfigRuntime(existingConfig, (next) => { writtenConfig = next; }), }); const result = await provider.apply( makeContext({ source, stateDir, workspaceDir, config: existingConfig, overwrite: true, reportDir, }), ); expect(result.items).toEqual([defaultModelItem("migrated")]); expect(writtenConfig?.agents?.list?.[0]?.model).toEqual({ primary: "openai/gpt-5.4", fallbacks: ["openrouter/anthropic/claude-opus-4.6"], }); expect(writtenConfig?.agents?.defaults?.model).toEqual(existingConfig.agents?.defaults?.model); }); it("reports late-created default models as conflicts without overwriting", async () => { const root = testWorkspace.dir; const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); await writeFile( path.join(source, "config.yaml"), "model:\n provider: openai\n model: gpt-5.4\n", ); const lateConfig = { agents: { defaults: { workspace: workspaceDir, model: "anthropic/claude-sonnet-4.6", }, }, } as OpenClawConfig; const provider = buildHermesMigrationProvider({ runtime: makeConfigRuntime(lateConfig), }); const ctx = makeContext({ source, stateDir, workspaceDir, reportDir }); const plan = await provider.plan(ctx); const result = await provider.apply(ctx, plan); expect(result.items).toEqual([defaultModelItem("conflict")]); expect(result.summary.conflicts).toBe(1); expect(lateConfig.agents?.defaults?.model).toBe("anthropic/claude-sonnet-4.6"); }); it("does not apply a custom default after its provider develops a late conflict", async () => { const root = testWorkspace.dir; const source = path.join(root, "hermes"); const workspaceDir = path.join(root, "workspace"); const stateDir = path.join(root, "state"); const reportDir = path.join(root, "report"); await writeFile( path.join(source, "config.yaml"), [ "model:", " provider: custom:acme", " default: imported-model", "providers:", " acme:", " base_url: https://new.example.test/v1", " transport: openai_chat", "", ].join("\n"), ); const lateConfig = { agents: { defaults: { workspace: workspaceDir } }, models: { providers: { acme: { baseUrl: "https://old.example.test/v1", api: "openai-completions", models: [], }, }, }, } as OpenClawConfig; const provider = buildHermesMigrationProvider({ runtime: makeConfigRuntime(lateConfig) }); const ctx = makeContext({ source, stateDir, workspaceDir, reportDir }); const plan = await provider.plan(ctx); const result = await provider.apply(ctx, plan); expect(result.items.find((item) => item.id === "config:model-provider:acme")?.status).toBe( "conflict", ); expect(result.items.find((item) => item.id === "config:default-model")).toEqual( expect.objectContaining({ status: "conflict", reason: HERMES_REASON_MODEL_PROVIDER_CONFLICT, }), ); expect(lateConfig.agents?.defaults?.model).toBeUndefined(); }); });