fix: refresh dynamic model metadata in session controls

This commit is contained in:
Erick Kinnee
2026-08-18 09:13:42 -05:00
committed by Martin Cleary
parent a4f17833ad
commit cc445a32bb
11 changed files with 239 additions and 22 deletions
+8
View File
@@ -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,
@@ -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);
@@ -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<GatewayRequestContext, SessionListState>();
const modelCatalogRevisions = new WeakMap<readonly ModelCatalogEntry[], number>();
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<SessionsListResult>;
@@ -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 });
@@ -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();
+22 -14
View File
@@ -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<Awaited<ReturnType<typeof listSessionsFromStoreAsync>>> {
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)) {
+4 -3
View File
@@ -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<GatewayModelChoice[] | undefined> {
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,
+8
View File
@@ -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 });
+10 -1
View File
@@ -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,
}),
);
+45
View File
@@ -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({
+8 -1
View File
@@ -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()) {
+37
View File
@@ -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: {