mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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(() => ({}));
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user