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
This commit is contained in:
zhang-guiping
2026-05-02 19:51:27 +08:00
parent 09c467241d
commit c89b3fab5c
2 changed files with 67 additions and 12 deletions

View File

@@ -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<string, SessionEntry> = {
[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();
});
});

View File

@@ -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,