diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift index 215113d5ad7f..baea3a6d1d12 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelOutboxTests.swift @@ -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. diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift index 7a1ed8ddd566..5e24abbfa53e 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelUnreadTests.swift @@ -26,22 +26,47 @@ private actor UnreadMutationRecorder { } } +private actor UnreadPatchGate { + private var continuation: CheckedContinuation? + 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), diff --git a/src/agents/model-catalog-browse.test.ts b/src/agents/model-catalog-browse.test.ts index f1f1784dd718..c0bc3370996a 100644 --- a/src/agents/model-catalog-browse.test.ts +++ b/src/agents/model-catalog-browse.test.ts @@ -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(() => {})); + + 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"); diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index 2271e81dec7b..8ef894dd399d 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -59,17 +59,19 @@ async function loadCatalogForBrowse(params: { view?: ModelCatalogBrowseView; loadCatalog: (params: { readOnly: boolean }) => Promise; empty: T; + timeoutFullDiscovery?: boolean; timeoutMs?: number; onTimeout?: (timeoutMs: number) => void; }): Promise { 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; + timeoutFullDiscovery?: boolean; timeoutMs?: number; onTimeout?: (timeoutMs: number) => void; }): Promise { diff --git a/src/agents/prepared-model-catalog.test.ts b/src/agents/prepared-model-catalog.test.ts index 9b89e3f284ad..1a5d0c9d3fdf 100644 --- a/src/agents/prepared-model-catalog.test.ts +++ b/src/agents/prepared-model-catalog.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ config: {} as object, + agentIds: ["main"], + agentDirs: new Map(), 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); diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index 78e70f3f5df6..d95598acdf04 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -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 { 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 { + return await loadPreparedModelCatalogOwnerSnapshotWithPolicy(params, "exact"); +} + +/** Resolves the currently published owner when Gateway config changes during the read. */ +export async function loadPublishedPreparedModelCatalogOwnerSnapshot( + params: LoadPreparedModelCatalogParams = {}, +): Promise { + 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 { return (await loadPreparedModelCatalogSnapshot(params)).entries; } + +/** Reads the committed owner generation for long-lived runtime work. */ +export async function loadPublishedPreparedModelCatalog( + params: LoadPreparedModelCatalogParams = {}, +): Promise { + return (await loadPublishedPreparedModelCatalogOwnerSnapshot(params)).modelCatalog.entries; +} diff --git a/src/cron/isolated-agent.hook-content-wrapping.test.ts b/src/cron/isolated-agent.hook-content-wrapping.test.ts index 88cf09cbb763..ba1c91d00796 100644 --- a/src/cron/isolated-agent.hook-content-wrapping.test.ts +++ b/src/cron/isolated-agent.hook-content-wrapping.test.ts @@ -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/", ""), diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index a9fa312593f9..076cbca54d00 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -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("../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( diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index bc9791f5f78f..3c14d702ebd3 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -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 } }, + 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; - cfgWithAgentDefaults?: Record; agentConfigOverride?: Pick; 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; + 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 }) => @@ -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( { diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index fca8c52a22d9..cd76a7c5288c 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -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 + >["modelCatalog"]["entries"]; +}; + type ResolveCronModelSelectionParams = { cfg: OpenClawConfig; - catalogConfig: OpenClawConfig; - cfgWithAgentDefaults: OpenClawConfig; + owner?: CronModelSelectionOwner; agentConfigOverride?: Pick; 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 { + 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 { + 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> | 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, + }; } diff --git a/src/cron/isolated-agent/run-model-catalog.runtime.ts b/src/cron/isolated-agent/run-model-catalog.runtime.ts deleted file mode 100644 index 0eafbf9c048e..000000000000 --- a/src/cron/isolated-agent/run-model-catalog.runtime.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Runtime model catalog seam for isolated cron agent model resolution. -export { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js"; diff --git a/src/cron/isolated-agent/run-model-selection.runtime.ts b/src/cron/isolated-agent/run-model-selection.runtime.ts index f84908e6e959..1ef1cbf27171 100644 --- a/src/cron/isolated-agent/run-model-selection.runtime.ts +++ b/src/cron/isolated-agent/run-model-selection.runtime.ts @@ -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, diff --git a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts index 847837641700..5030d2af5c88 100644 --- a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts +++ b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts @@ -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; diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index be2fa2c261b4..55f5fb938658 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -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 }); } diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 605d9316d357..62ade474c210 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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>; -type CronModelCatalogRuntime = typeof import("./run-model-catalog.runtime.js"); type CronDeliveryRuntime = typeof import("./run-delivery.runtime.js"); type ResolvedCronDeliveryTarget = Awaited>; @@ -615,8 +607,8 @@ async function prepareCronRunContext(params: { onLifecycleInterrupt: () => void; }): Promise { 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> | 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 = diff --git a/src/gateway/local-request-context.test.ts b/src/gateway/local-request-context.test.ts index 0ee0f0096b19..9c5e1491fbc1 100644 --- a/src/gateway/local-request-context.test.ts +++ b/src/gateway/local-request-context.test.ts @@ -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 () => { diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index 4e55cdff1ef3..31a26a4251b5 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -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[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>, diff --git a/src/gateway/server-methods/chat-history-handler.ts b/src/gateway/server-methods/chat-history-handler.ts index 03eb1213a192..a3b2f06a5b9a 100644 --- a/src/gateway/server-methods/chat-history-handler.ts +++ b/src/gateway/server-methods/chat-history-handler.ts @@ -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, diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts index 87342e156e98..29d92e982238 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -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() + .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); diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index abcd07e694c9..1543629de501 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -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; preloadedCatalog?: { agentId: string; + config: OpenClawConfig; snapshot: ModelCatalogSnapshot; }; catalogProjector?: ReturnType; + 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> + | 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, diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index c94ced84d0eb..864705513009 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -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; 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[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", diff --git a/src/gateway/server-methods/optional-model-catalog.ts b/src/gateway/server-methods/optional-model-catalog.ts index b1e7f75c5ad1..423c8bdfcc0c 100644 --- a/src/gateway/server-methods/optional-model-catalog.ts +++ b/src/gateway/server-methods/optional-model-catalog.ts @@ -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 = { }; type LoadOptionalServerMethodModelCatalogOptions = { + loadParams?: Parameters[0]; logOnceKey?: string; startedLoad?: OptionalServerMethodModelCatalogLoad; 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; - return Array.isArray(snapshot.entries) && Array.isArray(snapshot.routeVariants) - ? { entries: snapshot.entries, routeVariants: snapshot.routeVariants } + const snapshot = value as Partial; + 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 { + loadParams?: Parameters[0], +): OptionalServerMethodModelCatalogLoad { 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, -): Promise { + options?: LoadOptionalServerMethodModelCatalogOptions, +): Promise { return await loadOptionalServerMethodModelCatalogValue(context, surface, options, () => - startOptionalServerMethodModelCatalogSnapshotLoad(context), + startOptionalServerMethodModelCatalogSnapshotLoad(context, options?.loadParams), ); } diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index d86e3ea6eafa..b2e397564687 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -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; + }) => Promise; getHealthCache: () => HealthSummary | null; refreshHealthSnapshot: (opts?: { probe?: boolean; diff --git a/src/gateway/server-model-catalog.test.ts b/src/gateway/server-model-catalog.test.ts index 943bc1936357..fd4caf8f550d 100644 --- a/src/gateway/server-model-catalog.test.ts +++ b/src/gateway/server-model-catalog.test.ts @@ -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); + + 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(); - }); }); diff --git a/src/gateway/server-model-catalog.ts b/src/gateway/server-model-catalog.ts index 0d443937b00f..dc211d17afd6 100644 --- a/src/gateway/server-model-catalog.ts +++ b/src/gateway/server-model-catalog.ts @@ -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; -type LoadPreparedModelCatalogSnapshot = (params: { +type LoadPublishedPreparedModelCatalogOwnerSnapshot = (params: { agentId?: string; agentDir?: string; config: GatewayModelCatalogConfig; readOnly?: boolean; workspaceDir?: string; -}) => Promise; +}) => Promise; type LoadGatewayModelCatalogParams = { agentId?: string; agentDir?: string; getConfig?: () => GatewayModelCatalogConfig; - loadPreparedModelCatalogSnapshot?: LoadPreparedModelCatalogSnapshot; + loadPublishedPreparedModelCatalogOwnerSnapshot?: LoadPublishedPreparedModelCatalogOwnerSnapshot; readOnly?: boolean; workspaceDir?: string; }; async function resolveLoader( params?: LoadGatewayModelCatalogParams, -): Promise { - if (params?.loadPreparedModelCatalogSnapshot) { - return params.loadPreparedModelCatalogSnapshot; +): Promise { + 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 { resetModelCatalogBuilderCacheForTest(); } +async function loadGatewayModelCatalogOwnerSnapshot( + params?: LoadGatewayModelCatalogParams, +): Promise { + 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 { - 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 { + 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( diff --git a/src/gateway/server-model-catalog.types.ts b/src/gateway/server-model-catalog.types.ts new file mode 100644 index 000000000000..c029a7adffd8 --- /dev/null +++ b/src/gateway/server-model-catalog.types.ts @@ -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; diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 537b6696b426..4569bdb3711b 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -15,6 +15,7 @@ type GatewayRequestContextParams = Parameters = {}, ): GatewayRequestContextParams { + const config = {} as never; const runtimeState: Pick = { 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() }, diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 14e42111bd37..9f53c327686e 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -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 { } 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() - .mockResolvedValue([ - { - provider: "test-provider", - id: "catalog-model", - name: "Catalog Model", - reasoning: true, - compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] }, - }, - ]), + loadGatewayModelCatalogSnapshot: vi + .fn() + .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() - .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); diff --git a/src/system-agent/tui-backend.test.ts b/src/system-agent/tui-backend.test.ts index 33745b665fab..dd70b1b819dc 100644 --- a/src/system-agent/tui-backend.test.ts +++ b/src/system-agent/tui-backend.test.ts @@ -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()), +vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadPreparedModelCatalog: vi.fn(async () => []), +})); + +vi.mock("../plugins/providers.js", () => ({ resolveOwningPluginIdsForModelRefs: vi.fn(() => []), resolveOwningPluginIdsForProviderRef: vi.fn(() => []), }));