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
- Configure an agent default model and a different heartbeat model:
- Have an active session (e.g. Discord DM) running on
gpt-5.4.
- Wait for a heartbeat tick.
- 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.
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'smodel/modelProviderfields 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 callschat.history(or otherwise goes throughresolveSessionModelRef) shows the heartbeat model as the session's active model.The actual reply path is unaffected — model selection uses
agents.defaults.model+sessionEntry.modelOverride, notentry.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
gpt-5.4.chat.historyfor that session.Expected: Current model still shows
gpt-5.4(the agent default).Actual: Current model shows
gpt-5.1-codex-miniand stays that way until the next non-heartbeat turn runs.Root cause
persistSessionUsageUpdateinsrc/auto-reply/reply/session-usage.ts(minified atdist/agent-runner.runtime-*.js) writes the just-used model into the session entry unconditionally:resolveSessionModelRef(src/config/sessions/..., minified atsession-utils-*.js) then prefersentry.model/entry.modelProviderover 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 callingresolveSessionModelRefinherits this (e.g. session list summaries, model picker default, etc.).Trace evidence
In a real session transcript I observed:
model-snapshotwrittenmodel-snapshotwrittenThe actual reply path correctly resolved back to
gpt-5.4for the non-heartbeat turn (12:41 row) — confirming this is purely a persisted display field, not a real model selection bug.Suggested fix
Thread
isHeartbeatintopersistSessionUsageUpdate(it's already known at both call sites inrunReplyAgentandcreateFollowupRunner) and, whenisHeartbeatis true, leaveentry.model/entry.modelProvideruntouched:Same treatment for the no-usage branch a few lines below. The two call sites at the end of
runReplyAgentand inside the followup-runner closure both already haveopts?.isHeartbeatin 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
applyCliSessionIdToSessionPatchfor CLI providers likeopenai-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.