TUI: sync /model status immediately

This commit is contained in:
Vignesh Natarajan
2026-02-28 14:02:56 -08:00
parent a623c9c8d2
commit 0929c233d8
5 changed files with 144 additions and 5 deletions

View File

@@ -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 `<relevant-memories>...</relevant-memories>` 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.

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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;
}
}