fix(agents): stop transient live-switch mismatches

This commit is contained in:
Vincent Koc 2026-03-30 07:11:30 +09:00
parent e01ca8cfc6
commit bd89e07baa
4 changed files with 74 additions and 6 deletions

View File

@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai
- iMessage: stop leaking inline `[[reply_to:...]]` tags into delivered text by sending `reply_to` as RPC metadata and stripping stray directive tags from outbound messages. (#39512) Thanks @mvanhorn.
- CLI/plugins: make routed commands use the same auto-enabled bundled-channel snapshot as gateway startup, so configured bundled channels like Slack load without requiring a prior config rewrite. (#54809) Thanks @neeravmakwana.
- CLI/message send: write manual `openclaw message send` deliveries into the resolved agent session transcript again by always threading the default CLI agent through outbound mirroring. (#54187) Thanks @KevInTheCloud5617.
- Agents/live switch: stop transient cron and subagent model overrides from being misread as persisted live-session switches, so isolated runs no longer fail with `LiveSessionModelSwitchError`. Thanks @vincentkoc.
- CLI/onboarding: show the Kimi Code API key option again in the Moonshot setup menu so the interactive picker includes all Kimi setup paths together. Fixes #54412 Thanks @sparkyrider
- Agents/status: use provider-aware context window lookup for fresh Anthropic 4.6 model overrides so `/status` shows the correct 1.0m window instead of an underreported shared-cache minimum. (#54796) Thanks @neeravmakwana.
- OpenAI/WebSocket: preserve reasoning replay metadata and tool-call item ids on WebSocket tool turns, and start a fresh response chain when full-context resend is required. (#53856) Thanks @xujingchen1996.

View File

@ -90,6 +90,34 @@ describe("live model switch", () => {
});
});
it("prefers persisted runtime model fields ahead of session overrides", async () => {
state.loadSessionStoreMock.mockReturnValue({
main: {
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
modelProvider: "anthropic",
model: "claude-sonnet-4-6",
},
});
const { resolveLiveSessionModelSelection } = await loadModule();
expect(
resolveLiveSessionModelSelection({
cfg: { session: { store: "/tmp/custom-store.json" } },
sessionKey: "main",
agentId: "reply",
defaultProvider: "openai",
defaultModel: "gpt-5.4",
}),
).toEqual({
provider: "anthropic",
model: "claude-sonnet-4-6",
authProfileId: undefined,
authProfileIdSource: undefined,
});
});
it("queues a live switch only when an active run was aborted", async () => {
state.abortEmbeddedPiRunMock.mockReturnValue(true);
@ -126,4 +154,21 @@ describe("live model switch", () => {
),
).toBe(false);
});
it("does not track persisted live selection when the run started on a transient model override", async () => {
const { shouldTrackPersistedLiveSessionModelSelection } = await loadModule();
expect(
shouldTrackPersistedLiveSessionModelSelection(
{
provider: "anthropic",
model: "claude-haiku-4-5",
},
{
provider: "anthropic",
model: "claude-sonnet-4-6",
},
),
).toBe(false);
});
});

View File

@ -48,8 +48,10 @@ export function resolveLiveSessionModelSelection(params: {
agentId,
});
const entry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
const provider = entry?.providerOverride?.trim() || defaultModelRef.provider;
const model = entry?.modelOverride?.trim() || defaultModelRef.model;
const runtimeProvider = entry?.modelProvider?.trim();
const runtimeModel = entry?.model?.trim();
const provider = runtimeProvider || entry?.providerOverride?.trim() || defaultModelRef.provider;
const model = runtimeModel || entry?.modelOverride?.trim() || defaultModelRef.model;
const authProfileId = entry?.authProfileOverride?.trim() || undefined;
return {
provider,
@ -101,3 +103,15 @@ export function hasDifferentLiveSessionModelSelection(
next.authProfileIdSource
);
}
export function shouldTrackPersistedLiveSessionModelSelection(
current: {
provider: string;
model: string;
authProfileId?: string;
authProfileIdSource?: string;
},
persisted: LiveSessionModelSelection | null | undefined,
): boolean {
return !hasDifferentLiveSessionModelSelection(current, persisted);
}

View File

@ -28,6 +28,7 @@ import {
hasDifferentLiveSessionModelSelection,
LiveSessionModelSwitchError,
resolveLiveSessionModelSelection,
shouldTrackPersistedLiveSessionModelSelection,
consumeLiveSessionModelSwitch,
} from "../live-model-switch.js";
import {
@ -242,6 +243,10 @@ export async function runEmbeddedPiAgent(
defaultProvider: provider,
defaultModel: modelId,
});
const shouldTrackPersistedLiveSelection = shouldTrackPersistedLiveSessionModelSelection(
resolveCurrentLiveSelection(),
resolvePersistedLiveSelection(),
);
const {
advanceAuthProfile,
initializeAuthProfile,
@ -449,7 +454,9 @@ export async function runEmbeddedPiAgent(
};
}
runLoopIterations += 1;
const nextSelection = resolvePersistedLiveSelection();
const nextSelection = shouldTrackPersistedLiveSelection
? resolvePersistedLiveSelection()
: null;
if (hasDifferentLiveSessionModelSelection(resolveCurrentLiveSelection(), nextSelection)) {
log.info(
`live session model switch detected before attempt for ${params.sessionId}: ${provider}/${modelId} -> ${nextSelection.provider}/${nextSelection.model}`,
@ -605,9 +612,10 @@ export async function runEmbeddedPiAgent(
}
const failedOrAbortedAttempt =
aborted || Boolean(promptError) || Boolean(assistantErrorText) || timedOut;
const persistedSelection = failedOrAbortedAttempt
? resolvePersistedLiveSelection()
: null;
const persistedSelection =
failedOrAbortedAttempt && shouldTrackPersistedLiveSelection
? resolvePersistedLiveSelection()
: null;
if (
failedOrAbortedAttempt &&
canRestartForLiveSwitch &&