Skip to content

Heartbeat turn overwrites session's persisted model/modelProvider, leaving stale model in UI display #62954

Description

@tumbleweedlabs

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:

event timestamp (UTC) model
user turn 04:27:58 gpt-5.4
model-snapshot written 05:30:50 gpt-5.1-codex-mini
heartbeat turn 05:30:50 gpt-5.1-codex-mini
heartbeat turn (next) 11:30:56 gpt-5.1-codex-mini
model-snapshot written 12:41:41 gpt-5.4
user-triggered turn 12:41:41 gpt-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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions