mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:54:47 +00:00
fix fallback provenance across reloads (#82363)
This commit is contained in:
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Trajectory export: skip and report malformed session/runtime JSONL rows in `manifest.json` instead of letting wrong-shaped session rows crash support bundle export.
|
||||
- Config/doctor: copy fallback-enabled channel `allowFrom` entries into explicit `groupAllowFrom` allowlists during `openclaw doctor --fix`, preserving current group access without adding runtime fallback-transition flags.
|
||||
- Configure: show one OpenAI provider entry with ChatGPT/Codex sign-in and API key choices, and keep browsed Codex models in the saved `/model` picker allowlist.
|
||||
- Agents/model fallback: preserve auto fallback chains across deferred config reloads when session fallback provenance survives but `modelOverrideSource` is missing. Fixes #81982. Thanks @joshavant.
|
||||
- Hooks: raise bounded gateway lifecycle hook wait budgets to 5 seconds for shutdown and 10 seconds for pre-restart, giving short restart notification handlers time to finish before shutdown continues. (#82273) Thanks @bryanbaer.
|
||||
- Plugin releases: require external package compatibility metadata in the npm plugin publish plan, matching the ClawHub package contract before packages ship.
|
||||
- Agents/OpenAI-compatible: honor per-model `max_completion_tokens`/`max_tokens` params in embedded OpenAI-completions runs so high-token Kimi-style routes keep their configured completion cap. Fixes #82230. Thanks @albert-zen.
|
||||
|
||||
@@ -266,6 +266,7 @@ vi.mock("../utils/message-channel.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./agent-scope.js", () => ({
|
||||
hasSessionAutoModelFallbackProvenance: () => false,
|
||||
listAgentEntries: () => [],
|
||||
listAgentIds: () => ["default"],
|
||||
resolveAgentConfig: () => undefined,
|
||||
|
||||
@@ -39,6 +39,7 @@ import { createTrajectoryRuntimeRecorder } from "../trajectory/runtime.js";
|
||||
import { resolveMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveAgentRuntimeConfig } from "./agent-runtime-config.js";
|
||||
import {
|
||||
hasSessionAutoModelFallbackProvenance,
|
||||
listAgentIds,
|
||||
resolveAgentDir,
|
||||
resolveEffectiveModelFallbacks,
|
||||
@@ -748,6 +749,8 @@ async function agentCommandInternal(
|
||||
let storedModelOverrideSource = hasStoredOverride
|
||||
? sessionEntry?.modelOverrideSource
|
||||
: undefined;
|
||||
const hasStoredAutoFallbackProvenance =
|
||||
hasStoredOverride && hasSessionAutoModelFallbackProvenance(sessionEntry);
|
||||
const explicitProviderOverride =
|
||||
typeof opts.provider === "string"
|
||||
? normalizeExplicitOverrideInput(opts.provider, "provider")
|
||||
@@ -1032,6 +1035,9 @@ async function agentCommandInternal(
|
||||
hasSessionModelOverride:
|
||||
hasExplicitRunOverride || Boolean(storedProviderOverride || storedModelOverride),
|
||||
modelOverrideSource: hasExplicitRunOverride ? "user" : storedModelOverrideSource,
|
||||
hasAutoFallbackProvenance: hasExplicitRunOverride
|
||||
? false
|
||||
: hasStoredAutoFallbackProvenance,
|
||||
});
|
||||
|
||||
let fallbackAttemptIndex = 0;
|
||||
|
||||
@@ -324,6 +324,23 @@ describe("resolveAgentConfig", () => {
|
||||
hasSessionModelOverride: true,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg,
|
||||
agentId: "linus",
|
||||
hasSessionModelOverride: true,
|
||||
hasAutoFallbackProvenance: true,
|
||||
}),
|
||||
).toEqual(["openai/gpt-5.4"]);
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg,
|
||||
agentId: "linus",
|
||||
hasSessionModelOverride: true,
|
||||
modelOverrideSource: "user",
|
||||
hasAutoFallbackProvenance: true,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
expect(
|
||||
resolveEffectiveModelFallbacks({
|
||||
cfg: cfgNoOverride,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
|
||||
export { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
|
||||
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
||||
import type { AgentModelConfig } from "../config/types.agents-shared.js";
|
||||
import type { AgentConfig } from "../config/types.agents.js";
|
||||
@@ -271,12 +272,16 @@ export function resolveEffectiveModelFallbacks(params: {
|
||||
agentId: string;
|
||||
hasSessionModelOverride: boolean;
|
||||
modelOverrideSource?: "auto" | "user";
|
||||
hasAutoFallbackProvenance?: boolean;
|
||||
}): string[] | undefined {
|
||||
const agentFallbacksOverride = resolveAgentModelFallbacksOverride(params.cfg, params.agentId);
|
||||
if (!params.hasSessionModelOverride) {
|
||||
return agentFallbacksOverride;
|
||||
}
|
||||
if (params.modelOverrideSource !== "auto") {
|
||||
const canUseConfiguredFallbacks =
|
||||
params.modelOverrideSource === "auto" ||
|
||||
(params.modelOverrideSource === undefined && params.hasAutoFallbackProvenance === true);
|
||||
if (!canUseConfiguredFallbacks) {
|
||||
return [];
|
||||
}
|
||||
const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
|
||||
|
||||
@@ -3959,8 +3959,8 @@ describe("runAgentTurnWithFallback", () => {
|
||||
});
|
||||
|
||||
const followupRun = createFollowupRun();
|
||||
followupRun.run.provider = "anthropic";
|
||||
followupRun.run.model = "claude-opus-4-6";
|
||||
followupRun.run.provider = "bailian";
|
||||
followupRun.run.model = "qwen3.6-plus";
|
||||
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
@@ -4007,6 +4007,71 @@ describe("runAgentTurnWithFallback", () => {
|
||||
expect(sessionEntry.modelOverrideSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists fallback selection for recovered auto overrides without modelOverrideSource", async () => {
|
||||
state.runWithModelFallbackMock.mockImplementation(
|
||||
async (params: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await params.run("openai-codex", "gpt-5.4"),
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
attempts: [],
|
||||
}),
|
||||
);
|
||||
state.runEmbeddedPiAgentMock.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const followupRun = createFollowupRun();
|
||||
followupRun.run.provider = "anthropic";
|
||||
followupRun.run.model = "claude-opus-4-6";
|
||||
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
totalTokens: 1,
|
||||
compactionCount: 0,
|
||||
providerOverride: "bailian",
|
||||
modelOverride: "qwen3.6-plus",
|
||||
modelOverrideFallbackOriginProvider: "minimax",
|
||||
modelOverrideFallbackOriginModel: "MiniMax-M2.7",
|
||||
// modelOverrideSource intentionally absent
|
||||
};
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback({
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
sessionCtx: {
|
||||
Provider: "telegram",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
opts: {},
|
||||
typingSignals: createMockTypingSignaler(),
|
||||
blockReplyPipeline: null,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
applyReplyToMode: (payload) => payload,
|
||||
shouldEmitToolResult: () => true,
|
||||
shouldEmitToolOutput: () => false,
|
||||
pendingToolTasks: new Set(),
|
||||
resetSessionAfterCompactionFailure: async () => false,
|
||||
resetSessionAfterRoleOrderingConflict: async () => false,
|
||||
isHeartbeat: false,
|
||||
sessionKey: "main",
|
||||
getActiveSessionEntry: () => sessionEntry,
|
||||
activeSessionStore: sessionStore,
|
||||
resolvedVerboseLevel: "off",
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("success");
|
||||
expect(sessionEntry.providerOverride).toBe("openai-codex");
|
||||
expect(sessionEntry.modelOverride).toBe("gpt-5.4");
|
||||
expect(sessionEntry.modelOverrideSource).toBe("auto");
|
||||
expect(sessionEntry.modelOverrideFallbackOriginProvider).toBe("minimax");
|
||||
expect(sessionEntry.modelOverrideFallbackOriginModel).toBe("MiniMax-M2.7");
|
||||
});
|
||||
|
||||
it("does not persist fallback selection when modelOverrideSource is user", async () => {
|
||||
// Regression: fallback persistence overwrote user-initiated /models
|
||||
// selections. When the user explicitly picked a model, the fallback
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
hasOutboundReplyContent,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
buildOAuthRefreshFailureLoginCommand,
|
||||
classifyOAuthRefreshFailure,
|
||||
@@ -252,7 +253,11 @@ function resolveFallbackSelectionOrigin(params: { entry: SessionEntry; run: Foll
|
||||
provider: string;
|
||||
model: string;
|
||||
} {
|
||||
if (params.entry.modelOverrideSource === "auto") {
|
||||
if (
|
||||
params.entry.modelOverrideSource === "auto" ||
|
||||
(params.entry.modelOverrideSource === undefined &&
|
||||
hasSessionAutoModelFallbackProvenance(params.entry))
|
||||
) {
|
||||
const persistedOriginProvider = normalizeOptionalString(
|
||||
params.entry.modelOverrideFallbackOriginProvider,
|
||||
);
|
||||
@@ -1301,7 +1306,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
const isUserModelOverride =
|
||||
activeSessionEntry.modelOverrideSource === "user" ||
|
||||
(activeSessionEntry.modelOverrideSource === undefined &&
|
||||
Boolean(normalizeOptionalString(activeSessionEntry.modelOverride)));
|
||||
Boolean(normalizeOptionalString(activeSessionEntry.modelOverride)) &&
|
||||
!hasSessionAutoModelFallbackProvenance(activeSessionEntry));
|
||||
if (isUserModelOverride) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -31,17 +31,19 @@ export function resolveModelFallbackOptions(
|
||||
configOverride: FollowupRun["run"]["config"] = run.config,
|
||||
) {
|
||||
const config = configOverride;
|
||||
const fallbacksOverride = resolveEffectiveModelFallbacks({
|
||||
cfg: config,
|
||||
agentId: run.agentId,
|
||||
hasSessionModelOverride: run.hasSessionModelOverride === true,
|
||||
modelOverrideSource: run.modelOverrideSource,
|
||||
hasAutoFallbackProvenance: run.hasAutoFallbackProvenance === true,
|
||||
});
|
||||
return {
|
||||
cfg: config,
|
||||
provider: run.provider,
|
||||
model: run.model,
|
||||
agentDir: run.agentDir,
|
||||
fallbacksOverride: resolveEffectiveModelFallbacks({
|
||||
cfg: config,
|
||||
agentId: run.agentId,
|
||||
hasSessionModelOverride: run.hasSessionModelOverride === true,
|
||||
modelOverrideSource: run.modelOverrideSource,
|
||||
}),
|
||||
fallbacksOverride,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ export function buildEmbeddedRunBaseParams(params: {
|
||||
agentId: params.run.agentId,
|
||||
hasSessionModelOverride: params.run.hasSessionModelOverride === true,
|
||||
modelOverrideSource: params.run.modelOverrideSource,
|
||||
hasAutoFallbackProvenance: params.run.hasAutoFallbackProvenance === true,
|
||||
});
|
||||
return {
|
||||
sessionFile: params.run.sessionFile,
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("agent-runner-utils", () => {
|
||||
agentId: run.agentId,
|
||||
hasSessionModelOverride: true,
|
||||
modelOverrideSource: "user",
|
||||
hasAutoFallbackProvenance: false,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
cfg: run.config,
|
||||
@@ -83,6 +84,25 @@ describe("agent-runner-utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through recovered auto fallback provenance for model fallback options", () => {
|
||||
hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]);
|
||||
const run = makeRun({
|
||||
hasSessionModelOverride: true,
|
||||
hasAutoFallbackProvenance: true,
|
||||
});
|
||||
|
||||
const resolved = resolveModelFallbackOptions(run);
|
||||
|
||||
expect(hoisted.resolveEffectiveModelFallbacksMock).toHaveBeenCalledWith({
|
||||
cfg: run.config,
|
||||
agentId: run.agentId,
|
||||
hasSessionModelOverride: true,
|
||||
modelOverrideSource: undefined,
|
||||
hasAutoFallbackProvenance: true,
|
||||
});
|
||||
expect(resolved.fallbacksOverride).toEqual(["fallback-model"]);
|
||||
});
|
||||
|
||||
it("passes through missing agentId for helper-based fallback resolution", () => {
|
||||
hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]);
|
||||
const run = makeRun({ agentId: undefined });
|
||||
@@ -94,6 +114,7 @@ describe("agent-runner-utils", () => {
|
||||
agentId: undefined,
|
||||
hasSessionModelOverride: false,
|
||||
modelOverrideSource: undefined,
|
||||
hasAutoFallbackProvenance: false,
|
||||
});
|
||||
expect(resolved.fallbacksOverride).toEqual(["fallback-model"]);
|
||||
});
|
||||
@@ -135,6 +156,35 @@ describe("agent-runner-utils", () => {
|
||||
expect(resolved.runId).toBe("run-1");
|
||||
});
|
||||
|
||||
it("passes through recovered auto fallback provenance for embedded run params", () => {
|
||||
hoisted.resolveEffectiveModelFallbacksMock.mockReturnValue(["fallback-model"]);
|
||||
const run = makeRun({
|
||||
hasSessionModelOverride: true,
|
||||
hasAutoFallbackProvenance: true,
|
||||
});
|
||||
const authProfile = resolveProviderScopedAuthProfile({
|
||||
provider: "openai",
|
||||
primaryProvider: "openai",
|
||||
});
|
||||
|
||||
const resolved = buildEmbeddedRunBaseParams({
|
||||
run,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
runId: "run-1",
|
||||
authProfile,
|
||||
});
|
||||
|
||||
expect(hoisted.resolveEffectiveModelFallbacksMock).toHaveBeenCalledWith({
|
||||
cfg: run.config,
|
||||
agentId: run.agentId,
|
||||
hasSessionModelOverride: true,
|
||||
modelOverrideSource: undefined,
|
||||
hasAutoFallbackProvenance: true,
|
||||
});
|
||||
expect(resolved.modelFallbacksOverride).toEqual(["fallback-model"]);
|
||||
});
|
||||
|
||||
it("does not force final-tag enforcement for minimax providers", () => {
|
||||
const run = makeRun();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import {
|
||||
hasSessionAutoModelFallbackProvenance,
|
||||
hasConfiguredModelFallbacks,
|
||||
resolveAgentConfig,
|
||||
resolveSessionAgentId,
|
||||
@@ -186,7 +187,12 @@ function resolveConfiguredFallbackModel(params: {
|
||||
fallbackStateEntry?: SessionEntry;
|
||||
}): { provider: string; model: string; persistedAutoFallback: boolean } {
|
||||
const entry = params.fallbackStateEntry;
|
||||
if (entry?.modelOverrideSource === "auto") {
|
||||
const isAutoFallbackOverride =
|
||||
entry?.modelOverrideSource === "auto" ||
|
||||
(entry !== undefined &&
|
||||
entry.modelOverrideSource === undefined &&
|
||||
hasSessionAutoModelFallbackProvenance(entry));
|
||||
if (isAutoFallbackOverride && entry !== undefined) {
|
||||
const originProvider = normalizeOptionalString(entry.modelOverrideFallbackOriginProvider);
|
||||
const originModel = normalizeOptionalString(entry.modelOverrideFallbackOriginModel);
|
||||
if (originProvider && originModel) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js";
|
||||
import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
|
||||
import { resolveFastModeState } from "../../agents/fast-mode.js";
|
||||
@@ -965,6 +966,12 @@ export async function runPreparedReply(
|
||||
normalizeOptionalString(preparedSessionState.sessionEntry?.modelOverride) ||
|
||||
normalizeOptionalString(preparedSessionState.sessionEntry?.providerOverride),
|
||||
);
|
||||
const runModelOverrideSource = runHasSessionModelOverride
|
||||
? preparedSessionState.sessionEntry?.modelOverrideSource
|
||||
: undefined;
|
||||
const runHasAutoFallbackProvenance =
|
||||
runHasSessionModelOverride &&
|
||||
hasSessionAutoModelFallbackProvenance(preparedSessionState.sessionEntry);
|
||||
const followupRun = {
|
||||
prompt: queuedBody,
|
||||
transcriptPrompt: transcriptCommandBody,
|
||||
@@ -1018,9 +1025,8 @@ export async function runPreparedReply(
|
||||
provider,
|
||||
model,
|
||||
hasSessionModelOverride: runHasSessionModelOverride,
|
||||
modelOverrideSource: runHasSessionModelOverride
|
||||
? preparedSessionState.sessionEntry?.modelOverrideSource
|
||||
: undefined,
|
||||
modelOverrideSource: runModelOverrideSource,
|
||||
hasAutoFallbackProvenance: runHasAutoFallbackProvenance || undefined,
|
||||
authProfileId,
|
||||
authProfileIdSource,
|
||||
thinkLevel: resolvedThinkLevel,
|
||||
|
||||
@@ -1068,6 +1068,28 @@ describe("createModelSelectionState auto-failover overrides", () => {
|
||||
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
|
||||
});
|
||||
|
||||
it("keeps recovered heartbeat auto-failover override without modelOverrideSource", async () => {
|
||||
const { state, sessionStore } = await resolveStateWithOverride({
|
||||
providerOverride: "openrouter",
|
||||
modelOverride: "minimax/minimax-m2.7",
|
||||
modelOverrideSource: undefined,
|
||||
modelOverrideFallbackOriginProvider: "openai",
|
||||
modelOverrideFallbackOriginModel: "gpt-4o",
|
||||
primaryProvider: "openai",
|
||||
primaryModel: "gpt-4o",
|
||||
provider: "openrouter",
|
||||
model: "minimax/minimax-m2.7",
|
||||
isHeartbeat: true,
|
||||
});
|
||||
|
||||
expect(state.provider).toBe("openrouter");
|
||||
expect(state.model).toBe("minimax/minimax-m2.7");
|
||||
expect(state.resetModelOverride).toBe(false);
|
||||
expect(sessionStore[sessionKey]?.providerOverride).toBe("openrouter");
|
||||
expect(sessionStore[sessionKey]?.modelOverride).toBe("minimax/minimax-m2.7");
|
||||
expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears legacy heartbeat auto-failover override when no origin metadata exists", async () => {
|
||||
const { state, sessionStore } = await resolveStateWithOverride({
|
||||
providerOverride: "openrouter",
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("refreshQueuedFollowupSession", () => {
|
||||
const queuedRun: FollowupRun = {
|
||||
prompt: "queued message",
|
||||
enqueuedAt: Date.now(),
|
||||
run: makeRun(),
|
||||
run: { ...makeRun(), hasAutoFallbackProvenance: true },
|
||||
};
|
||||
queue.items.push(queuedRun);
|
||||
|
||||
|
||||
@@ -125,10 +125,12 @@ export function refreshQueuedFollowupSession(params: {
|
||||
Boolean(params.previousSessionId) &&
|
||||
Boolean(params.nextSessionId) &&
|
||||
params.previousSessionId !== params.nextSessionId;
|
||||
const shouldRewriteSelection =
|
||||
const shouldRewriteModelSelection =
|
||||
typeof params.nextProvider === "string" ||
|
||||
typeof params.nextModel === "string" ||
|
||||
Object.hasOwn(params, "nextModelOverrideSource") ||
|
||||
Object.hasOwn(params, "nextModelOverrideSource");
|
||||
const shouldRewriteSelection =
|
||||
shouldRewriteModelSelection ||
|
||||
Object.hasOwn(params, "nextAuthProfileId") ||
|
||||
Object.hasOwn(params, "nextAuthProfileIdSource");
|
||||
if (!shouldRewriteSession && !shouldRewriteSelection) {
|
||||
@@ -153,6 +155,9 @@ export function refreshQueuedFollowupSession(params: {
|
||||
if (typeof params.nextModel === "string") {
|
||||
run.model = params.nextModel;
|
||||
}
|
||||
if (shouldRewriteModelSelection) {
|
||||
delete run.hasAutoFallbackProvenance;
|
||||
}
|
||||
if (Object.hasOwn(params, "nextModelOverrideSource")) {
|
||||
run.hasSessionModelOverride = Boolean(run.provider || run.model);
|
||||
run.modelOverrideSource = params.nextModelOverrideSource;
|
||||
|
||||
@@ -87,6 +87,7 @@ export type FollowupRun = {
|
||||
model: string;
|
||||
hasSessionModelOverride?: boolean;
|
||||
modelOverrideSource?: "auto" | "user";
|
||||
hasAutoFallbackProvenance?: boolean;
|
||||
authProfileId?: string;
|
||||
authProfileIdSource?: "auto" | "user";
|
||||
thinkLevel?: ThinkLevel;
|
||||
|
||||
@@ -2407,6 +2407,65 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
|
||||
}
|
||||
});
|
||||
|
||||
it("clears recovered auto fallback model overrides without modelOverrideSource on /new and /reset", async () => {
|
||||
const storePath = await createStorePath("openclaw-reset-recovered-auto-fallback-");
|
||||
const sessionKey = "agent:main:telegram:direct:6761477233";
|
||||
const existingSessionId = "existing-session-recovered-auto-fallback";
|
||||
const autoOverrides = {
|
||||
providerOverride: "openai-codex",
|
||||
modelOverride: "gpt-5.4",
|
||||
modelOverrideFallbackOriginProvider: "anthropic",
|
||||
modelOverrideFallbackOriginModel: "claude-opus-4-6",
|
||||
verboseLevel: "on",
|
||||
} as const;
|
||||
const cases = [
|
||||
{ name: "new clears recovered auto fallback override", body: "/new" },
|
||||
{ name: "reset clears recovered auto fallback override", body: "/reset" },
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
await seedSessionStoreWithOverrides({
|
||||
storePath,
|
||||
sessionKey,
|
||||
sessionId: existingSessionId,
|
||||
overrides: { ...autoOverrides },
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
session: { store: storePath, idleMinutes: 999 },
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: testCase.body,
|
||||
RawBody: testCase.body,
|
||||
CommandBody: testCase.body,
|
||||
From: "6761477233",
|
||||
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.modelOverride, testCase.name).toBeUndefined();
|
||||
expect(result.sessionEntry.providerOverride, testCase.name).toBeUndefined();
|
||||
expect(result.sessionEntry.modelOverrideSource, testCase.name).toBeUndefined();
|
||||
expect(
|
||||
result.sessionEntry.modelOverrideFallbackOriginProvider,
|
||||
testCase.name,
|
||||
).toBeUndefined();
|
||||
expect(result.sessionEntry.modelOverrideFallbackOriginModel, testCase.name).toBeUndefined();
|
||||
expect(result.sessionEntry.verboseLevel, testCase.name).toBe(autoOverrides.verboseLevel);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves spawned session ownership metadata across /new and /reset", async () => {
|
||||
const storePath = await createStorePath("openclaw-reset-spawned-metadata-");
|
||||
const sessionKey = "subagent:owned-child";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
modelKey,
|
||||
normalizeModelRef,
|
||||
@@ -91,7 +92,15 @@ export function isStaleHeartbeatAutoFallbackOverride(params: {
|
||||
if (params.storedOverride?.source !== "session") {
|
||||
return false;
|
||||
}
|
||||
if (params.sessionEntry?.modelOverrideSource !== "auto") {
|
||||
const entry = params.sessionEntry;
|
||||
const recoveredAutoFallbackOverride =
|
||||
entry !== undefined &&
|
||||
entry.modelOverrideSource === undefined &&
|
||||
hasSessionAutoModelFallbackProvenance(entry);
|
||||
if (entry?.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
|
||||
return false;
|
||||
}
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -106,8 +115,8 @@ export function isStaleHeartbeatAutoFallbackOverride(params: {
|
||||
|
||||
const originKey = resolveModelRefKey({
|
||||
defaultProvider: params.defaultProvider,
|
||||
overrideProvider: params.sessionEntry.modelOverrideFallbackOriginProvider,
|
||||
overrideModel: params.sessionEntry.modelOverrideFallbackOriginModel,
|
||||
overrideProvider: entry.modelOverrideFallbackOriginProvider,
|
||||
overrideModel: entry.modelOverrideFallbackOriginModel,
|
||||
});
|
||||
if (originKey) {
|
||||
return originKey !== primaryKey;
|
||||
@@ -115,7 +124,7 @@ export function isStaleHeartbeatAutoFallbackOverride(params: {
|
||||
|
||||
const noticeSelectedKey = resolveModelRefKey({
|
||||
defaultProvider: params.defaultProvider,
|
||||
overrideModel: normalizeOptionalString(params.sessionEntry.fallbackNoticeSelectedModel),
|
||||
overrideModel: normalizeOptionalString(entry.fallbackNoticeSelectedModel),
|
||||
});
|
||||
if (noticeSelectedKey) {
|
||||
return noticeSelectedKey !== primaryKey;
|
||||
|
||||
24
src/config/sessions/model-override-provenance.ts
Normal file
24
src/config/sessions/model-override-provenance.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
export function hasSessionAutoModelFallbackProvenance(
|
||||
entry:
|
||||
| Pick<
|
||||
SessionEntry,
|
||||
| "providerOverride"
|
||||
| "modelOverride"
|
||||
| "modelOverrideFallbackOriginProvider"
|
||||
| "modelOverrideFallbackOriginModel"
|
||||
>
|
||||
| undefined,
|
||||
): boolean {
|
||||
const hasActiveOverride = Boolean(
|
||||
normalizeOptionalString(entry?.providerOverride) ||
|
||||
normalizeOptionalString(entry?.modelOverride),
|
||||
);
|
||||
return Boolean(
|
||||
hasActiveOverride &&
|
||||
normalizeOptionalString(entry?.modelOverrideFallbackOriginProvider) &&
|
||||
normalizeOptionalString(entry?.modelOverrideFallbackOriginModel),
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { hasSessionAutoModelFallbackProvenance } from "./model-override-provenance.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
export type ResetPreservedSelectionState = Pick<
|
||||
@@ -30,9 +31,13 @@ export function resolveResetPreservedSelection(params: {
|
||||
}
|
||||
|
||||
const preserved: Partial<ResetPreservedSelectionState> = {};
|
||||
const recoveredAutoFallbackOverride =
|
||||
entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry);
|
||||
const preserveLegacyUserModelOverride =
|
||||
entry.modelOverrideSource === "user" ||
|
||||
(entry.modelOverrideSource === undefined && Boolean(entry.modelOverride));
|
||||
(entry.modelOverrideSource === undefined &&
|
||||
Boolean(entry.modelOverride) &&
|
||||
!recoveredAutoFallbackOverride);
|
||||
if (preserveLegacyUserModelOverride && entry.modelOverride) {
|
||||
preserved.providerOverride = entry.providerOverride;
|
||||
preserved.modelOverride = entry.modelOverride;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js";
|
||||
import { resolveSessionLifecycleTimestamps } from "../../config/sessions/lifecycle.js";
|
||||
import { hasSessionAutoModelFallbackProvenance } from "../../config/sessions/model-override-provenance.js";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import {
|
||||
evaluateSessionFreshness,
|
||||
@@ -59,7 +60,9 @@ function copySessionFields(
|
||||
}
|
||||
|
||||
function preserveNonAutoModelOverride(target: SessionEntry, entry: SessionEntry): void {
|
||||
if (entry.modelOverrideSource !== "auto") {
|
||||
const recoveredAutoFallbackOverride =
|
||||
entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry);
|
||||
if (entry.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
|
||||
if (entry.modelOverride !== undefined) {
|
||||
target.modelOverride = entry.modelOverride;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user