Skip to content

Session-level AgentSession.this.model snapshot is never refreshed after /model switch (affects contextWindow, reasoning, thinkingLevelMap, branch summary) #92415

Description

@samson910022

TL;DR

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", () => {
  const oldModel = { id: "deepseek-v4-flash-free", provider: "opencode", contextWindow: 200_000, /* ... */ };
  const newModel = { 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:

  1. 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.

  2. /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.

  3. 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.

  4. 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.

  5. 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).

  6. 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)

  • New private helper in agent-session.ts:
    private resolveFreshModel(): Model | undefined {
      const current = this.model;
      if (!current) return undefined;
      return this.sessionModelRegistry.find(current.provider, current.id) ?? current;
    }
  • Replace three call sites:
    • agent-session.ts:1991checkCompaction: this.resolveFreshModel()?.contextWindow ?? 0
    • agent-session.ts:2573isRetryableError: same
    • agent-session.ts:3096getContextUsage: same
  • ~20 lines of code; isolated to one file.

PR1b — Remaining stale fields and object-passes

  • Use the same helper for the remaining five call sites:
    • agent-session.ts:1725getAvailableThinkingLevels(this.resolveFreshModel() ?? this.model!)
    • agent-session.ts:1732Boolean(this.resolveFreshModel()?.reasoning)
    • agent-session.ts:1746clampThinkingLevel(this.resolveFreshModel() ?? this.model!, level)
    • agent-session.ts:1926compact(preparation, this.resolveFreshModel() ?? this.model, ...)
    • agent-session.ts:2900const model = this.resolveFreshModel() ?? this.model!;
  • ~15 lines of code; same file.

PR2 — Tests

  • Unit tests (12 total) for the helper itself and each of the eight call sites, including:
    • this.model undefined → helper returns undefined, callers degrade gracefully
    • 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:

  1. 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).
  2. 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).

Suggested labels

bug, area:sessions, area:compaction, area:thinking, regression, severity:data-integrity

Supersedes

#92379 (closed as superseded by this issue).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerAuth, provider routing, model choice, or SecretRef resolution may break.impact:data-lossCan lose, corrupt, or silently drop user/session/config data.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions