mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix: refresh dynamic model metadata in session controls
This commit is contained in:
committed by
Martin Cleary
parent
a4f17833ad
commit
cc445a32bb
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user