Skip to content

Commit 6c9b49a

Browse files
yuweuiijalehman
andauthored
fix(sessions): clear stale contextTokens on model switch (#38044)
Merged via squash. Prepared head SHA: bac2df4 Co-authored-by: yuweuii <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent caf1b84 commit 6c9b49a

File tree

8 files changed

+99
-11
lines changed

8 files changed

+99
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
2323
- 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.
2424
- 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.
2525
- 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.
26+
- 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.
2627

2728
## 2026.3.7
2829

scripts/test-parallel.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const unitIsolatedFilesRaw = [
3131
"src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts",
3232
// Setup-heavy CLI update flow suite; move off unit-fast critical path.
3333
"src/cli/update-cli.test.ts",
34+
// Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes.
35+
"src/infra/git-commit.test.ts",
3436
// Expensive schema build/bootstrap checks; keep coverage but run in isolated lane.
3537
"src/config/schema.test.ts",
3638
"src/config/schema.tags.test.ts",

src/auto-reply/status.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
55
import { withTempHome } from "../../test/helpers/temp-home.js";
66
import type { OpenClawConfig } from "../config/config.js";
7+
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
78
import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js";
89
import {
910
buildCommandsMessage,
@@ -172,6 +173,39 @@ describe("buildStatusMessage", () => {
172173
expect(normalizeTestText(text)).toContain("Context: 200k/1.0m");
173174
});
174175

176+
it("recomputes context window from the active model after switching away from a smaller session override", () => {
177+
const sessionEntry = {
178+
sessionId: "switch-back",
179+
updatedAt: 0,
180+
providerOverride: "local",
181+
modelOverride: "small-model",
182+
contextTokens: 4_096,
183+
totalTokens: 1_024,
184+
};
185+
186+
applyModelOverrideToSessionEntry({
187+
entry: sessionEntry,
188+
selection: {
189+
provider: "local",
190+
model: "large-model",
191+
isDefault: true,
192+
},
193+
});
194+
195+
const text = buildStatusMessage({
196+
agent: {
197+
model: "local/large-model",
198+
contextTokens: 65_536,
199+
},
200+
sessionEntry,
201+
sessionKey: "agent:main:main",
202+
sessionScope: "per-sender",
203+
queue: { mode: "collect", depth: 0 },
204+
});
205+
206+
expect(normalizeTestText(text)).toContain("Context: 1.0k/66k");
207+
});
208+
175209
it("uses per-agent sandbox config when config and session key are provided", () => {
176210
const text = buildStatusMessage({
177211
config: {

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
3636
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
3737
const resolveGatewayPort = vi.fn(() => 18789);
3838
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
39-
const probeGateway = vi.fn<
40-
(opts: {
41-
url: string;
42-
auth?: { token?: string; password?: string };
43-
timeoutMs: number;
44-
}) => Promise<{
45-
ok: boolean;
46-
configSnapshot: unknown;
47-
}>
48-
>();
39+
const probeGateway =
40+
vi.fn<
41+
(opts: {
42+
url: string;
43+
auth?: { token?: string; password?: string };
44+
timeoutMs: number;
45+
}) => Promise<{
46+
ok: boolean;
47+
configSnapshot: unknown;
48+
}>
49+
>();
4950
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
5051
const loadConfig = vi.fn(() => ({}));
5152

src/infra/git-commit.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ describe("git commit resolution", () => {
4343

4444
afterEach(() => {
4545
process.chdir(originalCwd);
46+
vi.restoreAllMocks();
47+
vi.doUnmock("node:fs");
48+
vi.doUnmock("node:module");
4649
vi.resetModules();
4750
});
4851

src/process/supervisor/adapters/child.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ChildProcess } from "node:child_process";
22
import { EventEmitter } from "node:events";
33
import { PassThrough } from "node:stream";
4-
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
55

66
const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({
77
spawnWithFallbackMock: vi.fn(),
@@ -58,6 +58,10 @@ describe("createChildAdapter", () => {
5858
beforeEach(() => {
5959
spawnWithFallbackMock.mockClear();
6060
killProcessTreeMock.mockClear();
61+
delete process.env.OPENCLAW_SERVICE_MARKER;
62+
});
63+
64+
afterAll(() => {
6165
if (originalServiceMarker === undefined) {
6266
delete process.env.OPENCLAW_SERVICE_MARKER;
6367
} else {

src/sessions/model-overrides.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ describe("applyModelOverrideToSessionEntry", () => {
3030
model: "claude-sonnet-4-6",
3131
providerOverride: "anthropic",
3232
modelOverride: "claude-sonnet-4-6",
33+
contextTokens: 160_000,
3334
fallbackNoticeSelectedModel: "anthropic/claude-sonnet-4-6",
3435
fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6",
3536
fallbackNoticeReason: "provider temporary failure",
@@ -39,6 +40,7 @@ describe("applyModelOverrideToSessionEntry", () => {
3940

4041
expect(result.updated).toBe(true);
4142
expectRuntimeModelFieldsCleared(entry, before);
43+
expect(entry.contextTokens).toBeUndefined();
4244
expect(entry.fallbackNoticeSelectedModel).toBeUndefined();
4345
expect(entry.fallbackNoticeActiveModel).toBeUndefined();
4446
expect(entry.fallbackNoticeReason).toBeUndefined();
@@ -53,12 +55,14 @@ describe("applyModelOverrideToSessionEntry", () => {
5355
model: "claude-sonnet-4-6",
5456
providerOverride: "openai",
5557
modelOverride: "gpt-5.2",
58+
contextTokens: 160_000,
5659
};
5760

5861
const result = applyOpenAiSelection(entry);
5962

6063
expect(result.updated).toBe(true);
6164
expectRuntimeModelFieldsCleared(entry, before);
65+
expect(entry.contextTokens).toBeUndefined();
6266
});
6367

6468
it("retains aligned runtime model fields when selection and runtime already match", () => {
@@ -70,6 +74,7 @@ describe("applyModelOverrideToSessionEntry", () => {
7074
model: "gpt-5.2",
7175
providerOverride: "openai",
7276
modelOverride: "gpt-5.2",
77+
contextTokens: 200_000,
7378
};
7479

7580
const result = applyModelOverrideToSessionEntry({
@@ -83,6 +88,33 @@ describe("applyModelOverrideToSessionEntry", () => {
8388
expect(result.updated).toBe(false);
8489
expect(entry.modelProvider).toBe("openai");
8590
expect(entry.model).toBe("gpt-5.2");
91+
expect(entry.contextTokens).toBe(200_000);
8692
expect(entry.updatedAt).toBe(before);
8793
});
94+
95+
it("clears stale contextTokens when switching back to the default model", () => {
96+
const before = Date.now() - 5_000;
97+
const entry: SessionEntry = {
98+
sessionId: "sess-4",
99+
updatedAt: before,
100+
providerOverride: "local",
101+
modelOverride: "sunapi386/llama-3-lexi-uncensored:8b",
102+
contextTokens: 4_096,
103+
};
104+
105+
const result = applyModelOverrideToSessionEntry({
106+
entry,
107+
selection: {
108+
provider: "local",
109+
model: "llama3.1:8b",
110+
isDefault: true,
111+
},
112+
});
113+
114+
expect(result.updated).toBe(true);
115+
expect(entry.providerOverride).toBeUndefined();
116+
expect(entry.modelOverride).toBeUndefined();
117+
expect(entry.contextTokens).toBeUndefined();
118+
expect((entry.updatedAt ?? 0) > before).toBe(true);
119+
});
88120
});

src/sessions/model-overrides.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ export function applyModelOverrideToSessionEntry(params: {
6161
}
6262
}
6363

64+
// contextTokens are derived from the active session model. When the selected
65+
// model changes (or runtime model is already stale), the cached window can
66+
// pin the session to an older/smaller limit until another run refreshes it.
67+
if (
68+
entry.contextTokens !== undefined &&
69+
(selectionUpdated || (runtimePresent && !runtimeAligned))
70+
) {
71+
delete entry.contextTokens;
72+
updated = true;
73+
}
74+
6475
if (profileOverride) {
6576
if (entry.authProfileOverride !== profileOverride) {
6677
entry.authProfileOverride = profileOverride;

0 commit comments

Comments
 (0)