fix(models): require prefetched promotions refresh

This commit is contained in:
Vincent Koc
2026-08-01 14:48:36 +02:00
parent 2da8e56a52
commit ed9c67f1b7
4 changed files with 111 additions and 24 deletions

View File

@@ -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();
});
});

View File

@@ -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.
}

View File

@@ -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("");
});

View File

@@ -16,14 +16,14 @@ const PROMOTIONS_SECTION_MAX_ENTRIES = 3;
type PromotionsFeedRefresh = {
nowMs: number;
statePromise: ReturnType<typeof maybeRefreshPromotionsFeed>;
statePromise: Promise<Awaited<ReturnType<typeof maybeRefreshPromotionsFeed>> | 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<string>;
refresh?: PromotionsFeedRefresh;
refresh: PromotionsFeedRefresh;
runtime: RuntimeEnv;
nowMs?: number;
}): Promise<void> {
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;