From e011e9db61718b43707efdd19ce4eb3dbc3ed08c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 18 Aug 2026 09:24:15 -0700 Subject: [PATCH] fix: child sessions honor inherited model pins over channel defaults (#125880) * fix: honor inherited model pins over channel defaults Child sessions now keep parent model pins consistently across reply, status, harness prediction, and agentCommand. * test: align agent command model selection fixtures Keep concurrent override and threaded parent-selection coverage aligned with canonical model normalization and runtime snapshot access. --- .../agent-command.live-model-switch.test.ts | 9 +- src/agents/command/model-selection.ts | 26 +++-- src/agents/model-selection-persisted.ts | 56 ++++++++++ src/agents/model-selection.ts | 53 ++-------- .../dispatch-from-config.harness-defaults.ts | 2 +- .../reply/get-reply-native-slash-fast-path.ts | 36 ++++++- .../reply/get-reply-run.media-only.test.ts | 2 +- src/auto-reply/reply/get-reply.ts | 12 +-- src/auto-reply/reply/model-selection.ts | 7 +- src/auto-reply/reply/stored-model-override.ts | 100 +----------------- src/plugin-sdk/command-auth-native.ts | 2 +- src/plugin-sdk/command-auth.ts | 4 +- .../stored-model-overrides.test.ts} | 2 +- src/sessions/stored-model-overrides.ts | 98 +++++++++++++++++ .../turn-model-selection-differential.ts | 8 +- 15 files changed, 235 insertions(+), 182 deletions(-) create mode 100644 src/agents/model-selection-persisted.ts rename src/{auto-reply/reply/stored-model-override.test.ts => sessions/stored-model-overrides.test.ts} (99%) create mode 100644 src/sessions/stored-model-overrides.ts diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index c5e1bb447526..a7a619ca9186 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -378,6 +378,7 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ })); vi.mock("../config/runtime-snapshot.js", () => ({ + getRuntimeConfigSnapshot: () => state.runtimeConfigMock ?? state.defaultRuntimeConfig, registerRuntimeConfigSnapshotPreparer: vi.fn(), setRuntimeConfigSnapshot: vi.fn(), })); @@ -2039,7 +2040,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { models: { "anthropic/default-model": {}, "anthropic/stale-fallback-model": {}, - "google/gemini-3-pro": {}, + "google/user-model": {}, }, }, }, @@ -2064,17 +2065,17 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { ...sessionEntry, updatedAt: 2, providerOverride: "google", - modelOverride: "gemini-3-pro", + modelOverride: "user-model", modelOverrideSource: "user", }; }); - state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("google", "gemini-3-pro")); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("google", "user-model")); await runBasicAgentCommand(); const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams; expect(fallbackParams.provider).toBe("google"); - expect(fallbackParams.model).toBe("gemini-3-pro"); + expect(fallbackParams.model).toBe("user-model"); }); it("probes the channel primary when a session is pinned to an auto fallback", async () => { diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index b2dff2947786..05c64d8766f2 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -6,7 +6,6 @@ import { type ThinkLevel, } from "../../auto-reply/thinking.js"; import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; -import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { requireActivePluginRegistry } from "../../plugins/runtime.js"; @@ -18,6 +17,7 @@ import { isModelSelectionLocked, repairProviderWrappedModelOverride, } from "../../sessions/model-overrides.js"; +import { resolveStoredModelOverride } from "../../sessions/stored-model-overrides.js"; import { sessionDeliveryChannel, sessionDeliveryOrigin, @@ -116,9 +116,6 @@ export async function resolveEmbeddedModelSelection(params: { let sessionEntry = params.sessionEntry; const hasStoredOverride = Boolean(sessionEntry?.modelOverride || sessionEntry?.providerOverride); let storedModelOverrideSource = hasStoredOverride ? sessionEntry?.modelOverrideSource : undefined; - let storedModelOverrideRouteResolution = hasStoredOverride - ? resolveSessionModelOverrideRouteResolution(sessionEntry) - : undefined; let hasStoredAutoFallbackProvenance = hasStoredOverride && hasSessionAutoModelFallbackProvenance(sessionEntry); let hasLegacyAutoFallbackOverrideWithoutOrigin = @@ -232,9 +229,6 @@ export async function resolveEmbeddedModelSelection(params: { storedModelOverrideSource = adoptedHasStoredOverride ? sessionEntry?.modelOverrideSource : undefined; - storedModelOverrideRouteResolution = adoptedHasStoredOverride - ? resolveSessionModelOverrideRouteResolution(sessionEntry) - : undefined; hasStoredAutoFallbackProvenance = adoptedHasStoredOverride && hasSessionAutoModelFallbackProvenance(sessionEntry); hasLegacyAutoFallbackOverrideWithoutOrigin = @@ -246,12 +240,26 @@ export async function resolveEmbeddedModelSelection(params: { hasLegacyAutoFallbackOverrideWithoutOrigin = false; } + const effectiveStoredOverride = hasLegacyAutoFallbackOverrideWithoutOrigin + ? null + : resolveStoredModelOverride({ + sessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + parentSessionKey: sessionEntry?.parentSessionKey, + defaultProvider, + }); + if (effectiveStoredOverride?.source === "parent") { + storedModelOverrideSource = undefined; + hasStoredAutoFallbackProvenance = false; + } const storedProviderOverride = hasLegacyAutoFallbackOverrideWithoutOrigin ? undefined - : sessionEntry?.providerOverride?.trim(); + : (effectiveStoredOverride?.provider ?? sessionEntry?.providerOverride?.trim()); const storedModelOverride = hasLegacyAutoFallbackOverrideWithoutOrigin ? undefined - : sessionEntry?.modelOverride?.trim(); + : (effectiveStoredOverride?.model ?? sessionEntry?.modelOverride?.trim()); + const storedModelOverrideRouteResolution = effectiveStoredOverride?.routeResolution; const currentRunModelChannel = [ params.runContext.messageChannel, params.opts.replyChannel, diff --git a/src/agents/model-selection-persisted.ts b/src/agents/model-selection-persisted.ts new file mode 100644 index 000000000000..788975a147de --- /dev/null +++ b/src/agents/model-selection-persisted.ts @@ -0,0 +1,56 @@ +// Persisted model metadata normalization without loading the broader selection runtime. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { DEFAULT_PROVIDER } from "./defaults.js"; +import type { ModelRef } from "./model-ref-shared.js"; +import { parseModelRef } from "./model-selection-normalize.js"; + +function normalizePersistedDefaultProvider(value: unknown): string { + return normalizeOptionalString(value) ?? DEFAULT_PROVIDER; +} + +export function resolvePersistedOverrideModelRef(params: { + defaultProvider?: unknown; + overrideProvider?: unknown; + overrideModel?: unknown; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; +}): ModelRef | null { + const defaultProvider = normalizePersistedDefaultProvider(params.defaultProvider); + const overrideProvider = normalizeOptionalString(params.overrideProvider); + const overrideModel = normalizeOptionalString(params.overrideModel); + if (!overrideModel) { + return null; + } + const encodedOverride = overrideProvider ? `${overrideProvider}/${overrideModel}` : overrideModel; + return ( + parseModelRef(encodedOverride, defaultProvider, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }) ?? { + provider: overrideProvider || defaultProvider, + model: overrideModel, + } + ); +} + +export function normalizeStoredOverrideModel(params: { + providerOverride?: unknown; + modelOverride?: unknown; +}): { providerOverride?: string; modelOverride?: string } { + const providerOverride = normalizeOptionalString(params.providerOverride); + const modelOverride = normalizeOptionalString(params.modelOverride); + if (!providerOverride || !modelOverride) { + return { + providerOverride, + modelOverride, + }; + } + + const providerPrefix = `${providerOverride.toLowerCase()}/`; + return { + providerOverride, + modelOverride: modelOverride.toLowerCase().startsWith(providerPrefix) + ? modelOverride.slice(providerOverride.length + 1).trim() || modelOverride + : modelOverride, + }; +} diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index c75e7dd48c8c..47593fe2df33 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -26,6 +26,7 @@ import { resolveSubagentConfiguredModelSelection, } from "./model-selection-config.js"; import { findNormalizedProviderValue, parseModelRef } from "./model-selection-normalize.js"; +import { resolvePersistedOverrideModelRef } from "./model-selection-persisted.js"; import { resolveAllowedModelRefCore as resolveAllowedModelRefInternal, resolveConfiguredModelFallbacks, @@ -52,6 +53,11 @@ export type { ModelAliasIndex, ModelManifestNormalizationContext, ModelRef }; export { resolveDefaultModelForAgent, resolveSubagentConfiguredModelSelection }; +export { + normalizeStoredOverrideModel, + resolvePersistedOverrideModelRef, +} from "./model-selection-persisted.js"; + export { buildConfiguredModelCatalog, buildModelAliasIndex, @@ -83,31 +89,6 @@ function normalizePersistedDefaultProvider(value: unknown): string { return normalizeOptionalString(value) ?? DEFAULT_PROVIDER; } -export function resolvePersistedOverrideModelRef(params: { - defaultProvider?: unknown; - overrideProvider?: unknown; - overrideModel?: unknown; - allowManifestNormalization?: boolean; - allowPluginNormalization?: boolean; -}): ModelRef | null { - const defaultProvider = normalizePersistedDefaultProvider(params.defaultProvider); - const overrideProvider = normalizeOptionalString(params.overrideProvider); - const overrideModel = normalizeOptionalString(params.overrideModel); - if (!overrideModel) { - return null; - } - const encodedOverride = overrideProvider ? `${overrideProvider}/${overrideModel}` : overrideModel; - return ( - parseModelRef(encodedOverride, defaultProvider, { - allowManifestNormalization: params.allowManifestNormalization, - allowPluginNormalization: params.allowPluginNormalization, - }) ?? { - provider: overrideProvider || defaultProvider, - model: overrideModel, - } - ); -} - /** * Runtime-first resolver for persisted model metadata. * Use this when callers intentionally want the last executed model identity. @@ -180,28 +161,6 @@ export function resolvePersistedSelectedModelRef(params: { }); } -export function normalizeStoredOverrideModel(params: { - providerOverride?: unknown; - modelOverride?: unknown; -}): { providerOverride?: string; modelOverride?: string } { - const providerOverride = normalizeOptionalString(params.providerOverride); - const modelOverride = normalizeOptionalString(params.modelOverride); - if (!providerOverride || !modelOverride) { - return { - providerOverride, - modelOverride, - }; - } - - const providerPrefix = `${providerOverride.toLowerCase()}/`; - return { - providerOverride, - modelOverride: modelOverride.toLowerCase().startsWith(providerPrefix) - ? modelOverride.slice(providerOverride.length + 1).trim() || modelOverride - : modelOverride, - }; -} - export async function canonicalizeCaseOnlyCatalogModelRef(params: { raw: string | undefined; cfg?: OpenClawConfig; diff --git a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts index c318fa2dba58..ce54069bf115 100644 --- a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts +++ b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts @@ -13,6 +13,7 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { resolveStoredModelOverride } from "../../sessions/stored-model-overrides.js"; import { sessionDeliveryChannel, sessionDeliveryOrigin, @@ -24,7 +25,6 @@ import { loadSessionStoreEntry, resolveSessionStorePathCore, } from "./dispatch-from-config.runtime.js"; -import { resolveStoredModelOverride } from "./stored-model-override.js"; type HarnessSourceVisibleRepliesDefault = "automatic" | "message_tool"; diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index 63f3203e1f22..55b0b62e7720 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -10,6 +10,7 @@ import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import type { OpenClawConfig } from "../../config/config.js"; import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; import { recordSessionCreated } from "../../sessions/session-state-events.js"; +import { resolveStoredModelOverride } from "../../sessions/stored-model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { SkillCommandSpec } from "../../skills/types.js"; import { @@ -202,13 +203,27 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { if (command.commandBodyNormalized === "/status") { const targetSessionEntry = sessionState.sessionStore[sessionState.sessionKey] ?? sessionState.sessionEntry; + const canApplyStoredModel = + params.provider === params.defaultProvider && params.model === params.defaultModel; + const storedModelOverride = canApplyStoredModel + ? resolveStoredModelOverride({ + sessionEntry: targetSessionEntry, + sessionStore: sessionState.sessionStore, + sessionKey: sessionState.sessionKey, + parentSessionKey: + targetSessionEntry?.parentSessionKey ?? + params.ctx.ModelParentSessionKey ?? + params.ctx.ParentSessionKey, + defaultProvider: params.defaultProvider, + }) + : null; const canApplyChannelModel = params.cfg.channels?.modelByChannel && !isModelSelectionLocked(targetSessionEntry) && + !storedModelOverride && !normalizeOptionalString(targetSessionEntry?.modelOverride) && !normalizeOptionalString(targetSessionEntry?.providerOverride) && - params.provider === params.defaultProvider && - params.model === params.defaultModel; + canApplyStoredModel; const deliveryChannel = normalizeMessageChannel(sessionDeliveryChannel(targetSessionEntry)); // Shared sessions can retain another channel's peer; never let that stale // identity outrank the authorized current command's live sender. @@ -245,10 +260,23 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { aliasIndex: params.aliasIndex, }) : null; + const resolvedInheritedModel = + storedModelOverride?.source === "parent" + ? (resolveModelRefFromString({ + raw: `${storedModelOverride.provider ?? params.defaultProvider}/${storedModelOverride.model}`, + defaultProvider: params.defaultProvider, + aliasIndex: params.aliasIndex, + })?.ref ?? { + provider: storedModelOverride.provider ?? params.defaultProvider, + model: storedModelOverride.model, + }) + : null; // Native status returns before normal channel routing; select once before // preparing model-bound thinking, runtime, auth, context, or fast-mode facts. - const statusProvider = resolvedChannelModel?.ref.provider ?? params.provider; - const statusModel = resolvedChannelModel?.ref.model ?? params.model; + const statusProvider = + resolvedInheritedModel?.provider ?? resolvedChannelModel?.ref.provider ?? params.provider; + const statusModel = + resolvedInheritedModel?.model ?? resolvedChannelModel?.ref.model ?? params.model; let resolvedDefaultThinkingLevel: ThinkLevel | undefined; const resolveDefaultThinkingLevel = async () => { resolvedDefaultThinkingLevel ??= await resolveNativeSlashDefaultThinkingLevel({ diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 5d76c12fe353..27c7d7c8e350 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -296,7 +296,7 @@ vi.mock("./session-system-events.js", () => ({ drainFormattedSystemEvents: vi.fn().mockResolvedValue(undefined), })); -vi.mock("./stored-model-override.js", () => ({ +vi.mock("../../sessions/stored-model-overrides.js", () => ({ resolveStoredModelOverride: vi.fn( (params: { sessionEntry?: { providerOverride?: string; modelOverride?: string }; diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index c63354f0ebb8..0b4cced04628 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -37,6 +37,7 @@ import { ModelSelectionLockedError, } from "../../sessions/model-overrides.js"; import { ensureSessionDiffBaseline } from "../../sessions/session-diff-baseline.js"; +import { resolveStoredModelOverride } from "../../sessions/stored-model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { sessionDeliveryChannel, @@ -88,10 +89,7 @@ import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js" import { initSessionState, resolveReplySessionPreprocessingState } from "./session.js"; import { mergeSkillFilters } from "./skill-filter.js"; import { stageRemoteInboundMediaIfNeeded } from "./stage-remote-inbound-media.js"; -import { - isStaleHeartbeatAutoFallbackOverride, - resolveStoredModelOverride, -} from "./stored-model-override.js"; +import { isStaleHeartbeatAutoFallbackOverride } from "./stored-model-override.js"; import { createTypingController } from "./typing.js"; type ResetCommandAction = "new" | "reset"; @@ -840,13 +838,13 @@ export async function getReplyFromConfig( primaryModel, }) : undefined; - const hasEffectiveSessionModelOverride = - hasSessionModelOverride && + const hasEffectiveStoredModelOverride = + Boolean(storedModelOverride || hasSessionModelOverride) && !staleHeartbeatAutoFallbackOverride && !staleLegacyAutoFallbackWithoutOrigin; if ( !hasResolvedHeartbeatModelOverride && - !hasEffectiveSessionModelOverride && + !hasEffectiveStoredModelOverride && resolvedChannelModelOverride ) { provider = resolvedChannelModelOverride.ref.provider; diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 93fcc6067302..1ad8b07929be 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -39,14 +39,13 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isDiagnosticFlagEnabled } from "../../infra/diagnostic-flags.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; +import * as storedModelOverrides from "../../sessions/stored-model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeThinkLevel, type ThinkLevel } from "../thinking.shared.js"; import { normalizeRuntimeRef, resolveRuntimeNormalization } from "./model-runtime-normalization.js"; import { isStaleHeartbeatAutoFallbackOverride, normalizeStoredRuntimeModelRef, - resolveDirectStoredModelOverride, - resolveStoredModelOverride, } from "./stored-model-override.js"; export { resolveModelDirectiveSelection, @@ -244,7 +243,7 @@ export async function createModelSelectionState(params: { let resetModelOverride = false; let resetModelOverrideRef: string | undefined; let resetModelOverrideReason: "disallowed" | "stale" | "temporarily-unavailable" | undefined; - const directStoredModelOverride = resolveDirectStoredModelOverride({ + const directStoredModelOverride = storedModelOverrides.resolveDirectStoredModelOverride({ sessionEntry, defaultProvider, }); @@ -424,7 +423,7 @@ export async function createModelSelectionState(params: { } } - const storedOverride = resolveStoredModelOverride({ + const storedOverride = storedModelOverrides.resolveStoredModelOverride({ sessionEntry, sessionStore, sessionKey, diff --git a/src/auto-reply/reply/stored-model-override.ts b/src/auto-reply/reply/stored-model-override.ts index 46b47aa41734..3788377f3211 100644 --- a/src/auto-reply/reply/stored-model-override.ts +++ b/src/auto-reply/reply/stored-model-override.ts @@ -1,52 +1,18 @@ -// Persists and resolves per-session model override choices. +// Normalizes stored reply models and detects stale heartbeat fallback pins. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js"; import { resolveCliRuntimeCanonicalProvider } from "../../agents/cli-backends.js"; -import type { ModelFallbackRouteResolution } from "../../agents/model-fallback.types.js"; import { - modelKey, - normalizeModelRef, normalizeStoredOverrideModel, resolvePersistedOverrideModelRef, -} from "../../agents/model-selection.js"; +} from "../../agents/model-selection-persisted.js"; +import { modelKey, normalizeModelRef } from "../../agents/model-selection.js"; import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION } from "../../agents/model-visibility-policy.js"; -import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js"; -import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { StoredModelOverride } from "../../sessions/stored-model-overrides.js"; import type { RuntimeModelNormalization } from "./model-runtime-normalization.js"; -/** Model override loaded from the current session or its parent session. */ -export type StoredModelOverride = { - provider?: string; - model: string; - source: "session" | "parent"; - routeResolution: ModelFallbackRouteResolution; -}; - -/** Resolves only the current session's persisted model override. */ -export function resolveDirectStoredModelOverride(params: { - sessionEntry?: SessionEntry; - defaultProvider: string; -}): StoredModelOverride | null { - const normalized = normalizeStoredOverrideModel({ - providerOverride: params.sessionEntry?.providerOverride, - modelOverride: params.sessionEntry?.modelOverride, - }); - const direct = resolvePersistedOverrideModelRef({ - defaultProvider: params.defaultProvider, - overrideProvider: normalized.providerOverride, - overrideModel: normalized.modelOverride, - }); - return direct - ? { - ...direct, - source: "session", - routeResolution: resolveSessionModelOverrideRouteResolution(params.sessionEntry), - } - : null; -} - /** Normalizes a stored model ref, resolving runtime aliases only for CLI-bound sessions. */ export function normalizeStoredRuntimeModelRef( provider: string, @@ -69,64 +35,6 @@ export function normalizeStoredRuntimeModelRef( return canonicalProvider ? { ...normalized, provider: canonicalProvider } : normalized; } -function resolveParentSessionKeyCandidate(params: { - sessionKey?: string; - parentSessionKey?: string; -}): string | null { - const explicit = normalizeOptionalString(params.parentSessionKey); - if (explicit && explicit !== params.sessionKey) { - return explicit; - } - const derived = resolveSessionParentSessionKey(params.sessionKey); - if (derived && derived !== params.sessionKey) { - return derived; - } - return null; -} - -/** Resolves the persisted model override visible to the current session. */ -export function resolveStoredModelOverride(params: { - loadSessionEntry?: (sessionKey: string) => SessionEntry | undefined; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - parentSessionKey?: string; - defaultProvider: string; -}): StoredModelOverride | null { - const direct = resolveDirectStoredModelOverride({ - sessionEntry: params.sessionEntry, - defaultProvider: params.defaultProvider, - }); - if (direct) { - return direct; - } - const parentKey = resolveParentSessionKeyCandidate({ - sessionKey: params.sessionKey, - parentSessionKey: params.parentSessionKey, - }); - if (!parentKey) { - return null; - } - const parentEntry = params.loadSessionEntry?.(parentKey) ?? params.sessionStore?.[parentKey]; - const normalizedParentOverride = normalizeStoredOverrideModel({ - providerOverride: parentEntry?.providerOverride, - modelOverride: parentEntry?.modelOverride, - }); - const parentOverride = resolvePersistedOverrideModelRef({ - defaultProvider: params.defaultProvider, - overrideProvider: normalizedParentOverride.providerOverride, - overrideModel: normalizedParentOverride.modelOverride, - }); - if (!parentOverride) { - return null; - } - return { - ...parentOverride, - source: "parent", - routeResolution: resolveSessionModelOverrideRouteResolution(parentEntry), - }; -} - function resolveModelRefKey(params: { defaultProvider: string; overrideProvider?: string; diff --git a/src/plugin-sdk/command-auth-native.ts b/src/plugin-sdk/command-auth-native.ts index 730446979c03..dcc349d9e430 100644 --- a/src/plugin-sdk/command-auth-native.ts +++ b/src/plugin-sdk/command-auth-native.ts @@ -36,7 +36,7 @@ export { resolveCommandAuthorization, type CommandAuthorization, } from "../auto-reply/command-auth.js"; -export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; +export { resolveStoredModelOverride } from "../sessions/stored-model-overrides.js"; export { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js"; export { formatFastModeCommandOptions, diff --git a/src/plugin-sdk/command-auth.ts b/src/plugin-sdk/command-auth.ts index 25342f6aaf9b..17b50ba82134 100644 --- a/src/plugin-sdk/command-auth.ts +++ b/src/plugin-sdk/command-auth.ts @@ -101,8 +101,8 @@ export { resolveModelsCommandReply, } from "../auto-reply/reply/commands-models.js"; export type { ModelsProviderData } from "../auto-reply/reply/commands-models.js"; -export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; -export type { StoredModelOverride } from "../auto-reply/reply/stored-model-override.js"; +export { resolveStoredModelOverride } from "../sessions/stored-model-overrides.js"; +export type { StoredModelOverride } from "../sessions/stored-model-overrides.js"; /** * Inputs for legacy sender command authorization. diff --git a/src/auto-reply/reply/stored-model-override.test.ts b/src/sessions/stored-model-overrides.test.ts similarity index 99% rename from src/auto-reply/reply/stored-model-override.test.ts rename to src/sessions/stored-model-overrides.test.ts index b43ca97de053..22631d1405a2 100644 --- a/src/auto-reply/reply/stored-model-override.test.ts +++ b/src/sessions/stored-model-overrides.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveStoredModelOverride } from "./stored-model-override.js"; +import { resolveStoredModelOverride } from "./stored-model-overrides.js"; describe("resolveStoredModelOverride", () => { it("recovers resolved provenance for legacy auto-fallback overrides", () => { diff --git a/src/sessions/stored-model-overrides.ts b/src/sessions/stored-model-overrides.ts new file mode 100644 index 000000000000..d9291cae7005 --- /dev/null +++ b/src/sessions/stored-model-overrides.ts @@ -0,0 +1,98 @@ +// Resolves persisted per-session model choices across child and parent sessions. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { ModelFallbackRouteResolution } from "../agents/model-fallback.types.js"; +import { + normalizeStoredOverrideModel, + resolvePersistedOverrideModelRef, +} from "../agents/model-selection-persisted.js"; +import { resolveSessionParentSessionKey } from "../channels/plugins/session-conversation.js"; +import { resolveSessionModelOverrideRouteResolution } from "../config/sessions/model-override-provenance.js"; +import type { SessionEntry } from "../config/sessions/types.js"; + +/** Model override loaded from the current session or its parent session. */ +export type StoredModelOverride = { + provider?: string; + model: string; + source: "session" | "parent"; + routeResolution: ModelFallbackRouteResolution; +}; + +function resolveStoredOverrideFromEntry(params: { + entry?: SessionEntry; + defaultProvider: string; + source: StoredModelOverride["source"]; +}): StoredModelOverride | null { + const normalized = normalizeStoredOverrideModel({ + providerOverride: params.entry?.providerOverride, + modelOverride: params.entry?.modelOverride, + }); + const ref = resolvePersistedOverrideModelRef({ + defaultProvider: params.defaultProvider, + overrideProvider: normalized.providerOverride, + overrideModel: normalized.modelOverride, + }); + return ref + ? { + ...ref, + source: params.source, + routeResolution: resolveSessionModelOverrideRouteResolution(params.entry), + } + : null; +} + +/** Resolves only the current session's persisted model override. */ +export function resolveDirectStoredModelOverride(params: { + sessionEntry?: SessionEntry; + defaultProvider: string; +}): StoredModelOverride | null { + return resolveStoredOverrideFromEntry({ + entry: params.sessionEntry, + defaultProvider: params.defaultProvider, + source: "session", + }); +} + +function resolveParentSessionKeyCandidate(params: { + sessionKey?: string; + parentSessionKey?: string; +}): string | null { + const explicit = normalizeOptionalString(params.parentSessionKey); + if (explicit && explicit !== params.sessionKey) { + return explicit; + } + const derived = resolveSessionParentSessionKey(params.sessionKey); + if (derived && derived !== params.sessionKey) { + return derived; + } + return null; +} + +/** Resolves the persisted model override visible to the current session. */ +export function resolveStoredModelOverride(params: { + loadSessionEntry?: (sessionKey: string) => SessionEntry | undefined; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + parentSessionKey?: string; + defaultProvider: string; +}): StoredModelOverride | null { + const direct = resolveDirectStoredModelOverride({ + sessionEntry: params.sessionEntry, + defaultProvider: params.defaultProvider, + }); + if (direct) { + return direct; + } + const parentKey = resolveParentSessionKeyCandidate({ + sessionKey: params.sessionKey, + parentSessionKey: params.parentSessionKey, + }); + if (!parentKey) { + return null; + } + return resolveStoredOverrideFromEntry({ + entry: params.loadSessionEntry?.(parentKey) ?? params.sessionStore?.[parentKey], + defaultProvider: params.defaultProvider, + source: "parent", + }); +} diff --git a/src/test-utils/turn-model-selection-differential.ts b/src/test-utils/turn-model-selection-differential.ts index 0a16d87976b6..99ab4d9f7b18 100644 --- a/src/test-utils/turn-model-selection-differential.ts +++ b/src/test-utils/turn-model-selection-differential.ts @@ -260,13 +260,11 @@ export const TURN_MODEL_DIFFERENTIAL_FIXTURES: TurnModelDifferentialFixture[] = entry: createTurnModelEntry({ sessionId: "parent-session", override: TURN_MODEL_PARENT_REF }), }, modelByChannel: { telegram: { "*": turnModelRefLabel(TURN_MODEL_CHANNEL_REF) } }, - // OBSERVED, not endorsed: reply's child-only stored gate lets channel replace the - // resolved parent. Harness keeps parent; command does not inherit it. Product decision is separate. expected: { - reply: turnModelVerdict(TURN_MODEL_CHANNEL_REF), - status: turnModelVerdict(TURN_MODEL_CHANNEL_REF), + reply: turnModelVerdict(TURN_MODEL_PARENT_REF), + status: turnModelVerdict(TURN_MODEL_PARENT_REF), harness: turnModelVerdict(TURN_MODEL_PARENT_REF), - command: turnModelVerdict(TURN_MODEL_CHANNEL_REF), + command: turnModelVerdict(TURN_MODEL_PARENT_REF), }, }, ];