diff --git a/src/agents/model-catalog-browse.test.ts b/src/agents/model-catalog-browse.test.ts index d826c68ec75..0e9bda5430d 100644 --- a/src/agents/model-catalog-browse.test.ts +++ b/src/agents/model-catalog-browse.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { loadModelCatalogForBrowse } from "./model-catalog-browse.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; @@ -23,6 +23,10 @@ function config(params: { providerWildcard?: boolean } = {}): OpenClawConfig { } describe("loadModelCatalogForBrowse", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("uses the read-only catalog for default browse views", async () => { const loadCatalog = vi.fn(async ({ readOnly }: { readOnly: boolean }) => readOnly ? readOnlyCatalog : fullCatalog, @@ -79,4 +83,27 @@ describe("loadModelCatalogForBrowse", () => { expect(onTimeout).toHaveBeenCalledExactlyOnceWith(5); await new Promise((resolve) => setTimeout(resolve, 15)); }); + + it("uses the default timeout when timeoutMs is non-finite", async () => { + vi.useFakeTimers(); + const onTimeout = vi.fn(); + const loadCatalog = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => resolve(readOnlyCatalog), 5); + }), + ); + + const resultPromise = loadModelCatalogForBrowse({ + cfg: config(), + loadCatalog, + timeoutMs: Number.NaN, + onTimeout, + }); + + await vi.advanceTimersByTimeAsync(5); + + await expect(resultPromise).resolves.toBe(readOnlyCatalog); + expect(onTimeout).not.toHaveBeenCalled(); + }); }); diff --git a/src/agents/model-catalog-browse.ts b/src/agents/model-catalog-browse.ts index b6c707b75fb..81f1e298851 100644 --- a/src/agents/model-catalog-browse.ts +++ b/src/agents/model-catalog-browse.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseFiniteNumber } from "../shared/number-coercion.js"; import type { ModelCatalogEntry } from "./model-catalog.types.js"; import { parseConfiguredModelVisibilityEntries } from "./model-selection-shared.js"; @@ -6,6 +7,13 @@ export const DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS = 750; export type ModelCatalogBrowseView = "default" | "configured" | "all"; +function resolveModelCatalogBrowseTimeoutMs(value: number | undefined): number { + return Math.max( + 1, + Math.floor(parseFiniteNumber(value) ?? DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS), + ); +} + export async function loadModelCatalogForBrowse(params: { cfg: OpenClawConfig; view?: ModelCatalogBrowseView; @@ -22,7 +30,7 @@ export async function loadModelCatalogForBrowse(params: { } let timeout: NodeJS.Timeout | undefined; - const timeoutMs = params.timeoutMs ?? DEFAULT_MODEL_CATALOG_BROWSE_TIMEOUT_MS; + const timeoutMs = resolveModelCatalogBrowseTimeoutMs(params.timeoutMs); const timedOut = Symbol("model-catalog-browse-timeout"); const catalogPromise = params.loadCatalog({ readOnly: true }); const timeoutPromise = new Promise((resolve) => {