diff --git a/src/agents/prepared-model-catalog.scoped-thinking.test.ts b/src/agents/prepared-model-catalog.scoped-thinking.test.ts index aae855040ec7..db5d9e4c711b 100644 --- a/src/agents/prepared-model-catalog.scoped-thinking.test.ts +++ b/src/agents/prepared-model-catalog.scoped-thinking.test.ts @@ -1,6 +1,9 @@ // Boundary proof for the turn-path thinking fallback: manifest first, then a provider-scoped // static catalog, then scoped live discovery only for runtime-only models (e.g. Ollama). import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginMetadataSnapshot } from "../config/plugin-auto-enable.test-helpers.js"; +import { PreparedModelRuntimeOwnerNotPublishedError } from "./prepared-model-runtime.errors.js"; +import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.types.js"; const manifestCatalogMock = vi.fn((..._args: unknown[]): Array> => []); const scopedStaticMock = vi.fn( @@ -16,6 +19,13 @@ const scopedLiveMock = vi.fn( }), ); const publishedSnapshotMock = vi.fn((..._args: unknown[]) => undefined as unknown); +const preparedSnapshotMock = vi.fn< + (input: { agentDir: string }) => Promise +>(async (input) => { + throw new PreparedModelRuntimeOwnerNotPublishedError( + `not published for test (${input.agentDir})`, + ); +}); vi.mock("./model-catalog.js", () => ({ loadManifestModelCatalog: (...args: unknown[]) => manifestCatalogMock(...args), @@ -27,11 +37,8 @@ vi.mock("./prepared-model-runtime.js", async (importOriginal) => { ...actual, getPreparedModelRuntimeSnapshot: (...args: unknown[]) => publishedSnapshotMock(...args), // No published lifecycle owner: force the scoped read-only builders to run. - prepareModelRuntimeSnapshot: vi.fn(async (input: { agentDir: string }) => { - throw new actual.PreparedModelRuntimeOwnerNotPublishedError( - `not published for test (${input.agentDir})`, - ); - }), + prepareModelRuntimeSnapshot: (...args: Parameters) => + preparedSnapshotMock(...args), }; }); @@ -54,6 +61,11 @@ describe("loadProviderScopedThinkingCatalog", () => { scopedStaticMock.mockResolvedValue({ entries: [], routeVariants: [] }); scopedLiveMock.mockResolvedValue({ entries: [], routeVariants: [] }); publishedSnapshotMock.mockReturnValue(undefined); + preparedSnapshotMock.mockImplementation(async (input) => { + throw new PreparedModelRuntimeOwnerNotPublishedError( + `not published for test (${input.agentDir})`, + ); + }); }); it("prefers the published prepared generation over partial manifest compatibility", async () => { @@ -130,6 +142,42 @@ describe("loadProviderScopedThinkingCatalog", () => { expect(scopedLiveMock).not.toHaveBeenCalled(); }); + it("falls back to the scoped catalog while a published owner has the replaced config", async () => { + preparedSnapshotMock.mockResolvedValue({ + agentDir: "/tmp/model-catalog-test", + activeProjectKeys: [], + config: { skills: { entries: { marker: { enabled: false } } } }, + authModes: {}, + metadataSnapshot: createPluginMetadataSnapshot({ + config: {}, + manifestRegistry: { plugins: [], diagnostics: [] }, + }), + allowGatewaySubagentBinding: false, + modelCatalog: { entries: [], routeVariants: [] }, + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores: () => { + throw new Error("stores are outside this catalog fallback test"); + }, + }); + scopedStaticMock.mockResolvedValue({ + entries: [{ provider: "acme", id: "replacement-model", reasoning: true }], + routeVariants: [], + }); + const { loadProviderScopedThinkingCatalog } = await import("./prepared-model-catalog.js"); + + const catalog = await loadProviderScopedThinkingCatalog({ + config: { skills: { entries: { marker: { enabled: true } } } }, + provider: "acme", + model: "replacement-model", + }); + + expect(catalog).toEqual([ + expect.objectContaining({ provider: "acme", id: "replacement-model", reasoning: true }), + ]); + expect(scopedStaticMock).toHaveBeenCalledWith(expect.anything(), ["acme"]); + }); + it("runs provider-scoped live discovery for runtime-only models and keeps their thinking", async () => { scopedLiveMock.mockResolvedValue({ entries: [ollamaEntry], routeVariants: [] }); const { loadProviderScopedThinkingCatalog } = await import("./prepared-model-catalog.js"); diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index fe5b89738027..4586ac8eb4bb 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -332,7 +332,7 @@ async function loadScopedReadOnlyModelCatalog( try { const prepared = await prepareModelRuntimeSnapshot(candidate); if (!preparedModelRuntimeConfigsMatch(prepared.config, candidate.config)) { - throw new PreparedModelCatalogConfigReplacedError(candidate.agentDir); + continue; } if (isPreparedModelCatalogFull(prepared.modelCatalog)) { return prepared.modelCatalog;