diff --git a/packages/app/e2e/browser/model-search-across-providers.spec.ts b/packages/app/e2e/browser/model-search-across-providers.spec.ts index 4a4ab31ea2..ba2c37f895 100644 --- a/packages/app/e2e/browser/model-search-across-providers.spec.ts +++ b/packages/app/e2e/browser/model-search-across-providers.spec.ts @@ -1,4 +1,7 @@ -import { test } from "../support/fixtures"; +import path from "node:path"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { test, expect, type Page } from "../support/fixtures"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { closeModelPicker, expectModelSearchEmptyState, @@ -15,7 +18,7 @@ import { expectSearchResultsVirtualized, } from "../support/helpers/agent-profiles"; import { expectComposerVisible } from "../support/helpers/composer"; -import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher"; import { seedWorkspace } from "../support/helpers/seed-client"; const MOCK_PROVIDER_LABEL = "Mock Load Test"; @@ -158,3 +161,110 @@ test.describe("Cross-provider model search", () => { } }); }); + +async function setRuntimeCatalog(client: DaemonClient, count: number, cwd: string) { + await client.patchDaemonConfig({ + providers: { + gemini: { + extends: "acp", + label: "Catalog provider", + enabled: true, + command: [ + process.execPath, + path.resolve(__dirname, "../support/fixtures/catalog-acp.cjs"), + String(count), + ], + }, + }, + }); + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd })).entries.find( + (entry) => entry.provider === "gemini", + )?.status, + { timeout: 30_000 }, + ) + .toBe("ready"); +} +async function reloadSavedDraft(page: Page) { + await page.evaluate(() => + localStorage.setItem( + "@paseo:e2e-disable-default-seed-once", + localStorage.getItem("@paseo:e2e-seed-nonce")!, + ), + ); + await page.reload(); + await expectComposerVisible(page); +} +async function expectOneCatalogChoice(page: Page, label: string) { + await openModelPicker(page); + await page.getByRole("button", { name: "Back", exact: true }).click(); + await searchAllModels(page, label); + await expect(page.getByTestId("model-row-gemini-gemini-3.5-flash")).toHaveCount(1); + await closeModelPicker(page); +} + +async function configureModelOverride(client: DaemonClient, cwd: string) { + await client.patchDaemonConfig({ + providers: { + gemini: { + additionalModels: [{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }], + }, + }, + }); + await expect + .poll( + async () => + (await client.getProvidersSnapshot({ cwd: cwd })).entries.find( + (entry) => entry.provider === "gemini", + )?.models, + { timeout: 30_000 }, + ) + .toMatchObject([{ id: "gemini-3.5-flash", label: "Configured model", isDefault: true }]); +} + +const catalogTest = test.extend<{ catalogClient: DaemonClient }>({ + catalogClient: async ({ e2eWorker }, provide) => { + void e2eWorker; + const client = await connectDaemonClient({ clientIdPrefix: "catalog-models" }); + try { + await provide(client); + } finally { + try { + await client.patchDaemonConfig({ removeProviders: ["gemini"] }); + } finally { + await client.close(); + } + } + }, +}); + +catalogTest( + "New Agent and saved drafts stay usable with repeated runtime model rows", + async ({ page, withWorkspace, catalogClient: client }, testInfo) => { + const workspace = await withWorkspace({ prefix: "catalog-models-" }); + await test.step("open a draft with a one-row runtime catalog", async () => { + await setRuntimeCatalog(client, 1, workspace.repoPath); + await gotoWorkspace(page, workspace.workspaceId); + await expectComposerVisible(page); + }); + await test.step("open New Agent after the provider publishes repeated model rows", async () => { + await clickNewTerminal(page); + await setRuntimeCatalog(client, 2, workspace.repoPath); + await clickNewChat(page); + await expectComposerVisible(page); + await expectOneCatalogChoice(page, "Gemini 3.5 Flash"); + await page.screenshot({ path: testInfo.outputPath("duplicate-catalog-draft.png") }); + }); + await test.step("reopen the saved draft with the same catalog", async () => { + await reloadSavedDraft(page); + await expectOneCatalogChoice(page, "Gemini 3.5 Flash"); + }); + await test.step("retain configured model overrides when runtime IDs repeat", async () => { + await configureModelOverride(client, workspace.repoPath); + await reloadSavedDraft(page); + await expectOneCatalogChoice(page, "Configured model"); + }); + }, +); diff --git a/packages/app/e2e/support/fixtures/catalog-acp.cjs b/packages/app/e2e/support/fixtures/catalog-acp.cjs new file mode 100644 index 0000000000..fc6eafee73 --- /dev/null +++ b/packages/app/e2e/support/fixtures/catalog-acp.cjs @@ -0,0 +1,18 @@ +const readline = require("node:readline"); +const model = { modelId: "gemini-3.5-flash", name: "Gemini 3.5 Flash" }; +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + let result = {}; + if (request.method === "initialize") + result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new") + result = { + sessionId: "catalog-diagnostic", + models: { + currentModelId: model.modelId, + availableModels: Array.from({ length: Number(process.argv[2]) }, () => model), + }, + }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n"); +}); diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index b69b0f5d52..424657e3fa 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -130,6 +130,43 @@ function writeSnapshot( } describe("provider snapshot cache", () => { + it("normalizes duplicate model identities in live, legacy and persisted catalogs", async () => { + const storage = createStorage(); + const entries = snapshotEntries("shared"); + const first = entries[0].models![0]; + entries[0].models!.push({ ...first, label: "Duplicate" }); + const compactSnapshot = compactProviderSnapshot(entries); + const payload = { requestId: "catalog", generatedAt: "2026-09-01T00:00:00.000Z", entries }; + const cache = createProviderSnapshotCache(storage); + const legacy = await cache.materialize("server-1", payload); + expect(legacy.entries[0].models).toEqual([first]); + const live = await cache.materialize("server-1", { + ...payload, + snapshotHash: "old-hash", + compactSnapshot, + }); + expect(live.entries[0].models).toEqual([first]); + expect(live.compactSnapshot).toEqual(compactSnapshot); + await cache.write({ + serverId: "server-1", + cwd: "/repo", + hash: "old-hash", + generatedAt: payload.generatedAt, + compactSnapshot, + }); + const restarted = createProviderSnapshotCache(storage); + const saved = await restarted.read("server-1", "/repo"); + expect(saved?.entries[0].models).toEqual([first]); + const unchanged = await restarted.materialize("server-1", { + ...payload, + entries: [], + snapshotHash: "old-hash", + notModified: true, + }); + expect(unchanged.entries[0].models).toEqual([first]); + expect(unchanged.snapshotHash).toBe("old-hash"); + }); + it("removes externally lost body references and retains independent orphan bodies on reconciliation", async () => { const storage = createStorage(); const cache = createProviderSnapshotCache(storage); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index 04a10303e3..3871cbb1e0 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -1,6 +1,9 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { Buffer } from "buffer"; -import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { + normalizeAgentModelCatalog, + type ProviderSnapshotEntry, +} from "@getpaseo/protocol/agent-types"; import { expandProviderSnapshot, type CompactProviderSnapshot, @@ -77,6 +80,17 @@ export class ProviderSnapshotCacheMissError extends Error { } } +// COMPAT(duplicateModelCatalog): added in v0.8.1, remove after 2027-03-14 once +// the daemon floor is v0.8.1 and pre-fix cached catalogs are no longer supported. +// Keep the original compact body/hash: normalization belongs to its decoded view. +function normalizeCatalogs(entries: ProviderSnapshotEntry[]): ProviderSnapshotEntry[] { + return entries.map((entry) => { + if (!entry.models) return entry; + const models = normalizeAgentModelCatalog(entry.models); + return models === entry.models ? entry : { ...entry, models }; + }); +} + function cacheKey(serverId: string, cwd: string | null): string { return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "cwd", cwd])}`; } @@ -303,7 +317,7 @@ export function createProviderSnapshotCache( const body = { ...stored, compactSnapshot: stored.compactSnapshot, - entries: expandProviderSnapshot(stored.compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(stored.compactSnapshot)), }; bodies.set(key, body); return body; @@ -328,7 +342,7 @@ export function createProviderSnapshotCache( return { async materialize(serverId, snapshot) { const { snapshotHash: hash, compactSnapshot } = snapshot; - if (!hash) return snapshot; + if (!hash) return { ...snapshot, entries: normalizeCatalogs(snapshot.entries) }; let body = await safely(async () => { const cached = await readBody(serverId, hash); if (cached) return cached; @@ -348,7 +362,7 @@ export function createProviderSnapshotCache( hash, generatedAt: snapshot.generatedAt, compactSnapshot, - entries: expandProviderSnapshot(compactSnapshot), + entries: normalizeCatalogs(expandProviderSnapshot(compactSnapshot)), }; } return { diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 83932d3506..ea412c16be 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -102,6 +102,17 @@ export function normalizeAgentModelDefinition(model: AgentModelDefinition): Agen return { ...model, defaultThinkingOptionId }; } +/** A provider addresses models by ID; repeated rows retain the first definition. */ +export function normalizeAgentModelCatalog(models: AgentModelDefinition[]): AgentModelDefinition[] { + const ids = new Set(); + const unique = models.filter((model) => { + if (ids.has(model.id)) return false; + ids.add(model.id); + return true; + }); + return unique.length === models.length ? models : unique; +} + export interface ProviderSnapshotEntry { provider: AgentProvider; status: ProviderStatus; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 68837c8c5f..3f81e5e9c7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -138,6 +138,79 @@ async function runTestCatalogActivities( } describe("ProviderSnapshotManager public surface", () => { + test("publishes one model per provider identity and retains the first definition", async () => { + const first = { provider: "codex", id: "shared", label: "First", isDefault: true }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [first, { ...first, label: "Duplicate" }], + modes: [], + }), + }), + claude: createExtraClient("claude", { + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [{ ...first, provider: "claude" }], modes: [] }), + }), + }, + }); + try { + const codex = await manager.getProvider({ provider: "codex", wait: true }); + const claude = await manager.getProvider({ provider: "claude", wait: true }); + expect(codex.models).toEqual([first]); + expect(claude.models).toEqual([{ ...first, provider: "claude" }]); + } finally { + manager.destroy(); + } + }); + + test("normalizes plugin model catalogs at the same publication boundary", async () => { + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + manager.replacePluginProviders([ + { + id: "catalog-plugin", + label: "Catalog plugin", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type === "catalog") + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [ + { id: "same", label: "First" }, + { id: "same", label: "Second" }, + ], + modes: [], + }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() {}, + }; + }, + }, + ]); + try { + const entry = await manager.getProvider({ provider: "catalog-plugin", wait: true }); + expect(entry.models).toHaveLength(1); + expect(entry.models?.[0]).toMatchObject({ id: "same", label: "First" }); + } finally { + manager.destroy(); + } + }); + test("carries a plugin provider icon in snapshot metadata", () => { const iconSvg = ''; const registration: ProviderRegistration = { diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 610f7c8322..0307dc14be 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,3 +1,4 @@ +import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types"; import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { homedir } from "node:os"; @@ -1021,13 +1022,20 @@ export class ProviderSnapshotManager { return; } + const models = normalizeAgentModelCatalog(catalog.models); + if (models.length !== catalog.models.length) { + this.logger.warn( + { provider, discardedRows: catalog.models.length - models.length }, + "Provider catalog contains repeated model IDs; retaining the first definition", + ); + } setEntry({ ...base, defaultModeId: catalog.defaultModeId === undefined ? base.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, - models: catalog.models, + models, modes: catalog.modes, fetchedAt: new Date().toISOString(), }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 8b2acae1bd..b1c9df8a03 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -612,6 +612,115 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 120_000); + test.each([ + { name: "absent", variants: {} }, + { name: "empty", variants: { default: {} } }, + { name: "configured", variants: { default: { reasoningEffort: "low" } } }, + ])("catalog exposes one Default when upstream default is $name", async ({ variants }) => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.providerListResponse = { + data: { + connected: ["catalog-provider"], + all: [ + { + id: "catalog-provider", + name: "Catalog provider", + source: "api", + models: { + model: { + name: "Variant model", + variants: { ...variants, high: {}, "variant:default": {} }, + }, + }, + }, + ], + }, + }; + runtime.enqueueClient(openCodeClient); + const cwd = tmpCwd(); + try { + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + resolveHomeDir: () => cwd, + }); + const catalog = await client.fetchCatalog({ scope: "global", force: false }); + const options = catalog.models[0].thinkingOptions ?? []; + expect(options).toEqual([ + { id: "default", label: "Default", isDefault: true }, + { id: "high", label: "high" }, + { id: "variant:default", label: "variant:default" }, + ]); + expect(catalog.models[0].defaultThinkingOptionId).toBe("default"); + const execution = new TestOpenCodeClient(); + execution.sessionCreateResponse = { data: { id: "ses_catalog_variants" } }; + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_catalog_variants" } }, + ]; + runtime.enqueueClient(execution); + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "catalog-provider/model", + thinkingOptionId: "default", + }); + try { + await collectTurnEvents(streamSession(session, "Use OpenCode's default")); + await session.setThinkingOption!("high"); + await collectTurnEvents(streamSession(session, "Use high")); + await session.setThinkingOption!("variant:default"); + await collectTurnEvents(streamSession(session, "Use the literal variant name")); + await session.setThinkingOption!("default"); + await collectTurnEvents(streamSession(session, "Return to OpenCode's default")); + expect(execution.calls.sessionPromptAsync).toEqual([ + expect.not.objectContaining({ variant: expect.anything() }), + expect.objectContaining({ variant: "high" }), + expect.objectContaining({ variant: "variant:default" }), + expect.not.objectContaining({ variant: expect.anything() }), + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test.each([ + { saved: "default", variant: undefined }, + { saved: "high", variant: "high" }, + { saved: "variant:default", variant: "variant:default" }, + ])("resume preserves saved thinking choice $saved", async ({ saved, variant }) => { + const runtime = new TestOpenCodeHarness(); + const execution = new TestOpenCodeClient(); + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_saved_variant" } }, + ]; + runtime.enqueueClient(execution); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const cwd = tmpCwd(); + try { + const session = await client.resumeSession( + { provider: "opencode", sessionId: "ses_saved_variant", metadata: { cwd } }, + { thinkingOptionId: saved }, + ); + try { + await collectTurnEvents(streamSession(session, "Keep my saved choice")); + expect(execution.calls.sessionPromptAsync.map((request) => request.variant)).toEqual([ + variant, + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("fetchCatalog returns models with required fields", async () => { const runtime = new TestOpenCodeHarness(); const openCodeClient = new TestOpenCodeClient(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 19d5f95939..084ca70d7c 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -806,11 +806,14 @@ function buildOpenCodeModelDefinition( }, ): AgentModelDefinition { const rawVariants = model.variants ? Object.keys(model.variants) : []; - // OpenCode lists only overrides; its base model behavior is selected by omitting `variant`. + // Like OpenCode's web UI, Default omits `variant` and lets OpenCode resolve it. + // Reserve that choice instead of exposing a second upstream `default` entry. const thinkingOptions = rawVariants.length ? [ { id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true }, - ...rawVariants.map((id) => ({ id, label: id })), + ...rawVariants + .filter((id) => id !== OPENCODE_DEFAULT_VARIANT_ID) + .map((id) => ({ id, label: id })), ] : [];