openclaw / src /model-picker /apply-session-model-selection.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
34810d2 verified
Raw History Blame Contribute Delete
36.1 kB
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import { loadProviderScopedThinkingCatalog } from "../agents/model-catalog.runtime.js";
import {
loadSessionEntryReadOnly,
replaceSessionEntry,
} from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
onSessionLifecycleEvent,
type SessionLifecycleEvent,
} from "../sessions/session-lifecycle-events.js";
// Runtime eligibility belongs to the published-owner tests; these cases exercise its consumers.
vi.mock("../agents/model-runtime-choice.js", () => ({
preparePublishedModelRuntimeChoice: vi.fn(async () => ({
kind: "ready",
validate: () => undefined,
})),
}));
vi.mock("../agents/model-catalog.runtime.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
}));
const effects = vi.hoisted(() => ({
enqueueSystemEvent: vi.fn(),
info: vi.fn(),
mutateConfigFileWithRetry: vi.fn(),
refreshQueuedFollowupSession: vi.fn(),
triggerSessionPatchHook: vi.fn(),
warn: vi.fn(),
}));
const placementMocks = vi.hoisted(() => ({
getMany: vi.fn(),
resolveWorkerPlacementSessionRuntimeCapabilities: vi.fn(),
}));
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
let lifecycleEvents: SessionLifecycleEvent[];
let unsubscribeLifecycle: () => void;
vi.mock("../infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) => effects.enqueueSystemEvent(...args),
}));
vi.mock("../auto-reply/reply/queue.js", () => ({
refreshQueuedFollowupSession: (...args: unknown[]) =>
effects.refreshQueuedFollowupSession(...args),
}));
vi.mock("../gateway/session-patch-hooks.js", () => ({
triggerSessionPatchHook: (...args: unknown[]) => effects.triggerSessionPatchHook(...args),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
return { ...actual, mutateConfigFileWithRetry: effects.mutateConfigFileWithRetry };
});
vi.mock("../logging/subsystem.js", async () => {
const actual =
await vi.importActual<typeof import("../logging/subsystem.js")>("../logging/subsystem.js");
return {
...actual,
createSubsystemLogger: (subsystem: string) =>
subsystem === "agents/sticky-model-selection"
? { info: effects.info, warn: effects.warn }
: actual.createSubsystemLogger(subsystem),
};
});
vi.mock("../gateway/session-worker-placement-context.js", () => ({
resolveSessionWorkerPlacementContext: () => ({
workerSessionPlacementService: {
getMany: placementMocks.getMany,
},
}),
}));
vi.mock("../gateway/worker-environments/placement-session-runtime.js", () => ({
resolveWorkerPlacementSessionRuntimeCapabilities:
placementMocks.resolveWorkerPlacementSessionRuntimeCapabilities,
}));
import {
applySessionModelSelection,
type ApplySessionModelSelectionParams,
} from "./apply-session-model-selection.js";
const catalog = [
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus",
contextTokens: 32_000,
},
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", contextTokens: 16_000 },
] satisfies ModelCatalogEntry[];
function createEntry(overrides: Partial<SessionEntry> = {}): SessionEntry {
return {
sessionId: "session-1",
updatedAt: 1,
delivery: { kind: "none" },
...overrides,
};
}
function createParams(overrides: Partial<ApplySessionModelSelectionParams> = {}) {
const sessionEntry = overrides.sessionEntry ?? createEntry();
const sessionKey = overrides.sessionKey ?? "agent:main:dm:1";
return {
cfg: {},
agentId: "main",
sessionKey,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-6",
currentProvider: "anthropic",
currentModel: "claude-opus-4-6",
modelCatalog: catalog,
thinkingCatalog: catalog,
canPersistStickyModelSelection: false,
request: {
provider: "openai",
model: "gpt-4o",
isDefault: false,
runtime: { kind: "unchanged" },
},
markLiveSwitchPending: true,
...overrides,
} satisfies ApplySessionModelSelectionParams;
}
beforeEach(() => {
vi.mocked(loadProviderScopedThinkingCatalog).mockReset().mockResolvedValue([]);
lifecycleEvents = [];
unsubscribeLifecycle = onSessionLifecycleEvent((event) => lifecycleEvents.push(event));
effects.enqueueSystemEvent.mockReset();
effects.info.mockReset();
effects.warn.mockReset();
effects.mutateConfigFileWithRetry.mockReset().mockResolvedValue({
nextConfig: {},
result: "defaults",
});
effects.refreshQueuedFollowupSession.mockReset();
effects.triggerSessionPatchHook.mockReset();
placementMocks.getMany.mockReset().mockReturnValue(new Map());
placementMocks.resolveWorkerPlacementSessionRuntimeCapabilities.mockReset();
});
afterEach(() => unsubscribeLifecycle());
describe("applySessionModelSelection", () => {
it.each([false, true])("uses configured default only with reset intent=%s", async (reset) => {
const modelCatalog = [
{ provider: "fixture", id: "automatic", name: "Automatic" },
{ provider: "fixture", id: "manual", name: "Manual" },
];
const sessionEntry = createEntry({ providerOverride: "fixture", modelOverride: "manual" });
const result = await applySessionModelSelection(
createParams({
cfg: {
agents: {
defaults: {
model: "fixture/automatic",
modelPolicy: { allow: ["fixture/manual"] },
},
},
models: {
providers: {
fixture: {
api: "openai-completions",
baseUrl: "https://fixture.invalid/v1",
models: modelCatalog.map<ModelDefinitionConfig>(({ id, name }) => ({
id,
name,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
maxTokens: 4_096,
})),
},
},
},
},
sessionEntry,
defaultProvider: "fixture",
defaultModel: "stale-default-hint",
currentProvider: "fixture",
currentModel: "manual",
modelCatalog,
thinkingCatalog: modelCatalog,
request: {
provider: "fixture",
model: reset ? "manual" : "automatic",
isDefault: true,
...(reset ? { resetToDefault: true as const } : {}),
runtime: { kind: "unchanged" },
},
}),
);
expect(result).toMatchObject(
reset
? { status: "applied", provider: "fixture", model: "automatic" }
: { status: "rejected", reason: "not-allowed" },
);
expect(sessionEntry.modelOverride).toBe(reset ? undefined : "manual");
});
it("uses selected route metadata for context and thinking outside the prepared inventory", async () => {
const selected: ModelCatalogEntry = {
provider: "fixture-route",
id: "reasoner",
name: "Reasoner",
api: "openai-responses",
contextWindow: 48_000,
contextTokens: 24_000,
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
};
vi.mocked(loadProviderScopedThinkingCatalog).mockResolvedValueOnce([selected]);
const sessionEntry = createEntry({ thinkingLevel: "max" });
const result = await applySessionModelSelection(
createParams({
cfg: {
models: {
providers: {
"fixture-route": {
api: "openai-responses",
baseUrl: "https://fixture.invalid/v1",
models: [],
},
},
},
},
sessionEntry,
modelCatalog: [catalog[0]!],
thinkingCatalog: [catalog[0]!],
request: {
provider: selected.provider,
model: selected.id,
isDefault: false,
runtime: { kind: "set", runtime: "openclaw" },
},
}),
);
expect(result).toMatchObject({ status: "applied", contextTokens: 24_000 });
expect(result).not.toHaveProperty("thinkingRemap");
expect(sessionEntry).toMatchObject({
providerOverride: selected.provider,
modelOverride: selected.id,
thinkingLevel: "max",
});
expect(loadProviderScopedThinkingCatalog).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ provider: selected.provider, model: selected.id }),
);
expect(effects.refreshQueuedFollowupSession).toHaveBeenCalledWith(
expect.objectContaining({
nextThinking: expect.objectContaining({
level: "max",
catalog: expect.arrayContaining([selected]),
}),
}),
);
});
it.each([
{
provider: "missing-provider",
model: "reasoner",
runtime: { kind: "unchanged" } as const,
reason: "unknown-provider",
},
{
provider: "openai",
model: "gpt-5.6-luna",
runtime: { kind: "set", runtime: "missing-runtime" } as const,
reason: "invalid-runtime",
},
])(
"rejects $reason without persistence under unrestricted policy",
async ({ provider, model, runtime, reason }) => {
const sessionEntry = createEntry({ thinkingLevel: "high" });
const initial = structuredClone(sessionEntry);
const result = await applySessionModelSelection(
createParams({
sessionEntry,
modelCatalog: [catalog[0]!],
thinkingCatalog: [catalog[0]!],
request: { provider, model, runtime, isDefault: false },
}),
);
expect(result).toMatchObject({ status: "rejected", reason });
expect(sessionEntry).toEqual(initial);
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(loadProviderScopedThinkingCatalog).not.toHaveBeenCalled();
},
);
it.each([undefined, {}, { allow: [] }, { allow: ["openai/*"] }])(
"persists an off-catalog selection under policy %j without credentials",
async (modelPolicy) => {
const sessionEntry = createEntry({ thinkingLevel: "high" });
const cfg: OpenClawConfig = { agents: { defaults: { modelPolicy } } };
const result = await applySessionModelSelection(
createParams({
cfg,
sessionEntry,
modelCatalog: [catalog[0]!],
thinkingCatalog: [catalog[0]!],
request: {
provider: "openai",
model: "gpt-5.6-luna",
isDefault: false,
runtime: { kind: "unchanged" },
},
}),
);
expect(result).toMatchObject({
status: "applied",
provider: "openai",
model: "gpt-5.6-luna",
});
expect(sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-5.6-luna",
modelOverrideSource: "user",
thinkingLevel: "high",
});
expect(effects.mutateConfigFileWithRetry).not.toHaveBeenCalled();
},
);
it("publishes a profile-only selection after the scoped session has persisted", async () => {
const tempRoot = tempDirs.make("openclaw-model-picker-profile-");
const storePath = path.join(tempRoot, "sessions.json");
const sessionKey = "agent:main:dm:profile";
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-5.6-luna",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
authProfileOverride: "openai:work",
authProfileOverrideSource: "auto",
});
await replaceSessionEntry({ sessionKey, storePath }, sessionEntry);
let publishedEntry: SessionEntry | undefined;
const unsubscribe = onSessionLifecycleEvent(() => {
publishedEntry = loadSessionEntryReadOnly({ sessionKey, storePath });
});
try {
const result = await applySessionModelSelection(
createParams({
sessionEntry,
sessionKey,
storePath,
currentProvider: "openai",
currentModel: "gpt-5.6-luna",
modelCatalog: [{ provider: "openai", id: "gpt-5.6-luna", name: "Luna" }],
request: {
provider: "openai",
model: "gpt-5.6-luna",
isDefault: false,
profileOverride: "openai:work",
runtime: { kind: "unchanged" },
},
}),
);
expect(result).toMatchObject({ status: "applied", changed: true });
expect(lifecycleEvents).toEqual([{ sessionKey, agentId: "main", reason: "patch" }]);
expect(publishedEntry).toMatchObject({
sessionId: "session-1",
modelOverride: "gpt-5.6-luna",
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
});
expect(effects.enqueueSystemEvent).not.toHaveBeenCalled();
expect(effects.mutateConfigFileWithRetry).not.toHaveBeenCalled();
} finally {
unsubscribe();
}
});
it("applies a non-default selection, auth profile, cleanup, and side effects once", async () => {
const sessionEntry = createEntry({
model: "claude-opus-4-6",
modelProvider: "anthropic",
contextTokens: 8_000,
contextBudgetStatus: {} as NonNullable<SessionEntry["contextBudgetStatus"]>,
});
const result = await applySessionModelSelection(
createParams({
sessionEntry,
canPersistStickyModelSelection: true,
request: {
provider: "openai",
model: "gpt-4o",
isDefault: false,
alias: "Fast",
profileOverride: "openai:work",
runtime: { kind: "unchanged" },
},
}),
);
expect(result).toMatchObject({
status: "applied",
provider: "openai",
model: "gpt-4o",
effectiveModelRef: "openai/gpt-4o",
changed: true,
contextTokens: 16_000,
configuredDefaultUpdate: "requested",
});
expect(sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-4o",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
liveModelSwitchPending: true,
});
expect(sessionEntry.model).toBeUndefined();
expect(sessionEntry.modelProvider).toBeUndefined();
expect(sessionEntry.contextTokens).toBeUndefined();
expect(sessionEntry.contextBudgetStatus).toBeUndefined();
expect(effects.triggerSessionPatchHook).toHaveBeenCalledOnce();
await vi.waitFor(() => expect(effects.mutateConfigFileWithRetry).toHaveBeenCalledOnce());
expect(effects.refreshQueuedFollowupSession).toHaveBeenCalledOnce();
expect(effects.enqueueSystemEvent).toHaveBeenCalledWith(
"Model switched to Fast (openai/gpt-4o).",
{ sessionKey: "agent:main:dm:1", contextKey: "model:openai/gpt-4o" },
);
});
it("resets to a cross-provider default and clears incompatible auth plus runtime", async () => {
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 3,
agentHarnessId: "codex",
agentRuntimeOverride: "codex",
});
const result = await applySessionModelSelection(
createParams({
sessionEntry,
currentProvider: "openai",
currentModel: "gpt-4o",
request: {
provider: "anthropic",
model: "claude-opus-4-6",
isDefault: true,
runtime: { kind: "unchanged" },
},
}),
);
expect(result).toMatchObject({ status: "applied", runtimeChange: { kind: "clear" } });
expect(sessionEntry.providerOverride).toBeUndefined();
expect(sessionEntry.modelOverride).toBeUndefined();
expect(sessionEntry.modelOverrideSource).toBe("default");
expect(sessionEntry.authProfileOverride).toBeUndefined();
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
expect(sessionEntry.agentRuntimeOverride).toBeUndefined();
expect(sessionEntry.agentHarnessId).toBe("codex");
expect(effects.mutateConfigFileWithRetry).not.toHaveBeenCalled();
});
it("resets to a same-provider default without clearing compatible auth or writing config", async () => {
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-4.1",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 3,
});
const result = await applySessionModelSelection(
createParams({
sessionEntry,
defaultProvider: "openai",
defaultModel: "gpt-4o",
currentProvider: "openai",
currentModel: "gpt-4.1",
canPersistStickyModelSelection: true,
request: {
provider: "openai",
model: "gpt-4o",
isDefault: true,
runtime: { kind: "unchanged" },
},
}),
);
expect(result).toMatchObject({ status: "applied", changed: true });
expect(result).not.toHaveProperty("configuredDefaultUpdate");
expect(sessionEntry.providerOverride).toBeUndefined();
expect(sessionEntry.modelOverride).toBeUndefined();
expect(sessionEntry.modelOverrideSource).toBe("default");
expect(sessionEntry.modelOverrideRouteResolution).toBeUndefined();
expect(sessionEntry).toMatchObject({
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 3,
});
expect(effects.refreshQueuedFollowupSession).toHaveBeenCalledWith(
expect.objectContaining({ nextModelOverrideSource: undefined }),
);
expect(effects.mutateConfigFileWithRetry).not.toHaveBeenCalled();
});
it("preserves a compatible auth profile when changing models within a provider", async () => {
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-4.1",
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 3,
});
await applySessionModelSelection(
createParams({
sessionEntry,
currentProvider: "openai",
currentModel: "gpt-4.1",
}),
);
expect(sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-4o",
authProfileOverride: "openai:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 3,
});
});
it.each([
{ name: "legacy user", marker: undefined, expectedSource: "user" as const },
{ name: "marker-backed auto", marker: 0, expectedSource: "auto" as const },
])(
"forwards a source-less $name auth profile canonically to queued work",
async ({ marker, expectedSource }) => {
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-4.1",
authProfileOverride: "openai:work",
...(marker === undefined ? {} : { authProfileOverrideCompactionCount: marker }),
});
await applySessionModelSelection(
createParams({
sessionEntry,
currentProvider: "openai",
currentModel: "gpt-4.1",
}),
);
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
expect(sessionEntry.authProfileOverrideCompactionCount).toBe(marker);
expect(effects.refreshQueuedFollowupSession).toHaveBeenCalledWith(
expect.objectContaining({
nextAuthProfileId: "openai:work",
nextAuthProfileIdSource: expectedSource,
}),
);
},
);
it("keeps an accepted selection session-scoped without config authority", async () => {
const sessionEntry = createEntry();
const result = await applySessionModelSelection(
createParams({ sessionEntry, canPersistStickyModelSelection: false }),
);
expect(result.status).toBe("applied");
expect(result).not.toHaveProperty("configuredDefaultUpdate");
expect(sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
expect(effects.mutateConfigFileWithRetry).not.toHaveBeenCalled();
});
it("returns session success and warns when the sticky config write fails", async () => {
const sessionEntry = createEntry();
effects.mutateConfigFileWithRetry.mockRejectedValueOnce(new Error("config write failed"));
const result = await applySessionModelSelection(
createParams({ sessionEntry, canPersistStickyModelSelection: true }),
);
expect(result).toMatchObject({
status: "applied",
configuredDefaultUpdate: "requested",
});
expect(sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-4o",
});
await vi.waitFor(() =>
expect(effects.warn).toHaveBeenCalledWith(
"failed sticky model persistence agentId=main model=openai/gpt-4o reason=config write failed",
),
);
});
it("resolves SDK effective persistence from the current write draft", async () => {
const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } };
const draft = {
agents: {
...cfg.agents,
entries: { main: { model: "anthropic/claude-sonnet-4-6" } },
},
};
effects.mutateConfigFileWithRetry.mockImplementationOnce(
async ({ mutate }: { mutate: (config: OpenClawConfig) => string }) => ({
nextConfig: draft,
result: mutate(draft),
}),
);
await applySessionModelSelection(createParams({ cfg, canPersistStickyModelSelection: true }));
await vi.waitFor(() => expect(effects.info).toHaveBeenCalledOnce());
expect(draft.agents.defaults.model).toBe("anthropic/claude-opus-4-6");
expect(draft.agents.entries.main.model).toBe("openai/gpt-4o");
});
it.each([
{
name: "clears overrides for an authoritative default",
request: {
provider: "anthropic",
model: "claude-opus-4-6",
isDefault: false,
runtime: { kind: "unchanged" } as const,
},
expectedOverride: undefined,
},
{
name: "persists an authoritative non-default",
request: {
provider: "openai",
model: "gpt-4o",
isDefault: true,
runtime: { kind: "unchanged" } as const,
},
expectedOverride: "gpt-4o",
},
])("$name instead of trusting request.isDefault", async ({ request, expectedOverride }) => {
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
});
await applySessionModelSelection(createParams({ sessionEntry, request }));
expect(sessionEntry.modelOverride).toBe(expectedOverride);
});
it.each([
{
name: "set",
initial: undefined,
runtime: { kind: "set", runtime: "openclaw" } as const,
expected: "openclaw",
runtimeChange: { kind: "set", runtime: "openclaw" },
agentRuntime: "openclaw",
},
{
name: "set idempotently",
initial: "openclaw",
runtime: { kind: "set", runtime: "openclaw" } as const,
expected: "openclaw",
runtimeChange: { kind: "set", runtime: "openclaw" },
agentRuntime: "openclaw",
},
{
name: "clear",
initial: "openclaw",
runtime: { kind: "clear" } as const,
expected: undefined,
runtimeChange: { kind: "clear" },
agentRuntime: "codex",
},
{
name: "clear idempotently",
initial: undefined,
runtime: { kind: "clear" } as const,
expected: undefined,
runtimeChange: { kind: "clear" },
agentRuntime: "codex",
},
{
name: "unchanged",
initial: "openclaw",
runtime: { kind: "unchanged" } as const,
expected: "openclaw",
runtimeChange: undefined,
agentRuntime: "openclaw",
},
])(
"supports runtime $name",
async ({ initial, runtime, expected, runtimeChange, agentRuntime }) => {
const sessionEntry = createEntry({ agentRuntimeOverride: initial });
const result = await applySessionModelSelection(
createParams({
sessionEntry,
request: { provider: "openai", model: "gpt-4o", isDefault: false, runtime },
}),
);
expect(result.status).toBe("applied");
if (result.status === "applied") {
expect(result.runtimeChange).toEqual(runtimeChange);
expect(result.agentRuntime).toBe(agentRuntime);
}
expect(sessionEntry.agentRuntimeOverride).toBe(expected);
},
);
it("rejects an incompatible runtime without mutation or side effects", async () => {
const sessionEntry = createEntry();
const initial = structuredClone(sessionEntry);
const result = await applySessionModelSelection(
createParams({
sessionEntry,
request: {
provider: "anthropic",
model: "claude-opus-4-6",
isDefault: true,
runtime: { kind: "set", runtime: "codex" },
},
}),
);
expect(result).toEqual({
status: "rejected",
reason: "invalid-runtime",
message: 'Runtime "codex" is not supported for anthropic.',
});
expect(sessionEntry).toEqual(initial);
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(effects.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("rejects locked selection without mutation or side effects", async () => {
const sessionEntry = createEntry({ modelSelectionLocked: true });
const initial = structuredClone(sessionEntry);
const result = await applySessionModelSelection(createParams({ sessionEntry }));
expect(result).toEqual({
status: "rejected",
reason: "locked",
message: "Model selection is locked for this session.",
});
expect(lifecycleEvents).toEqual([]);
expect(sessionEntry).toEqual(initial);
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(effects.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("rejects a stale in-memory snapshot when the session store row is locked", async () => {
const sessionEntry = createEntry();
const lockedEntry = createEntry({ modelSelectionLocked: true, updatedAt: 2 });
const sessionKey = "agent:main:dm:locked-store";
const result = await applySessionModelSelection(
createParams({
sessionEntry,
sessionKey,
sessionStore: { [sessionKey]: lockedEntry },
}),
);
expect(result).toMatchObject({ status: "rejected", reason: "locked" });
expect(sessionEntry).toEqual(createEntry());
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
});
it.each([
{
name: "locked",
concurrent: createEntry({ modelSelectionLocked: true }),
outcome: { status: "rejected", reason: "locked" },
},
{
name: "replaced",
concurrent: createEntry({ sessionId: "session-2" }),
outcome: { status: "conflict" },
},
])(
"preserves an in-memory session $name during metadata preparation",
async ({ concurrent, outcome }) => {
const metadata = createDeferred<ModelCatalogEntry[]>();
vi.mocked(loadProviderScopedThinkingCatalog).mockReturnValueOnce(metadata.promise);
const params = createParams();
const pending = applySessionModelSelection(params);
params.sessionStore[params.sessionKey] = concurrent;
metadata.resolve([]);
expect(await pending).toMatchObject(outcome);
expect(params.sessionStore[params.sessionKey]).toBe(concurrent);
expect(params.sessionEntry).toEqual(createEntry());
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(effects.enqueueSystemEvent).not.toHaveBeenCalled();
},
);
it("rejects when the authoritative persisted row became locked", async () => {
const tempRoot = tempDirs.make("openclaw-model-picker-lock-");
const storePath = path.join(tempRoot, "sessions.json");
const sessionKey = "agent:main:dm:locked-disk";
const sessionEntry = createEntry();
const lockedEntry = createEntry({ modelSelectionLocked: true, updatedAt: 2 });
await replaceSessionEntry({ sessionKey, storePath }, lockedEntry);
const result = await applySessionModelSelection(
createParams({ sessionEntry, sessionKey, storePath }),
);
expect(result).toMatchObject({ status: "rejected", reason: "locked" });
expect(sessionEntry).toEqual(lockedEntry);
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(effects.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("rejects account selection authority revoked during metadata preparation", async () => {
const metadata = createDeferred<ModelCatalogEntry[]>();
vi.mocked(loadProviderScopedThinkingCatalog).mockReturnValueOnce(metadata.promise);
let authorized = true;
const params = createParams({
validateAuthProfileSelection: () => (authorized ? undefined : "Select an account you own."),
request: {
provider: "openai",
model: "gpt-4o",
isDefault: false,
profileOverride: "openai:work",
runtime: { kind: "unchanged" },
},
});
const initial = structuredClone(params.sessionEntry);
const pending = applySessionModelSelection(params);
authorized = false;
metadata.resolve([]);
expect(await pending).toMatchObject({
status: "rejected",
message: "Select an account you own.",
});
expect(params.sessionEntry).toEqual(initial);
expect(lifecycleEvents).toEqual([]);
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
});
it.each([
{
name: "allowlist",
overrides: { cfg: { agents: { defaults: { modelPolicy: { allow: ["anthropic/*"] } } } } },
},
])("rejects a model missing from the $name", async ({ overrides }) => {
const sessionEntry = createEntry();
const initial = structuredClone(sessionEntry);
const result = await applySessionModelSelection(createParams({ sessionEntry, ...overrides }));
expect(result).toEqual({
status: "rejected",
reason: "not-allowed",
message: "Model openai/gpt-4o is not available for this agent.",
});
expect(sessionEntry).toEqual(initial);
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
});
it("remaps unsupported thinking and reasserts live switching", async () => {
const sessionEntry = createEntry({ thinkingLevel: "adaptive" });
const result = await applySessionModelSelection(createParams({ sessionEntry }));
expect(result).toMatchObject({
status: "applied",
thinkingRemap: {
from: "adaptive",
to: "medium",
provider: "openai",
model: "gpt-4o",
},
});
expect(sessionEntry.thinkingLevel).toBe("medium");
expect(sessionEntry.liveModelSwitchPending).toBe(true);
});
it("refreshes queued work when an idempotent selection only remaps thinking", async () => {
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
thinkingLevel: "adaptive",
});
const result = await applySessionModelSelection(
createParams({ sessionEntry, currentProvider: "openai", currentModel: "gpt-4o" }),
);
expect(result).toMatchObject({ status: "applied", changed: true });
expect(sessionEntry.thinkingLevel).toBe("medium");
expect(effects.triggerSessionPatchHook).toHaveBeenCalledOnce();
expect(effects.refreshQueuedFollowupSession).toHaveBeenCalledWith(
expect.objectContaining({
nextThinking: expect.objectContaining({ level: "medium" }),
}),
);
});
it("uses the resolved parent or bound target session key for every effect", async () => {
const sessionKey = "agent:main:telegram:bound:thread:42";
await applySessionModelSelection(createParams({ sessionKey }));
expect(effects.triggerSessionPatchHook).toHaveBeenCalledWith(
expect.objectContaining({ sessionKey, patch: { key: sessionKey, model: "openai/gpt-4o" } }),
);
expect(effects.refreshQueuedFollowupSession).toHaveBeenCalledWith(
expect.objectContaining({ key: sessionKey }),
);
expect(effects.enqueueSystemEvent).toHaveBeenCalledWith(expect.any(String), {
sessionKey,
contextKey: "model:openai/gpt-4o",
});
});
it.each([
{
name: "session replacement",
concurrent: createEntry({ sessionId: "session-2", providerOverride: "anthropic" }),
},
{
name: "model switch",
concurrent: createEntry({
providerOverride: "openai",
modelOverride: "gpt-5.5",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
}),
},
])("returns conflict without a hybrid row after concurrent $name", async ({ concurrent }) => {
const tempRoot = tempDirs.make("openclaw-model-picker-service-");
const storePath = path.join(tempRoot, "sessions.json");
const sessionEntry = createEntry({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
});
const sessionKey = "agent:main:dm:race";
await replaceSessionEntry({ sessionKey, storePath }, concurrent);
const result = await applySessionModelSelection(
createParams({ sessionKey, storePath, sessionEntry }),
);
expect(result).toEqual({
status: "conflict",
message: "Model change was not applied because the session changed. Retry.",
});
expect(lifecycleEvents).toEqual([]);
expect(sessionEntry).toEqual(concurrent);
expect(sessionEntry).not.toMatchObject({ modelOverride: "gpt-4o" });
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(effects.enqueueSystemEvent).not.toHaveBeenCalled();
});
it("keeps idempotent model acknowledgement facts without duplicate effects", async () => {
const sessionEntry = createEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
modelOverrideSource: "user",
modelOverrideRouteResolution: "resolved",
});
const result = await applySessionModelSelection(
createParams({ sessionEntry, currentProvider: "openai", currentModel: "gpt-4o" }),
);
expect(result).toMatchObject({
status: "applied",
effectiveModelRef: "openai/gpt-4o",
changed: false,
});
expect(lifecycleEvents).toEqual([]);
expect(effects.triggerSessionPatchHook).not.toHaveBeenCalled();
expect(effects.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(effects.enqueueSystemEvent).not.toHaveBeenCalled();
});
});