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