diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f72564ee2..9d05f853c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Docs: https://docs.openclaw.ai - Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007. - Web UI/Assistant text: strip internal `...` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70. - Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz. +- TUI/Session model status: clear stale runtime model identity when model overrides change so `/model` updates are reflected immediately in `sessions.patch` responses and `sessions.list` status surfaces. (#28619) Thanks @lejean2000. - Podman/Quadlet setup: fix `sed` escaping and UID mismatch in Podman Quadlet setup. (#26414) Thanks @KnHack and @vincentkoc. - Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc. - Agents/Ollama discovery: skip Ollama discovery when explicit models are configured. (#28827) Thanks @Kansodata and @vincentkoc. diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index d9ee2aea47d..bba4f6658a9 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -466,6 +466,9 @@ export const sessionsHandlers: GatewayRequestHandlers = { const next = await updateSessionStore(storePath, (store) => { const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); const entry = store[primaryKey]; + const parsed = parseAgentSessionKey(primaryKey); + const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); + const resolvedModel = resolveSessionModelRef(cfg, entry, sessionAgentId); oldSessionId = entry?.sessionId; oldSessionFile = entry?.sessionFile; const now = Date.now(); @@ -478,8 +481,8 @@ export const sessionsHandlers: GatewayRequestHandlers = { verboseLevel: entry?.verboseLevel, reasoningLevel: entry?.reasoningLevel, responseUsage: entry?.responseUsage, - model: entry?.model, - modelProvider: entry?.modelProvider, + model: resolvedModel.model, + modelProvider: resolvedModel.provider, contextTokens: entry?.contextTokens, sendPolicy: entry?.sendPolicy, label: entry?.label, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 244b0adce1b..09090e3c2f8 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -447,7 +447,13 @@ describe("gateway server sessions", () => { piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }]; const modelPatched = await rpcReq<{ ok: true; - entry: { modelOverride?: string; providerOverride?: string }; + entry: { + modelOverride?: string; + providerOverride?: string; + model?: string; + modelProvider?: string; + }; + resolved?: { model?: string; modelProvider?: string }; }>(ws, "sessions.patch", { key: "agent:main:main", model: "openai/gpt-test-a", @@ -455,6 +461,20 @@ describe("gateway server sessions", () => { expect(modelPatched.ok).toBe(true); expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a"); expect(modelPatched.payload?.entry.providerOverride).toBe("openai"); + expect(modelPatched.payload?.entry.model).toBeUndefined(); + expect(modelPatched.payload?.entry.modelProvider).toBeUndefined(); + expect(modelPatched.payload?.resolved?.modelProvider).toBe("openai"); + expect(modelPatched.payload?.resolved?.model).toBe("gpt-test-a"); + + const listAfterModelPatch = await rpcReq<{ + sessions: Array<{ key: string; modelProvider?: string; model?: string }>; + }>(ws, "sessions.list", {}); + expect(listAfterModelPatch.ok).toBe(true); + const mainAfterModelPatch = listAfterModelPatch.payload?.sessions.find( + (session) => session.key === "agent:main:main", + ); + expect(mainAfterModelPatch?.modelProvider).toBe("openai"); + expect(mainAfterModelPatch?.model).toBe("gpt-test-a"); const compacted = await rpcReq<{ ok: true; compacted: boolean }>(ws, "sessions.compact", { key: "agent:main:main", @@ -492,8 +512,8 @@ describe("gateway server sessions", () => { expect(reset.ok).toBe(true); expect(reset.payload?.key).toBe("agent:main:main"); expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); - expect(reset.payload?.entry.modelProvider).toBe("anthropic"); - expect(reset.payload?.entry.model).toBe("claude-sonnet-4-6"); + expect(reset.payload?.entry.modelProvider).toBe("openai"); + expect(reset.payload?.entry.model).toBe("gpt-test-a"); const filesAfterReset = await fs.readdir(dir); expect(filesAfterReset.some((f) => f.startsWith("sess-main.jsonl.reset."))).toBe(true); diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts new file mode 100644 index 00000000000..7e5d1b0b117 --- /dev/null +++ b/src/sessions/model-overrides.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import { applyModelOverrideToSessionEntry } from "./model-overrides.js"; + +describe("applyModelOverrideToSessionEntry", () => { + it("clears stale runtime model fields when switching overrides", () => { + const before = Date.now() - 5_000; + const entry: SessionEntry = { + sessionId: "sess-1", + updatedAt: before, + modelProvider: "anthropic", + model: "claude-sonnet-4-6", + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-6", + fallbackNoticeSelectedModel: "anthropic/claude-sonnet-4-6", + fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6", + fallbackNoticeReason: "provider temporary failure", + }; + + const result = applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: "openai", + model: "gpt-5.2", + }, + }); + + expect(result.updated).toBe(true); + expect(entry.providerOverride).toBe("openai"); + expect(entry.modelOverride).toBe("gpt-5.2"); + expect(entry.modelProvider).toBeUndefined(); + expect(entry.model).toBeUndefined(); + expect(entry.fallbackNoticeSelectedModel).toBeUndefined(); + expect(entry.fallbackNoticeActiveModel).toBeUndefined(); + expect(entry.fallbackNoticeReason).toBeUndefined(); + expect((entry.updatedAt ?? 0) > before).toBe(true); + }); + + it("clears stale runtime model fields even when override selection is unchanged", () => { + const before = Date.now() - 5_000; + const entry: SessionEntry = { + sessionId: "sess-2", + updatedAt: before, + modelProvider: "anthropic", + model: "claude-sonnet-4-6", + providerOverride: "openai", + modelOverride: "gpt-5.2", + }; + + const result = applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: "openai", + model: "gpt-5.2", + }, + }); + + expect(result.updated).toBe(true); + expect(entry.providerOverride).toBe("openai"); + expect(entry.modelOverride).toBe("gpt-5.2"); + expect(entry.modelProvider).toBeUndefined(); + expect(entry.model).toBeUndefined(); + expect((entry.updatedAt ?? 0) > before).toBe(true); + }); + + it("retains aligned runtime model fields when selection and runtime already match", () => { + const before = Date.now() - 5_000; + const entry: SessionEntry = { + sessionId: "sess-3", + updatedAt: before, + modelProvider: "openai", + model: "gpt-5.2", + providerOverride: "openai", + modelOverride: "gpt-5.2", + }; + + const result = applyModelOverrideToSessionEntry({ + entry, + selection: { + provider: "openai", + model: "gpt-5.2", + }, + }); + + expect(result.updated).toBe(false); + expect(entry.modelProvider).toBe("openai"); + expect(entry.model).toBe("gpt-5.2"); + expect(entry.updatedAt).toBe(before); + }); +}); diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index 5a8b9ea8268..910d324ee08 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -15,24 +15,49 @@ export function applyModelOverrideToSessionEntry(params: { const { entry, selection, profileOverride } = params; const profileOverrideSource = params.profileOverrideSource ?? "user"; let updated = false; + let selectionUpdated = false; if (selection.isDefault) { if (entry.providerOverride) { delete entry.providerOverride; updated = true; + selectionUpdated = true; } if (entry.modelOverride) { delete entry.modelOverride; updated = true; + selectionUpdated = true; } } else { if (entry.providerOverride !== selection.provider) { entry.providerOverride = selection.provider; updated = true; + selectionUpdated = true; } if (entry.modelOverride !== selection.model) { entry.modelOverride = selection.model; updated = true; + selectionUpdated = true; + } + } + + // Model overrides supersede previously recorded runtime model identity. + // If runtime fields are stale (or the override changed), clear them so status + // surfaces reflect the selected model immediately. + const runtimeModel = typeof entry.model === "string" ? entry.model.trim() : ""; + const runtimeProvider = typeof entry.modelProvider === "string" ? entry.modelProvider.trim() : ""; + const runtimePresent = runtimeModel.length > 0 || runtimeProvider.length > 0; + const runtimeAligned = + runtimeModel === selection.model && + (runtimeProvider.length === 0 || runtimeProvider === selection.provider); + if (runtimePresent && (selectionUpdated || !runtimeAligned)) { + if (entry.model !== undefined) { + delete entry.model; + updated = true; + } + if (entry.modelProvider !== undefined) { + delete entry.modelProvider; + updated = true; } }