From c89b3fab5ce57b54e4e5565a221bd47476990a9a Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Sat, 2 May 2026 19:51:27 +0800 Subject: [PATCH] fix(agents): prevent heartbeat model override from persisting in session state - Skip setting runtime model when preserveRuntimeModel is true and no prior model exists - Preserve model-only entries without borrowing heartbeat provider to avoid invalid cross-provider pairs --- src/agents/command/session-store.test.ts | 60 ++++++++++++++++++++++-- src/agents/command/session-store.ts | 19 +++++--- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 4e1104eaa6a..75033234924 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -980,7 +980,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }); }); - it("falls back to run model when preserveRuntimeModel is true but entry has no prior runtime model", async () => { + it("does not set runtime model when preserveRuntimeModel is true and entry has no prior runtime model", async () => { await withTempSessionStore(async ({ storePath }) => { const cfg = {} as OpenClawConfig; const sessionKey = "agent:main:explicit:test-heartbeat-new-session"; @@ -1017,10 +1017,60 @@ describe("updateSessionStoreAfterAgentRun", () => { preserveRuntimeModel: true, }); - // No prior runtime model, so falls back to the run's model - expect(sessionStore[sessionKey]?.model).toBe("llama3.2:1b"); - expect(sessionStore[sessionKey]?.modelProvider).toBe("ollama"); - expect(sessionStore[sessionKey]?.contextTokens).toBe(128_000); + // Heartbeat should NOT establish initial model state on an empty session + expect(sessionStore[sessionKey]?.model).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined(); + expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined(); + }); + }); + + it("preserves model without borrowing heartbeat provider when entry has model but no modelProvider", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-heartbeat-model-no-provider"; + const sessionId = "test-heartbeat-model-no-provider-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + model: "claude-opus-4-6", + // modelProvider intentionally missing + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + // Heartbeat turn uses a different provider + const result: EmbeddedPiRunResult = { + meta: { + durationMs: 500, + agentMeta: { + sessionId, + provider: "ollama", + model: "llama3.2:1b", + contextTokens: 128_000, + }, + }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + result, + preserveRuntimeModel: true, + }); + + // Model preserved, provider NOT borrowed from heartbeat + expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined(); + + const persisted = loadSessionStore(storePath); + expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(persisted[sessionKey]?.modelProvider).toBeUndefined(); }); }); diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index c14b9e5a6b4..0e5d5217efb 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -125,14 +125,19 @@ export async function updateSessionStoreAfterAgentRun(params: { // leave contextTokens unset rather than falling back to the heartbeat // run's context window; status derives it from the preserved model. next.contextTokens = entry.contextTokens; - } else { - // No prior runtime model: heartbeat establishes initial state. - next.contextTokens = entry.contextTokens ?? contextTokens; + if (entry.modelProvider) { + setSessionRuntimeModel(next, { + provider: entry.modelProvider, + model: entry.model, + }); + } else { + // Retain the model-only entry without borrowing the heartbeat provider + // to avoid invalid cross-provider pairs (e.g. ollama/claude-opus-4-6). + next.model = entry.model; + } } - setSessionRuntimeModel(next, { - provider: entry.modelProvider ?? providerUsed, - model: entry.model ?? modelUsed, - }); + // When there is no prior runtime model, do nothing: a heartbeat turn + // should not establish initial model state on an empty session. } else { setSessionRuntimeModel(next, { provider: providerUsed,