mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 04:11:39 +00:00
fix(models): require prefetched promotions refresh
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user