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

Merged via squash.

Prepared head SHA: bac2df4b7f
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. - 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. - 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. - 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 ## 2026.3.7

View File

@@ -31,6 +31,8 @@ const unitIsolatedFilesRaw = [
"src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts",
// Setup-heavy CLI update flow suite; move off unit-fast critical path. // Setup-heavy CLI update flow suite; move off unit-fast critical path.
"src/cli/update-cli.test.ts", "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. // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane.
"src/config/schema.test.ts", "src/config/schema.test.ts",
"src/config/schema.tags.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 { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js"; import { withTempHome } from "../../test/helpers/temp-home.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js"; import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js";
import { import {
buildCommandsMessage, buildCommandsMessage,
@@ -172,6 +173,39 @@ describe("buildStatusMessage", () => {
expect(normalizeTestText(text)).toContain("Context: 200k/1.0m"); 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", () => { it("uses per-agent sandbox config when config and session key are provided", () => {
const text = buildStatusMessage({ const text = buildStatusMessage({
config: { config: {

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ describe("applyModelOverrideToSessionEntry", () => {
model: "claude-sonnet-4-6", model: "claude-sonnet-4-6",
providerOverride: "anthropic", providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-6", modelOverride: "claude-sonnet-4-6",
contextTokens: 160_000,
fallbackNoticeSelectedModel: "anthropic/claude-sonnet-4-6", fallbackNoticeSelectedModel: "anthropic/claude-sonnet-4-6",
fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6", fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6",
fallbackNoticeReason: "provider temporary failure", fallbackNoticeReason: "provider temporary failure",
@@ -39,6 +40,7 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(result.updated).toBe(true); expect(result.updated).toBe(true);
expectRuntimeModelFieldsCleared(entry, before); expectRuntimeModelFieldsCleared(entry, before);
expect(entry.contextTokens).toBeUndefined();
expect(entry.fallbackNoticeSelectedModel).toBeUndefined(); expect(entry.fallbackNoticeSelectedModel).toBeUndefined();
expect(entry.fallbackNoticeActiveModel).toBeUndefined(); expect(entry.fallbackNoticeActiveModel).toBeUndefined();
expect(entry.fallbackNoticeReason).toBeUndefined(); expect(entry.fallbackNoticeReason).toBeUndefined();
@@ -53,12 +55,14 @@ describe("applyModelOverrideToSessionEntry", () => {
model: "claude-sonnet-4-6", model: "claude-sonnet-4-6",
providerOverride: "openai", providerOverride: "openai",
modelOverride: "gpt-5.2", modelOverride: "gpt-5.2",
contextTokens: 160_000,
}; };
const result = applyOpenAiSelection(entry); const result = applyOpenAiSelection(entry);
expect(result.updated).toBe(true); expect(result.updated).toBe(true);
expectRuntimeModelFieldsCleared(entry, before); expectRuntimeModelFieldsCleared(entry, before);
expect(entry.contextTokens).toBeUndefined();
}); });
it("retains aligned runtime model fields when selection and runtime already match", () => { it("retains aligned runtime model fields when selection and runtime already match", () => {
@@ -70,6 +74,7 @@ describe("applyModelOverrideToSessionEntry", () => {
model: "gpt-5.2", model: "gpt-5.2",
providerOverride: "openai", providerOverride: "openai",
modelOverride: "gpt-5.2", modelOverride: "gpt-5.2",
contextTokens: 200_000,
}; };
const result = applyModelOverrideToSessionEntry({ const result = applyModelOverrideToSessionEntry({
@@ -83,6 +88,33 @@ describe("applyModelOverrideToSessionEntry", () => {
expect(result.updated).toBe(false); expect(result.updated).toBe(false);
expect(entry.modelProvider).toBe("openai"); expect(entry.modelProvider).toBe("openai");
expect(entry.model).toBe("gpt-5.2"); expect(entry.model).toBe("gpt-5.2");
expect(entry.contextTokens).toBe(200_000);
expect(entry.updatedAt).toBe(before); 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 (profileOverride) {
if (entry.authProfileOverride !== profileOverride) { if (entry.authProfileOverride !== profileOverride) {
entry.authProfileOverride = profileOverride; entry.authProfileOverride = profileOverride;