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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ describe("git commit resolution", () => {
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
vi.restoreAllMocks();
|
||||
vi.doUnmock("node:fs");
|
||||
vi.doUnmock("node:module");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user