fix: keep model catalog coherent across config reloads (#112331)

* fix(gateway): keep model catalog reads generation-safe

* fix(gateway): type owner-aware catalog snapshots

* fix(sessions): break lifecycle accessor import cycle

* fix(gateway): preserve requested catalog owner

* fix(gateway): keep catalog owner identity explicit

* fix(gateway): isolate catalog snapshot types

* fix(gateway): respect published catalog owner

* fix(gateway): reject mismatched catalog owners

* test(apple): gate unread patch timing explicitly

* fix(gateway): keep startup metadata bounded

* fix(gateway): forbid startup catalog fallback

* test(apple): wait for terminal outbox flush

* fix(cron): follow published model catalog owner

* test(cron): align catalog runtime mocks

* test(system-agent): isolate TUI catalog metadata

* test(gateway): refresh deletion session snapshot

* fix(gateway): preserve authoritative catalog owners

* fix(gateway): retain equivalent catalog preload

* fix(gateway): reject ownerless catalog projections

* fix(gateway): scope catalog loads to resolved agent

* fix(gateway): restore canonical catalog owner identity

* fix(cron): preserve published catalog owner

* test(cron): align owner snapshot mocks

* fix(cron): preserve replacement owner workspace

* fix(runtime): reject explicit catalog owner mismatch

* fix(runtime): preserve implicit owner fallback

* test(cron): resolve implicit owner fixtures
This commit is contained in:
Peter Steinberger
2026-07-23 10:54:27 -07:00
committed by GitHub
parent 1c102d419f
commit 78b987aa2e
29 changed files with 1560 additions and 349 deletions
@@ -1741,6 +1741,9 @@ struct ChatViewModelOutboxTests {
vm.messages.contains { vm.outboxState(for: $0.id)?.isFailed == true }
}
}
try await waitUntil("terminal failure flush settled") {
await MainActor.run { !vm.isFlushingOutbox }
}
// Tap-to-retry resets attempts; with the gateway accepting again the
// command now flushes and the row disappears.
@@ -26,22 +26,47 @@ private actor UnreadMutationRecorder {
}
}
private actor UnreadPatchGate {
private var continuation: CheckedContinuation<Void, Never>?
private var released = false
func wait() async {
guard !self.released else { return }
await withCheckedContinuation { continuation in
if self.released {
continuation.resume()
} else {
self.continuation = continuation
}
}
}
func release() {
self.released = true
self.continuation?.resume()
self.continuation = nil
}
}
private final class UnreadTestTransport: @unchecked Sendable, OpenClawChatTransport {
private let state: UnreadTestTransportState
private let sessions: [OpenClawChatSessionEntry]
private let respectsListLimit: Bool
private let patchDelay: Duration?
private let patchGate: UnreadPatchGate?
init(
sessions: [OpenClawChatSessionEntry],
historyFailures: Int = 0,
patchFailures: Int = 0,
respectsListLimit: Bool = false,
patchDelay: Duration? = nil)
patchDelay: Duration? = nil,
patchGate: UnreadPatchGate? = nil)
{
self.sessions = sessions
self.respectsListLimit = respectsListLimit
self.patchDelay = patchDelay
self.patchGate = patchGate
self.state = UnreadTestTransportState(
historyFailures: historyFailures,
patchFailures: patchFailures)
@@ -99,7 +124,9 @@ private final class UnreadTestTransport: @unchecked Sendable, OpenClawChatTransp
{
guard let unread else { return }
await self.state.recordUnreadPatchStart()
if let patchDelay {
if let patchGate {
await patchGate.wait()
} else if let patchDelay {
try await Task.sleep(for: patchDelay)
}
await self.state.recordUnreadPatch(key: key, unread: unread)
@@ -459,12 +486,13 @@ struct ChatViewModelUnreadTests {
}
@Test func `pending explicit unread overlays stale list until fresh observation`() async throws {
let patchGate = UnreadPatchGate()
let transport = UnreadTestTransport(
sessions: [
self.entry(key: "a", unread: false),
self.entry(key: "b", unread: false),
],
patchDelay: .milliseconds(200))
patchGate: patchGate)
let viewModel = self.viewModel(sessionKey: "b", transport: transport)
viewModel.refreshSessions()
try await self.waitUntil { viewModel.sessions.count == 2 }
@@ -477,6 +505,7 @@ struct ChatViewModelUnreadTests {
#expect(viewModel.sessions.first(where: { $0.key == "a" })?.unread == true)
#expect(viewModel.unreadPatchGuard.localUnreadOverride(key: "a") == true)
await patchGate.release()
await transport.setSessions([
self.entry(key: "a", unread: true),
self.entry(key: "b", unread: false),
+20
View File
@@ -160,6 +160,26 @@ describe("loadPreparedModelCatalogSnapshotForBrowse", () => {
expect(onTimeout).toHaveBeenCalledExactlyOnceWith(5);
});
it("can preserve the timeout fallback while escalating to full discovery", async () => {
vi.useFakeTimers();
const onTimeout = vi.fn();
const loadCatalog = vi.fn(() => new Promise<ModelCatalogSnapshot>(() => {}));
const resultPromise = loadPreparedModelCatalogSnapshotForBrowse({
cfg: config({ providerWildcard: true }),
view: "configured",
loadCatalog,
timeoutFullDiscovery: true,
timeoutMs: 5,
onTimeout,
});
await vi.advanceTimersByTimeAsync(5);
await expect(resultPromise).resolves.toEqual({ entries: [], routeVariants: [] });
expect(loadCatalog).toHaveBeenCalledExactlyOnceWith({ readOnly: false });
expect(onTimeout).toHaveBeenCalledExactlyOnceWith(5);
});
it("uses the default timeout when timeoutMs is non-finite", async () => {
const onTimeout = vi.fn();
const setTimeout = vi.spyOn(globalThis, "setTimeout");
+5 -2
View File
@@ -59,17 +59,19 @@ async function loadCatalogForBrowse<T>(params: {
view?: ModelCatalogBrowseView;
loadCatalog: (params: { readOnly: boolean }) => Promise<T>;
empty: T;
timeoutFullDiscovery?: boolean;
timeoutMs?: number;
onTimeout?: (timeoutMs: number) => void;
}): Promise<T> {
const view = params.view ?? "default";
if (modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view })) {
const requiresFullDiscovery = modelCatalogBrowseRequiresFullDiscovery({ cfg: params.cfg, view });
if (requiresFullDiscovery && !params.timeoutFullDiscovery) {
return await params.loadCatalog({ readOnly: false });
}
let timeout: NodeJS.Timeout | undefined;
const timeoutMs = resolveModelCatalogBrowseTimeoutMs(params.timeoutMs);
const catalogPromise = params.loadCatalog({ readOnly: true });
const catalogPromise = params.loadCatalog({ readOnly: !requiresFullDiscovery });
const catalogResult = catalogPromise.then((value) => ({ kind: "catalog" as const, value }));
const timeoutPromise = new Promise<{ kind: "timeout" }>((resolve) => {
timeout = globalThis.setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
@@ -97,6 +99,7 @@ export function loadPreparedModelCatalogSnapshotForBrowse(params: {
cfg: OpenClawConfig;
view?: ModelCatalogBrowseView;
loadCatalog: (params: { readOnly: boolean }) => Promise<ModelCatalogSnapshot>;
timeoutFullDiscovery?: boolean;
timeoutMs?: number;
onTimeout?: (timeoutMs: number) => void;
}): Promise<ModelCatalogSnapshot> {
+73 -2
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
config: {} as object,
agentIds: ["main"],
agentDirs: new Map<string, string>(),
activateSnapshot: vi.fn(),
acquireSnapshot: vi.fn(),
getSnapshot: vi.fn(),
@@ -15,8 +17,9 @@ vi.mock("../config/config.js", () => ({
}));
vi.mock("./agent-scope.js", () => ({
listAgentIds: () => ["main"],
resolveAgentDir: () => "/tmp/prepared-model-catalog-agent",
listAgentIds: () => mocks.agentIds,
resolveAgentDir: (_config: object, agentId: string) =>
mocks.agentDirs.get(agentId) ?? "/tmp/prepared-model-catalog-agent",
resolveAgentWorkspaceDir: () => "/tmp/prepared-model-catalog-workspace",
resolveDefaultAgentDir: () => "/tmp/prepared-model-catalog-agent",
resolveDefaultAgentId: () => "main",
@@ -47,6 +50,8 @@ import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalo
import {
getPreparedModelCatalogSnapshot,
loadPreparedModelCatalogSnapshot,
loadPublishedPreparedModelCatalog,
loadPublishedPreparedModelCatalogOwnerSnapshot,
} from "./prepared-model-catalog.js";
import { PreparedModelRuntimeOwnerNotPublishedError } from "./prepared-model-runtime.js";
@@ -64,6 +69,8 @@ const readOnlySnapshot = {
describe("prepared model catalog access", () => {
beforeEach(() => {
mocks.agentIds = ["main"];
mocks.agentDirs.clear();
mocks.activateSnapshot.mockReset();
mocks.acquireSnapshot.mockReset();
mocks.getSnapshot.mockReset();
@@ -128,6 +135,70 @@ describe("prepared model catalog access", () => {
expect(mocks.loadSnapshot).not.toHaveBeenCalled();
});
it.each([{ readOnly: true }, { readOnly: false }])(
"returns the published replacement owner for Gateway reads (readOnly=$readOnly)",
async ({ readOnly }) => {
const committedConfig = { agents: { defaults: { model: "openai/committed" } } };
const committedSnapshot = {
...fullSnapshot,
agentDir: "/tmp/prepared-model-catalog-agent",
config: committedConfig,
};
mocks.prepareSnapshot.mockResolvedValue(committedSnapshot);
await expect(
loadPublishedPreparedModelCatalogOwnerSnapshot({ readOnly }),
).resolves.toMatchObject({
...committedSnapshot,
agentId: "main",
});
expect(mocks.loadSnapshot).not.toHaveBeenCalled();
expect(mocks.activateSnapshot).not.toHaveBeenCalled();
expect(mocks.acquireSnapshot).not.toHaveBeenCalled();
},
);
it("restores the unique configured agent identity for a published replacement owner", async () => {
const committedSnapshot = {
...fullSnapshot,
agentDir: "/tmp/prepared-model-catalog-agent",
config: { agents: { list: [{ id: "main", default: true }] } },
};
mocks.prepareSnapshot.mockResolvedValue(committedSnapshot);
await expect(
loadPublishedPreparedModelCatalogOwnerSnapshot({ agentId: "MAIN", readOnly: true }),
).resolves.toMatchObject({ agentId: "main", agentDir: committedSnapshot.agentDir });
});
it("keeps a shared-directory published replacement owner ambiguous", async () => {
mocks.agentIds = ["main", "worker"];
mocks.agentDirs.set("main", "/tmp/shared-agent-dir");
mocks.agentDirs.set("worker", "/tmp/shared-agent-dir");
const committedSnapshot = {
...fullSnapshot,
agentDir: "/tmp/shared-agent-dir",
config: { agents: { list: [{ id: "main", default: true }, { id: "worker" }] } },
};
mocks.prepareSnapshot.mockResolvedValue(committedSnapshot);
await expect(
loadPublishedPreparedModelCatalogOwnerSnapshot({ agentId: "worker", readOnly: true }),
).resolves.not.toHaveProperty("agentId");
});
it("projects published replacement entries for runtime callers", async () => {
const committedSnapshot = {
...fullSnapshot,
config: { agents: { defaults: { model: "openai/committed" } } },
};
mocks.prepareSnapshot.mockResolvedValue(committedSnapshot);
await expect(loadPublishedPreparedModelCatalog({ readOnly: true })).resolves.toBe(
committedSnapshot.modelCatalog.entries,
);
});
it("prefers the full published generation for read-only access", () => {
mocks.getSnapshot.mockReturnValue(fullSnapshot);
+65 -9
View File
@@ -31,6 +31,40 @@ export type LoadPreparedModelCatalogParams = {
env?: NodeJS.ProcessEnv;
};
type PreparedModelCatalogConfigPolicy = "exact" | "published";
function attachAuthoritativeAgentId(
snapshot: PreparedModelRuntimeSnapshot,
params: LoadPreparedModelCatalogParams,
): PreparedModelRuntimeSnapshot {
if (snapshot.agentId) {
return snapshot;
}
const requestedAgentId =
params.agentId ??
(params.agentDir === undefined ? resolveDefaultAgentId(snapshot.config) : undefined);
if (
!requestedAgentId ||
resolveAgentDir(snapshot.config, requestedAgentId) !== snapshot.agentDir
) {
return snapshot;
}
const matchingAgentIds = listAgentIds(snapshot.config).filter(
(agentId) => resolveAgentDir(snapshot.config, agentId) === snapshot.agentDir,
);
return matchingAgentIds.length === 1
? Object.freeze({ ...snapshot, agentId: matchingAgentIds[0] })
: snapshot;
}
function acceptsPreparedSnapshotConfig(
snapshot: PreparedModelRuntimeSnapshot,
input: PreparedModelRuntimeInput,
policy: PreparedModelCatalogConfigPolicy,
): boolean {
return policy === "published" || preparedModelRuntimeConfigsMatch(snapshot.config, input.config);
}
function resolveInputs(params: LoadPreparedModelCatalogParams = {}): {
exact: PreparedModelRuntimeInput;
full: PreparedModelRuntimeInput;
@@ -112,9 +146,9 @@ export function getPreparedModelCatalogSnapshot(
: undefined;
}
/** Resolves the lifecycle owner used for a catalog read. */
export async function loadPreparedModelCatalogOwnerSnapshot(
params: LoadPreparedModelCatalogParams = {},
async function loadPreparedModelCatalogOwnerSnapshotWithPolicy(
params: LoadPreparedModelCatalogParams,
configPolicy: PreparedModelCatalogConfigPolicy,
): Promise<PreparedModelRuntimeSnapshot> {
const { activationExact, activationFull, exact, full } = resolveInputs(params);
if (params.readOnly) {
@@ -124,7 +158,7 @@ export async function loadPreparedModelCatalogOwnerSnapshot(
try {
// Full lifecycle owners include provider augmentation omitted by read-only fallback builds.
const prepared = await prepareModelRuntimeSnapshot(candidate);
if (!preparedModelRuntimeConfigsMatch(prepared.config, candidate.config)) {
if (!acceptsPreparedSnapshotConfig(prepared, candidate, configPolicy)) {
throw new PreparedModelCatalogConfigReplacedError(candidate.agentDir);
}
return prepared;
@@ -136,7 +170,7 @@ export async function loadPreparedModelCatalogOwnerSnapshot(
}
const lease = await acquireReadOnlyPreparedModelRuntime(activationExact);
try {
if (!preparedModelRuntimeConfigsMatch(lease.snapshot.config, activationExact.config)) {
if (!acceptsPreparedSnapshotConfig(lease.snapshot, activationExact, configPolicy)) {
throw new PreparedModelCatalogConfigReplacedError(activationExact.agentDir);
}
return lease.snapshot;
@@ -150,7 +184,7 @@ export async function loadPreparedModelCatalogOwnerSnapshot(
for (const candidate of fullCandidates) {
try {
const preparedFull = await prepareModelRuntimeSnapshot(candidate);
if (preparedModelRuntimeConfigsMatch(preparedFull.config, full.config)) {
if (acceptsPreparedSnapshotConfig(preparedFull, full, configPolicy)) {
return preparedFull;
}
} catch (error) {
@@ -162,7 +196,7 @@ export async function loadPreparedModelCatalogOwnerSnapshot(
}
try {
const preparedExact = await prepareModelRuntimeSnapshot(exact);
if (preparedModelRuntimeConfigsMatch(preparedExact.config, exact.config)) {
if (acceptsPreparedSnapshotConfig(preparedExact, exact, configPolicy)) {
return preparedExact;
}
} catch (error) {
@@ -173,7 +207,7 @@ export async function loadPreparedModelCatalogOwnerSnapshot(
// Direct commands own a persistent standalone generation. During gateway lifetime, writable
// publication belongs exclusively to startup/reload or agent-run admission.
const activated = await activateStandalonePreparedModelRuntime(activationExact);
if (activated && preparedModelRuntimeConfigsMatch(activated.config, activationExact.config)) {
if (activated && acceptsPreparedSnapshotConfig(activated, activationExact, configPolicy)) {
return activated;
}
if (activated) {
@@ -185,7 +219,7 @@ export async function loadPreparedModelCatalogOwnerSnapshot(
// Lease a complete exact generation so provider catalog hooks remain visible for this read.
const lease = await acquireAgentRunPreparedModelRuntime(activationFull);
try {
if (!preparedModelRuntimeConfigsMatch(lease.snapshot.config, activationFull.config)) {
if (!acceptsPreparedSnapshotConfig(lease.snapshot, activationFull, configPolicy)) {
throw new PreparedModelRuntimeOwnerNotPublishedError(
`prepared model catalog owner was not published for the requested config (${activationFull.agentDir})`,
);
@@ -196,6 +230,21 @@ export async function loadPreparedModelCatalogOwnerSnapshot(
}
}
/** Resolves the lifecycle owner for an exact caller-supplied config. */
export async function loadPreparedModelCatalogOwnerSnapshot(
params: LoadPreparedModelCatalogParams = {},
): Promise<PreparedModelRuntimeSnapshot> {
return await loadPreparedModelCatalogOwnerSnapshotWithPolicy(params, "exact");
}
/** Resolves the currently published owner when Gateway config changes during the read. */
export async function loadPublishedPreparedModelCatalogOwnerSnapshot(
params: LoadPreparedModelCatalogParams = {},
): Promise<PreparedModelRuntimeSnapshot> {
const snapshot = await loadPreparedModelCatalogOwnerSnapshotWithPolicy(params, "published");
return attachAuthoritativeAgentId(snapshot, params);
}
/** Reads one atomic catalog generation, activating a lifecycle owner when needed. */
export async function loadPreparedModelCatalogSnapshot(
params: LoadPreparedModelCatalogParams = {},
@@ -208,3 +257,10 @@ export async function loadPreparedModelCatalog(
): Promise<ModelCatalogEntry[]> {
return (await loadPreparedModelCatalogSnapshot(params)).entries;
}
/** Reads the committed owner generation for long-lived runtime work. */
export async function loadPublishedPreparedModelCatalog(
params: LoadPreparedModelCatalogParams = {},
): Promise<ModelCatalogEntry[]> {
return (await loadPublishedPreparedModelCatalogOwnerSnapshot(params)).modelCatalog.entries;
}
@@ -91,8 +91,6 @@ describe("runCronIsolatedAgentTurn hook content wrapping", () => {
const resolved = await resolveCronModelSelection({
cfg,
catalogConfig: cfg,
cfgWithAgentDefaults: cfg,
sessionEntry: {},
payload: {
kind: "agentTurn",
@@ -105,7 +103,7 @@ describe("runCronIsolatedAgentTurn hook content wrapping", () => {
workspaceDir: `${home}/workspace`,
});
expect(resolved).toEqual({
expect(resolved).toMatchObject({
ok: true,
provider: "openrouter",
model: GMAIL_MODEL.replace("openrouter/", ""),
+31 -3
View File
@@ -4,15 +4,43 @@
import "../utils/usage-format.js";
import { vi } from "vitest";
const loadPreparedModelCatalog = vi.hoisted(() => vi.fn());
vi.mock("../agents/embedded-agent.js", () => ({
abortEmbeddedAgentRun: vi.fn().mockReturnValue(false),
runEmbeddedAgent: vi.fn(),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
}));
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadPreparedModelCatalog: vi.fn(),
}));
vi.mock("../agents/prepared-model-catalog.js", async () => {
const { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } =
await vi.importActual<typeof import("../agents/agent-scope.js")>("../agents/agent-scope.js");
return {
loadPreparedModelCatalog,
loadPublishedPreparedModelCatalog: loadPreparedModelCatalog,
loadPublishedPreparedModelCatalogOwnerSnapshot: vi.fn(
async (params: {
agentId?: string;
agentDir?: string;
config?: object;
workspaceDir?: string;
}) => {
const config = params.config ?? {};
const agentId = params.agentId ?? resolveDefaultAgentId(config);
return {
agentId,
agentDir: params.agentDir ?? resolveAgentDir(config, agentId),
workspaceDir: params.workspaceDir ?? resolveAgentWorkspaceDir(config, agentId),
config,
modelCatalog: {
entries: (await loadPreparedModelCatalog(params)) ?? [],
routeVariants: [],
},
};
},
),
};
});
vi.mock("../agents/model-selection.js", async () => {
const actual = await vi.importActual<typeof import("../agents/model-selection.js")>(
@@ -36,8 +36,14 @@ vi.mock("./isolated-agent/run-model-selection.runtime.js", () => ({
DEFAULT_MODEL: "claude-opus-4-6",
DEFAULT_PROVIDER: "anthropic",
getModelRefStatus: getModelRefStatusMock,
loadPreparedModelCatalog: loadModelCatalogMock,
loadPreparedModelCatalogOwnerSnapshot: loadModelCatalogMock,
normalizeModelSelection: normalizeModelSelectionMock,
resolveAgentConfig: (cfg: { agents?: { list?: AgentConfig[] } }, agentId: string) =>
cfg.agents?.list?.find((agent) => agent.id === agentId),
resolveAgentWorkspaceDir: (
cfg: { agents?: { list?: Array<AgentConfig & { workspace?: string }> } },
agentId: string,
) => cfg.agents?.list?.find((agent) => agent.id === agentId)?.workspace ?? "/tmp/workspace",
resolveAllowedModelRef: resolveAllowedModelRefMock,
resolveConfiguredModelRef: resolveConfiguredModelRefMock,
resolveHooksGmailModel: resolveHooksGmailModelMock,
@@ -73,7 +79,6 @@ type AgentTurnPayload = {
type SelectModelOptions = {
cfg?: Record<string, unknown>;
cfgWithAgentDefaults?: Record<string, unknown>;
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
payload?: AgentTurnPayload;
sessionEntry?: {
@@ -138,8 +143,6 @@ async function selectModel(options: SelectModelOptions = {}) {
const cfg = options.cfg ?? {};
return resolveCronModelSelection({
cfg: cfg as never,
catalogConfig: cfg as never,
cfgWithAgentDefaults: (options.cfgWithAgentDefaults ?? cfg) as never,
agentConfigOverride: options.agentConfigOverride,
sessionEntry: options.sessionEntry ?? {},
payload: options.payload ?? defaultPayload(),
@@ -165,7 +168,20 @@ async function expectDefaultSelectedModel(options: SelectModelOptions = {}) {
describe("cron model formatting and precedence edge cases", () => {
beforeEach(() => {
vi.clearAllMocks();
loadModelCatalogMock.mockResolvedValue([]);
loadModelCatalogMock.mockImplementation(
async (params: {
config: Record<string, unknown>;
agentId?: string;
agentDir: string;
workspaceDir: string;
}) => ({
agentId: params.agentId ?? "main",
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
config: params.config,
modelCatalog: { entries: [], routeVariants: [] },
}),
);
getModelRefStatusMock.mockReturnValue({ allowed: false });
resolveHooksGmailModelMock.mockReturnValue(null);
resolveConfiguredModelRefMock.mockImplementation(({ cfg }: { cfg?: Record<string, unknown> }) =>
@@ -292,20 +308,8 @@ describe("cron model formatting and precedence edge cases", () => {
],
},
};
const cfgWithAgentDefaults = {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents.defaults,
models: cfg.agents.list[0]?.models,
},
},
};
await selectModel({
cfg,
cfgWithAgentDefaults,
agentId: "worker",
payload: { kind: "agentTurn", message: DEFAULT_MESSAGE, model: "approved" },
});
@@ -315,6 +319,70 @@ describe("cron model formatting and precedence edge cases", () => {
);
});
it("uses one published replacement owner for cron model selection", async () => {
const callerConfig = {
agents: {
defaults: { model: "anthropic/caller-model" },
list: [{ id: "worker", default: true }],
},
};
const ownerConfig = {
agents: {
defaults: {
model: "openai/owner-default",
modelPolicy: { allow: ["openai/*"] },
},
list: [{ id: "main", default: true }],
},
};
const ownerCatalog = [{ id: "owner-model", name: "Owner Model", provider: "openai" }];
loadModelCatalogMock.mockResolvedValueOnce({
agentId: "main",
agentDir: "/tmp/owner-agent",
workspaceDir: "/tmp/owner-workspace",
config: ownerConfig,
modelCatalog: { entries: ownerCatalog, routeVariants: [] },
});
const result = await selectModel({
cfg: callerConfig,
payload: {
kind: "agentTurn",
message: DEFAULT_MESSAGE,
model: "openai/owner-model",
},
});
expect(loadModelCatalogMock).toHaveBeenCalledOnce();
expect(loadModelCatalogMock).toHaveBeenCalledWith({
config: callerConfig,
readOnly: true,
});
expect(resolveConfiguredModelRefMock).toHaveBeenCalledWith(
expect.objectContaining({ cfg: expect.objectContaining(ownerConfig) }),
);
expect(resolveAllowedModelRefMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg: ownerConfig,
agentId: "main",
catalog: ownerCatalog,
raw: "openai/owner-model",
}),
);
expect(result).toMatchObject({
ok: true,
provider: "openai",
model: "owner-model",
owner: {
config: ownerConfig,
agentId: "main",
agentDir: "/tmp/owner-agent",
workspaceDir: "/tmp/owner-workspace",
catalog: ownerCatalog,
},
});
});
it("normalizes provider casing", async () => {
await expectSelectedModel(
{
+107 -37
View File
@@ -2,13 +2,17 @@ import { resolveConfiguredModelPolicyAllow } from "../../agents/model-selection-
/** Resolves provider/model precedence for isolated cron runs. */
import type { AgentConfig } from "../../config/types.agents.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { CronJob } from "../types.js";
import { buildCronAgentDefaultsConfig } from "./run-config.js";
import {
DEFAULT_MODEL,
DEFAULT_PROVIDER,
getModelRefStatus,
loadPreparedModelCatalog,
loadPreparedModelCatalogOwnerSnapshot,
normalizeModelSelection,
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveAllowedModelRef,
resolveConfiguredModelRef,
resolveHooksGmailModel,
@@ -23,10 +27,19 @@ type CronSessionModelOverrides = {
type CronModelSelectionSource = "default" | "subagent" | "agent" | "hook" | "payload" | "session";
/** Inputs used to resolve the model for one isolated cron run. */
type CronModelSelectionOwner = {
config: OpenClawConfig;
agentId: string;
agentDir: string;
workspaceDir: string;
catalog: Awaited<
ReturnType<typeof loadPreparedModelCatalogOwnerSnapshot>
>["modelCatalog"]["entries"];
};
type ResolveCronModelSelectionParams = {
cfg: OpenClawConfig;
catalogConfig: OpenClawConfig;
cfgWithAgentDefaults: OpenClawConfig;
owner?: CronModelSelectionOwner;
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
sessionEntry: CronSessionModelOverrides;
payload: CronJob["payload"];
@@ -43,6 +56,8 @@ type ResolveCronModelSelectionResult =
provider: string;
model: string;
modelSource: CronModelSelectionSource;
cfgWithAgentDefaults: OpenClawConfig;
owner: CronModelSelectionOwner;
}
| {
ok: false;
@@ -73,12 +88,74 @@ function formatCronPayloadModelRejection(params: {
return `cron payload.model '${modelOverride}' rejected: ${error}`;
}
export async function resolveCronModelSelectionOwner(params: {
cfg: OpenClawConfig;
agentId?: string;
requiredAgentId?: string;
agentDir?: string;
workspaceDir?: string;
}): Promise<CronModelSelectionOwner> {
const owner = await loadPreparedModelCatalogOwnerSnapshot({
config: params.cfg,
...(params.agentId ? { agentId: params.agentId } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
readOnly: true,
});
if (!owner.agentId) {
throw new Error(`cron model catalog owner did not identify an agent (${owner.agentDir})`);
}
if (
params.requiredAgentId &&
normalizeAgentId(owner.agentId) !== normalizeAgentId(params.requiredAgentId)
) {
throw new Error(
`cron model catalog owner changed from ${normalizeAgentId(params.requiredAgentId)} to ${normalizeAgentId(owner.agentId)}`,
);
}
return {
config: owner.config,
agentId: owner.agentId,
agentDir: owner.agentDir,
workspaceDir: owner.workspaceDir ?? resolveAgentWorkspaceDir(owner.config, owner.agentId),
catalog: owner.modelCatalog.entries,
};
}
/** Resolves the effective model for an isolated cron run across defaults, agents, hooks, payload, and session state. */
export async function resolveCronModelSelection(
params: ResolveCronModelSelectionParams,
): Promise<ResolveCronModelSelectionResult> {
const owner =
params.owner ??
(await resolveCronModelSelectionOwner({
cfg: params.cfg,
...(params.agentId
? {
agentId: params.agentId,
requiredAgentId: params.agentId,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
}
: {}),
}));
const ownerAgentId = owner.agentId;
const ownerAgentConfigOverride = params.agentConfigOverride
? owner.config === params.cfg && (!params.agentId || ownerAgentId === params.agentId)
? params.agentConfigOverride
: resolveAgentConfig(owner.config, ownerAgentId)
: undefined;
const ownerAgentDefaults = buildCronAgentDefaultsConfig({
defaults: owner.config.agents?.defaults,
agentConfigOverride: ownerAgentConfigOverride,
});
const cfgWithAgentDefaults: OpenClawConfig = {
...owner.config,
agents: Object.assign({}, owner.config.agents, { defaults: ownerAgentDefaults }),
};
const catalog = owner.catalog;
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfgWithAgentDefaults,
cfg: cfgWithAgentDefaults,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
@@ -86,24 +163,10 @@ export async function resolveCronModelSelection(
let model = resolvedDefault.model;
let modelSource: CronModelSelectionSource = "default";
let catalog: Awaited<ReturnType<typeof loadPreparedModelCatalog>> | undefined;
const loadCatalogOnce = async () => {
if (!catalog) {
catalog = await loadPreparedModelCatalog({
config: params.catalogConfig,
agentId: params.agentId,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
readOnly: true,
});
}
return catalog;
};
const subagentModelConfigSelection = resolveSubagentModelConfigSelectionResult({
cfg: params.cfg,
agentId: params.agentId,
agentConfigOverride: params.agentConfigOverride,
cfg: owner.config,
agentId: ownerAgentId,
agentConfigOverride: ownerAgentConfigOverride,
});
const subagentModelRaw = normalizeModelSelection(subagentModelConfigSelection?.raw);
const subagentModelSource: CronModelSelectionSource =
@@ -112,12 +175,12 @@ export async function resolveCronModelSelection(
// Subagent/agent model config is advisory here: invalid refs fall back to
// defaults so an agent config typo does not prevent unrelated cron runs.
const resolvedSubagent = resolveAllowedModelRef({
cfg: params.cfg,
catalog: await loadCatalogOnce(),
cfg: owner.config,
catalog,
raw: subagentModelRaw,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId: params.agentId,
agentId: ownerAgentId,
});
if (!("error" in resolvedSubagent)) {
provider = resolvedSubagent.ref.provider;
@@ -129,7 +192,7 @@ export async function resolveCronModelSelection(
let hooksGmailModelApplied = false;
const hooksGmailModelRef = params.isGmailHook
? resolveHooksGmailModel({
cfg: params.cfg,
cfg: owner.config,
defaultProvider: DEFAULT_PROVIDER,
})
: null;
@@ -137,12 +200,12 @@ export async function resolveCronModelSelection(
// Gmail hook models are specialized defaults: apply them only when the
// configured ref is allowed, otherwise keep the broader cron default.
const status = getModelRefStatus({
cfg: params.cfg,
catalog: await loadCatalogOnce(),
cfg: owner.config,
catalog,
ref: hooksGmailModelRef,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId: params.agentId,
agentId: ownerAgentId,
});
if (status.allowed) {
provider = hooksGmailModelRef.provider;
@@ -158,19 +221,19 @@ export async function resolveCronModelSelection(
// Payload model overrides are explicit cron config, so reject disallowed
// refs instead of silently falling back to defaults.
const resolvedOverride = resolveAllowedModelRef({
cfg: params.cfg,
catalog: await loadCatalogOnce(),
cfg: owner.config,
catalog,
raw: modelOverride,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId: params.agentId,
agentId: ownerAgentId,
});
if ("error" in resolvedOverride) {
return {
ok: false,
error: formatCronPayloadModelRejection({
cfg: params.cfg,
agentId: params.agentId,
cfg: owner.config,
agentId: ownerAgentId,
modelOverride,
error: resolvedOverride.error,
}),
@@ -189,12 +252,12 @@ export async function resolveCronModelSelection(
const sessionProviderOverride =
params.sessionEntry.providerOverride?.trim() || resolvedDefault.provider;
const resolvedSessionOverride = resolveAllowedModelRef({
cfg: params.cfg,
catalog: await loadCatalogOnce(),
cfg: owner.config,
catalog,
raw: `${sessionProviderOverride}/${sessionModelOverride}`,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
agentId: params.agentId,
agentId: ownerAgentId,
});
if (!("error" in resolvedSessionOverride)) {
provider = resolvedSessionOverride.ref.provider;
@@ -204,5 +267,12 @@ export async function resolveCronModelSelection(
}
}
return { ok: true, provider, model, modelSource };
return {
ok: true,
provider,
model,
modelSource,
cfgWithAgentDefaults,
owner,
};
}
@@ -1,2 +0,0 @@
// Runtime model catalog seam for isolated cron agent model resolution.
export { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
@@ -1,7 +1,11 @@
// Runtime model-selection seam for isolated cron agent runs.
export { resolveAgentConfig } from "../../agents/agent-scope-config.js";
export {
resolveAgentWorkspaceDir,
resolveSubagentModelConfigSelectionResult,
} from "../../agents/agent-scope.js";
export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
export { resolveSubagentModelConfigSelectionResult } from "../../agents/agent-scope.js";
export { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
export { loadPublishedPreparedModelCatalogOwnerSnapshot as loadPreparedModelCatalogOwnerSnapshot } from "../../agents/prepared-model-catalog.js";
export {
getModelRefStatus,
normalizeModelSelection,
@@ -4,12 +4,15 @@ import {
clearCliSessionMock,
clearFastTestEnv,
getCliSessionBindingMock,
ensureAgentWorkspaceMock,
ensureRuntimePluginsLoadedMock,
isCliProviderMock,
loadRunCronIsolatedAgentTurn,
makeCronSession,
makeCronSessionEntry,
isThinkingLevelSupportedMock,
loadModelCatalogMock,
loadModelCatalogOwnerMock,
resolveAgentConfigMock,
resolveAgentModelFallbacksOverrideMock,
resolveAllowedModelRefMock,
@@ -144,6 +147,63 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)",
restoreFastTestEnv(previousFastTestEnv);
});
it("builds cron context from the published replacement owner", async () => {
const callerConfig = { agents: { defaults: { model: "anthropic/caller" } } };
const ownerConfig = {
agents: {
defaults: { model: "google/gemini-2.0-flash" },
list: [{ id: "main", default: true, workspace: "/tmp/replacement-workspace" }],
},
};
const ownerCatalog = [{ provider: "google", id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }];
loadModelCatalogOwnerMock.mockResolvedValueOnce({
agentId: "main",
agentDir: "/tmp/owner-agent",
config: ownerConfig,
modelCatalog: { entries: ownerCatalog, routeVariants: [] },
});
ensureAgentWorkspaceMock.mockImplementationOnce(async ({ dir }: { dir: string }) => ({ dir }));
runWithModelFallbackMock.mockResolvedValueOnce(makeSuccessfulRunResult());
const result = await runCronIsolatedAgentTurn(makeParams({ cfg: callerConfig }));
expect(result.status).toBe("ok");
expect(loadModelCatalogOwnerMock).toHaveBeenCalledWith({
config: callerConfig,
readOnly: true,
});
expect(ensureAgentWorkspaceMock).toHaveBeenCalledWith(
expect.objectContaining({ dir: "/tmp/replacement-workspace" }),
);
expect(ensureRuntimePluginsLoadedMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining(ownerConfig),
workspaceDir: "/tmp/replacement-workspace",
}),
);
expect(resolveCronSessionMock).toHaveBeenCalledWith(
expect.objectContaining({ cfg: ownerConfig, agentId: "main" }),
);
});
it("rejects a replacement owner that changes an explicitly requested agent", async () => {
const callerConfig = {
agents: { list: [{ id: "main", default: true }, { id: "worker" }] },
};
loadModelCatalogOwnerMock.mockResolvedValueOnce({
agentId: "main",
agentDir: "/tmp/main-agent",
workspaceDir: "/tmp/main-workspace",
config: callerConfig,
modelCatalog: { entries: [], routeVariants: [] },
});
await expect(
runCronIsolatedAgentTurn(makeParams({ cfg: callerConfig, agentId: "worker" })),
).rejects.toThrow("cron model catalog owner changed from worker to main");
expect(runWithModelFallbackMock).not.toHaveBeenCalled();
});
it("passes the cron payload model override to runWithModelFallback", async () => {
// Track the provider/model passed to runWithModelFallback
let capturedProvider: string | undefined;
+30 -7
View File
@@ -52,6 +52,10 @@ function usesRealAccessorStore(storePath?: string): boolean {
export const buildWorkspaceSkillSnapshotMock = createMock();
export const resolveAgentConfigMock = createMock();
const resolveAgentWorkspaceDirMock = vi.fn(
(cfg: { agents?: { list?: Array<{ id?: string; workspace?: string }> } }, agentId: string) =>
cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ?? "/tmp/workspace",
);
const resolveEffectiveModelFallbacksMock = createMock();
const resolveSubagentModelFallbacksOverrideMock = createMock();
export const resolveAgentModelFallbacksOverrideMock = createMock();
@@ -105,7 +109,7 @@ export const resolveCronAgentLaneMock = createMock();
const resolveAgentTimeoutMsMock = createMock();
export const deriveSessionTotalTokensMock = createMock();
const hasNonzeroUsageMock = createMock();
const ensureAgentWorkspaceMock = createMock();
export const ensureAgentWorkspaceMock = createMock();
const normalizeThinkLevelMock = createMock();
const normalizeVerboseLevelMock = createMock();
export const isThinkingLevelSupportedMock = createMock();
@@ -121,13 +125,14 @@ const isExternalHookSessionMock = createMock();
const resolveHookExternalContentSourceMock = createMock();
const getSkillsSnapshotVersionMock = createMock();
export const loadModelCatalogMock = createMock();
export const loadModelCatalogOwnerMock = createMock();
const getRemoteSkillEligibilityMock = createMock();
vi.mock("./run.runtime.js", async () => ({
resolveAgentConfig: resolveAgentConfigMock,
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"),
resolveAgentWorkspaceDir: resolveAgentWorkspaceDirMock,
resolveDefaultAgentId: vi.fn().mockReturnValue("default"),
resolveCronStyleNow: resolveCronStyleNowMock,
DEFAULT_CONTEXT_TOKENS: 128000,
@@ -177,10 +182,6 @@ vi.mock("./run-context.runtime.js", () => ({
lookupContextTokens: lookupContextTokensMock,
}));
vi.mock("./run-model-catalog.runtime.js", () => ({
loadPreparedModelCatalog: loadModelCatalogMock,
}));
vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({
ensureRuntimePluginsLoaded: ensureRuntimePluginsLoadedMock,
}));
@@ -230,7 +231,9 @@ vi.mock("../../skills/runtime/cron-snapshot.runtime.js", () => ({
vi.mock("./run-model-selection.runtime.js", () => ({
DEFAULT_MODEL: "gpt-5.4",
DEFAULT_PROVIDER: "openai",
loadPreparedModelCatalog: loadModelCatalogMock,
loadPreparedModelCatalogOwnerSnapshot: loadModelCatalogOwnerMock,
resolveAgentConfig: resolveAgentConfigMock,
resolveAgentWorkspaceDir: resolveAgentWorkspaceDirMock,
getModelRefStatus: getModelRefStatusMock,
normalizeModelSelection: normalizeModelSelectionForTest,
resolveAllowedModelRef: resolveAllowedModelRefMock,
@@ -565,6 +568,26 @@ function resetRunConfigMocks(): void {
resolveHookExternalContentSourceMock.mockReturnValue(undefined);
getSkillsSnapshotVersionMock.mockReturnValue(42);
loadModelCatalogMock.mockResolvedValue([]);
loadModelCatalogOwnerMock.mockImplementation(
async (params: {
agentId?: string;
agentDir?: string;
config: object;
workspaceDir?: string;
}) => {
const agentId = params.agentId ?? "default";
return {
agentId,
agentDir: params.agentDir ?? "/tmp/agent-dir",
workspaceDir: params.workspaceDir ?? resolveAgentWorkspaceDirMock(params.config, agentId),
config: params.config,
modelCatalog: {
entries: await loadModelCatalogMock(params),
routeVariants: [],
},
};
},
);
getRemoteSkillEligibilityMock.mockResolvedValue({ remoteSkillsEnabled: false });
}
+42 -52
View File
@@ -88,7 +88,7 @@ import {
resolveCronPayloadOutcome,
resolveHeartbeatAckMaxChars,
} from "./helpers.js";
import { resolveCronModelSelection } from "./model-selection.js";
import { resolveCronModelSelection, resolveCronModelSelectionOwner } from "./model-selection.js";
import { buildCronAgentDefaultsConfig, resolveCronActiveRuntimeConfig } from "./run-config.js";
import { resolveCronPreflightCandidates } from "./run-fallback-policy.js";
import {
@@ -148,9 +148,6 @@ const cronAuthProfileRuntimeLoader = createLazyImportLoader(
() => import("./run-auth-profile.runtime.js"),
);
const cronContextRuntimeLoader = createLazyImportLoader(() => import("./run-context.runtime.js"));
const cronModelCatalogRuntimeLoader = createLazyImportLoader(
() => import("./run-model-catalog.runtime.js"),
);
const cronDeliveryRuntimeLoader = createLazyImportLoader(() => import("./run-delivery.runtime.js"));
const cronModelPreflightRuntimeLoader = createLazyImportLoader(
() => import("./model-preflight.runtime.js"),
@@ -186,10 +183,6 @@ async function loadCronContextRuntime() {
return await cronContextRuntimeLoader.load();
}
async function loadCronModelCatalogRuntime() {
return await cronModelCatalogRuntimeLoader.load();
}
async function loadCronDeliveryRuntime() {
return await cronDeliveryRuntimeLoader.load();
}
@@ -250,7 +243,6 @@ async function retireRolledCronSessionMcpRuntime(params: {
type CronExecutionRuntime = typeof import("./run-executor.runtime.js");
type CronExecutionResult = Awaited<ReturnType<CronExecutionRuntime["executeCronRun"]>>;
type CronModelCatalogRuntime = typeof import("./run-model-catalog.runtime.js");
type CronDeliveryRuntime = typeof import("./run-delivery.runtime.js");
type ResolvedCronDeliveryTarget = Awaited<ReturnType<CronDeliveryRuntime["resolveDeliveryTarget"]>>;
@@ -615,8 +607,8 @@ async function prepareCronRunContext(params: {
onLifecycleInterrupt: () => void;
}): Promise<CronPreparationResult> {
const { input } = params;
const runtimeCfg = resolveCronActiveRuntimeConfig(input.cfg);
const defaultAgentId = resolveDefaultAgentId(runtimeCfg);
const requestedRuntimeCfg = resolveCronActiveRuntimeConfig(input.cfg);
const requestedDefaultAgentId = resolveDefaultAgentId(requestedRuntimeCfg);
const requestedAgentId =
typeof input.agentId === "string" && input.agentId.trim()
? input.agentId
@@ -624,35 +616,33 @@ async function prepareCronRunContext(params: {
? input.job.agentId
: undefined;
const normalizedRequested = requestedAgentId ? normalizeAgentId(requestedAgentId) : undefined;
const agentId = normalizedRequested ?? defaultAgentId;
const initialAgentId = normalizedRequested ?? requestedDefaultAgentId;
const initialAgentDir = resolveAgentDir(requestedRuntimeCfg, initialAgentId);
const initialWorkspaceDir = resolveAgentWorkspaceDir(requestedRuntimeCfg, initialAgentId);
const modelOwner = await resolveCronModelSelectionOwner({
cfg: requestedRuntimeCfg,
...(normalizedRequested
? {
agentId: initialAgentId,
requiredAgentId: normalizedRequested,
agentDir: initialAgentDir,
workspaceDir: initialWorkspaceDir,
}
: {}),
});
const runtimeCfg = modelOwner.config;
const agentId = modelOwner.agentId;
const agentDir = modelOwner.agentDir;
const selectedAgentConfig = resolveAgentConfig(runtimeCfg, agentId);
const agentConfigOverride = normalizedRequested ? selectedAgentConfig : undefined;
const matchesDefaultFallbackAgentStringModel =
typeof selectedAgentConfig?.model === "string" &&
resolveAgentModelPrimaryValue(selectedAgentConfig.model) ===
resolveAgentModelPrimaryValue(runtimeCfg.agents?.defaults?.model);
const agentCfg: AgentDefaultsConfig = buildCronAgentDefaultsConfig({
defaults: runtimeCfg.agents?.defaults,
agentConfigOverride,
});
const cfgWithAgentDefaults: OpenClawConfig = {
const requestedCfgWithAgentDefaults: OpenClawConfig = {
...runtimeCfg,
agents: Object.assign({}, runtimeCfg.agents, { defaults: agentCfg }),
};
let catalog: Awaited<ReturnType<CronModelCatalogRuntime["loadPreparedModelCatalog"]>> | undefined;
const loadCatalog = async () => {
if (!catalog) {
catalog = await (
await loadCronModelCatalogRuntime()
).loadPreparedModelCatalog({
config: runtimeCfg,
agentId,
agentDir,
readOnly: true,
});
}
return catalog;
};
const baseSessionKey = (input.sessionKey?.trim() || `cron:${input.job.id}`).trim();
const currentBoundSourceKey =
@@ -666,14 +656,14 @@ async function prepareCronRunContext(params: {
const agentSessionKey = resolveCronAgentSessionKey({
sessionKey: cronExecutionSessionKey,
agentId,
mainKey: input.cfg.session?.mainKey,
cfg: input.cfg,
mainKey: runtimeCfg.session?.mainKey,
cfg: runtimeCfg,
});
const resolvedBaseSessionKey = resolveCronAgentSessionKey({
sessionKey: currentBoundSourceKey ?? baseSessionKey,
agentId,
mainKey: input.cfg.session?.mainKey,
cfg: input.cfg,
mainKey: runtimeCfg.session?.mainKey,
cfg: runtimeCfg,
});
const sourceSessionKey =
currentBoundSourceKey && resolvedBaseSessionKey !== agentSessionKey
@@ -684,10 +674,8 @@ async function prepareCronRunContext(params: {
const hookExternalContentSource =
payloadHookExternalContentSource ?? resolveHookExternalContentSource(baseSessionKey);
const workspaceDirRaw = resolveAgentWorkspaceDir(runtimeCfg, agentId);
const agentDir = resolveAgentDir(runtimeCfg, agentId);
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
dir: modelOwner.workspaceDir,
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !params.isFastTestEnv,
skipOptionalBootstrapFiles: agentCfg?.skipOptionalBootstrapFiles,
});
@@ -695,7 +683,7 @@ async function prepareCronRunContext(params: {
const { ensureRuntimePluginsLoaded } = await loadRuntimePlugins();
ensureRuntimePluginsLoaded({
config: cfgWithAgentDefaults,
config: requestedCfgWithAgentDefaults,
workspaceDir,
allowGatewaySubagentBinding: true,
});
@@ -703,7 +691,7 @@ async function prepareCronRunContext(params: {
const isGmailHook = hookExternalContentSource === "gmail";
const now = Date.now();
const cronSession = resolveCronSession({
cfg: input.cfg,
cfg: runtimeCfg,
sessionKey: agentSessionKey,
sourceSessionKey,
agentId,
@@ -793,11 +781,8 @@ async function prepareCronRunContext(params: {
}
const resolvedModelSelection = await resolveCronModelSelection({
// Authorization needs the unflattened active config so inherited policy
// aliases cannot be rebound by the selected agent's metadata aliases.
cfg: runtimeCfg,
catalogConfig: runtimeCfg,
cfgWithAgentDefaults,
owner: modelOwner,
agentConfigOverride,
sessionEntry: cronSession.sessionEntry,
payload: input.job.payload,
@@ -819,6 +804,13 @@ async function prepareCronRunContext(params: {
}),
};
}
const cfgWithAgentDefaults = resolvedModelSelection.cfgWithAgentDefaults;
const thinkingCatalog = modelOwner.catalog;
const ownerAgentConfig = resolveAgentConfig(modelOwner.config, modelOwner.agentId);
const matchesDefaultFallbackAgentStringModel =
typeof ownerAgentConfig?.model === "string" &&
resolveAgentModelPrimaryValue(ownerAgentConfig.model) ===
resolveAgentModelPrimaryValue(modelOwner.config.agents?.defaults?.model);
let provider = resolvedModelSelection.provider;
let model = resolvedModelSelection.model;
const useSubagentFallbacks = resolvedModelSelection.modelSource === "subagent";
@@ -831,7 +823,7 @@ async function prepareCronRunContext(params: {
const preflightCandidates = resolveCronPreflightCandidates({
cfg: cfgWithAgentDefaults,
job: input.job,
agentId,
agentId: modelOwner.agentId,
provider,
model,
useSubagentFallbacks,
@@ -894,7 +886,7 @@ async function prepareCronRunContext(params: {
}
const hooksGmailThinking = isGmailHook
? normalizeThinkLevel(input.cfg.hooks?.gmail?.thinking)
? normalizeThinkLevel(runtimeCfg.hooks?.gmail?.thinking)
: undefined;
const jobThink = normalizeThinkLevel(
(input.job.payload.kind === "agentTurn" ? input.job.payload.thinking : undefined) ?? undefined,
@@ -904,13 +896,12 @@ async function prepareCronRunContext(params: {
cfg: cfgWithAgentDefaults,
provider,
modelId: model,
agentId,
agentId: modelOwner.agentId,
sessionKey: agentSessionKey,
sessionEntry: cronSession.sessionEntry,
});
let requestedThinkLevel: ThinkLevel | undefined = jobThink ?? hooksGmailThinking ?? sessionThink;
if (!requestedThinkLevel) {
const thinkingCatalog = await loadCatalog();
requestedThinkLevel = resolveThinkingDefault({
cfg: cfgWithAgentDefaults,
provider,
@@ -919,7 +910,6 @@ async function prepareCronRunContext(params: {
agentRuntime: effectiveAgentRuntime,
});
}
const thinkingCatalog = await loadCatalog();
if (
!isThinkingLevelSupported({
provider,
@@ -968,8 +958,8 @@ async function prepareCronRunContext(params: {
provider,
model,
modelApi,
agentId,
agentDir,
agentId: modelOwner.agentId,
agentDir: modelOwner.agentDir,
sessionKey: agentSessionKey,
agentPayload,
});
@@ -980,7 +970,7 @@ async function prepareCronRunContext(params: {
agentId,
});
const { formattedTime, timeLine } = resolveCronStyleNow(input.cfg, now);
const { formattedTime, timeLine } = resolveCronStyleNow(runtimeCfg, now);
const message = resolveCronAgentTurnMessage(input);
const base = `[cron:${input.job.id} ${input.job.name}] ${message}`.trim();
const isExternalHook =
+11 -6
View File
@@ -42,9 +42,13 @@ describe("local gateway request context", () => {
it("defaults local model catalog snapshot reads to read-only", async () => {
const cfg = {} as OpenClawConfig;
const loadSnapshot = vi
.spyOn(preparedModelCatalog, "loadPreparedModelCatalogSnapshot")
.mockResolvedValue({ entries: [], routeVariants: [] });
const loadOwner = vi
.spyOn(preparedModelCatalog, "loadPublishedPreparedModelCatalogOwnerSnapshot")
.mockResolvedValue({
agentDir: "/tmp/local-model-catalog-agent",
config: cfg,
modelCatalog: { entries: [], routeVariants: [] },
} as never);
await withLocalGatewayRequestScope(
{
@@ -56,12 +60,13 @@ describe("local gateway request context", () => {
if (!context) {
throw new Error("expected local gateway request context");
}
await context.loadGatewayModelCatalogSnapshot();
const snapshot = await context.loadGatewayModelCatalogSnapshot({ agentId: "worker" });
expect(snapshot).not.toHaveProperty("agentId");
},
);
expect(loadSnapshot).toHaveBeenCalledWith({ config: cfg, readOnly: true });
loadSnapshot.mockRestore();
expect(loadOwner).toHaveBeenCalledWith({ agentId: "worker", config: cfg, readOnly: true });
loadOwner.mockRestore();
});
it("commits agent deletion through the canonical cron store", async () => {
+26 -20
View File
@@ -2,10 +2,7 @@ import { isAgentDeletionBlocked } from "../agents/agent-lifecycle-registry.js";
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
// Local embedded Gateway request context.
// Lets local agent paths reuse Gateway server methods without starting a server.
import {
loadPreparedModelCatalog,
loadPreparedModelCatalogSnapshot,
} from "../agents/prepared-model-catalog.js";
import { loadPublishedPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js";
import type { CliDeps } from "../cli/deps.types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { CronService } from "../cron/service.js";
@@ -116,6 +113,19 @@ function createLocalGatewayRequestContext(
bufferedAgentEvents.delete(key);
}
};
const loadModelCatalogOwner = async ({
agentId,
agentDir,
readOnly,
workspaceDir,
}: NonNullable<Parameters<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>[0]> = {}) =>
loadPublishedPreparedModelCatalogOwnerSnapshot({
...(agentId ? { agentId } : {}),
...(agentDir ? { agentDir } : {}),
config: params.getRuntimeConfig(),
readOnly: readOnly !== false,
...(workspaceDir ? { workspaceDir } : {}),
});
return {
deps: params.deps,
cron,
@@ -124,22 +134,18 @@ function createLocalGatewayRequestContext(
notifyPluginMetadataChanged: () => {},
resolveTerminalLaunchPolicy: () => ({ ok: false, block: { kind: "disabled" } }),
isTerminalEnabled: () => false,
loadGatewayModelCatalog: async ({ agentId, agentDir, readOnly, workspaceDir } = {}) =>
loadPreparedModelCatalog({
...(agentId ? { agentId } : {}),
...(agentDir ? { agentDir } : {}),
config: params.getRuntimeConfig(),
readOnly: readOnly !== false,
...(workspaceDir ? { workspaceDir } : {}),
}),
loadGatewayModelCatalogSnapshot: async ({ agentId, agentDir, readOnly, workspaceDir } = {}) =>
loadPreparedModelCatalogSnapshot({
...(agentId ? { agentId } : {}),
...(agentDir ? { agentDir } : {}),
config: params.getRuntimeConfig(),
readOnly: readOnly !== false,
...(workspaceDir ? { workspaceDir } : {}),
}),
loadGatewayModelCatalog: async (loadParams) =>
(await loadModelCatalogOwner(loadParams)).modelCatalog.entries,
loadGatewayModelCatalogSnapshot: async (loadParams) => {
const owner = await loadModelCatalogOwner(loadParams);
return {
...owner.modelCatalog,
...(owner.agentId ? { agentId: owner.agentId } : {}),
agentDir: owner.agentDir,
...(owner.workspaceDir ? { workspaceDir: owner.workspaceDir } : {}),
config: owner.config,
};
},
getHealthCache: () => null,
refreshHealthSnapshot: async () =>
({}) as Awaited<ReturnType<GatewayRequestContext["refreshHealthSnapshot"]>>,
@@ -17,6 +17,7 @@ import {
} from "../../agents/agent-scope.js";
import { modelCatalogBrowseRequiresFullDiscovery } from "../../agents/model-catalog-browse.js";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js";
import { hashRuntimeConfigValue } from "../../config/runtime-snapshot.js";
import {
isSessionTranscriptProjectionUnavailableError,
resolveTranscriptSessionKeyBySessionId,
@@ -33,6 +34,7 @@ import {
} from "../chat-abort.js";
import { resolveEffectiveChatHistoryMaxChars } from "../chat-display-projection.js";
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
import type { GatewayModelCatalogSnapshot } from "../server-model-catalog.types.js";
import { capArrayByJsonBytes } from "../session-transcript-readers.js";
import {
buildGatewaySessionInfo,
@@ -58,7 +60,6 @@ import {
import { resolveRequestedChatAgentId, validateChatSelectedAgent } from "./chat-origin-routing.js";
import { normalizeOptionalChatText as normalizeOptionalText } from "./chat-text-normalization.js";
import {
loadOptionalServerMethodModelCatalog,
loadOptionalServerMethodModelCatalogSnapshot,
startOptionalServerMethodModelCatalogSnapshotLoad,
} from "./optional-model-catalog.js";
@@ -76,6 +77,17 @@ type ChatMetadataResult = {
models?: unknown[];
};
function runtimeConfigsMatch(left: OpenClawConfig, right: OpenClawConfig): boolean {
if (left === right) {
return true;
}
try {
return hashRuntimeConfigValue(left) === hashRuntimeConfigValue(right);
} catch {
return false;
}
}
async function handleChatMetadataRequest({
params,
respond,
@@ -151,7 +163,7 @@ async function buildChatStartupMetadataResult(params: {
cfg: OpenClawConfig;
context: GatewayRequestContext;
agentId: string;
modelCatalog: ModelCatalogSnapshot | undefined;
modelCatalog: GatewayModelCatalogSnapshot | undefined;
catalogProjector?: ReturnType<
(typeof import("./models-list-result.js"))["createGatewayAgentModelCatalogProjector"]
>;
@@ -164,14 +176,23 @@ async function buildChatStartupMetadataResult(params: {
}
try {
const { buildModelsListResult } = await import("./models-list-result.js");
const currentConfig = params.context.getRuntimeConfig();
if (
params.modelCatalog.agentId !== params.agentId ||
!runtimeConfigsMatch(currentConfig, params.cfg)
) {
return undefined;
}
return await buildModelsListResult({
context: params.context,
agentId: params.agentId,
params: { view: "configured" },
preloadedCatalog: {
agentId: params.agentId,
config: currentConfig,
snapshot: params.modelCatalog,
},
preloadedOnly: true,
...(params.catalogProjector ? { catalogProjector: params.catalogProjector } : {}),
});
} catch (err) {
@@ -393,7 +414,7 @@ async function handleChatHistoryRequest({
}
const startupModelCatalogLoad =
method === "chat.startup"
? startOptionalServerMethodModelCatalogSnapshotLoad(context)
? startOptionalServerMethodModelCatalogSnapshotLoad(context, { agentId: sessionAgentId })
: undefined;
const modelCatalogPromise = measureDiagnosticsTimelineSpan(
`gateway.${method}.model_catalog`,
@@ -404,9 +425,9 @@ async function handleChatHistoryRequest({
startedLoad: startupModelCatalogLoad,
timeoutMs: CHAT_STARTUP_OPTIONAL_MODEL_CATALOG_TIMEOUT_MS,
})
: loadOptionalServerMethodModelCatalog(context, method).then((entries) =>
entries ? { entries, routeVariants: entries } : undefined,
),
: loadOptionalServerMethodModelCatalogSnapshot(context, method, {
loadParams: { agentId: sessionAgentId },
}),
{
config: cfg,
phase: method,
@@ -504,12 +525,14 @@ async function handleChatHistoryRequest({
logDebug: (message) => context.logGateway.debug(message),
});
const modelCatalogSnapshot = await modelCatalogPromise;
const modelCatalog = modelCatalogSnapshot?.entries;
const defaultAgentId = resolveDefaultAgentId(cfg);
const catalogOwnedBySessionAgent = modelCatalogSnapshot?.agentId === sessionAgentId;
const catalogConfig = catalogOwnedBySessionAgent ? modelCatalogSnapshot.config : cfg;
const modelCatalog = catalogOwnedBySessionAgent ? modelCatalogSnapshot.entries : undefined;
const defaultAgentId = resolveDefaultAgentId(catalogConfig);
const startupCatalogProjection =
method === "chat.startup" && modelCatalogSnapshot
method === "chat.startup" && catalogOwnedBySessionAgent
? await buildChatStartupModelCatalogProjection({
cfg,
cfg: catalogConfig,
snapshot: modelCatalogSnapshot,
sessionAgentId,
sessionEntry: entry,
@@ -523,7 +546,7 @@ async function handleChatHistoryRequest({
modelCatalog;
const startupMetadata = includeMetadata
? await buildChatStartupMetadataResult({
cfg,
cfg: catalogConfig,
context,
agentId: sessionAgentId,
modelCatalog: modelCatalogSnapshot,
@@ -28,11 +28,18 @@ async function listModels(params: {
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
view?: "all" | "configured" | "provider-config" | "default";
}) {
const config = params.cfg ?? ({} as OpenClawConfig);
const context = {
getRuntimeConfig: () => params.cfg ?? ({} as OpenClawConfig),
getRuntimeConfig: () => config,
loadGatewayModelCatalog: vi.fn(() => Promise.resolve(params.catalog)),
loadGatewayModelCatalogSnapshot: vi.fn(() =>
Promise.resolve({ entries: params.catalog, routeVariants: params.catalog }),
Promise.resolve({
agentId: "main",
agentDir: "/tmp/models-list-openai-agent",
config,
entries: params.catalog,
routeVariants: params.catalog,
}),
),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
@@ -45,17 +52,22 @@ async function listModels(params: {
describe("models.list OpenAI routes", () => {
it("does not reuse a preloaded catalog owned by another agent", async () => {
const config = {
agents: {
defaults: {},
list: [{ id: "main", default: true }, { id: "worker" }],
},
} as OpenClawConfig;
const loadGatewayModelCatalogSnapshot = vi.fn(() =>
Promise.resolve({ entries: [], routeVariants: [] }),
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [],
routeVariants: [],
}),
);
const context = {
getRuntimeConfig: () =>
({
agents: {
defaults: {},
list: [{ id: "main", default: true }, { id: "worker" }],
},
}) as OpenClawConfig,
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
@@ -69,7 +81,7 @@ describe("models.list OpenAI routes", () => {
context,
agentId: "worker",
params: { view: "default" },
preloadedCatalog: { agentId: "main", snapshot: preloadedCatalog },
preloadedCatalog: { agentId: "main", config, snapshot: preloadedCatalog },
}),
).resolves.toEqual({ models: [] });
expect(loadGatewayModelCatalogSnapshot).toHaveBeenCalledWith(
@@ -77,6 +89,331 @@ describe("models.list OpenAI routes", () => {
);
});
it("does not reuse a preloaded catalog from another config generation", async () => {
const config = {} as OpenClawConfig;
const loadGatewayModelCatalogSnapshot = vi.fn(() =>
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [],
routeVariants: [],
}),
);
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(
buildModelsListResult({
context,
params: { view: "default" },
preloadedCatalog: {
agentId: "main",
config: {} as OpenClawConfig,
snapshot: { entries: [catalogEntry("stale", "openai-responses")], routeVariants: [] },
},
}),
).resolves.toEqual({ models: [] });
expect(loadGatewayModelCatalogSnapshot).toHaveBeenCalledOnce();
});
it("does not reuse a preloaded projector after a full replacement-owner load", async () => {
const config = {} as OpenClawConfig;
const replacementConfig = {} as OpenClawConfig;
const loadGatewayModelCatalogSnapshot = vi.fn(() =>
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config: replacementConfig,
entries: [],
routeVariants: [],
}),
);
const evaluateEntry = vi.fn();
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(
buildModelsListResult({
context,
params: { view: "all" },
preloadedCatalog: {
agentId: "main",
config,
snapshot: { entries: [catalogEntry("stale", "openai-responses")], routeVariants: [] },
},
catalogProjector: { evaluateEntry } as never,
}),
).resolves.toEqual({ models: [] });
expect(loadGatewayModelCatalogSnapshot).toHaveBeenCalledWith({
agentId: "main",
readOnly: false,
});
expect(evaluateEntry).not.toHaveBeenCalled();
});
it("does not start full discovery when restricted to a preloaded catalog", async () => {
const config = {} as OpenClawConfig;
const loadGatewayModelCatalogSnapshot = vi.fn();
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(
buildModelsListResult({
context,
params: { view: "all" },
preloadedCatalog: {
agentId: "main",
config,
snapshot: { entries: [catalogEntry("stale", "openai-responses")], routeVariants: [] },
},
preloadedOnly: true,
}),
).resolves.toEqual({ models: [] });
expect(loadGatewayModelCatalogSnapshot).not.toHaveBeenCalled();
});
it("uses the published fallback owner's identity for implicit projection", async () => {
const config = {
agents: {
defaults: {},
list: [
{
id: "main",
models: { "openai/gpt-owner": { agentRuntime: { id: "codex" } } },
},
{
id: "worker",
default: true,
models: { "openai/gpt-owner": { agentRuntime: { id: "openclaw" } } },
},
],
},
} as OpenClawConfig;
const ownerEntry = catalogEntry("gpt-owner", "openai-responses");
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: vi.fn(() =>
Promise.resolve({
agentId: "main",
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [ownerEntry],
routeVariants: [ownerEntry],
}),
),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
const result = await buildModelsListResult({
context,
params: { view: "all" },
});
expect(result).toEqual({
models: [
expect.objectContaining({
id: "gpt-owner",
provider: "openai",
agentRuntime: { id: "codex", source: "model" },
}),
],
});
expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledWith({
agentId: "worker",
readOnly: false,
});
});
it("escalates full discovery using the replacement owner's agent", async () => {
const initialConfig = {
agents: { defaults: {}, list: [{ id: "main" }, { id: "worker", default: true }] },
} as OpenClawConfig;
const replacementConfig = {
agents: {
defaults: { models: { "openai/*": {} } },
list: [{ id: "main", default: true }, { id: "worker" }],
},
} as OpenClawConfig;
const entry = catalogEntry("gpt-owner", "openai-responses");
const loadGatewayModelCatalogSnapshot = vi
.fn<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>()
.mockResolvedValueOnce({
agentId: "main",
agentDir: "/tmp/models-list-main-agent",
config: replacementConfig,
entries: [entry],
routeVariants: [entry],
})
.mockResolvedValueOnce({
agentId: "main",
agentDir: "/tmp/models-list-main-agent",
config: replacementConfig,
entries: [entry],
routeVariants: [entry],
});
const context = {
getRuntimeConfig: () => initialConfig,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await buildModelsListResult({ context, params: { view: "configured" } });
expect(loadGatewayModelCatalogSnapshot.mock.calls).toEqual([
[{ agentId: "worker", readOnly: true }],
[{ agentId: "main", readOnly: false }],
]);
});
it("passes the resolved default agent to catalog loads", async () => {
const config = {
agents: { defaults: {}, list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const loadGatewayModelCatalogSnapshot = vi.fn(
(params: { agentId?: string; readOnly?: boolean }) =>
Promise.resolve({
agentId: params.agentId,
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [],
routeVariants: [],
}),
);
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot,
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(buildModelsListResult({ context, params: { view: "all" } })).resolves.toEqual({
models: [],
});
expect(loadGatewayModelCatalogSnapshot).toHaveBeenCalledWith({
agentId: "main",
readOnly: false,
});
});
it("does not project an ownerless catalog as the requested agent", async () => {
const config = {
agents: {
defaults: {},
list: [
{ id: "main", default: true },
{
id: "worker",
models: { "openai/gpt-ownerless": { agentRuntime: { id: "openclaw" } } },
},
],
},
} as OpenClawConfig;
const ownerlessEntry = catalogEntry("gpt-ownerless", "openai-responses");
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: vi.fn(() =>
Promise.resolve({
agentDir: "/tmp/models-list-openai-agent",
config,
entries: [ownerlessEntry],
routeVariants: [ownerlessEntry],
}),
),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(
buildModelsListResult({
context,
agentId: "worker",
params: { view: "all" },
}),
).resolves.toEqual({ models: [] });
});
it("does not project another owner's catalog as an explicitly requested agent", async () => {
const config = {
agents: {
defaults: {},
list: [{ id: "main", default: true }, { id: "worker" }],
},
} as OpenClawConfig;
const mainEntry = catalogEntry("gpt-main", "openai-responses");
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: vi.fn(() =>
Promise.resolve({
agentId: "main",
agentDir: "/tmp/models-list-main-agent",
config,
entries: [mainEntry],
routeVariants: [mainEntry],
}),
),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(
buildModelsListResult({
context,
agentId: "worker",
params: { view: "all" },
}),
).resolves.toEqual({ models: [] });
});
it("accepts a canonical owner for a noncanonical explicit agent request", async () => {
const config = {
agents: {
defaults: {},
list: [
{ id: "main", default: true },
{
id: "worker",
models: { "openai/gpt-worker": { agentRuntime: { id: "openclaw" } } },
},
],
},
} as OpenClawConfig;
const workerEntry = catalogEntry("gpt-worker", "openai-responses");
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: vi.fn(() =>
Promise.resolve({
agentId: "worker",
agentDir: "/tmp/models-list-worker-agent",
config,
entries: [workerEntry],
routeVariants: [workerEntry],
}),
),
logGateway: { debug: vi.fn() },
} as unknown as GatewayRequestContext;
await expect(
buildModelsListResult({
context,
agentId: "WORKER",
params: { view: "all" },
}),
).resolves.toEqual({
models: [
expect.objectContaining({
id: "gpt-worker",
provider: "openai",
agentRuntime: { id: "openclaw", source: "model" },
}),
],
});
});
it("keeps route-aware default browse indeterminate without the provider artifact", async () => {
const resolveRoutes = vi.fn(() => null);
const createResolver = vi.fn(() => resolveRoutes);
@@ -20,6 +20,7 @@ import { hasSyntheticLocalProviderAuthConfig } from "../../agents/model-auth.js"
import {
buildProviderConfigModelCatalogForBrowse,
loadPreparedModelCatalogSnapshotForBrowse,
modelCatalogBrowseRequiresFullDiscovery,
type ModelCatalogBrowseView,
} from "../../agents/model-catalog-browse.js";
import {
@@ -48,6 +49,7 @@ import { getRuntimeConfigSourceSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadPluginRegistrySnapshotWithMetadata } from "../../plugins/plugin-registry.js";
import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-choices.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { GatewayAgentRuntime } from "../../shared/session-types.js";
import type { GatewayRequestContext } from "./types.js";
@@ -444,45 +446,110 @@ function apiKeyProviderCapabilities(params: {
return { providers: capabilities, resolveProvider };
}
export async function buildModelsListResult(params: {
type BuildModelsListResultParams = {
context: GatewayRequestContext;
agentId?: string;
params: Record<string, unknown>;
preloadedCatalog?: {
agentId: string;
config: OpenClawConfig;
snapshot: ModelCatalogSnapshot;
};
catalogProjector?: ReturnType<typeof createGatewayAgentModelCatalogProjector>;
preloadedOnly?: boolean;
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
}): Promise<{ models: ModelsListEntryWithCapabilities[] }> {
const cfg = params.context.getRuntimeConfig();
const agentId = params.agentId ?? resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir();
};
export async function buildModelsListResult(
params: BuildModelsListResultParams,
): Promise<{ models: ModelsListEntryWithCapabilities[] }> {
const initialConfig = params.context.getRuntimeConfig();
const initialAgentId = normalizeAgentId(params.agentId ?? resolveDefaultAgentId(initialConfig));
const view = resolveModelsListView(params.params);
const snapshot = await loadPreparedModelCatalogSnapshotForBrowse({
cfg,
const preloadedCatalog =
params.preloadedCatalog?.agentId === initialAgentId &&
params.preloadedCatalog.config === initialConfig
? params.preloadedCatalog
: undefined;
let loadedSnapshot:
| Awaited<ReturnType<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>>
| undefined;
let loadedReadOnly = true;
let usedPreloadedCatalog = false;
const handleCatalogTimeout = (timeoutMs: number) => {
if (loggedSlowModelsListCatalog) {
return;
}
loggedSlowModelsListCatalog = true;
params.context.logGateway.debug(
`models.list continuing without model catalog after ${timeoutMs}ms`,
);
};
let snapshot = await loadPreparedModelCatalogSnapshotForBrowse({
cfg: initialConfig,
view,
loadCatalog: async (loadParams) => {
const readOnlyLoad = loadParams.readOnly ?? true;
if (params.preloadedCatalog?.agentId === agentId && readOnlyLoad) {
return params.preloadedCatalog.snapshot;
loadedReadOnly = loadParams.readOnly ?? true;
if (preloadedCatalog && loadedReadOnly) {
usedPreloadedCatalog = true;
return preloadedCatalog.snapshot;
}
return await params.context.loadGatewayModelCatalogSnapshot({
...loadParams,
agentId,
agentDir: resolveAgentDir(cfg, agentId),
if (params.preloadedOnly) {
return { entries: [], routeVariants: [] };
}
loadedSnapshot = await params.context.loadGatewayModelCatalogSnapshot({
agentId: initialAgentId,
readOnly: loadedReadOnly,
});
return loadedSnapshot;
},
onTimeout: (timeoutMs) => {
if (loggedSlowModelsListCatalog) {
return;
}
loggedSlowModelsListCatalog = true;
params.context.logGateway.debug(
`models.list continuing without model catalog after ${timeoutMs}ms`,
);
},
onTimeout: handleCatalogTimeout,
});
if (loadedSnapshot && !loadedSnapshot.agentId) {
return { models: [] };
}
if (
loadedSnapshot &&
loadedReadOnly &&
modelCatalogBrowseRequiresFullDiscovery({ cfg: loadedSnapshot.config, view })
) {
const escalationAgentId = loadedSnapshot.agentId;
let escalationTimedOut = false;
let fullSnapshot: typeof loadedSnapshot | undefined;
const escalatedCatalog = await loadPreparedModelCatalogSnapshotForBrowse({
cfg: loadedSnapshot.config,
view,
loadCatalog: async ({ readOnly }) => {
fullSnapshot = await params.context.loadGatewayModelCatalogSnapshot({
agentId: escalationAgentId,
readOnly,
});
return fullSnapshot;
},
timeoutFullDiscovery: true,
onTimeout: (timeoutMs) => {
escalationTimedOut = true;
handleCatalogTimeout(timeoutMs);
},
});
if (!escalationTimedOut && fullSnapshot) {
loadedSnapshot = fullSnapshot;
snapshot = escalatedCatalog;
}
}
if (
loadedSnapshot &&
(!loadedSnapshot.agentId ||
(params.agentId !== undefined && normalizeAgentId(loadedSnapshot.agentId) !== initialAgentId))
) {
return { models: [] };
}
const cfg = loadedSnapshot?.config ?? initialConfig;
const agentId = loadedSnapshot?.agentId ?? initialAgentId;
const workspaceDir =
loadedSnapshot?.workspaceDir ??
resolveAgentWorkspaceDir(cfg, agentId) ??
resolveDefaultAgentWorkspaceDir();
const catalog = snapshot.entries;
const routeVariants = snapshot.routeVariants;
const includeProviderCapabilities = params.params.includeProviderCapabilities === true;
@@ -531,7 +598,7 @@ export async function buildModelsListResult(params: {
...RUNTIME_MODEL_VISIBILITY_NORMALIZATION,
});
const evaluateEntry =
params.catalogProjector?.evaluateEntry ??
(usedPreloadedCatalog ? params.catalogProjector?.evaluateEntry : undefined) ??
createModelsListEntryEvaluator({
cfg,
agentId,
+76 -2
View File
@@ -4,6 +4,7 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
replaceRuntimeAuthProfileStoreSnapshots,
@@ -49,7 +50,9 @@ function requestModelsList(params: {
view: "default" | "configured" | "provider-config" | "all";
respond?: ReturnType<typeof vi.fn>;
runtimeConfig?: OpenClawConfig;
getRuntimeConfig?: () => OpenClawConfig;
loadGatewayModelCatalog: (params?: {
agentId?: string;
agentDir?: string;
readOnly?: boolean;
workspaceDir?: string;
@@ -58,6 +61,8 @@ function requestModelsList(params: {
includeProviderCapabilities?: boolean;
}) {
const respond = params.respond ?? vi.fn();
const runtimeConfig = params.runtimeConfig ?? ({} as OpenClawConfig);
const getRuntimeConfig = params.getRuntimeConfig ?? (() => runtimeConfig);
const request = expectDefined(
modelsHandlers["models.list"],
'modelsHandlers["models.list"] test invariant',
@@ -79,13 +84,20 @@ function requestModelsList(params: {
client: null,
isWebchatConnect: () => false,
context: {
getRuntimeConfig: () => params.runtimeConfig ?? ({} as OpenClawConfig),
getRuntimeConfig,
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot: async (
loadParams: Parameters<typeof params.loadGatewayModelCatalog>[0],
) => {
const entries = await params.loadGatewayModelCatalog(loadParams);
return { entries, routeVariants: entries };
const config = getRuntimeConfig();
return {
agentId: loadParams?.agentId ?? resolveDefaultAgentId(config),
agentDir: "/tmp/models-list-agent",
config,
entries,
routeVariants: entries,
};
},
logGateway: {
debug: vi.fn(),
@@ -96,6 +108,68 @@ function requestModelsList(params: {
}
describe("models.list", () => {
it("uses the replacement owner config for the whole catalog projection", async () => {
const initialConfig = {
agents: { defaults: { models: { "test/old": {} } } },
} as OpenClawConfig;
const latestConfig = {
agents: { defaults: { models: { "test/demo": {} } } },
} as OpenClawConfig;
let currentConfig = initialConfig;
const loadGatewayModelCatalog = vi.fn(async () => {
if (currentConfig === initialConfig) {
currentConfig = latestConfig;
}
return [{ id: "demo", name: "Demo", provider: "test" }];
});
const { request, respond } = requestModelsList({
view: "configured",
getRuntimeConfig: () => currentConfig,
loadGatewayModelCatalog,
});
await request;
expect(loadGatewayModelCatalog).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledOnce();
expect(respond).toHaveBeenCalledWith(
true,
{ models: [expect.objectContaining({ id: "demo", provider: "test" })] },
undefined,
);
});
it("escalates to the full owner when replacement config adds a provider wildcard", async () => {
const initialConfig = {
agents: { defaults: { models: { "test/demo": {} } } },
} as OpenClawConfig;
const latestConfig = {
agents: { defaults: { models: { "test/*": {} } } },
} as OpenClawConfig;
let currentConfig = initialConfig;
let firstLoad = true;
const loadGatewayModelCatalog = vi.fn(async (_params?: { readOnly?: boolean }) => {
if (firstLoad) {
firstLoad = false;
currentConfig = latestConfig;
}
return [{ id: "demo", name: "Demo", provider: "test" }];
});
const { request, respond } = requestModelsList({
view: "configured",
getRuntimeConfig: () => currentConfig,
loadGatewayModelCatalog,
});
await request;
expect(loadGatewayModelCatalog.mock.calls.map(([params]) => params?.readOnly)).toEqual([
true,
false,
]);
expect(respond).toHaveBeenCalledWith(true, { models: [] }, undefined);
});
it("reports API-key capability from provider auth contracts when requested", async () => {
const { request, respond } = requestModelsList({
view: "all",
@@ -1,6 +1,7 @@
// Optional model-catalog loading gives session/tool methods metadata when fast
// while never blocking their primary response path on catalog discovery.
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "../../agents/model-catalog.types.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import type { GatewayModelCatalogSnapshot } from "../server-model-catalog.types.js";
import type { GatewayRequestContext } from "./types.js";
/**
@@ -16,6 +17,7 @@ type OptionalServerMethodModelCatalogLoad<T> = {
};
type LoadOptionalServerMethodModelCatalogOptions<T> = {
loadParams?: Parameters<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>[0];
logOnceKey?: string;
startedLoad?: OptionalServerMethodModelCatalogLoad<T>;
timeoutMs?: number;
@@ -25,13 +27,18 @@ function normalizeOptionalModelCatalog(value: unknown): ModelCatalogEntry[] | un
return Array.isArray(value) ? value : undefined;
}
function normalizeOptionalModelCatalogSnapshot(value: unknown): ModelCatalogSnapshot | undefined {
function normalizeOptionalModelCatalogSnapshot(
value: unknown,
): GatewayModelCatalogSnapshot | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const snapshot = value as Partial<ModelCatalogSnapshot>;
return Array.isArray(snapshot.entries) && Array.isArray(snapshot.routeVariants)
? { entries: snapshot.entries, routeVariants: snapshot.routeVariants }
const snapshot = value as Partial<GatewayModelCatalogSnapshot>;
return typeof snapshot.agentDir === "string" &&
snapshot.config &&
Array.isArray(snapshot.entries) &&
Array.isArray(snapshot.routeVariants)
? (snapshot as GatewayModelCatalogSnapshot)
: undefined;
}
@@ -61,9 +68,10 @@ function startOptionalServerMethodModelCatalogLoad(
export function startOptionalServerMethodModelCatalogSnapshotLoad(
context: GatewayRequestContext,
): OptionalServerMethodModelCatalogLoad<ModelCatalogSnapshot> {
loadParams?: Parameters<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>[0],
): OptionalServerMethodModelCatalogLoad<GatewayModelCatalogSnapshot> {
return startOptionalServerMethodModelCatalogValueLoad({
load: () => context.loadGatewayModelCatalogSnapshot(),
load: () => context.loadGatewayModelCatalogSnapshot(loadParams),
normalize: normalizeOptionalModelCatalogSnapshot,
});
}
@@ -117,9 +125,9 @@ export async function loadOptionalServerMethodModelCatalog(
export async function loadOptionalServerMethodModelCatalogSnapshot(
context: GatewayRequestContext,
surface: string,
options?: LoadOptionalServerMethodModelCatalogOptions<ModelCatalogSnapshot>,
): Promise<ModelCatalogSnapshot | undefined> {
options?: LoadOptionalServerMethodModelCatalogOptions<GatewayModelCatalogSnapshot>,
): Promise<GatewayModelCatalogSnapshot | undefined> {
return await loadOptionalServerMethodModelCatalogValue(context, surface, options, () =>
startOptionalServerMethodModelCatalogSnapshotLoad(context),
startOptionalServerMethodModelCatalogSnapshotLoad(context, options?.loadParams),
);
}
+2 -2
View File
@@ -9,7 +9,6 @@ import type {
ErrorShape,
RequestFrame,
} from "../../../packages/gateway-protocol/src/schema/frames.js";
import type { ModelCatalogSnapshot } from "../../agents/model-catalog.types.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import type { CliDeps } from "../../cli/deps.types.js";
import type { HealthSummary } from "../../commands/health.types.js";
@@ -47,6 +46,7 @@ import type {
GatewayApprovalEventPublisher,
GatewayRecoveryRuntime,
} from "../server-instance-runtime.types.js";
import type { GatewayModelCatalogSnapshot } from "../server-model-catalog.types.js";
import type { DedupeEntry } from "../server-shared.js";
import type { GatewayEventLoopHealth } from "../server/event-loop-health.js";
import type { SessionObserverService } from "../session-observer-contract.js";
@@ -195,7 +195,7 @@ export type GatewayRequestContext = {
agentDir?: string;
readOnly?: boolean;
workspaceDir?: string;
}) => Promise<ModelCatalogSnapshot>;
}) => Promise<GatewayModelCatalogSnapshot>;
getHealthCache: () => HealthSummary | null;
refreshHealthSnapshot: (opts?: {
probe?: boolean;
+77 -58
View File
@@ -1,9 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
import { PreparedModelCatalogConfigReplacedError } from "../agents/prepared-model-catalog.errors.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot,
type GatewayModelCatalogSnapshot,
} from "./server-model-catalog.js";
const snapshot: ModelCatalogSnapshot = {
@@ -11,18 +12,31 @@ const snapshot: ModelCatalogSnapshot = {
routeVariants: [],
};
function ownerSnapshot(
config: OpenClawConfig,
modelCatalog: ModelCatalogSnapshot = snapshot,
agentId?: string,
) {
return {
...(agentId ? { agentId } : {}),
agentDir: "/tmp/gateway-agent",
config,
modelCatalog,
};
}
describe("gateway prepared model catalog", () => {
it("reads the published read-only generation directly", async () => {
const config = {};
const loadPreparedModelCatalogSnapshot = vi.fn(async () => snapshot);
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () => ownerSnapshot(config));
await expect(
loadGatewayModelCatalog({
getConfig: () => config,
loadPreparedModelCatalogSnapshot,
loadPublishedPreparedModelCatalogOwnerSnapshot,
}),
).resolves.toBe(snapshot.entries);
expect(loadPreparedModelCatalogSnapshot).toHaveBeenCalledWith({
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledWith({
config,
readOnly: true,
});
@@ -30,16 +44,28 @@ describe("gateway prepared model catalog", () => {
it("forwards the requested agent lifecycle owner", async () => {
const config = {};
const loadPreparedModelCatalogSnapshot = vi.fn(async () => snapshot);
await loadGatewayModelCatalogSnapshot({
agentDir: "/tmp/gateway-agent",
getConfig: () => config,
loadPreparedModelCatalogSnapshot,
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () => ({
...ownerSnapshot(config, snapshot, "worker"),
workspaceDir: "/tmp/gateway-workspace",
});
}));
expect(loadPreparedModelCatalogSnapshot).toHaveBeenCalledWith({
await expect(
loadGatewayModelCatalogSnapshot({
agentId: "worker",
agentDir: "/tmp/gateway-agent",
getConfig: () => config,
loadPublishedPreparedModelCatalogOwnerSnapshot,
workspaceDir: "/tmp/gateway-workspace",
}),
).resolves.toMatchObject({
agentId: "worker",
agentDir: "/tmp/gateway-agent",
config,
workspaceDir: "/tmp/gateway-workspace",
} satisfies Partial<GatewayModelCatalogSnapshot>);
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledWith({
agentId: "worker",
agentDir: "/tmp/gateway-agent",
config,
readOnly: true,
@@ -47,18 +73,51 @@ describe("gateway prepared model catalog", () => {
});
});
it("does not infer agent identity when the published owner omits it", async () => {
const config = {};
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () => ownerSnapshot(config));
await expect(
loadGatewayModelCatalogSnapshot({
agentId: "worker",
getConfig: () => config,
loadPublishedPreparedModelCatalogOwnerSnapshot,
}),
).resolves.not.toHaveProperty("agentId");
});
it("returns an equivalent replacement owner without repeating discovery", async () => {
const initialConfig = { logging: { level: "info" as const } };
const latestConfig = { logging: { level: "info" as const } };
const latestSnapshot: ModelCatalogSnapshot = {
entries: [{ provider: "openai", id: "latest", name: "Latest" }],
routeVariants: [],
};
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () =>
ownerSnapshot(latestConfig, latestSnapshot),
);
await expect(
loadGatewayModelCatalogSnapshot({
getConfig: () => initialConfig,
loadPublishedPreparedModelCatalogOwnerSnapshot,
}),
).resolves.toMatchObject({ config: latestConfig, entries: latestSnapshot.entries });
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledOnce();
});
it("selects the full prepared owner when requested", async () => {
const config = {};
const loadPreparedModelCatalogSnapshot = vi.fn(async () => snapshot);
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () => ownerSnapshot(config));
await expect(
loadGatewayModelCatalogSnapshot({
getConfig: () => config,
loadPreparedModelCatalogSnapshot,
loadPublishedPreparedModelCatalogOwnerSnapshot,
readOnly: false,
}),
).resolves.toBe(snapshot);
expect(loadPreparedModelCatalogSnapshot).toHaveBeenCalledWith({
).resolves.toMatchObject(snapshot);
expect(loadPublishedPreparedModelCatalogOwnerSnapshot).toHaveBeenCalledWith({
config,
readOnly: false,
});
@@ -66,52 +125,12 @@ describe("gateway prepared model catalog", () => {
it("does not hide lifecycle publication failures behind stale data", async () => {
const error = new Error("generation failed");
const loadPreparedModelCatalogSnapshot = vi.fn(async () => {
const loadPublishedPreparedModelCatalogOwnerSnapshot = vi.fn(async () => {
throw error;
});
await expect(
loadGatewayModelCatalogSnapshot({ loadPreparedModelCatalogSnapshot }),
loadGatewayModelCatalogSnapshot({ loadPublishedPreparedModelCatalogOwnerSnapshot }),
).rejects.toBe(error);
});
it("follows a committed config that replaces the catalog owner during a read", async () => {
const initialConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
const replacementConfig = { agents: { defaults: { model: "openai/gpt-5.6" } } };
const getConfig = vi.fn().mockReturnValueOnce(initialConfig).mockReturnValue(replacementConfig);
const loadPreparedModelCatalogSnapshot = vi
.fn()
.mockRejectedValueOnce(new PreparedModelCatalogConfigReplacedError("/tmp/gateway-agent"))
.mockResolvedValueOnce(snapshot);
await expect(
loadGatewayModelCatalogSnapshot({
getConfig,
loadPreparedModelCatalogSnapshot,
}),
).resolves.toBe(snapshot);
expect(loadPreparedModelCatalogSnapshot).toHaveBeenNthCalledWith(1, {
config: initialConfig,
readOnly: true,
});
expect(loadPreparedModelCatalogSnapshot).toHaveBeenNthCalledWith(2, {
config: replacementConfig,
readOnly: true,
});
expect(getConfig).toHaveBeenCalledTimes(2);
});
it("does not loop when the runtime config has not advanced", async () => {
const config = { agents: { defaults: { model: "openai/gpt-5.5" } } };
const error = new PreparedModelCatalogConfigReplacedError("/tmp/gateway-agent");
const loadPreparedModelCatalogSnapshot = vi.fn().mockRejectedValue(error);
await expect(
loadGatewayModelCatalogSnapshot({
getConfig: () => config,
loadPreparedModelCatalogSnapshot,
}),
).rejects.toBe(error);
expect(loadPreparedModelCatalogSnapshot).toHaveBeenCalledOnce();
});
});
+36 -40
View File
@@ -1,36 +1,39 @@
// Gateway catalog reads use the atomic prepared runtime generation.
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
import { PreparedModelCatalogConfigReplacedError } from "../agents/prepared-model-catalog.errors.js";
import { getRuntimeConfig } from "../config/io.js";
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import type {
GatewayModelCatalogOwnerSnapshot,
GatewayModelCatalogSnapshot,
} from "./server-model-catalog.types.js";
export type GatewayModelChoice = import("../agents/model-catalog.js").ModelCatalogEntry;
export type { GatewayModelCatalogSnapshot } from "./server-model-catalog.types.js";
type GatewayModelCatalogConfig = ReturnType<typeof getRuntimeConfig>;
type LoadPreparedModelCatalogSnapshot = (params: {
type LoadPublishedPreparedModelCatalogOwnerSnapshot = (params: {
agentId?: string;
agentDir?: string;
config: GatewayModelCatalogConfig;
readOnly?: boolean;
workspaceDir?: string;
}) => Promise<ModelCatalogSnapshot>;
}) => Promise<GatewayModelCatalogOwnerSnapshot>;
type LoadGatewayModelCatalogParams = {
agentId?: string;
agentDir?: string;
getConfig?: () => GatewayModelCatalogConfig;
loadPreparedModelCatalogSnapshot?: LoadPreparedModelCatalogSnapshot;
loadPublishedPreparedModelCatalogOwnerSnapshot?: LoadPublishedPreparedModelCatalogOwnerSnapshot;
readOnly?: boolean;
workspaceDir?: string;
};
async function resolveLoader(
params?: LoadGatewayModelCatalogParams,
): Promise<LoadPreparedModelCatalogSnapshot> {
if (params?.loadPreparedModelCatalogSnapshot) {
return params.loadPreparedModelCatalogSnapshot;
): Promise<LoadPublishedPreparedModelCatalogOwnerSnapshot> {
if (params?.loadPublishedPreparedModelCatalogOwnerSnapshot) {
return params.loadPublishedPreparedModelCatalogOwnerSnapshot;
}
const { loadPreparedModelCatalogSnapshot } = await import("../agents/prepared-model-catalog.js");
return loadPreparedModelCatalogSnapshot;
const { loadPublishedPreparedModelCatalogOwnerSnapshot } =
await import("../agents/prepared-model-catalog.js");
return loadPublishedPreparedModelCatalogOwnerSnapshot;
}
// Isolated gateway tests share process module state with lifecycle-owner tests.
@@ -44,37 +47,30 @@ export async function resetPreparedModelCatalogForTest(): Promise<void> {
resetModelCatalogBuilderCacheForTest();
}
async function loadGatewayModelCatalogOwnerSnapshot(
params?: LoadGatewayModelCatalogParams,
): Promise<GatewayModelCatalogOwnerSnapshot> {
const loadOwner = await resolveLoader(params);
return await loadOwner({
...(params?.agentId ? { agentId: params.agentId } : {}),
...(params?.agentDir ? { agentDir: params.agentDir } : {}),
config: (params?.getConfig ?? getRuntimeConfig)(),
readOnly: params?.readOnly !== false,
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
}
export async function loadGatewayModelCatalogSnapshot(
params?: LoadGatewayModelCatalogParams,
): Promise<ModelCatalogSnapshot> {
const loadSnapshot = await resolveLoader(params);
const getConfig = params?.getConfig ?? getRuntimeConfig;
let config = getConfig();
let configFingerprint = hashRuntimeConfigValue(config);
for (;;) {
try {
return await loadSnapshot({
...(params?.agentId ? { agentId: params.agentId } : {}),
...(params?.agentDir ? { agentDir: params.agentDir } : {}),
config,
readOnly: params?.readOnly !== false,
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
} catch (error) {
if (!(error instanceof PreparedModelCatalogConfigReplacedError)) {
throw error;
}
// Config publication may replace the prepared owner while this request is waiting. Follow
// that committed generation; explicit read-only draft callers retain strict isolation.
const replacementConfig = getConfig();
const replacementFingerprint = hashRuntimeConfigValue(replacementConfig);
if (replacementFingerprint === configFingerprint) {
throw error;
}
config = replacementConfig;
configFingerprint = replacementFingerprint;
}
}
): Promise<GatewayModelCatalogSnapshot> {
const owner = await loadGatewayModelCatalogOwnerSnapshot(params);
return {
...owner.modelCatalog,
...(owner.agentId ? { agentId: owner.agentId } : {}),
agentDir: owner.agentDir,
...(owner.workspaceDir ? { workspaceDir: owner.workspaceDir } : {}),
config: owner.config,
};
}
export async function loadGatewayModelCatalog(
+13
View File
@@ -0,0 +1,13 @@
import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
export type GatewayModelCatalogOwnerSnapshot = {
agentId?: string;
agentDir: string;
workspaceDir?: string;
config: OpenClawConfig;
modelCatalog: ModelCatalogSnapshot;
};
export type GatewayModelCatalogSnapshot = ModelCatalogSnapshot &
Omit<GatewayModelCatalogOwnerSnapshot, "modelCatalog">;
+8 -2
View File
@@ -15,6 +15,7 @@ type GatewayRequestContextParams = Parameters<typeof createGatewayRequestContext
function makeContextParams(
overrides: Partial<GatewayRequestContextParams> = {},
): GatewayRequestContextParams {
const config = {} as never;
const runtimeState: Pick<GatewayServerLiveState, "cronState" | "configReloader"> = {
cronState: {
cron: { start: vi.fn(), stop: vi.fn() } as never,
@@ -29,7 +30,7 @@ function makeContextParams(
return {
deps: {} as never,
runtimeState,
getRuntimeConfig: vi.fn(() => ({}) as never),
getRuntimeConfig: vi.fn(() => config),
sessionObserver: {} as never,
resolveTerminalLaunchPolicy: vi.fn(() => ({
ok: false as const,
@@ -40,7 +41,12 @@ function makeContextParams(
pluginApprovalManager: undefined,
listSessionPendingApprovals: undefined,
loadGatewayModelCatalog: vi.fn(async () => []),
loadGatewayModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })),
loadGatewayModelCatalogSnapshot: vi.fn(async () => ({
agentDir: "/tmp/model-catalog-agent",
config,
entries: [],
routeVariants: [],
})),
getHealthCache: vi.fn(() => null),
refreshHealthSnapshot: vi.fn(async () => ({}) as never),
logHealth: { error: vi.fn() },
@@ -23,6 +23,7 @@ import {
} from "../config/sessions/session-accessor.js";
import { waitForSessionTranscriptIndexReconcile } from "../config/sessions/session-transcript-reconcile.js";
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { rotateAgentEventLifecycleGeneration } from "../infra/agent-events.js";
import { onDiagnosticEvent, type DiagnosticPayloadLargeEvent } from "../infra/diagnostic-events.js";
import { runExclusiveSessionLifecycleMutation } from "../sessions/session-lifecycle-admission.js";
@@ -209,9 +210,13 @@ async function removeTempDir(dir: string): Promise<void> {
}
function createDirectChatContext(): GatewayRequestContext {
const config = {};
return {
loadGatewayModelCatalog: vi.fn().mockResolvedValue([]),
loadGatewayModelCatalogSnapshot: vi.fn().mockResolvedValue({
agentId: "main",
agentDir: "/tmp/chat-model-catalog-agent",
config,
entries: [],
routeVariants: [],
}),
@@ -239,7 +244,7 @@ function createDirectChatContext(): GatewayRequestContext {
getSessionEventSubscriberConnIds: () => new Set(),
nodeSendToSession: vi.fn(),
registerToolEventRecipient: vi.fn(),
getRuntimeConfig: () => ({}),
getRuntimeConfig: () => config,
recoveryRuntime: {
dispatchAgent: vi.fn(),
waitForAgent: vi.fn(),
@@ -519,18 +524,28 @@ describe("gateway server chat", () => {
},
});
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
const config = {
agents: { defaults: { model: { primary: "test-provider/catalog-model" } } },
};
const catalog = [
{
provider: "test-provider",
id: "catalog-model",
name: "Catalog Model",
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
},
];
const context = {
loadGatewayModelCatalog: vi
.fn<GatewayRequestContext["loadGatewayModelCatalog"]>()
.mockResolvedValue([
{
provider: "test-provider",
id: "catalog-model",
name: "Catalog Model",
reasoning: true,
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
},
]),
loadGatewayModelCatalogSnapshot: vi
.fn<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>()
.mockResolvedValue({
agentId: "main",
agentDir: "/tmp/chat-history-agent",
config,
entries: catalog,
routeVariants: catalog,
}),
logGateway: {
info: vi.fn(),
warn: vi.fn(),
@@ -559,7 +574,7 @@ describe("gateway server chat", () => {
context,
});
expect(context.loadGatewayModelCatalog).toHaveBeenCalledTimes(1);
expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledTimes(1);
expect(responses).toHaveLength(1);
expect(responses[0]?.ok).toBe(true);
const payload = responses[0]?.payload as
@@ -763,6 +778,205 @@ describe("gateway server chat", () => {
});
});
test("chat.startup omits model metadata from a fallback owner", async () => {
const config = {
agents: {
defaults: {},
list: [{ id: "main", default: true }, { id: "work" }],
},
} as OpenClawConfig;
const context = {
getRuntimeConfig: () => config,
loadGatewayModelCatalogSnapshot: vi.fn(async () => ({
agentId: "main",
agentDir: "/tmp/chat-main-agent",
config,
entries: [{ id: "main-only", name: "Main only", provider: "test" }],
routeVariants: [],
})),
logGateway: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
chatAbortControllers: new Map(),
chatRunBuffers: new Map(),
} as unknown as GatewayRequestContext;
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
await writeSessionStore({
entries: { "agent:work:main": { sessionId: "sess-work", updatedAt: Date.now() } },
});
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
const { chatHandlers } = await import("./server-methods/chat.js");
await expectDefined(
chatHandlers["chat.startup"],
'chatHandlers["chat.startup"] test invariant',
)({
req: {
type: "req",
id: "startup-fallback-owner",
method: "chat.startup",
params: { sessionKey: "agent:work:main" },
},
params: { sessionKey: "agent:work:main" },
client: null,
isWebchatConnect: () => false,
respond: ((ok, payload, error) => responses.push({ ok, payload, error })) as RespondFn,
context,
});
expect(responses[0]?.ok).toBe(true);
expect(
(responses[0]?.payload as { metadata?: { models?: unknown[] } })?.metadata?.models,
).toBe(undefined);
expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledWith({ agentId: "work" });
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(sessionDir);
}
});
test("chat.startup omits model metadata when config advances after catalog load", async () => {
const initialConfig = {
agents: { defaults: {}, list: [{ id: "main", default: true }] },
} as OpenClawConfig;
const replacementConfig = {
agents: {
defaults: { models: { "test/*": {} } },
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
let currentConfig = initialConfig;
const context = {
getRuntimeConfig: () => currentConfig,
loadGatewayModelCatalogSnapshot: vi.fn(async () => {
currentConfig = replacementConfig;
return {
agentId: "main",
agentDir: "/tmp/chat-main-agent",
config: initialConfig,
entries: [{ id: "initial", name: "Initial", provider: "test" }],
routeVariants: [],
};
}),
logGateway: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
chatAbortControllers: new Map(),
chatRunBuffers: new Map(),
} as unknown as GatewayRequestContext;
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
await writeSessionStore({
entries: { main: { sessionId: "sess-main", updatedAt: Date.now() } },
});
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
const { chatHandlers } = await import("./server-methods/chat.js");
await expectDefined(
chatHandlers["chat.startup"],
'chatHandlers["chat.startup"] test invariant',
)({
req: {
type: "req",
id: "startup-config-advanced",
method: "chat.startup",
params: { sessionKey: "main" },
},
params: { sessionKey: "main" },
client: null,
isWebchatConnect: () => false,
respond: ((ok, payload, error) => responses.push({ ok, payload, error })) as RespondFn,
context,
});
expect(responses[0]?.ok).toBe(true);
expect(
(responses[0]?.payload as { metadata?: { models?: unknown[] } })?.metadata?.models,
).toBe(undefined);
expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledOnce();
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(sessionDir);
}
});
test("chat.startup keeps model metadata for an equivalent config replacement", async () => {
const initialConfig = {
agents: {
defaults: { models: { "test/initial": {} } },
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
const equivalentConfig = structuredClone(initialConfig);
let currentConfig = initialConfig;
const context = {
getRuntimeConfig: () => currentConfig,
loadGatewayModelCatalogSnapshot: vi.fn(async () => {
currentConfig = equivalentConfig;
return {
agentId: "main",
agentDir: "/tmp/chat-main-agent",
config: initialConfig,
entries: [
{
id: "initial",
name: "Catalog Initial",
provider: "test",
contextWindow: 123_456,
},
],
routeVariants: [],
};
}),
logGateway: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
chatAbortControllers: new Map(),
chatRunBuffers: new Map(),
} as unknown as GatewayRequestContext;
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
testState.sessionStorePath = path.join(sessionDir, "sessions.json");
await writeSessionStore({
entries: { main: { sessionId: "sess-main", updatedAt: Date.now() } },
});
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
const { chatHandlers } = await import("./server-methods/chat.js");
await expectDefined(
chatHandlers["chat.startup"],
'chatHandlers["chat.startup"] test invariant',
)({
req: {
type: "req",
id: "startup-config-equivalent",
method: "chat.startup",
params: { sessionKey: "main" },
},
params: { sessionKey: "main" },
client: null,
isWebchatConnect: () => false,
respond: ((ok, payload, error) => responses.push({ ok, payload, error })) as RespondFn,
context,
});
expect(responses[0]?.ok).toBe(true);
expect(
(responses[0]?.payload as { metadata?: { models?: unknown[] } })?.metadata?.models,
).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "initial",
provider: "test",
name: "Catalog Initial",
contextWindow: 123_456,
}),
]),
);
expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledOnce();
} finally {
testState.sessionStorePath = undefined;
await removeTempDir(sessionDir);
}
});
test("chat.startup does not wait for slow optional model catalog metadata", async () => {
const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-"));
try {
@@ -972,7 +1186,12 @@ describe("gateway server chat", () => {
const context = {
loadGatewayModelCatalogSnapshot: vi
.fn<GatewayRequestContext["loadGatewayModelCatalogSnapshot"]>()
.mockResolvedValue(catalogSnapshot),
.mockResolvedValue({
agentId: "work",
agentDir: "/tmp/chat-work-agent",
config,
...catalogSnapshot,
}),
logGateway: {
info: vi.fn(),
warn: vi.fn(),
@@ -1024,6 +1243,7 @@ describe("gateway server chat", () => {
});
expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledTimes(1);
expect(context.loadGatewayModelCatalogSnapshot).toHaveBeenCalledWith({ agentId: "work" });
expect(responses).toHaveLength(1);
expect(responses[0]?.ok).toBe(true);
const payload = responses[0]?.payload as
@@ -1193,7 +1413,7 @@ describe("gateway server chat", () => {
},
},
},
};
} as unknown as OpenClawConfig;
await writeGatewayConfig(config);
const responses: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = [];
const context = {
@@ -1214,7 +1434,13 @@ describe("gateway server chat", () => {
provider: "minimax",
},
];
return { entries, routeVariants: entries };
return {
agentId: "work",
agentDir: "/tmp/chat-work-agent",
config,
entries,
routeVariants: entries,
};
}),
logGateway: {
info: vi.fn(),
@@ -1942,18 +2168,25 @@ describe("gateway server chat", () => {
run: async () => {
mutationStarted.resolve();
await performDeletion.promise;
// Use the resolved store target: writeSessionStore also rewrites the
// suite config, adding an unrelated config-watcher race to this test.
// Read the authoritative row inside the mutation. Admission startup
// may refresh metadata before it blocks, but this test deletes that
// same session generation rather than a stale pre-admission snapshot.
const deletionSession = loadGatewaySessionEntry("main");
const deletionEntry = expectDefined(
deletionSession.entry,
"session deletion test invariant",
);
expect(deletionEntry.sessionId).toBe(seededSessionId);
const deletion = await deleteSessionEntryLifecycle({
agentId: "main",
archiveTranscript: false,
expectedEntry: seededSession.entry,
expectedEntry: deletionEntry,
expectedSessionId: seededSessionId,
requireWriteSuccess: true,
storePath: seededSession.storePath,
storePath: deletionSession.storePath,
target: {
canonicalKey: seededSession.canonicalKey,
storeKeys: seededSession.storeKeys,
canonicalKey: deletionSession.canonicalKey,
storeKeys: deletionSession.storeKeys,
},
});
expect(deletion.deleted).toBe(true);
+5 -2
View File
@@ -8,8 +8,11 @@ import type { SystemAgentOverview } from "./overview.js";
import { createSystemAgentVerifiedInferenceTestFixture } from "./system-agent.test-helpers.js";
import { runSystemAgentTui, type SystemAgentTuiOptions } from "./tui-backend.js";
vi.mock("../plugins/providers.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../plugins/providers.js")>()),
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadPreparedModelCatalog: vi.fn(async () => []),
}));
vi.mock("../plugins/providers.js", () => ({
resolveOwningPluginIdsForModelRefs: vi.fn(() => []),
resolveOwningPluginIdsForProviderRef: vi.fn(() => []),
}));