openclaw - ✅(Solved) Fix Heartbeat turn overwrites session's persisted model/modelProvider, leaving stale model in UI display [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#62954Fetched 2026-04-09 08:00:15
View on GitHub
Comments
0
Participants
1
Timeline
3
Reactions
0
Participants
Timeline (top)
cross-referenced ×2referenced ×1

When the built-in heartbeat (agents.defaults.heartbeat.model) runs a turn, it correctly resolves its model override per-turn — but then the post-turn usage persistence step overwrites the session entry's model / modelProvider fields with the heartbeat model. Those fields back the UI's "current model" display, so until the next non-heartbeat turn runs, every UI surface that calls chat.history (or otherwise goes through resolveSessionModelRef) shows the heartbeat model as the session's active model.

The actual reply path is unaffected — model selection uses agents.defaults.model + sessionEntry.modelOverride, not entry.model — so this is purely a stale-display bug, but it's a confusing one because the displayed model doesn't match what subsequent user turns will actually run on.

Root Cause

persistSessionUsageUpdate in src/auto-reply/reply/session-usage.ts (minified at dist/agent-runner.runtime-*.js) writes the just-used model into the session entry unconditionally:

const patch = {
    modelProvider: params.providerUsed ?? entry.modelProvider,
    model:        params.modelUsed    ?? entry.model,
    contextTokens: resolvedContextTokens,
    systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
    updatedAt: Date.now()
};

resolveSessionModelRef (src/config/sessions/..., minified at session-utils-*.js) then prefers entry.model / entry.modelProvider over the agent default when reporting the session's model — so the heartbeat's per-turn override gets reflected as the session's persistent model.

It's not just chat.history — anything calling resolveSessionModelRef inherits this (e.g. session list summaries, model picker default, etc.).

Fix Action

Fix / Workaround

const patch = {
    modelProvider: params.providerUsed ?? entry.modelProvider,
    model:        params.modelUsed    ?? entry.model,
    contextTokens: resolvedContextTokens,
    systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
    updatedAt: Date.now()
};
const patch = {
    modelProvider: params.isHeartbeat
        ? entry.modelProvider
        : (params.providerUsed ?? entry.modelProvider),
    model: params.isHeartbeat
        ? entry.model
        : (params.modelUsed ?? entry.model),
    // ... unchanged ...
};

There's a similar concern with applyCliSessionIdToSessionPatch for CLI providers like openai-codex — a heartbeat turn's CLI session binding will overwrite the user session's CLI binding under the same provider key. Didn't observe a symptom for this, but flagging it in case it's the same shape of bug.

PR fix notes

PR #62987: fix(auto-reply): avoid persisting heartbeat model to session entry #6…

Description (problem / solution / changelog)

Summary

  • Problem: Heartbeat turns persist model / modelProvider onto the session entry, causing UI surfaces (e.g. chat.history) to display the heartbeat model as the “current model” until the next user turn.
  • Why it matters: The displayed session model becomes misleading and does not match what subsequent non-heartbeat turns will actually run.
  • What changed: Threaded an isHeartbeat flag into usage persistence and, for heartbeat turns, keep the persisted entry.model / entry.modelProvider unchanged.
  • What did NOT change: Actual model selection for replies; this is a persistence/display correctness fix only.

Change Type (select all)

  • Bug fix

Scope (select all touched areas)

  • Gateway / orchestration
  • UI / DX

Linked Issue/PR

  • Fixes #62954

User-visible / Behavior Changes

  • Heartbeat turns no longer overwrite the session’s persisted “current model/provider” fields used by UI display.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)

Repro + Verification

Environment

  • OS: Linux
  • Runtime/container: Node (repo default)
  • Relevant config (redacted):
    • agents.defaults.model set to a non-heartbeat model
    • agents.defaults.heartbeat.model set to a different model

Steps

  1. Start a session using the default model (non-heartbeat).
  2. Allow a heartbeat tick to run.
  3. Inspect the session model shown in Control UI / chat.history.

Expected

  • The session continues to show the default model (non-heartbeat) as the current model.

Actual (before)

  • The session shows the heartbeat model as the current model until a non-heartbeat turn runs again.

Evidence

  • Added a unit test covering heartbeat persistence behavior:
    • src/auto-reply/reply/session.test.ts

Tests

  • pnpm test src/auto-reply/reply/session.test.ts -t "persistSessionUsageUpdate"

Changed files

  • src/auto-reply/reply/agent-runner.ts (modified, +1/-0)
  • src/auto-reply/reply/followup-runner.ts (modified, +1/-0)
  • src/auto-reply/reply/session-usage.ts (modified, +11/-4)
  • src/auto-reply/reply/session.test.ts (modified, +29/-0)

Code Example

"agents": {
     "defaults": {
       "model": "openai-codex/gpt-5.4",
       "heartbeat": {
         "model": "openai-codex/gpt-5.1-codex-mini",
         "every": "1h"
       }
     }
   }

---

const patch = {
    modelProvider: params.providerUsed ?? entry.modelProvider,
    model:        params.modelUsed    ?? entry.model,
    contextTokens: resolvedContextTokens,
    systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
    updatedAt: Date.now()
};

---

const patch = {
    modelProvider: params.isHeartbeat
        ? entry.modelProvider
        : (params.providerUsed ?? entry.modelProvider),
    model: params.isHeartbeat
        ? entry.model
        : (params.modelUsed ?? entry.model),
    // ... unchanged ...
};
RAW_BUFFERClick to expand / collapse

