diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f05fcaac0..115481dd284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,10 @@ Docs: https://docs.openclaw.ai - Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr. - Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman. - Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468. +- Models/chat commands: keep `/model ...@YYYYMMDD` version suffixes intact by default, but still honor matching stored numeric auth-profile overrides for the same provider. (#48896) Thanks @Alix-007. + +### Fixes + - Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus. - Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc. - macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67. diff --git a/src/agents/model-ref-profile.test.ts b/src/agents/model-ref-profile.test.ts index 92c2211eff7..f85c7d37675 100644 --- a/src/agents/model-ref-profile.test.ts +++ b/src/agents/model-ref-profile.test.ts @@ -53,4 +53,17 @@ describe("splitTrailingAuthProfile", () => { profile: "google-gemini-cli:test@gmail.com", }); }); + + it("keeps @YYYYMMDD version suffixes in model ids", () => { + expect(splitTrailingAuthProfile("custom/vertex-ai_claude-haiku-4-5@20251001")).toEqual({ + model: "custom/vertex-ai_claude-haiku-4-5@20251001", + }); + }); + + it("supports auth profiles after @YYYYMMDD version suffixes", () => { + expect(splitTrailingAuthProfile("custom/vertex-ai_claude-haiku-4-5@20251001@work")).toEqual({ + model: "custom/vertex-ai_claude-haiku-4-5@20251001", + profile: "work", + }); + }); }); diff --git a/src/agents/model-ref-profile.ts b/src/agents/model-ref-profile.ts index 54ec79f905f..7437a554590 100644 --- a/src/agents/model-ref-profile.ts +++ b/src/agents/model-ref-profile.ts @@ -8,11 +8,20 @@ export function splitTrailingAuthProfile(raw: string): { } const lastSlash = trimmed.lastIndexOf("/"); - const profileDelimiter = trimmed.indexOf("@", lastSlash + 1); + let profileDelimiter = trimmed.indexOf("@", lastSlash + 1); if (profileDelimiter <= 0) { return { model: trimmed }; } + const versionSuffix = trimmed.slice(profileDelimiter + 1); + if (/^\d{8}(?:@|$)/.test(versionSuffix)) { + const nextDelimiter = trimmed.indexOf("@", profileDelimiter + 9); + if (nextDelimiter < 0) { + return { model: trimmed }; + } + profileDelimiter = nextDelimiter; + } + const model = trimmed.slice(0, profileDelimiter).trim(); const profile = trimmed.slice(profileDelimiter + 1).trim(); if (!model || !profile) { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index b815ecfc9b9..f80ebecfc91 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -1,4 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearRuntimeAuthProfileStoreSnapshots, + replaceRuntimeAuthProfileStoreSnapshots, +} from "../../agents/auth-profiles.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -8,6 +12,7 @@ import { maybeHandleModelDirectiveInfo, resolveModelSelectionFromDirective, } from "./directive-handling.model.js"; +import { persistInlineDirectives } from "./directive-handling.persist.js"; // Mock dependencies for directive handling persistence. vi.mock("../../agents/agent-scope.js", () => ({ @@ -28,6 +33,8 @@ vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: vi.fn(), })); +const TEST_AGENT_DIR = "/tmp/agent"; + function baseAliasIndex(): ModelAliasIndex { return { byAlias: new Map(), byKey: new Map() }; } @@ -39,6 +46,31 @@ function baseConfig(): OpenClawConfig { } as unknown as OpenClawConfig; } +beforeEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir: TEST_AGENT_DIR, + store: { version: 1, profiles: {} }, + }, + ]); +}); + +afterEach(() => { + clearRuntimeAuthProfileStoreSnapshots(); +}); + +function setAuthProfiles( + profiles: Record, +) { + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir: TEST_AGENT_DIR, + store: { version: 1, profiles }, + }, + ]); +} + function resolveModelSelectionForCommand(params: { command: string; allowedModelKeys: Set; @@ -47,7 +79,7 @@ function resolveModelSelectionForCommand(params: { return resolveModelSelectionFromDirective({ directives: parseInlineDirectives(params.command), cfg: { commands: { text: true } } as unknown as OpenClawConfig, - agentDir: "/tmp/agent", + agentDir: TEST_AGENT_DIR, defaultProvider: "anthropic", defaultModel: "claude-opus-4-5", aliasIndex: baseAliasIndex(), @@ -63,7 +95,7 @@ async function resolveModelInfoReply( return maybeHandleModelDirectiveInfo({ directives: parseInlineDirectives("/model"), cfg: baseConfig(), - agentDir: "/tmp/agent", + agentDir: TEST_AGENT_DIR, activeAgentId: "main", provider: "anthropic", model: "claude-opus-4-5", @@ -181,6 +213,342 @@ describe("/model chat UX", () => { isDefault: false, }); }); + + it("treats @YYYYMMDD as a profile override when that profile exists for the resolved provider", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const resolved = resolveModelSelectionForCommand({ + command: "/model openai/gpt-4o@20251001", + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [], + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openai", + model: "gpt-4o", + isDefault: false, + }); + expect(resolved.profileOverride).toBe("20251001"); + }); + + it("supports alias selections with numeric auth-profile overrides", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const aliasIndex: ModelAliasIndex = { + byAlias: new Map([["gpt", { alias: "gpt", ref: { provider: "openai", model: "gpt-4o" } }]]), + byKey: new Map([["openai/gpt-4o", ["gpt"]]]), + }; + + const resolved = resolveModelSelectionFromDirective({ + directives: parseInlineDirectives("/model gpt@20251001"), + cfg: { commands: { text: true } } as unknown as OpenClawConfig, + agentDir: "/tmp/agent", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex, + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [], + provider: "anthropic", + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openai", + model: "gpt-4o", + isDefault: false, + alias: "gpt", + }); + expect(resolved.profileOverride).toBe("20251001"); + }); + + it("supports providerless allowlist selections with numeric auth-profile overrides", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const resolved = resolveModelSelectionForCommand({ + command: "/model gpt-4o@20251001", + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [], + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "openai", + model: "gpt-4o", + isDefault: false, + }); + expect(resolved.profileOverride).toBe("20251001"); + }); + + it("keeps @YYYYMMDD as part of the model when the stored numeric profile is for another provider", () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "anthropic", + key: "sk-test", + }, + }); + + const resolved = resolveModelSelectionForCommand({ + command: "/model custom/vertex-ai_claude-haiku-4-5@20251001", + allowedModelKeys: new Set(["custom/vertex-ai_claude-haiku-4-5@20251001"]), + allowedModelCatalog: [], + }); + + expect(resolved.errorText).toBeUndefined(); + expect(resolved.modelSelection).toEqual({ + provider: "custom", + model: "vertex-ai_claude-haiku-4-5@20251001", + isDefault: false, + }); + expect(resolved.profileOverride).toBeUndefined(); + }); + + it("persists inferred numeric auth-profile overrides for mixed-content messages", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives("/model openai/gpt-4o@20251001 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["openai/gpt-4o", "openai/gpt-4o@20251001"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + }); + + it("persists alias-based numeric auth-profile overrides for mixed-content messages", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const aliasIndex: ModelAliasIndex = { + byAlias: new Map([["gpt", { alias: "gpt", ref: { provider: "openai", model: "gpt-4o" } }]]), + byKey: new Map([["openai/gpt-4o", ["gpt"]]]), + }; + const directives = parseInlineDirectives("/model gpt@20251001 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex, + allowedModelKeys: new Set(["openai/gpt-4o"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + }); + + it("persists providerless numeric auth-profile overrides for mixed-content messages", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives("/model gpt-4o@20251001 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["openai/gpt-4o"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + }); + + it("persists explicit auth profiles after @YYYYMMDD version suffixes in mixed-content messages", async () => { + setAuthProfiles({ + work: { + type: "api_key", + provider: "custom", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives( + "/model custom/vertex-ai_claude-haiku-4-5@20251001@work hello", + ); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["custom/vertex-ai_claude-haiku-4-5@20251001"]), + provider: "anthropic", + model: "claude-opus-4-5", + initialModelLabel: "anthropic/claude-opus-4-5", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(sessionEntry.providerOverride).toBe("custom"); + expect(sessionEntry.modelOverride).toBe("vertex-ai_claude-haiku-4-5@20251001"); + expect(sessionEntry.authProfileOverride).toBe("work"); + }); + + it("ignores invalid mixed-content model directives during persistence", async () => { + setAuthProfiles({ + "20251001": { + type: "api_key", + provider: "openai", + key: "sk-test", + }, + }); + + const directives = parseInlineDirectives("/model 99 hello"); + const sessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + providerOverride: "openai", + modelOverride: "gpt-4o", + authProfileOverride: "20251001", + authProfileOverrideSource: "user", + } as SessionEntry; + const sessionStore = { "agent:main:dm:1": sessionEntry }; + + const persisted = await persistInlineDirectives({ + directives, + effectiveModelDirective: directives.rawModelDirective, + cfg: baseConfig(), + agentDir: TEST_AGENT_DIR, + sessionEntry, + sessionStore, + sessionKey: "agent:main:dm:1", + storePath: undefined, + elevatedEnabled: false, + elevatedAllowed: false, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelKeys: new Set(["openai/gpt-4o"]), + provider: "openai", + model: "gpt-4o", + initialModelLabel: "openai/gpt-4o", + formatModelSwitchEvent: (label) => label, + agentCfg: baseConfig().agents?.defaults, + }); + + expect(persisted.provider).toBe("openai"); + expect(persisted.model).toBe("gpt-4o"); + expect(sessionEntry.providerOverride).toBe("openai"); + expect(sessionEntry.modelOverride).toBe("gpt-4o"); + expect(sessionEntry.authProfileOverride).toBe("20251001"); + expect(sessionEntry.authProfileOverrideSource).toBe("user"); + }); }); describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 521d3bd6fea..986f632bcb5 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,8 +1,12 @@ -import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js"; +import { + ensureAuthProfileStore, + resolveAuthStorePathForDisplay, +} from "../../agents/auth-profiles.js"; import { type ModelAliasIndex, modelKey, normalizeProviderId, + normalizeProviderIdForAuth, resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; @@ -353,6 +357,39 @@ export async function maybeHandleModelDirectiveInfo(params: { return { text: lines.join("\n") }; } +function resolveStoredNumericProfileModelDirective(params: { raw: string; agentDir: string }): { + modelRaw: string; + profileId: string; + profileProvider: string; +} | null { + const trimmed = params.raw.trim(); + const lastSlash = trimmed.lastIndexOf("/"); + const profileDelimiter = trimmed.indexOf("@", lastSlash + 1); + if (profileDelimiter <= 0) { + return null; + } + + const profileId = trimmed.slice(profileDelimiter + 1).trim(); + if (!/^\d{8}$/.test(profileId)) { + return null; + } + + const modelRaw = trimmed.slice(0, profileDelimiter).trim(); + if (!modelRaw) { + return null; + } + + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const profile = store.profiles[profileId]; + if (!profile) { + return null; + } + + return { modelRaw, profileId, profileProvider: profile.provider }; +} + export function resolveModelSelectionFromDirective(params: { directives: InlineDirectives; cfg: OpenClawConfig; @@ -376,6 +413,28 @@ export function resolveModelSelectionFromDirective(params: { } const raw = params.directives.rawModelDirective.trim(); + const storedNumericProfile = + params.directives.rawModelProfile === undefined + ? resolveStoredNumericProfileModelDirective({ + raw, + agentDir: params.agentDir, + }) + : null; + const storedNumericProfileSelection = storedNumericProfile + ? resolveModelDirectiveSelection({ + raw: storedNumericProfile.modelRaw, + defaultProvider: params.defaultProvider, + defaultModel: params.defaultModel, + aliasIndex: params.aliasIndex, + allowedModelKeys: params.allowedModelKeys, + }) + : null; + const useStoredNumericProfile = + Boolean(storedNumericProfileSelection?.selection) && + normalizeProviderIdForAuth(storedNumericProfileSelection?.selection?.provider ?? "") === + normalizeProviderIdForAuth(storedNumericProfile?.profileProvider ?? ""); + const modelRaw = + useStoredNumericProfile && storedNumericProfile ? storedNumericProfile.modelRaw : raw; let modelSelection: ModelDirectiveSelection | undefined; if (/^[0-9]+$/.test(raw)) { @@ -390,7 +449,7 @@ export function resolveModelSelectionFromDirective(params: { } const explicit = resolveModelRefFromString({ - raw, + raw: modelRaw, defaultProvider: params.defaultProvider, aliasIndex: params.aliasIndex, }); @@ -410,7 +469,7 @@ export function resolveModelSelectionFromDirective(params: { if (!modelSelection) { const resolved = resolveModelDirectiveSelection({ - raw, + raw: modelRaw, defaultProvider: params.defaultProvider, defaultModel: params.defaultModel, aliasIndex: params.aliasIndex, @@ -427,9 +486,12 @@ export function resolveModelSelectionFromDirective(params: { } let profileOverride: string | undefined; - if (modelSelection && params.directives.rawModelProfile) { + const rawProfile = + params.directives.rawModelProfile ?? + (useStoredNumericProfile ? storedNumericProfile?.profileId : undefined); + if (modelSelection && rawProfile) { const profileResolved = resolveProfileOverride({ - rawProfile: params.directives.rawModelProfile, + rawProfile, provider: modelSelection.provider, cfg: params.cfg, agentDir: params.agentDir, diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index f4087055801..ddb308ae6d7 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -8,16 +8,14 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { buildModelAliasIndex, type ModelAliasIndex, - modelKey, resolveDefaultModelForAgent, - resolveModelRefFromString, } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; -import { resolveProfileOverride } from "./directive-handling.auth.js"; +import { resolveModelSelectionFromDirective } from "./directive-handling.model.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { enqueueModeSwitchEvents } from "./directive-handling.shared.js"; import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; @@ -64,7 +62,7 @@ export async function persistInlineDirectives(params: { const activeAgentId = sessionKey ? resolveSessionAgentId({ sessionKey, config: cfg }) : resolveDefaultAgentId(cfg); - const agentDir = resolveAgentDir(cfg, activeAgentId); + const agentDir = params.agentDir ?? resolveAgentDir(cfg, activeAgentId); if (sessionEntry && sessionStore && sessionKey) { const prevElevatedLevel = @@ -139,49 +137,40 @@ export async function persistInlineDirectives(params: { ? params.effectiveModelDirective : undefined; if (modelDirective) { - const resolved = resolveModelRefFromString({ - raw: modelDirective, + const modelResolution = resolveModelSelectionFromDirective({ + directives: { + ...directives, + hasModelDirective: true, + rawModelDirective: modelDirective, + }, + cfg, + agentDir, defaultProvider, + defaultModel, aliasIndex, + allowedModelKeys, + allowedModelCatalog: [], + provider, }); - if (resolved) { - const key = modelKey(resolved.ref.provider, resolved.ref.model); - if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { - let profileOverride: string | undefined; - if (directives.rawModelProfile) { - const profileResolved = resolveProfileOverride({ - rawProfile: directives.rawModelProfile, - provider: resolved.ref.provider, - cfg, - agentDir, - }); - if (profileResolved.error) { - throw new Error(profileResolved.error); - } - profileOverride = profileResolved.profileId; - } - const isDefault = - resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel; - const { updated: modelUpdated } = applyModelOverrideToSessionEntry({ - entry: sessionEntry, - selection: { - provider: resolved.ref.provider, - model: resolved.ref.model, - isDefault, - }, - profileOverride, - }); - provider = resolved.ref.provider; - model = resolved.ref.model; - const nextLabel = `${provider}/${model}`; - if (nextLabel !== initialModelLabel) { - enqueueSystemEvent(formatModelSwitchEvent(nextLabel, resolved.alias), { + if (modelResolution.modelSelection) { + const { updated: modelUpdated } = applyModelOverrideToSessionEntry({ + entry: sessionEntry, + selection: modelResolution.modelSelection, + profileOverride: modelResolution.profileOverride, + }); + provider = modelResolution.modelSelection.provider; + model = modelResolution.modelSelection.model; + const nextLabel = `${provider}/${model}`; + if (nextLabel !== initialModelLabel) { + enqueueSystemEvent( + formatModelSwitchEvent(nextLabel, modelResolution.modelSelection.alias), + { sessionKey, contextKey: `model:${nextLabel}`, - }); - } - updated = updated || modelUpdated; + }, + ); } + updated = updated || modelUpdated; } } if (directives.hasQueueDirective && directives.queueReset) { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 5c382a74aa9..fb43946a6b4 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1428,6 +1428,63 @@ describe("initSessionState preserves behavior overrides across /new and /reset", } }); + it("preserves selected auth profile overrides across /new and /reset", async () => { + const storePath = await createStorePath("openclaw-reset-model-auth-"); + const sessionKey = "agent:main:telegram:dm:user-model-auth"; + const existingSessionId = "existing-session-model-auth"; + const overrides = { + providerOverride: "openai", + modelOverride: "gpt-4o", + authProfileOverride: "20251001", + authProfileOverrideSource: "user", + authProfileOverrideCompactionCount: 2, + } as const; + const cases = [ + { + name: "new preserves selected auth profile overrides", + body: "/new", + }, + { + name: "reset preserves selected auth profile overrides", + body: "/reset", + }, + ] as const; + + for (const testCase of cases) { + await seedSessionStoreWithOverrides({ + storePath, + sessionKey, + sessionId: existingSessionId, + overrides: { ...overrides }, + }); + + const cfg = { + session: { store: storePath, idleMinutes: 999 }, + } as OpenClawConfig; + + const result = await initSessionState({ + ctx: { + Body: testCase.body, + RawBody: testCase.body, + CommandBody: testCase.body, + From: "user-model-auth", + To: "bot", + ChatType: "direct", + SessionKey: sessionKey, + Provider: "telegram", + Surface: "telegram", + }, + cfg, + commandAuthorized: true, + }); + + expect(result.isNewSession, testCase.name).toBe(true); + expect(result.resetTriggered, testCase.name).toBe(true); + expect(result.sessionId, testCase.name).not.toBe(existingSessionId); + expect(result.sessionEntry, testCase.name).toMatchObject(overrides); + } + }); + it("archives the old session store entry on /new", async () => { const storePath = await createStorePath("openclaw-archive-old-"); const sessionKey = "agent:main:telegram:dm:user-archive"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index a2c0b1c7cf4..f6f5d3bfdfa 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -217,6 +217,9 @@ export async function initSessionState(params: { let persistedTtsAuto: TtsAutoMode | undefined; let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; + let persistedAuthProfileOverride: string | undefined; + let persistedAuthProfileOverrideSource: SessionEntry["authProfileOverrideSource"]; + let persistedAuthProfileOverrideCompactionCount: number | undefined; let persistedLabel: string | undefined; const normalizedChatType = normalizeChatType(ctx.ChatType); @@ -353,6 +356,9 @@ export async function initSessionState(params: { persistedTtsAuto = entry.ttsAuto; persistedModelOverride = entry.modelOverride; persistedProviderOverride = entry.providerOverride; + persistedAuthProfileOverride = entry.authProfileOverride; + persistedAuthProfileOverrideSource = entry.authProfileOverrideSource; + persistedAuthProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount; persistedLabel = entry.label; } else { sessionId = crypto.randomUUID(); @@ -369,6 +375,9 @@ export async function initSessionState(params: { persistedTtsAuto = entry.ttsAuto; persistedModelOverride = entry.modelOverride; persistedProviderOverride = entry.providerOverride; + persistedAuthProfileOverride = entry.authProfileOverride; + persistedAuthProfileOverrideSource = entry.authProfileOverrideSource; + persistedAuthProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount; persistedLabel = entry.label; } } @@ -420,6 +429,11 @@ export async function initSessionState(params: { responseUsage: baseEntry?.responseUsage, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, + authProfileOverride: persistedAuthProfileOverride ?? baseEntry?.authProfileOverride, + authProfileOverrideSource: + persistedAuthProfileOverrideSource ?? baseEntry?.authProfileOverrideSource, + authProfileOverrideCompactionCount: + persistedAuthProfileOverrideCompactionCount ?? baseEntry?.authProfileOverrideCompactionCount, label: persistedLabel ?? baseEntry?.label, sendPolicy: baseEntry?.sendPolicy, queueMode: baseEntry?.queueMode,