diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 1f082aa9827e..d1a7631225fb 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -400,23 +400,68 @@ describe("modelsListCommand forward-compat", () => { it("preserves the human-readable message for an empty model list", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + const refreshToken = { nowMs: 1, statePromise: Promise.resolve() }; + mocks.startPromotionsFeedRefresh.mockReturnValueOnce(refreshToken); const runtime = createRuntime(); await modelsListCommand({ provider: "autoqa-no-such-provider" }, runtime as never); expect(runtime.log).toHaveBeenCalledWith("No models found."); expect(mocks.printModelTable).not.toHaveBeenCalled(); + expect(mocks.startPromotionsFeedRefresh).toHaveBeenCalledOnce(); + expect(mocks.printAvailablePromotionsSection).toHaveBeenCalledWith( + expect.objectContaining({ refresh: refreshToken }), + ); }); }); describe("promotion refresh scheduling", () => { - it("constructs rows before refresh resolves and appends promotion output after the table", async () => { + it("does not start refresh when config resolution fails", async () => { + mocks.loadModelsConfigWithSource.mockRejectedValueOnce(new Error("config failed")); + + await expect(modelsListCommand({}, createRuntime() as never)).rejects.toThrow( + "config failed", + ); + + expect(mocks.startPromotionsFeedRefresh).not.toHaveBeenCalled(); + }); + + it("does not start refresh when registry loading fails", async () => { + mocks.loadModelRegistry.mockRejectedValueOnce(new Error("registry failed")); + const runtime = createRuntime(); + + await modelsListCommand({ all: true }, runtime as never); + + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("registry failed")); + expect(mocks.startPromotionsFeedRefresh).not.toHaveBeenCalled(); + expect(mocks.printAvailablePromotionsSection).not.toHaveBeenCalled(); + }); + + it.each([{ json: true }, { plain: true }])( + "does not start refresh for machine output", + async (options) => { + await modelsListCommand(options, createRuntime() as never); + + expect(mocks.startPromotionsFeedRefresh).not.toHaveBeenCalled(); + expect(mocks.printAvailablePromotionsSection).not.toHaveBeenCalled(); + }, + ); + + it("starts refresh before row construction finishes and appends output after the table", async () => { const refresh = createDeferred(); + const rowConstructionStarted = createDeferred(); + const releaseRowConstruction = createDeferred(); const tablePrinted = createDeferred(); - mocks.startPromotionsFeedRefresh.mockReturnValueOnce({ + const refreshToken = { nowMs: 1, statePromise: refresh.promise, + }; + mocks.loadModelCatalog.mockImplementationOnce(async () => { + rowConstructionStarted.resolve(); + await releaseRowConstruction.promise; + return []; }); + mocks.startPromotionsFeedRefresh.mockReturnValueOnce(refreshToken); mocks.printModelTable.mockImplementationOnce((_rows, runtime) => { runtime.log("model table"); tablePrinted.resolve(); @@ -430,16 +475,45 @@ describe("modelsListCommand forward-compat", () => { const runtime = createRuntime(); const commandPromise = modelsListCommand({}, runtime as never); + await rowConstructionStarted.promise; + + expect(mocks.startPromotionsFeedRefresh).toHaveBeenCalledOnce(); + expect(mocks.printModelTable).not.toHaveBeenCalled(); + + releaseRowConstruction.resolve(); await tablePrinted.promise; expectRowKeys(lastPrintedRows<{ key: string }>(), ["openai/gpt-5.4"]); - expect(mocks.startPromotionsFeedRefresh).toHaveBeenCalledOnce(); expect(runtime.log.mock.calls).toEqual([["model table"]]); refresh.resolve(); await commandPromise; expect(runtime.log.mock.calls).toEqual([["model table"], ["promotion section"]]); + expect(mocks.printAvailablePromotionsSection).toHaveBeenCalledWith( + expect.objectContaining({ refresh: refreshToken }), + ); + }); + + it("keeps a rejected refresh fail-silent", async () => { + const refresh = createDeferred(); + const sectionStarted = createDeferred(); + mocks.startPromotionsFeedRefresh.mockReturnValueOnce({ + nowMs: 1, + statePromise: refresh.promise, + }); + mocks.printAvailablePromotionsSection.mockImplementationOnce( + async ({ refresh: pendingRefresh }) => { + sectionStarted.resolve(); + await pendingRefresh.statePromise; + }, + ); + + const commandPromise = modelsListCommand({}, createRuntime() as never); + await sectionStarted.promise; + refresh.reject(new Error("refresh failed")); + + await expect(commandPromise).resolves.toBeUndefined(); }); }); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 4f157bd11508..d473474d8b75 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -72,7 +72,6 @@ export async function modelsListCommand( return; } const humanReadable = !opts.json && !opts.plain; - const promotionsModulePromise = humanReadable ? promotionsModuleLoader.load() : undefined; const [ { loadAuthProfileStoreWithoutExternalProfiles }, { resolveAgentWorkspaceDir, resolveDefaultAgentDir, resolveDefaultAgentId }, @@ -102,9 +101,6 @@ export async function modelsListCommand( }) : undefined; const { entries } = resolveConfiguredEntries(cfg, metadataSnapshot); - const promotionsRefreshPromise = promotionsModulePromise?.then((promotionsModule) => - promotionsModule.startPromotionsFeedRefresh(), - ); const authIndex = createModelListAuthIndex({ cfg, authStore, @@ -188,6 +184,10 @@ export async function modelsListCommand( process.exitCode = 1; return; } + const promotionsModulePromise = humanReadable ? promotionsModuleLoader.load() : undefined; + const promotionsRefreshPromise = promotionsModulePromise + ?.then((promotionsModule) => promotionsModule.startPromotionsFeedRefresh()) + .catch(() => undefined); const buildRowContext = (skipRuntimeModelSuppression: boolean) => ({ cfg, agentId, @@ -255,11 +255,14 @@ export async function modelsListCommand( // the configured entries, not the rendered rows — filtered and --all // listings show a different set. try { - await promotionsModule.printAvailablePromotionsSection({ - configuredKeys: new Set(entries.map((entry) => entry.key)), - refresh: await promotionsRefreshPromise, - runtime, - }); + const refresh = await promotionsRefreshPromise; + if (refresh) { + await promotionsModule.printAvailablePromotionsSection({ + configuredKeys: new Set(entries.map((entry) => entry.key)), + refresh, + runtime, + }); + } } catch { // Passive discovery must never fail the listing. } diff --git a/src/commands/models/list.promotions.test.ts b/src/commands/models/list.promotions.test.ts index 10f4b0cf964b..fed3089c8139 100644 --- a/src/commands/models/list.promotions.test.ts +++ b/src/commands/models/list.promotions.test.ts @@ -8,7 +8,11 @@ import { createOpenClawTestState, type OpenClawTestState, } from "../../test-utils/openclaw-test-state.js"; -import { applyPromotionClaimTags, printAvailablePromotionsSection } from "./list.promotions.js"; +import { + applyPromotionClaimTags, + printAvailablePromotionsSection, + startPromotionsFeedRefresh, +} from "./list.promotions.js"; import type { ModelRow } from "./list.types.js"; const NOW = Date.parse("2026-07-05T12:00:00.000Z"); @@ -116,8 +120,8 @@ describe("models list promotion decorations", () => { const { runtime, lines } = makeRuntime(); await printAvailablePromotionsSection({ configuredKeys: new Set(["other/model"]), + refresh: startPromotionsFeedRefresh(NOW), runtime, - nowMs: NOW, }); const text = lines.join("\n"); expect(text).toContain("Available via promotion:"); @@ -133,8 +137,8 @@ describe("models list promotion decorations", () => { const first = makeRuntime(); await printAvailablePromotionsSection({ configuredKeys: configured, + refresh: startPromotionsFeedRefresh(NOW), runtime: first.runtime, - nowMs: NOW, }); const firstText = first.lines.join("\n"); expect(firstText).not.toContain("Available via promotion:"); @@ -143,8 +147,8 @@ describe("models list promotion decorations", () => { const second = makeRuntime(); await printAvailablePromotionsSection({ configuredKeys: configured, + refresh: startPromotionsFeedRefresh(NOW), runtime: second.runtime, - nowMs: NOW, }); expect(second.lines.join("\n")).toBe(""); }); @@ -152,7 +156,11 @@ describe("models list promotion decorations", () => { it("renders the section for an empty model list (fresh install)", async () => { await seedFeedCache([liveEntry]); const { runtime, lines } = makeRuntime(); - await printAvailablePromotionsSection({ configuredKeys: new Set(), runtime, nowMs: NOW }); + await printAvailablePromotionsSection({ + configuredKeys: new Set(), + refresh: startPromotionsFeedRefresh(NOW), + runtime, + }); const text = lines.join("\n"); expect(text).toContain("Available via promotion:"); expect(text).toContain("openclaw promos claim example-models-launch"); @@ -163,8 +171,8 @@ describe("models list promotion decorations", () => { const { runtime, lines } = makeRuntime(); await printAvailablePromotionsSection({ configuredKeys: new Set(["other/model"]), + refresh: startPromotionsFeedRefresh(NOW), runtime, - nowMs: NOW, }); expect(lines.join("\n")).toBe(""); }); diff --git a/src/commands/models/list.promotions.ts b/src/commands/models/list.promotions.ts index 8071be612d18..b4f5814b9a4b 100644 --- a/src/commands/models/list.promotions.ts +++ b/src/commands/models/list.promotions.ts @@ -16,14 +16,14 @@ const PROMOTIONS_SECTION_MAX_ENTRIES = 3; type PromotionsFeedRefresh = { nowMs: number; - statePromise: ReturnType; + statePromise: Promise> | undefined>; }; /** Starts the passive feed refresh so callers can overlap it with model row construction. */ export function startPromotionsFeedRefresh(nowMs = Date.now()): PromotionsFeedRefresh { return { nowMs, - statePromise: maybeRefreshPromotionsFeed({ nowMs }), + statePromise: maybeRefreshPromotionsFeed({ nowMs }).catch(() => undefined), }; } @@ -77,13 +77,15 @@ function canonicalPromotionModelKey( */ export async function printAvailablePromotionsSection(params: { configuredKeys: ReadonlySet; - refresh?: PromotionsFeedRefresh; + refresh: PromotionsFeedRefresh; runtime: RuntimeEnv; - nowMs?: number; }): Promise { - const refresh = params.refresh ?? startPromotionsFeedRefresh(params.nowMs); - const nowMs = params.nowMs ?? refresh.nowMs; + const { refresh } = params; + const nowMs = refresh.nowMs; const state = await refresh.statePromise; + if (!state) { + return; + } const live = listLivePromotionEntries(state, nowMs); if (live.length === 0) { return;