Summary

When the built-in heartbeat (agents.defaults.heartbeat.model) runs a turn, it correctly resolves its model override per-turn — but then the post-turn usage persistence step overwrites the session entry's model / modelProvider fields with the heartbeat model. Those fields back the UI's "current model" display, so until the next non-heartbeat turn runs, every UI surface that calls chat.history (or otherwise goes through resolveSessionModelRef) shows the heartbeat model as the session's active model.

The actual reply path is unaffected — model selection uses agents.defaults.model + sessionEntry.modelOverride, not entry.model — so this is purely a stale-display bug, but it's a confusing one because the displayed model doesn't match what subsequent user turns will actually run on.

Version

[email protected] (global install via Homebrew)

Reproduction

  1. Configure an agent default model and a different heartbeat model:
    "agents": {
      "defaults": {
        "model": "openai-codex/gpt-5.4",
        "heartbeat": {
          "model": "openai-codex/gpt-5.1-codex-mini",
          "every": "1h"
        }
      }
    }
  2. Have an active session (e.g. Discord DM) running on gpt-5.4.
  3. Wait for a heartbeat tick.
  4. Open the control UI / inspect chat.history for that session.

Expected: Current model still shows gpt-5.4 (the agent default). Actual: Current model shows gpt-5.1-codex-mini and stays that way until the next non-heartbeat turn runs.

Root cause

persistSessionUsageUpdate in src/auto-reply/reply/session-usage.ts (minified at dist/agent-runner.runtime-*.js) writes the just-used model into the session entry unconditionally:

const patch = {
    modelProvider: params.providerUsed ?? entry.modelProvider,
    model:        params.modelUsed    ?? entry.model,
    contextTokens: resolvedContextTokens,
    systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
    updatedAt: Date.now()
};

resolveSessionModelRef (src/config/sessions/..., minified at session-utils-*.js) then prefers entry.model / entry.modelProvider over the agent default when reporting the session's model — so the heartbeat's per-turn override gets reflected as the session's persistent model.

It's not just chat.history — anything calling resolveSessionModelRef inherits this (e.g. session list summaries, model picker default, etc.).

Trace evidence

In a real session transcript I observed:

eventtimestamp (UTC)model
user turn04:27:58gpt-5.4
model-snapshot written05:30:50gpt-5.1-codex-mini
heartbeat turn05:30:50gpt-5.1-codex-mini
heartbeat turn (next)11:30:56gpt-5.1-codex-mini
model-snapshot written12:41:41gpt-5.4
user-triggered turn12:41:41gpt-5.4 ✅

The actual reply path correctly resolved back to gpt-5.4 for the non-heartbeat turn (12:41 row) — confirming this is purely a persisted display field, not a real model selection bug.

Suggested fix

Thread isHeartbeat into persistSessionUsageUpdate (it's already known at both call sites in runReplyAgent and createFollowupRunner) and, when isHeartbeat is true, leave entry.model / entry.modelProvider untouched:

const patch = {
    modelProvider: params.isHeartbeat
        ? entry.modelProvider
        : (params.providerUsed ?? entry.modelProvider),
    model: params.isHeartbeat
        ? entry.model
        : (params.modelUsed ?? entry.model),
    // ... unchanged ...
};

Same treatment for the no-usage branch a few lines below. The two call sites at the end of runReplyAgent and inside the followup-runner closure both already have opts?.isHeartbeat in scope.

Open question

Should heartbeat token usage also be excluded from the user session's running totals (inputTokens, outputTokens, estimatedCostUsd, totalTokens)? Right now they're folded in, which means heartbeat costs inflate the displayed session cost. Arguably a separate issue, but worth considering as part of the same fix — happy to file separately if you'd prefer.

Potentially related

There's a similar concern with applyCliSessionIdToSessionPatch for CLI providers like openai-codex — a heartbeat turn's CLI session binding will overwrite the user session's CLI binding under the same provider key. Didn't observe a symptom for this, but flagging it in case it's the same shape of bug.

extent analysis

TL;DR

The issue can be fixed by modifying the persistSessionUsageUpdate function to conditionally update the session entry's model fields based on whether the current turn is a heartbeat or not.

Guidance

  • Identify the persistSessionUsageUpdate function in src/auto-reply/reply/session-usage.ts and modify it to include a conditional check for isHeartbeat.
  • Update the model and modelProvider fields in the patch object to only update when isHeartbeat is false.
  • Verify that the fix works by checking the session entry's model fields after a heartbeat turn and ensuring they remain unchanged.
  • Consider excluding heartbeat token usage from the user session's running totals to prevent inflated displayed session costs.

Example

const patch = {
    modelProvider: params.isHeartbeat
        ? entry.modelProvider
        : (params.providerUsed ?? entry.modelProvider),
    model: params.isHeartbeat
        ? entry.model
        : (params.modelUsed ?? entry.model),
    // ... unchanged ...
};

Notes

The suggested fix only addresses the display issue and does not affect the actual reply path. The open question regarding heartbeat token usage and its impact on session costs should be considered separately.

Recommendation

Apply the suggested fix to the persistSessionUsageUpdate function to resolve the display issue. This fix is a targeted solution that addresses the specific problem without introducing unnecessary changes.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING