You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
AgentSession.this.model is never refreshed after /model is used. The pre-attempt LLM path resolves the new model from the registry correctly, but the session-internal this.model snapshot (used by 8 post-turn reads in AgentSession) keeps a reference to the previous model. After a switch, post-turn decisions use stale contextWindow, reasoning, thinkingLevelMap, and stale provider routing for branch summaries. /status, the webUI, and getContextUsage() disagree with each other and with the actual compaction threshold.
This is a regression of model-switch correctness for the session layer; it is not specific to any single channel or runtime, and it is invisible from /status and the webUI (which both re-resolve on every read).
This issue supersedes#92379. The original report only documented the contextWindow symptom; the full audit (PR1a/PR1b) shows the same root cause affects 8 post-turn reads and a number of distinct user-visible failure modes (see "Severity" below).
Minimum reproduction
# Prereqs# 1. openclaw installed (build from HEAD = 66b91d78fe or later 2026.6.x)# 2. openclaw configured with default model opencode/deepseek-v4-flash-free# (200k context) and a fallback to opencode-go/deepseek-v4-flash (1M).# 3. A Telegram bot token configured, OR use the CLI /model command.# Step 1 — Start a session.
openclaw
# (or in Telegram, send any message to start the session)# Step 2 — Switch to the 1M model.# CLI: /model opencode-go/deepseek-v4-flash# Telegram: tap the inline /model picker, choose opencode-go, choose deepseek-v4-flash# Expected: "✅ Model changed to opencode-go/deepseek-v4-flash"# Step 3 — Push context past the previous model's limit.# Have a long-running conversation that grows past ~150k tokens of history# (e.g. paste a few long files).# Step 4 — Observe.# /status: model=opencode-go/deepseek-v4-flash, context=1M# webUI: same as /status# getContextUsage(): percent computed against 200k (DISAGREES with /status)# auto-compaction: fires at the 200k threshold, NOT the 1M threshold# isRetryableError: mis-classifies 422 context-overflow responses from the# new model because it uses the previous window
A unit-level repro is in agent-session.test.ts (PR2 will add this):
test("checkCompaction uses fresh model from registry",()=>{constoldModel={id: "deepseek-v4-flash-free",provider: "opencode",contextWindow: 200_000,/* ... */};constnewModel={id: "deepseek-v4-flash",provider: "opencode-go",contextWindow: 1_000_000,/* ... */};// session.agent.state.model = oldModel// mock sessionModelRegistry.find("opencode-go", "deepseek-v4-flash") → newModel// call checkCompaction// expect: threshold computed against 1_000_000, not 200_000});
Severity (why this is data-integrity, not cosmetic)
These are real failure modes observed or reproducible from the audit, not hypotheticals:
Auto-compaction fires too early after a switch to a larger-context model (e.g. 200k → 1M). The session silently shrinks the conversation at the old model's budget. Repro: above.
/status / webUI disagree with getContextUsage() and with the compaction trigger. A user can see "1M / 15% used" in /status and then have a compaction fire at 75% (computed against 200k). No status surface exposes the disagreement, so the user cannot debug it. Repro: above.
isRetryableError mis-classifies 422 context-overflow responses from the new model using the previous model's contextWindow. A perfectly good LLM response can be dropped or duplicated. The user loses the response or pays for an unnecessary retry that may itself hit the new model's real limit. Repro: switch to a model with a larger window, send a long prompt that is fine for the new model but looks oversized vs the old window.
Reasoning model → non-reasoning model switch.supportsThinking() returns true for the previous model, getAvailableThinkingLevels() returns reasoning levels for a model that does not support them, and clampThinkingLevel() happily returns a reasoning level the new model does not support. The next request is sent with a reasoning-effort header the new model either ignores or rejects, and on some providers the 400 then gets mis-classified by isRetryableError (see WA business, groups & office hours #3). Repro: switch between opencode-go/de deepseek-v4-flash (reasoning) and opencode/mimo-v2.5-free (no reasoning), open /thinking.
Branch summary routed through the wrong provider.generateBranchSummary uses this.model to derive api/baseUrl/headers for the summary call. If the user switches providers mid-session, every subsequent branch summary is sent to the previous provider's endpoint with the previous model's auth header. Best case: 404/422. Worst case: a malformed request that leaks auth to the wrong provider. Repro: switch from an openai-completions model to an anthropic-messages model, trigger a branch summary (large conversation or /summarize).
RunCompactionWork chooses the wrong summary parameters.compact() is called with this.model rather than the active model, so the max-output budget and reasoning style reflect the previous model. The summary is still produced but the prompt assembly is sub-optimal. Repro: switch reasoning ↔ non-reasoning mid-session, observe compaction summary length or reasoning content.
These collectively cross the bar from "display glitch" to "data integrity": the user can lose LLM responses, pay for redundant retries, route traffic to the wrong provider, and see silent disagreements between surfaces.
Environment
OpenClaw 2026.6.5 (5181e4f)
Local repo HEAD: 66b91d78fe (already aligned with upstream main)
All channels affected: Telegram inline picker, Discord model picker, voice-call model selection, inline /model + persist directives, CLI /model, session-patch tool, heartbeat model override, cron payload.model, liveModelSwitchPending path
Discovered while investigating: switching from opencode/deepseek-v4-flash-free (200k) to opencode-go/deepseek-v4-flash (1M) in Telegram → auto-compaction still fires at the 200k threshold and /status shows 1M (mismatch)
Root cause
AgentSession.this.model is a getter for this.agent.state.model (an agent-core snapshot). That field is assigned in exactly four places, none of which fire for the typical /model callback:
File:line
Assignment path
When it fires
agent-session.ts:1571
setModel(model)
CLI /model, slash command, extension API
agent-session.ts:1619
cycleAvailableModel paths (model-cycle hotkey)
Manual model cycling
agent-session.ts:1654
cycleAvailableModel paths (model-cycle hotkey)
Manual model cycling
agent-session.ts:2258
refreshCurrentModelFromRegistry()
Only on registerProvider / unregisterProvider — not on model switch
The channel-side /model callbacks (Telegram, Discord, voice, inline, persist) call applyModelOverrideToSessionEntry(...) in src/sessions/model-overrides.ts and then return. The next message goes through agent-command.ts:1207-1380 and model.ts:846, which correctly re-resolves the model from the registry and uses effectiveModel for the request and for pre-turn compaction. But this.model (the post-turn snapshot) is never updated, so every post-turn check that reads from it operates on stale data.
The refreshCurrentModelFromRegistry() function already exists and would do the right thing if it were called. It is just not wired into the /model flow.
Affected reads of this.model in AgentSession
All post-turn reads; every one of them sees stale data after a /model switch unless the helper fix is applied.
File:line
What is read
Stale-impact
agent-session.ts:1725
getSupportedThinkingLevels(this.model) — uses .reasoning and .thinkingLevelMap
Wrong available thinking levels
agent-session.ts:1732
this.model?.reasoning
Wrong supportsThinking
agent-session.ts:1746
clampThinkingLevel(this.model, level) — uses .reasoning and .thinkingLevelMap
Wrong clamp result
agent-session.ts:1926
compact(preparation, this.model, ...)
Wrong overflow threshold + wrong summary params
agent-session.ts:1991
this.model?.contextWindow (in checkCompaction)
Wrong compaction threshold
agent-session.ts:2573
this.model?.contextWindow (in isRetryableError)
Wrong retry classification
agent-session.ts:2900
this.model! (in branch summary)
Wrong provider/baseUrl/headers for summary
agent-session.ts:3091/3096
this.model and .contextWindow (in getContextUsage)
Wrong percentage in TUI / status tools
Fields that are not read from this.model and therefore not affected: cost.*, compat.*, baseUrl, api, headers, maxTokens, input, output, name, displayName, stream, toolCalls. They are read from effectiveModel (the per-attempt fresh model from resolveModelAsync) and remain correct. So the bug is bounded to the eight reads above; cost, compat, and request-routing for the main turn are fine.
Proposed fix (PR1a + PR1b + PR2)
Defense-in-depth: every read in the table above is rewritten to consult the registry first, falling back to the snapshot. No channel-side code is touched. No this.model consumer is broken (the snapshot still exists; the reads just prefer the registry copy when it is fresher).
PR1a — contextWindow hotfix (smallest, most user-visible)
registry has no entry for the snapshot's provider+id → helper returns the snapshot
registry has a different object with the same provider+id → helper returns the registry instance (and the value reflects the registry's catalog)
helper returns undefined and the caller has a guard → no !-related runtime crash
One integration test: mock sessionModelRegistry.find to return a different model instance, call all eight methods, assert each reflects the registry value.
~250 lines of test code; only agent-session.test.ts (and possibly agent-session.harness.test.ts) are touched.
Why not "fix it at the source by calling session.setModel from the channel handlers"?
Two reasons:
Scope. There are five channel-side callers plus the session-patch tool, the heartbeat model-override path, the cron payload.model path, and liveModelSwitchPending. Wiring setModel into all of them is more code than the helper fix and does not fix any of the post-turn reads that already operate on the wrong snapshot (the helper is needed regardless).
The snapshot is a contract.agent.state.model is treated as a frozen snapshot by agent-core. Several read sites (e.g. isRetryableError line 2573) assume "the model that produced the assistant message I'm inspecting." Replacing it mid-turn with a different model object instance is a larger semantic change than the symptom warrants and risks subtle ordering bugs (see refreshCurrentModelFromRegistry's === guard at line 2252 — the contract is "only replace if reference actually changed", and the helper approach is closer to that contract than re-calling setModel).
What is not being changed
No channel handler is modified (Telegram, Discord, voice, inline, persist).
applyModelOverrideToSessionEntry is not modified.
liveModelSwitchPending is not modified.
The setModel / cycleModel / refreshCurrentModelFromRegistry APIs are not modified.
resolveModelAsync and the per-attempt effectiveModel are not modified (they are already correct).
compat, cost, baseUrl, api, headers, maxTokens, input, output, name, displayName, stream are not modified and are not in scope (they are 0-read on this.model and therefore not affected by this bug).
TL;DR
AgentSession.this.modelis never refreshed after/modelis used. The pre-attempt LLM path resolves the new model from the registry correctly, but the session-internalthis.modelsnapshot (used by 8 post-turn reads inAgentSession) keeps a reference to the previous model. After a switch, post-turn decisions use stalecontextWindow,reasoning,thinkingLevelMap, and stale provider routing for branch summaries./status, the webUI, andgetContextUsage()disagree with each other and with the actual compaction threshold.This is a regression of model-switch correctness for the session layer; it is not specific to any single channel or runtime, and it is invisible from
/statusand the webUI (which both re-resolve on every read).This issue supersedes #92379. The original report only documented the
contextWindowsymptom; the full audit (PR1a/PR1b) shows the same root cause affects 8 post-turn reads and a number of distinct user-visible failure modes (see "Severity" below).Minimum reproduction
A unit-level repro is in
agent-session.test.ts(PR2 will add this):Severity (why this is data-integrity, not cosmetic)
These are real failure modes observed or reproducible from the audit, not hypotheticals:
Auto-compaction fires too early after a switch to a larger-context model (e.g. 200k → 1M). The session silently shrinks the conversation at the old model's budget. Repro: above.
/status/ webUI disagree withgetContextUsage()and with the compaction trigger. A user can see "1M / 15% used" in/statusand then have a compaction fire at 75% (computed against 200k). No status surface exposes the disagreement, so the user cannot debug it. Repro: above.isRetryableErrormis-classifies 422 context-overflow responses from the new model using the previous model'scontextWindow. A perfectly good LLM response can be dropped or duplicated. The user loses the response or pays for an unnecessary retry that may itself hit the new model's real limit. Repro: switch to a model with a larger window, send a long prompt that is fine for the new model but looks oversized vs the old window.Reasoning model → non-reasoning model switch.
supportsThinking()returnstruefor the previous model,getAvailableThinkingLevels()returns reasoning levels for a model that does not support them, andclampThinkingLevel()happily returns a reasoning level the new model does not support. The next request is sent with a reasoning-effort header the new model either ignores or rejects, and on some providers the 400 then gets mis-classified byisRetryableError(see WA business, groups & office hours #3). Repro: switch betweenopencode-go/de deepseek-v4-flash(reasoning) andopencode/mimo-v2.5-free(no reasoning), open/thinking.Branch summary routed through the wrong provider.
generateBranchSummaryusesthis.modelto deriveapi/baseUrl/headersfor the summary call. If the user switches providers mid-session, every subsequent branch summary is sent to the previous provider's endpoint with the previous model's auth header. Best case: 404/422. Worst case: a malformed request that leaks auth to the wrong provider. Repro: switch from anopenai-completionsmodel to ananthropic-messagesmodel, trigger a branch summary (large conversation or/summarize).RunCompactionWork chooses the wrong summary parameters.
compact()is called withthis.modelrather than the active model, so the max-output budget and reasoning style reflect the previous model. The summary is still produced but the prompt assembly is sub-optimal. Repro: switch reasoning ↔ non-reasoning mid-session, observe compaction summary length or reasoning content.These collectively cross the bar from "display glitch" to "data integrity": the user can lose LLM responses, pay for redundant retries, route traffic to the wrong provider, and see silent disagreements between surfaces.
Environment
OpenClaw 2026.6.5(5181e4f)66b91d78fe(already aligned with upstreammain)/model+ persist directives, CLI/model, session-patch tool, heartbeat model override, cronpayload.model,liveModelSwitchPendingpathopencode/deepseek-v4-flash-free(200k) toopencode-go/deepseek-v4-flash(1M) in Telegram → auto-compaction still fires at the 200k threshold and/statusshows 1M (mismatch)Root cause
AgentSession.this.modelis a getter forthis.agent.state.model(anagent-coresnapshot). That field is assigned in exactly four places, none of which fire for the typical/modelcallback:agent-session.ts:1571setModel(model)/model, slash command, extension APIagent-session.ts:1619cycleAvailableModelpaths (model-cycle hotkey)agent-session.ts:1654cycleAvailableModelpaths (model-cycle hotkey)agent-session.ts:2258refreshCurrentModelFromRegistry()registerProvider/unregisterProvider— not on model switchThe channel-side
/modelcallbacks (Telegram, Discord, voice, inline, persist) callapplyModelOverrideToSessionEntry(...)insrc/sessions/model-overrides.tsand then return. The next message goes throughagent-command.ts:1207-1380andmodel.ts:846, which correctly re-resolves the model from the registry and useseffectiveModelfor the request and for pre-turn compaction. Butthis.model(the post-turn snapshot) is never updated, so every post-turn check that reads from it operates on stale data.The
refreshCurrentModelFromRegistry()function already exists and would do the right thing if it were called. It is just not wired into the/modelflow.Affected reads of
this.modelinAgentSessionAll post-turn reads; every one of them sees stale data after a
/modelswitch unless the helper fix is applied.agent-session.ts:1725getSupportedThinkingLevels(this.model)— uses.reasoningand.thinkingLevelMapagent-session.ts:1732this.model?.reasoningsupportsThinkingagent-session.ts:1746clampThinkingLevel(this.model, level)— uses.reasoningand.thinkingLevelMapagent-session.ts:1926compact(preparation, this.model, ...)agent-session.ts:1991this.model?.contextWindow(incheckCompaction)agent-session.ts:2573this.model?.contextWindow(inisRetryableError)agent-session.ts:2900this.model!(in branch summary)baseUrl/headersfor summaryagent-session.ts:3091/3096this.modeland.contextWindow(ingetContextUsage)Fields that are not read from
this.modeland therefore not affected:cost.*,compat.*,baseUrl,api,headers,maxTokens,input,output,name,displayName,stream,toolCalls. They are read fromeffectiveModel(the per-attempt fresh model fromresolveModelAsync) and remain correct. So the bug is bounded to the eight reads above; cost, compat, and request-routing for the main turn are fine.Proposed fix (PR1a + PR1b + PR2)
Defense-in-depth: every read in the table above is rewritten to consult the registry first, falling back to the snapshot. No channel-side code is touched. No
this.modelconsumer is broken (the snapshot still exists; the reads just prefer the registry copy when it is fresher).PR1a —
contextWindowhotfix (smallest, most user-visible)agent-session.ts:agent-session.ts:1991—checkCompaction:this.resolveFreshModel()?.contextWindow ?? 0agent-session.ts:2573—isRetryableError: sameagent-session.ts:3096—getContextUsage: samePR1b — Remaining stale fields and object-passes
agent-session.ts:1725—getAvailableThinkingLevels(this.resolveFreshModel() ?? this.model!)agent-session.ts:1732—Boolean(this.resolveFreshModel()?.reasoning)agent-session.ts:1746—clampThinkingLevel(this.resolveFreshModel() ?? this.model!, level)agent-session.ts:1926—compact(preparation, this.resolveFreshModel() ?? this.model, ...)agent-session.ts:2900—const model = this.resolveFreshModel() ?? this.model!;PR2 — Tests
this.modelundefined → helper returns undefined, callers degrade gracefullyprovider+id→ helper returns the snapshotprovider+id→ helper returns the registry instance (and the value reflects the registry's catalog)!-related runtime crashsessionModelRegistry.findto return a different model instance, call all eight methods, assert each reflects the registry value.agent-session.test.ts(and possiblyagent-session.harness.test.ts) are touched.Why not "fix it at the source by calling
session.setModelfrom the channel handlers"?Two reasons:
payload.modelpath, andliveModelSwitchPending. WiringsetModelinto all of them is more code than the helper fix and does not fix any of the post-turn reads that already operate on the wrong snapshot (the helper is needed regardless).agent.state.modelis treated as a frozen snapshot byagent-core. Several read sites (e.g.isRetryableErrorline 2573) assume "the model that produced the assistant message I'm inspecting." Replacing it mid-turn with a different model object instance is a larger semantic change than the symptom warrants and risks subtle ordering bugs (seerefreshCurrentModelFromRegistry's===guard at line 2252 — the contract is "only replace if reference actually changed", and the helper approach is closer to that contract than re-callingsetModel).What is not being changed
applyModelOverrideToSessionEntryis not modified.liveModelSwitchPendingis not modified.setModel/cycleModel/refreshCurrentModelFromRegistryAPIs are not modified.resolveModelAsyncand the per-attempteffectiveModelare not modified (they are already correct).compat,cost,baseUrl,api,headers,maxTokens,input,output,name,displayName,streamare not modified and are not in scope (they are 0-read onthis.modeland therefore not affected by this bug).Suggested labels
bug,area:sessions,area:compaction,area:thinking,regression,severity:data-integritySupersedes
#92379 (closed as superseded by this issue).