diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index a1f28b05db4..22d9e9955fd 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -9,6 +9,11 @@ import { applySessionsPatchToStore } from "./sessions-patch.js"; const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5"; const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child"; const MAIN_SESSION_KEY = "agent:main:main"; +const ANTHROPIC_SONNET_MODEL = "anthropic/claude-sonnet-4-6"; +const ANTHROPIC_SONNET_ID = "claude-sonnet-4-6"; +const ANTHROPIC_OPUS_ID = "claude-opus-4-6"; +const OPENAI_GPT_MODEL = "openai/gpt-5.4"; +const OPENAI_GPT_ID = "gpt-5.4"; const EMPTY_CFG = {} as OpenClawConfig; type ApplySessionsPatchArgs = Parameters[0]; @@ -52,6 +57,100 @@ function expectPatchError( expect(result.error.message).toContain(message); } +function mainStoreEntry(overrides: Partial): Record { + return { + [MAIN_SESSION_KEY]: { + sessionId: "sess", + updatedAt: 1, + ...overrides, + } as SessionEntry, + }; +} + +function mainAuthOverrideStore(overrides: Partial): Record { + return mainStoreEntry({ + providerOverride: "anthropic", + modelOverride: ANTHROPIC_OPUS_ID, + authProfileOverrideSource: "user", + ...overrides, + }); +} + +function catalogEntry(ref: string, name?: string) { + const separator = ref.indexOf("/"); + if (separator < 0) { + throw new Error(`model ref must include provider: ${ref}`); + } + const id = ref.slice(separator + 1); + return { + provider: ref.slice(0, separator), + id, + name: name ?? id, + }; +} + +function loadCatalog(...refs: string[]): ApplySessionsPatchArgs["loadGatewayModelCatalog"] { + return async () => refs.map((ref) => catalogEntry(ref)); +} + +function expectModelSelection( + entry: SessionEntry, + providerOverride: string | undefined, + modelOverride: string | undefined, +) { + expect(entry.providerOverride).toBe(providerOverride); + expect(entry.modelOverride).toBe(modelOverride); +} + +async function applyMainModelPatch(params: { + store?: Record; + cfg?: OpenClawConfig; + model: string | null; + catalogRefs?: string[]; +}) { + return expectPatchOk( + await runPatch({ + store: params.store, + cfg: params.cfg, + patch: { key: MAIN_SESSION_KEY, model: params.model }, + loadGatewayModelCatalog: + params.catalogRefs === undefined ? undefined : loadCatalog(...params.catalogRefs), + }), + ); +} + +async function expectProviderChangeClearsAuthOverride(store: Record) { + const entry = await applyMainModelPatch({ + store, + model: OPENAI_GPT_MODEL, + catalogRefs: [OPENAI_GPT_MODEL], + }); + expectModelSelection(entry, "openai", OPENAI_GPT_ID); + expectAuthOverride(entry, { profile: undefined }); +} + +function expectAuthOverride( + entry: SessionEntry, + expected: { + profile: string | undefined; + source?: string; + compactionCount?: number; + }, +) { + expect(entry.authProfileOverride).toBe(expected.profile); + if (expected.profile === undefined) { + expect(entry.authProfileOverrideSource).toBeUndefined(); + expect(entry.authProfileOverrideCompactionCount).toBeUndefined(); + return; + } + expect(entry.authProfileOverrideSource).toBe(expected.source ?? "user"); + if (expected.compactionCount === undefined) { + expect(entry.authProfileOverrideCompactionCount).toBeUndefined(); + } else { + expect(entry.authProfileOverrideCompactionCount).toBe(expected.compactionCount); + } +} + async function applySubagentModelPatch(cfg: OpenClawConfig) { return expectPatchOk( await runPatch({ @@ -62,7 +161,7 @@ async function applySubagentModelPatch(cfg: OpenClawConfig) { model: SUBAGENT_MODEL, }, loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, + { provider: "anthropic", id: ANTHROPIC_SONNET_ID, name: "sonnet" }, { provider: "synthetic", id: "hf:moonshotai/Kimi-K2.5", name: "kimi" }, ], }), @@ -100,9 +199,9 @@ function createAllowlistedAnthropicModelCfg(): OpenClawConfig { return { agents: { defaults: { - model: { primary: "openai/gpt-5.4" }, + model: { primary: OPENAI_GPT_MODEL }, models: { - "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, + [ANTHROPIC_SONNET_MODEL]: { alias: "sonnet" }, }, }, }, @@ -184,7 +283,7 @@ describe("gateway sessions patch", () => { sessionFile: "stale.jsonl", label: "Stale Session", sendPolicy: "deny", - modelOverride: "gpt-5.4", + modelOverride: OPENAI_GPT_ID, responseUsage: "tokens", parentSessionKey: "agent:main:main", } as SessionEntry, @@ -202,16 +301,14 @@ describe("gateway sessions patch", () => { expect(entry.sessionFile).toBeUndefined(); expect(entry.label).toBeUndefined(); expect(entry.sendPolicy).toBe("deny"); - expect(entry.modelOverride).toBe("gpt-5.4"); + expect(entry.modelOverride).toBe(OPENAI_GPT_ID); expect(entry.responseUsage).toBe("tokens"); expect(entry.parentSessionKey).toBe("agent:main:main"); expect(entry.fastMode).toBe(true); }); test("clears fastMode when patch sets null", async () => { - const store: Record = { - [MAIN_SESSION_KEY]: { fastMode: true } as SessionEntry, - }; + const store = mainStoreEntry({ fastMode: true }); const entry = expectPatchOk( await runPatch({ store, @@ -276,223 +373,125 @@ describe("gateway sessions patch", () => { }); test("preserves same-provider auth overrides when model patch changes", async () => { - const store: Record = { - "agent:main:main": { - sessionId: "sess", - updatedAt: 1, - providerOverride: "anthropic", - modelOverride: "claude-opus-4-6", - authProfileOverride: "anthropic:default", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 3, - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, - ], - }), - ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); - expect(entry.authProfileOverride).toBe("anthropic:default"); - expect(entry.authProfileOverrideSource).toBe("user"); - expect(entry.authProfileOverrideCompactionCount).toBe(3); + const store = mainAuthOverrideStore({ + authProfileOverride: "anthropic:default", + authProfileOverrideCompactionCount: 3, + }); + const entry = await applyMainModelPatch({ + store, + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [ANTHROPIC_SONNET_MODEL], + }); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); + expectAuthOverride(entry, { profile: "anthropic:default", compactionCount: 3 }); }); test("preserves auth overrides for provider-auth aliases when model patch changes", async () => { - const store: Record = { - "agent:main:main": { - sessionId: "sess-alias", - updatedAt: 1, - providerOverride: "byteplus", - modelOverride: "seedance-1-0-lite-t2v-250428", - authProfileOverride: "byteplus:work", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 2, - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - patch: { key: MAIN_SESSION_KEY, model: "byteplus-plan/ark-code-latest" }, - loadGatewayModelCatalog: async () => [ - { provider: "byteplus-plan", id: "ark-code-latest", name: "ark-code-latest" }, - ], - }), - ); - expect(entry.providerOverride).toBe("byteplus-plan"); - expect(entry.modelOverride).toBe("ark-code-latest"); - expect(entry.authProfileOverride).toBe("byteplus:work"); - expect(entry.authProfileOverrideSource).toBe("user"); - expect(entry.authProfileOverrideCompactionCount).toBe(2); + const store = mainStoreEntry({ + sessionId: "sess-alias", + providerOverride: "byteplus", + modelOverride: "seedance-1-0-lite-t2v-250428", + authProfileOverride: "byteplus:work", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + }); + const entry = await applyMainModelPatch({ + store, + model: "byteplus-plan/ark-code-latest", + catalogRefs: ["byteplus-plan/ark-code-latest"], + }); + expectModelSelection(entry, "byteplus-plan", "ark-code-latest"); + expectAuthOverride(entry, { profile: "byteplus:work", compactionCount: 2 }); }); test("preserves unprefixed auth overrides when existing provider matches model patch", async () => { - const store: Record = { - "agent:main:main": { - sessionId: "sess-unprefixed-same-provider", - updatedAt: 1, - providerOverride: "anthropic", - modelOverride: "claude-opus-4-6", - authProfileOverride: "work", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 4, - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, - ], - }), - ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); - expect(entry.authProfileOverride).toBe("work"); - expect(entry.authProfileOverrideSource).toBe("user"); - expect(entry.authProfileOverrideCompactionCount).toBe(4); + const store = mainAuthOverrideStore({ + sessionId: "sess-unprefixed-same-provider", + authProfileOverride: "work", + authProfileOverrideCompactionCount: 4, + }); + const entry = await applyMainModelPatch({ + store, + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [ANTHROPIC_SONNET_MODEL], + }); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); + expectAuthOverride(entry, { profile: "work", compactionCount: 4 }); }); test("preserves unprefixed auth overrides when existing provider is the default", async () => { - const store: Record = { - "agent:main:main": { - sessionId: "sess-unprefixed-default-provider", - updatedAt: 1, - authProfileOverride: "work", - authProfileOverrideSource: "user", - authProfileOverrideCompactionCount: 4, - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - cfg: { - agents: { - defaults: { - model: { primary: "anthropic/claude-opus-4-6" }, - }, + const store = mainStoreEntry({ + sessionId: "sess-unprefixed-default-provider", + authProfileOverride: "work", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 4, + }); + const entry = await applyMainModelPatch({ + cfg: { + agents: { + defaults: { + model: { primary: `anthropic/${ANTHROPIC_OPUS_ID}` }, }, - } as OpenClawConfig, - store, - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "sonnet" }, - ], - }), - ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); - expect(entry.authProfileOverride).toBe("work"); - expect(entry.authProfileOverrideSource).toBe("user"); - expect(entry.authProfileOverrideCompactionCount).toBe(4); + }, + } as OpenClawConfig, + store, + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [ANTHROPIC_SONNET_MODEL], + }); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); + expectAuthOverride(entry, { profile: "work", compactionCount: 4 }); }); test("clears unprefixed auth overrides when model patch changes provider", async () => { - const store: Record = { - "agent:main:main": { + await expectProviderChangeClearsAuthOverride( + mainAuthOverrideStore({ sessionId: "sess-unprefixed-provider-change", - updatedAt: 1, - providerOverride: "anthropic", - modelOverride: "claude-opus-4-6", authProfileOverride: "work", - authProfileOverrideSource: "user", authProfileOverrideCompactionCount: 4, - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.4" }, - loadGatewayModelCatalog: async () => [ - { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, - ], }), ); - expect(entry.providerOverride).toBe("openai"); - expect(entry.modelOverride).toBe("gpt-5.4"); - expect(entry.authProfileOverride).toBeUndefined(); - expect(entry.authProfileOverrideSource).toBeUndefined(); - expect(entry.authProfileOverrideCompactionCount).toBeUndefined(); }); test("clears provider-prefixed auth overrides when model patch changes provider", async () => { - const store: Record = { - "agent:main:main": { + await expectProviderChangeClearsAuthOverride( + mainAuthOverrideStore({ sessionId: "sess-provider-change", - updatedAt: 1, - providerOverride: "anthropic", - modelOverride: "claude-opus-4-6", authProfileOverride: "anthropic:default", - authProfileOverrideSource: "user", authProfileOverrideCompactionCount: 3, - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.4" }, - loadGatewayModelCatalog: async () => [ - { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, - ], }), ); - expect(entry.providerOverride).toBe("openai"); - expect(entry.modelOverride).toBe("gpt-5.4"); - expect(entry.authProfileOverride).toBeUndefined(); - expect(entry.authProfileOverrideSource).toBeUndefined(); - expect(entry.authProfileOverrideCompactionCount).toBeUndefined(); }); test("marks explicit model patches as pending live model switches", async () => { - const store: Record = { - [MAIN_SESSION_KEY]: { - sessionId: "sess-live", - updatedAt: 1, - providerOverride: "openai", - modelOverride: "gpt-5.4", - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - cfg: createAllowlistedAnthropicModelCfg(), - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" }, - loadGatewayModelCatalog: async () => [ - { provider: "openai", id: "gpt-5.4", name: "gpt-5.4" }, - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, - ], - }), - ); + const store = mainStoreEntry({ + sessionId: "sess-live", + providerOverride: "openai", + modelOverride: OPENAI_GPT_ID, + }); + const entry = await applyMainModelPatch({ + store, + cfg: createAllowlistedAnthropicModelCfg(), + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [OPENAI_GPT_MODEL, ANTHROPIC_SONNET_MODEL], + }); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); expect(entry.liveModelSwitchPending).toBe(true); }); test("marks model reset patches as pending live model switches", async () => { - const store: Record = { - [MAIN_SESSION_KEY]: { - sessionId: "sess-live-reset", - updatedAt: 1, - providerOverride: "anthropic", - modelOverride: "claude-sonnet-4-6", - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - cfg: createAllowlistedAnthropicModelCfg(), - patch: { key: MAIN_SESSION_KEY, model: null }, - }), - ); + const store = mainStoreEntry({ + sessionId: "sess-live-reset", + providerOverride: "anthropic", + modelOverride: ANTHROPIC_SONNET_ID, + }); + const entry = await applyMainModelPatch({ + store, + cfg: createAllowlistedAnthropicModelCfg(), + model: null, + }); - expect(entry.providerOverride).toBeUndefined(); - expect(entry.modelOverride).toBeUndefined(); + expectModelSelection(entry, undefined, undefined); expect(entry.liveModelSwitchPending).toBe(true); }); @@ -515,12 +514,11 @@ describe("gateway sessions patch", () => { const entry = expectPatchOk( await runPatch({ cfg: createAllowlistedAnthropicModelCfg(), - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" }, + patch: { key: MAIN_SESSION_KEY, model: ANTHROPIC_SONNET_MODEL }, loadGatewayModelCatalog: async () => catalog, }), ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); }); test("sets spawnDepth for subagent sessions", async () => { @@ -808,7 +806,7 @@ describe("gateway sessions patch", () => { test("allows target agent own model for subagent session even when missing from global allowlist", async () => { const cfg = makeKimiSubagentCfg({ - agentPrimaryModel: "synthetic/hf:moonshotai/Kimi-K2.5", + agentPrimaryModel: SUBAGENT_MODEL, }); const entry = await applySubagentModelPatch(cfg); @@ -819,13 +817,12 @@ describe("gateway sessions patch", () => { test("allows target agent subagents.model for subagent session even when missing from global allowlist", async () => { const cfg = makeKimiSubagentCfg({ - agentPrimaryModel: "anthropic/claude-sonnet-4-6", + agentPrimaryModel: ANTHROPIC_SONNET_MODEL, agentSubagentModel: SUBAGENT_MODEL, }); const entry = await applySubagentModelPatch(cfg); - expect(entry.providerOverride).toBe("synthetic"); - expect(entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5"); + expectModelSelection(entry, "synthetic", "hf:moonshotai/Kimi-K2.5"); }); test("allows global defaults.subagents.model for subagent session even when missing from global allowlist", async () => { @@ -834,86 +831,57 @@ describe("gateway sessions patch", () => { }); const entry = await applySubagentModelPatch(cfg); - expect(entry.providerOverride).toBe("synthetic"); - expect(entry.modelOverride).toBe("hf:moonshotai/Kimi-K2.5"); + expectModelSelection(entry, "synthetic", "hf:moonshotai/Kimi-K2.5"); }); test("persists trailing @profile suffix as authProfileOverride on model patch", async () => { - const entry = expectPatchOk( - await runPatch({ - cfg: createAllowlistedAnthropicModelCfg(), - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6@myprofile" }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, - ], - }), - ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); - expect(entry.authProfileOverride).toBe("myprofile"); - expect(entry.authProfileOverrideSource).toBe("user"); + const entry = await applyMainModelPatch({ + cfg: createAllowlistedAnthropicModelCfg(), + model: `${ANTHROPIC_SONNET_MODEL}@myprofile`, + catalogRefs: [ANTHROPIC_SONNET_MODEL], + }); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); + expectAuthOverride(entry, { profile: "myprofile" }); expect(entry.liveModelSwitchPending).toBe(true); }); test("marks same-model @profile patches as pending live model switches", async () => { - const store: Record = { - [MAIN_SESSION_KEY]: { - sessionId: "sess-live-profile-only", - updatedAt: 1, - providerOverride: "anthropic", - modelOverride: "claude-sonnet-4-6", - authProfileOverride: "oldprofile", - authProfileOverrideSource: "user", - } as SessionEntry, - }; - const entry = expectPatchOk( - await runPatch({ - store, - cfg: createAllowlistedAnthropicModelCfg(), - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6@newprofile" }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, - ], - }), - ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); - expect(entry.authProfileOverride).toBe("newprofile"); + const store = mainStoreEntry({ + sessionId: "sess-live-profile-only", + providerOverride: "anthropic", + modelOverride: ANTHROPIC_SONNET_ID, + authProfileOverride: "oldprofile", + authProfileOverrideSource: "user", + }); + const entry = await applyMainModelPatch({ + store, + cfg: createAllowlistedAnthropicModelCfg(), + model: `${ANTHROPIC_SONNET_MODEL}@newprofile`, + catalogRefs: [ANTHROPIC_SONNET_MODEL], + }); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); + expectAuthOverride(entry, { profile: "newprofile" }); expect(entry.liveModelSwitchPending).toBe(true); }); test("does not set authProfileOverride when profile suffix is missing", async () => { - const entry = expectPatchOk( - await runPatch({ - cfg: createAllowlistedAnthropicModelCfg(), - patch: { key: MAIN_SESSION_KEY, model: "anthropic/claude-sonnet-4-6" }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, - ], - }), - ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); - expect(entry.authProfileOverride).toBeUndefined(); + const entry = await applyMainModelPatch({ + cfg: createAllowlistedAnthropicModelCfg(), + model: ANTHROPIC_SONNET_MODEL, + catalogRefs: [ANTHROPIC_SONNET_MODEL], + }); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); + expectAuthOverride(entry, { profile: undefined }); }); test("persists full provider:profile authProfileOverride on model patch", async () => { - const entry = expectPatchOk( - await runPatch({ - cfg: createAllowlistedAnthropicModelCfg(), - patch: { - key: MAIN_SESSION_KEY, - model: "anthropic/claude-sonnet-4-6@openai:user@example.com", - }, - loadGatewayModelCatalog: async () => [ - { provider: "anthropic", id: "claude-sonnet-4-6", name: "claude-sonnet-4-6" }, - ], - }), - ); - expect(entry.providerOverride).toBe("anthropic"); - expect(entry.modelOverride).toBe("claude-sonnet-4-6"); - expect(entry.authProfileOverride).toBe("openai:user@example.com"); - expect(entry.authProfileOverrideSource).toBe("user"); + const entry = await applyMainModelPatch({ + cfg: createAllowlistedAnthropicModelCfg(), + model: `${ANTHROPIC_SONNET_MODEL}@openai:user@example.com`, + catalogRefs: [ANTHROPIC_SONNET_MODEL], + }); + expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID); + expectAuthOverride(entry, { profile: "openai:user@example.com" }); }); test("resolves bare allowlisted model ids before persisting @profile suffix", async () => {