diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eff14ac382..05c1b0a8748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao. - Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot. - Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser. +- Agents/sessions: preserve pre-existing runtime model and context window after heartbeat turns so a per-run heartbeat model override does not bleed into shared-session status. Fixes #75452. Thanks @zhang-guiping. - Model commands: clarify direct and inline `/model` acknowledgements for non-default selections as session-scoped. Thanks @addu2612. - Doctor/gateway: stop warning that non-existent, unconfigured user-bin directories are required in the Gateway service PATH. Fixes #76017. Thanks @xiphis. - TUI/chat: skip full provider model normalization during context-window warmup while preserving provider-owned context metadata, avoiding cold-start stalls with large model registries. Thanks @547895019. diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 4fe2908e10d..4e1104eaa6a 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -931,6 +931,55 @@ describe("updateSessionStoreAfterAgentRun", () => { }); }); + it("leaves contextTokens unset when entry has prior model but no contextTokens (heartbeat bleed guard)", async () => { + await withTempSessionStore(async ({ storePath }) => { + const cfg = {} as OpenClawConfig; + const sessionKey = "agent:main:explicit:test-heartbeat-no-context-tokens"; + const sessionId = "test-heartbeat-no-context-tokens-session"; + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: 1, + modelProvider: "anthropic", + model: "claude-opus-4-6", + // contextTokens intentionally missing — older session without cached context + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2)); + + // Heartbeat turn uses a different, smaller model + 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, + }); + + // Runtime model should be preserved + expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6"); + expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic"); + // contextTokens should NOT bleed from the heartbeat run's smaller window + expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined(); + }); + }); + it("falls back to run model when preserveRuntimeModel is true but entry has no prior runtime model", async () => { await withTempSessionStore(async ({ storePath }) => { const cfg = {} as OpenClawConfig; diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 9d7f9add0fd..c14b9e5a6b4 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -120,7 +120,15 @@ export async function updateSessionStoreAfterAgentRun(params: { // Keep the pre-existing runtime model and context window so a background // heartbeat turn using a different model does not bleed into the main // session's perceived state. - next.contextTokens = entry.contextTokens ?? contextTokens; + if (entry.model) { + // Prior runtime model exists: preserve its contextTokens. When missing, + // 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; + } setSessionRuntimeModel(next, { provider: entry.modelProvider ?? providerUsed, model: entry.model ?? modelUsed,