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.
This commit is contained in:
Peter Steinberger
2026-08-18 09:24:15 -07:00
committed by GitHub
parent e68e973c05
commit e011e9db61
15 changed files with 235 additions and 182 deletions
@@ -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 () => {
+17 -9
View File
@@ -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,
+56
View File
@@ -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,
};
}
+6 -47
View File
@@ -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;
@@ -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";
@@ -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({
@@ -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 };
+5 -7
View File
@@ -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;
+3 -4
View File
@@ -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,
+4 -96
View File
@@ -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<string, SessionEntry>;
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;
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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.
@@ -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", () => {
+98
View File
@@ -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<string, SessionEntry>;
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",
});
}
@@ -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),
},
},
];