fix(sessions): clear stale contextTokens on model switch (#38044)

Merged via squash.

Prepared head SHA: bac2df4b7f920ce271f0a15f1db9ed99b35300f3
Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
yuweuii
2026-03-09 01:59:16 +08:00
committed by GitHub
parent caf1b84822
commit 6c9b49a10b
8 changed files with 99 additions and 11 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
## 2026.3.7

View File

@@ -31,6 +31,8 @@ const unitIsolatedFilesRaw = [
"src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts",
// Setup-heavy CLI update flow suite; move off unit-fast critical path.
"src/cli/update-cli.test.ts",
// Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes.
"src/infra/git-commit.test.ts",
// Expensive schema build/bootstrap checks; keep coverage but run in isolated lane.
"src/config/schema.test.ts",
"src/config/schema.tags.test.ts",

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js";
import {
buildCommandsMessage,
@@ -172,6 +173,39 @@ describe("buildStatusMessage", () => {
expect(normalizeTestText(text)).toContain("Context: 200k/1.0m");
});
it("recomputes context window from the active model after switching away from a smaller session override", () => {
const sessionEntry = {
sessionId: "switch-back",
updatedAt: 0,
providerOverride: "local",
modelOverride: "small-model",
contextTokens: 4_096,
totalTokens: 1_024,
};
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: {
provider: "local",
model: "large-model",
isDefault: true,
},
});
const text = buildStatusMessage({
agent: {
model: "local/large-model",
contextTokens: 65_536,
},
sessionEntry,
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
});
expect(normalizeTestText(text)).toContain("Context: 1.0k/66k");
});
it("uses per-agent sandbox config when config and session key are provided", () => {
const text = buildStatusMessage({
config: {

View File

@@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
const resolveGatewayPort = vi.fn(() => 18789);
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
const probeGateway = vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const probeGateway =
vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.fn(() => ({}));

View File

@@ -43,6 +43,9 @@ describe("git commit resolution", () => {
afterEach(() => {
process.chdir(originalCwd);
vi.restoreAllMocks();
vi.doUnmock("node:fs");
vi.doUnmock("node:module");
vi.resetModules();
});

View File

@@ -1,7 +1,7 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({
spawnWithFallbackMock: vi.fn(),
@@ -58,6 +58,10 @@ describe("createChildAdapter", () => {
beforeEach(() => {
spawnWithFallbackMock.mockClear();
killProcessTreeMock.mockClear();
delete process.env.OPENCLAW_SERVICE_MARKER;
});
afterAll(() => {
if (originalServiceMarker === undefined) {
delete process.env.OPENCLAW_SERVICE_MARKER;
} else {

View File

@@ -30,6 +30,7 @@ describe("applyModelOverrideToSessionEntry", () => {
model: "claude-sonnet-4-6",
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-6",
contextTokens: 160_000,
fallbackNoticeSelectedModel: "anthropic/claude-sonnet-4-6",
fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6",
fallbackNoticeReason: "provider temporary failure",
@@ -39,6 +40,7 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(result.updated).toBe(true);
expectRuntimeModelFieldsCleared(entry, before);
expect(entry.contextTokens).toBeUndefined();
expect(entry.fallbackNoticeSelectedModel).toBeUndefined();
expect(entry.fallbackNoticeActiveModel).toBeUndefined();
expect(entry.fallbackNoticeReason).toBeUndefined();
@@ -53,12 +55,14 @@ describe("applyModelOverrideToSessionEntry", () => {
model: "claude-sonnet-4-6",
providerOverride: "openai",
modelOverride: "gpt-5.2",
contextTokens: 160_000,
};
const result = applyOpenAiSelection(entry);
expect(result.updated).toBe(true);
expectRuntimeModelFieldsCleared(entry, before);
expect(entry.contextTokens).toBeUndefined();
});
it("retains aligned runtime model fields when selection and runtime already match", () => {
@@ -70,6 +74,7 @@ describe("applyModelOverrideToSessionEntry", () => {
model: "gpt-5.2",
providerOverride: "openai",
modelOverride: "gpt-5.2",
contextTokens: 200_000,
};
const result = applyModelOverrideToSessionEntry({
@@ -83,6 +88,33 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(result.updated).toBe(false);
expect(entry.modelProvider).toBe("openai");
expect(entry.model).toBe("gpt-5.2");
expect(entry.contextTokens).toBe(200_000);
expect(entry.updatedAt).toBe(before);
});
it("clears stale contextTokens when switching back to the default model", () => {
const before = Date.now() - 5_000;
const entry: SessionEntry = {
sessionId: "sess-4",
updatedAt: before,
providerOverride: "local",
modelOverride: "sunapi386/llama-3-lexi-uncensored:8b",
contextTokens: 4_096,
};
const result = applyModelOverrideToSessionEntry({
entry,
selection: {
provider: "local",
model: "llama3.1:8b",
isDefault: true,
},
});
expect(result.updated).toBe(true);
expect(entry.providerOverride).toBeUndefined();
expect(entry.modelOverride).toBeUndefined();
expect(entry.contextTokens).toBeUndefined();
expect((entry.updatedAt ?? 0) > before).toBe(true);
});
});

View File

@@ -61,6 +61,17 @@ export function applyModelOverrideToSessionEntry(params: {
}
}
// contextTokens are derived from the active session model. When the selected
// model changes (or runtime model is already stale), the cached window can
// pin the session to an older/smaller limit until another run refreshes it.
if (
entry.contextTokens !== undefined &&
(selectionUpdated || (runtimePresent && !runtimeAligned))
) {
delete entry.contextTokens;
updated = true;
}
if (profileOverride) {
if (entry.authProfileOverride !== profileOverride) {
entry.authProfileOverride = profileOverride;