From cc445a32bb661dc6f5da14773689195be58a6e51 Mon Sep 17 00:00:00 2001 From: Erick Kinnee Date: Tue, 18 Aug 2026 09:13:42 -0500 Subject: [PATCH] fix: refresh dynamic model metadata in session controls --- src/agents/prepared-model-catalog.ts | 8 +++ ...pared-model-runtime.startup-static.test.ts | 18 +++++++ .../server-methods/sessions-list-cache.ts | 32 ++++++++++-- .../sessions-read-cache.test.ts | 50 +++++++++++++++++++ src/gateway/server-methods/sessions-read.ts | 36 +++++++------ src/gateway/server-model-catalog.ts | 7 +-- src/gateway/session-utils-model.ts | 8 +++ src/gateway/session-utils-row.ts | 11 +++- src/gateway/session-utils.test.ts | 45 +++++++++++++++++ ui/src/pages/chat/chat-state-refresh.ts | 9 +++- ui/src/pages/chat/chat-state.test.ts | 37 ++++++++++++++ 11 files changed, 239 insertions(+), 22 deletions(-) diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index af185ec36f56..2f2d273ca549 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -209,6 +209,14 @@ export function getPreparedModelCatalogSnapshot( return getPreparedModelCatalogOwnerSnapshot(params)?.modelCatalog; } +/** Returns the newest completed catalog for the current generation without starting discovery. */ +export function getAvailablePreparedModelCatalogSnapshot( + params: LoadPreparedModelCatalogParams = {}, +): ModelCatalogSnapshot | undefined { + const owner = getPreparedModelCatalogOwnerSnapshot(params); + return owner?.readFullModelCatalog?.() ?? owner?.modelCatalog; +} + async function resolvePreparedModelCatalogOwnerSnapshotWithPolicy( params: LoadPreparedModelCatalogParams, configPolicy: PreparedModelCatalogConfigPolicy, diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index 0f188cde0e3e..a988bbbb85e1 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -211,6 +211,7 @@ vi.mock("../logging/subsystem.js", () => ({ const { getPreparedModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots } = await import("./prepared-model-runtime.js"); +const { getAvailablePreparedModelCatalogSnapshot } = await import("./prepared-model-catalog.js"); const { prepareScopedReadOnlyLiveModelCatalog, prepareScopedReadOnlyModelCatalog } = await import("./prepared-model-runtime.scoped-catalog.js"); const { resetPreparedModelRuntimeSnapshotsForTest } = @@ -416,6 +417,14 @@ describe("prepared model runtime Gateway catalog mode", () => { inheritedAuthDir: "/tmp/prepared-static-agent", workspaceDir: "/tmp/prepared-static-workspace", }); + expect( + getAvailablePreparedModelCatalogSnapshot({ + agentId: "default", + config, + agentDir: "/tmp/prepared-static-agent", + workspaceDir: "/tmp/prepared-static-workspace", + }), + ).toBe(snapshot?.modelCatalog); expect(snapshot?.configuredRuntimeModels).toHaveLength(1); expect(snapshot?.pluginRegistry).toBeDefined(); expect(snapshot?.messageToolCatalog).toBeUndefined(); @@ -432,6 +441,15 @@ describe("prepared model runtime Gateway catalog mode", () => { expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce(); expect(snapshot?.readFullModelCatalog?.()).toEqual({ entries: [], routeVariants: [] }); + expect( + getAvailablePreparedModelCatalogSnapshot({ + agentId: "default", + config, + agentDir: "/tmp/prepared-static-agent", + workspaceDir: "/tmp/prepared-static-workspace", + }), + ).toEqual({ entries: [], routeVariants: [] }); + expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledOnce(); await snapshot?.loadFullModelCatalog?.({ refresh: true }); expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2); diff --git a/src/gateway/server-methods/sessions-list-cache.ts b/src/gateway/server-methods/sessions-list-cache.ts index 1523663de303..88cdea657a76 100644 --- a/src/gateway/server-methods/sessions-list-cache.ts +++ b/src/gateway/server-methods/sessions-list-cache.ts @@ -1,4 +1,5 @@ import type { SessionsListParams } from "../../../packages/gateway-protocol/src/index.js"; +import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { readAgentRunIndexVersion } from "../../infra/agent-run-registry.js"; import { readSessionIdentityMutationVersion } from "../../sessions/session-lifecycle-events.js"; @@ -21,6 +22,7 @@ type SessionListFence = { agentDatabaseRegistryToken: symbol; incognitoDatabaseGeneration: number; lifecyclePersistenceVersion: number; + modelCatalogRevision: number; sessionAutomationVersion: number; sessionIdentityMutationVersion: number; sessionsMutationVersion: number; @@ -38,13 +40,32 @@ type SessionListState = { const SESSIONS_LIST_COMPLETED_CACHE_LIMIT = 64; const sessionListsByContext = new WeakMap(); +const modelCatalogRevisions = new WeakMap(); +let nextModelCatalogRevision = 1; -function readSessionListFence(context: GatewayRequestContext): SessionListFence { +function readModelCatalogRevision(modelCatalog: readonly ModelCatalogEntry[] | undefined): number { + if (!modelCatalog) { + return 0; + } + const existing = modelCatalogRevisions.get(modelCatalog); + if (existing !== undefined) { + return existing; + } + const revision = nextModelCatalogRevision++; + modelCatalogRevisions.set(modelCatalog, revision); + return revision; +} + +function readSessionListFence( + context: GatewayRequestContext, + modelCatalog: readonly ModelCatalogEntry[] | undefined, +): SessionListFence { return { agentRunIndexVersion: readAgentRunIndexVersion(), agentDatabaseRegistryToken: readOpenClawAgentDatabaseRegistryToken(), incognitoDatabaseGeneration: readOpenIncognitoAgentDatabaseGeneration(), lifecyclePersistenceVersion: readSessionLifecyclePersistenceVersion(), + modelCatalogRevision: readModelCatalogRevision(modelCatalog), sessionAutomationVersion: readSessionAutomationVersion(), sessionIdentityMutationVersion: readSessionIdentityMutationVersion(), sessionsMutationVersion: readSessionsMutationVersion(context), @@ -62,6 +83,7 @@ function matchesSessionListFence(value: SessionListFence, fence: SessionListFenc value.agentDatabaseRegistryToken === fence.agentDatabaseRegistryToken && value.incognitoDatabaseGeneration === fence.incognitoDatabaseGeneration && value.lifecyclePersistenceVersion === fence.lifecyclePersistenceVersion && + value.modelCatalogRevision === fence.modelCatalogRevision && value.sessionAutomationVersion === fence.sessionAutomationVersion && value.sessionIdentityMutationVersion === fence.sessionIdentityMutationVersion && value.sessionsMutationVersion === fence.sessionsMutationVersion && @@ -137,6 +159,7 @@ export async function respondWithCachedSessionList(params: { client: GatewayClient | null; config: OpenClawConfig; context: GatewayRequestContext; + modelCatalog?: readonly ModelCatalogEntry[]; request: SessionsListParams; respond: RespondFn; run: () => Promise; @@ -145,7 +168,7 @@ export async function respondWithCachedSessionList(params: { const state = sessionListState(params.context, params.config); // Every input that can change a projected row must fence reuse. Session identity, // Gateway projection, and live-run mutations have separate monotonic owners. - const fence = readSessionListFence(params.context); + const fence = readSessionListFence(params.context, params.modelCatalog); // Activity windows and child retention expire without mutations; hidden paginated rows // prevent deriving a safe deadline, so only concurrent temporal requests share work. const cacheCompleted = params.request.activeMinutes === undefined && !params.request.spawnedBy; @@ -169,7 +192,10 @@ export async function respondWithCachedSessionList(params: { const promise = Promise.resolve() .then(params.run) .then((result) => { - if (cacheCompleted && matchesSessionListFence(readSessionListFence(params.context), fence)) { + if ( + cacheCompleted && + matchesSessionListFence(readSessionListFence(params.context, params.modelCatalog), fence) + ) { const expiresAt = resolveSessionListExpiration(result); if (expiresAt !== null && (expiresAt === undefined || expiresAt > Date.now())) { rememberCompletedSessionList(state, workKey, { ...fence, result, expiresAt }); diff --git a/src/gateway/server-methods/sessions-read-cache.test.ts b/src/gateway/server-methods/sessions-read-cache.test.ts index d0606401c7d2..6d179520c0e1 100644 --- a/src/gateway/server-methods/sessions-read-cache.test.ts +++ b/src/gateway/server-methods/sessions-read-cache.test.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionsListParams } from "../../../packages/gateway-protocol/src/index.js"; +import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { addSubagentRunForTests, resetSubagentRegistryForTests, @@ -269,6 +270,55 @@ describe("sessions.list single-flight", () => { }); }); + it("reprojects a cached list when a completed model catalog replaces startup metadata", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const config = await seedSessions(); + config.agents = { + ...config.agents, + defaults: { model: { primary: "dynamic-router/reasoner" } }, + }; + const startupCatalog: ModelCatalogEntry[] = [ + { + provider: "dynamic-router", + id: "reasoner", + name: "Reasoner", + reasoning: false, + }, + ]; + const fullCatalog: ModelCatalogEntry[] = [ + { + provider: "dynamic-router", + id: "reasoner", + name: "Reasoner", + reasoning: true, + compat: { supportedReasoningEfforts: ["low", "high", "max"] }, + }, + ]; + let catalog = startupCatalog; + const context = { + ...requestContext(config), + readPreparedGatewayModelCatalog: vi.fn(async () => catalog), + }; + const client = identifiedClient("owner@example.com"); + const request = { archived: "all" as const, limit: 100 }; + + const first = await listSessions({ client, context, request }); + expect(first.sessions.find((session) => session.agentId === "main")?.thinkingOptions).toEqual( + ["off"], + ); + expect(await listSessions({ client, context, request })).toBe(first); + expect(loader.calls).toHaveBeenCalledTimes(1); + + catalog = fullCatalog; + const refreshed = await listSessions({ client, context, request }); + expect(refreshed).not.toBe(first); + expect( + refreshed.sessions.find((session) => session.agentId === "main")?.thinkingOptions, + ).toEqual(expect.arrayContaining(["off", "low", "high", "max"])); + expect(loader.calls).toHaveBeenCalledTimes(2); + }); + }); + it("rebuilds configured targets after registry-only register and unregister", async () => { await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const config = await seedSessions(); diff --git a/src/gateway/server-methods/sessions-read.ts b/src/gateway/server-methods/sessions-read.ts index 28d4ac51bd20..7ffc427874cf 100644 --- a/src/gateway/server-methods/sessions-read.ts +++ b/src/gateway/server-methods/sessions-read.ts @@ -211,6 +211,18 @@ export const sessionReadHandlers: GatewayRequestHandlers = { const cfg = context.getRuntimeConfig(); const configuredAgentsOnly = p.configuredAgentsOnly === true; const identityId = gatewayClientSessionCreator(client)?.id; + const preparedModelCatalog = await measureDiagnosticsTimelineSpan( + "gateway.sessions.list.model_catalog", + () => + readPreparedServerMethodModelCatalog( + context, + p.agentId ? { agentId: p.agentId } : undefined, + ), + { + config: cfg, + phase: "sessions.list", + }, + ); const run = () => measureDiagnosticsTimelineSpan( "gateway.sessions.list", @@ -226,18 +238,6 @@ export const sessionReadHandlers: GatewayRequestHandlers = { ): Promise>> { let loaded = options.loaded; if (!loaded) { - const modelCatalog = await measureDiagnosticsTimelineSpan( - "gateway.sessions.list.model_catalog", - () => - readPreparedServerMethodModelCatalog( - context, - p.agentId ? { agentId: p.agentId } : undefined, - ), - { - config: cfg, - phase: "sessions.list", - }, - ); const loadedStore = measureDiagnosticsTimelineSpanSync( "gateway.sessions.list.store_load", () => @@ -255,7 +255,7 @@ export const sessionReadHandlers: GatewayRequestHandlers = { }, }, ); - loaded = { ...loadedStore, modelCatalog }; + loaded = { ...loadedStore, modelCatalog: preparedModelCatalog }; } if (!loaded) { throw new Error("sessions.list store input was not loaded"); @@ -473,7 +473,15 @@ export const sessionReadHandlers: GatewayRequestHandlers = { }, }, ); - await respondWithCachedSessionList({ client, config: cfg, context, request: p, respond, run }); + await respondWithCachedSessionList({ + client, + config: cfg, + context, + modelCatalog: preparedModelCatalog, + request: p, + respond, + run, + }); }, "sessions.cleanup": async ({ params, respond, context }) => { if (!assertValidParams(params, validateSessionsCleanupParams, "sessions.cleanup", respond)) { diff --git a/src/gateway/server-model-catalog.ts b/src/gateway/server-model-catalog.ts index bde70d6cf6da..5b24741939ec 100644 --- a/src/gateway/server-model-catalog.ts +++ b/src/gateway/server-model-catalog.ts @@ -167,13 +167,14 @@ export async function loadGatewayModelCatalog( return (await loadGatewayModelCatalogSnapshot(params)).entries; } -/** Reads the already-published startup catalog without starting provider discovery. */ +/** Reads the newest completed published catalog without starting provider discovery. */ export async function readPreparedGatewayModelCatalog( params?: LoadGatewayModelCatalogParams, ): Promise { - const { getPreparedModelCatalogSnapshot } = await import("../agents/prepared-model-catalog.js"); + const { getAvailablePreparedModelCatalogSnapshot } = + await import("../agents/prepared-model-catalog.js"); const config = (params?.getConfig ?? getRuntimeConfig)(); - return getPreparedModelCatalogSnapshot({ + return getAvailablePreparedModelCatalogSnapshot({ ...(params?.agentId ? { agentId: params.agentId } : {}), ...(params?.agentDir ? { agentDir: params.agentDir } : {}), config, diff --git a/src/gateway/session-utils-model.ts b/src/gateway/session-utils-model.ts index 45fa6f68d770..88f72643d086 100644 --- a/src/gateway/session-utils-model.ts +++ b/src/gateway/session-utils-model.ts @@ -316,11 +316,19 @@ export function getSessionDefaults( defaultModel: DEFAULT_MODEL, allowPluginNormalization: options?.allowPluginNormalization, }); + const catalogEntry = modelCatalog + ? findModelCatalogEntry(modelCatalog, { + provider: resolved.provider, + modelId: resolved.model, + }) + : undefined; const contextTokens = resolveContextTokensForModel({ cfg, provider: resolved.provider, model: resolved.model, + modelContextTokens: catalogEntry?.contextTokens, + modelContextWindow: catalogEntry?.contextWindow, allowAsyncLoad: false, }) ?? DEFAULT_CONTEXT_TOKENS; const sessionKey = resolveAgentMainSessionKey({ cfg, agentId }); diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 0b0ed9712076..f1d1ca8e0c7d 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -11,7 +11,7 @@ import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveFastModeState } from "../agents/fast-mode.js"; import { resolveAgentIdentity } from "../agents/identity.js"; -import type { ModelCatalogEntry } from "../agents/model-catalog.js"; +import { findModelCatalogEntry, type ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveSessionModelIdentityRef } from "../agents/session-model-ref.js"; import { countActiveDescendantRuns, @@ -472,11 +472,20 @@ export function buildGatewaySessionRow(params: { rowContext, providerPolicySource: lightweight ? "active" : undefined, }); + const catalogEntry = + params.modelCatalog && rowModelProvider && rowModel + ? findModelCatalogEntry(params.modelCatalog, { + provider: rowModelProvider, + modelId: rowModel, + }) + : undefined; const resolvedCurrentContextTokens = resolvePositiveNumber( resolveContextTokensForModel({ cfg, provider: rowModelProvider, model: rowModel, + modelContextTokens: catalogEntry?.contextTokens, + modelContextWindow: catalogEntry?.contextWindow, allowAsyncLoad: false, }), ); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index de8af51eb206..5dcfbdf78d06 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -811,6 +811,51 @@ describe("gateway session utils", () => { expect(row.thinkingDefault).toBe("medium"); }); + test("session defaults and rows use dynamic catalog context limits with authored caps", () => { + const catalog = [ + { + provider: "dynamic-router", + id: "reasoner", + name: "Reasoner", + contextWindow: 256_000, + contextTokens: 200_000, + }, + ]; + const cfg = createModelDefaultsConfig({ primary: "dynamic-router/reasoner" }); + + expect(getSessionDefaults(cfg, catalog).contextTokens).toBe(200_000); + expect( + buildGatewaySessionRow({ + cfg, + storePath: "", + store: {}, + key: "agent:main:main", + modelCatalog: catalog, + }).contextTokens, + ).toBe(200_000); + + const capped = { + ...cfg, + models: { + providers: { + "dynamic-router": { + models: [{ id: "reasoner", contextWindow: 128_000 }], + }, + }, + }, + } as unknown as OpenClawConfig; + expect(getSessionDefaults(capped, catalog).contextTokens).toBe(128_000); + expect( + buildGatewaySessionRow({ + cfg: capped, + storePath: "", + store: {}, + key: "agent:main:main", + modelCatalog: catalog, + }).contextTokens, + ).toBe(128_000); + }); + test("session rows project automation bindings and event fields forward them", () => { const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); registerSessionAutomationSource({ diff --git a/ui/src/pages/chat/chat-state-refresh.ts b/ui/src/pages/chat/chat-state-refresh.ts index edcaa9854ca0..4b20453c7df1 100644 --- a/ui/src/pages/chat/chat-state-refresh.ts +++ b/ui/src/pages/chat/chat-state-refresh.ts @@ -14,7 +14,10 @@ import { refreshChatAvatar, resolveAgentIdForSession } from "./chat-avatar.ts"; import { applyRemoteSlashCommandsResult, refreshSlashCommands } from "./chat-commands.ts"; import { loadChatHistory } from "./chat-history.ts"; import { flushChatQueueForEvent } from "./chat-send-actions.ts"; -import { flushChatQueueAfterIdleSessionReconciliation } from "./chat-session.ts"; +import { + flushChatQueueAfterIdleSessionReconciliation, + refreshCurrentChatSessionList, +} from "./chat-session.ts"; import type { ChatPageHost } from "./chat-state-host.ts"; import { resolveChatAgentId } from "./chat-state-route.ts"; import { loadModels } from "./models.ts"; @@ -194,6 +197,10 @@ export async function refreshChatModelCatalogOnDemand(host: ChatPageHost): Promi if (ownsRequest()) { host.chatModelCatalog = models; host.chatModelCatalogError = null; + // Full model discovery can complete after the session projection used at mount time. + // Refresh through the normal session owner so thinking/context metadata converges without + // letting the UI guess which provider- or runtime-specific levels are valid. + await refreshCurrentChatSessionList(host).catch(() => undefined); } } catch (error) { if (ownsRequest()) { diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index c43abc93c73f..ba75eaddacf8 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -18,6 +18,7 @@ import type { ChatPageHost } from "./chat-state-host.ts"; import { createPageState } from "./chat-state-page.ts"; import { refreshChatMetadata, + refreshChatModelCatalogOnDemand, refreshChatModelAuthStatus, retireChatMetadataRequests, } from "./chat-state-refresh.ts"; @@ -2118,6 +2119,42 @@ describe("refreshChatMetadata", () => { } as unknown as ChatPageHost; } + it("refreshes session metadata after full model discovery completes", async () => { + const refreshSessions = vi.fn().mockResolvedValue(undefined); + const request = vi.fn(async (method: string, params?: unknown) => { + expect(method).toBe("models.list"); + expect(params).toEqual({ view: "configured", agentId: "work" }); + return { + models: [ + { + id: "reasoner", + name: "Reasoner", + provider: "dynamic-router", + reasoning: true, + }, + ], + }; + }); + const state = createMetadataState(request, { + sessions: { refresh: refreshSessions } as never, + }); + + await refreshChatModelCatalogOnDemand(state); + + expect(state.chatModelCatalog).toEqual([ + { + id: "reasoner", + name: "Reasoner", + provider: "dynamic-router", + reasoning: true, + }, + ]); + expect(refreshSessions).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "work", force: true }), + ); + expect(state.chatModelCatalogError).toBeNull(); + }); + it("applies agent-scoped metadata after a same-agent session switch", async () => { let resolveMetadata: | ((value: {