From 4305e414514d1935565b50a0defe8418dafa4209 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 22 Jun 2026 13:56:36 +0700 Subject: [PATCH 1/6] fix(server): align provider catalog diagnostics --- .../components/provider-diagnostic-sheet.tsx | 23 ++- .../src/server/agent/agent-sdk-types.ts | 16 ++ .../server/agent/provider-registry.test.ts | 116 ++++++++++- .../src/server/agent/provider-registry.ts | 117 ++++++++--- .../agent/provider-snapshot-manager.test.ts | 44 +++- .../server/agent/provider-snapshot-manager.ts | 37 +++- .../src/server/agent/providers/acp-agent.ts | 32 +++ .../server/agent/providers/claude/agent.ts | 32 +-- .../agent/providers/codex-app-server-agent.ts | 31 +-- .../agent/providers/copilot-acp-agent.ts | 32 --- .../generic-acp-agent.diagnostic.test.ts | 132 +----------- .../agent/providers/generic-acp-agent.ts | 148 +------------ .../server/agent/providers/opencode-agent.ts | 194 +++++++++--------- .../src/server/agent/providers/pi/agent.ts | 96 ++------- .../server/agent/providers/pi/cli-runtime.ts | 6 +- .../src/server/agent/providers/pi/runtime.ts | 2 +- .../agent/providers/pi/test-utils/fake-pi.ts | 2 +- 17 files changed, 489 insertions(+), 571 deletions(-) diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 3172ebf558..c805098456 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -600,13 +600,22 @@ export function ProviderDiagnosticSheet({ const modelsRefreshing = isRefreshing || providerSnapshotRefreshing; const stableDiscoveredRef = useRef([]); - if (providerEntry?.models && providerEntry.models.length > 0) { - stableDiscoveredRef.current = providerEntry.models; - } - const discoveredModels = - providerEntry?.models && providerEntry.models.length > 0 - ? providerEntry.models - : stableDiscoveredRef.current; + const currentModels = providerEntry?.models; + useEffect(() => { + if (currentModels && currentModels.length > 0) { + stableDiscoveredRef.current = currentModels; + } + }, [currentModels]); + + const discoveredModels = useMemo(() => { + if (currentModels && currentModels.length > 0) { + return currentModels; + } + if (providerSnapshotRefreshing) { + return stableDiscoveredRef.current; + } + return []; + }, [currentModels, providerSnapshotRefreshing]); const [clockTick, setClockTick] = useState(0); useEffect(() => { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index e411332b7f..af13429f26 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -646,6 +646,16 @@ export interface ListModesOptions { force: boolean; } +export interface FetchCatalogOptions { + cwd: string; + force: boolean; +} + +export interface ProviderCatalog { + models: AgentModelDefinition[]; + modes: AgentMode[]; +} + export interface AgentClient { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; @@ -661,6 +671,12 @@ export interface AgentClient { ): Promise; listModels(options: ListModelsOptions): Promise; listModes?(options: ListModesOptions): Promise; + /** + * Discover models and modes together when the provider supports a single + * catalog probe. Implementations should spawn at most one runtime process. + * The registry is responsible for merging configured model overrides. + */ + fetchCatalog?(options: FetchCatalogOptions): Promise; resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult; isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; listCommands?(config: AgentSessionConfig): Promise; diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index b31ccd73f0..e71b664f56 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import type { AgentModelDefinition } from "./agent-sdk-types.js"; +import type { AgentClient, AgentModelDefinition, AgentMode } from "./agent-sdk-types.js"; const mockState = vi.hoisted(() => { interface ConstructorEntry { @@ -1286,3 +1286,117 @@ describe("model merging", () => { expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3"); }); }); + +describe("fetchCatalog", () => { + test("returns merged models and modes from listModels/listModes fallback", async () => { + mockState.runtimeModels.set("codex", [ + { provider: "codex", id: "codex-runtime", label: "Codex Runtime" }, + ]); + + const registry = buildProviderRegistry(logger); + const catalog = await registry.codex.fetchCatalog({ + cwd: "/tmp/catalog", + force: false, + }); + + expect(catalog.models.map((model) => model.id)).toEqual(["codex-runtime"]); + expect(catalog.modes).toEqual([]); + }); + + test("replacement models skip runtime model discovery but preserve additionalModels", async () => { + mockState.runtimeModels.set("codex", [ + { provider: "codex", id: "codex-runtime", label: "Codex Runtime" }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + codex: { + models: [{ id: "profile-model", label: "Profile Model" }], + additionalModels: [{ id: "extra-model", label: "Extra Model" }], + }, + }, + }); + + const catalog = await registry.codex.fetchCatalog({ + cwd: "/tmp/catalog", + force: false, + }); + + expect(catalog.models.map((model) => model.id)).toEqual(["profile-model", "extra-model"]); + }); + + test("additionalModels can override replacement model fields", async () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + codex: { + models: [{ id: "shared-model", label: "Profile Label" }], + additionalModels: [{ id: "shared-model", label: "Additional Label" }], + }, + }, + }); + + const catalog = await registry.codex.fetchCatalog({ + cwd: "/tmp/catalog", + force: false, + }); + + expect(catalog.models).toEqual([ + { + provider: "codex", + id: "shared-model", + label: "Additional Label", + }, + ]); + }); + + test("uses injected client instead of base client when provided", async () => { + const injectedModels: AgentModelDefinition[] = [ + { provider: "codex", id: "injected-model", label: "Injected Model" }, + ]; + const injectedModes: AgentMode[] = [{ id: "agent", label: "Agent" }]; + const injectedClient = { + provider: "codex", + capabilities: {}, + listModels: vi.fn(async () => injectedModels), + listModes: vi.fn(async () => injectedModes), + isAvailable: vi.fn(async () => true), + } satisfies Partial as AgentClient; + + const registry = buildProviderRegistry(logger); + const catalog = await registry.codex.fetchCatalog( + { cwd: "/tmp/catalog", force: false }, + injectedClient, + ); + + expect(injectedClient.listModels).toHaveBeenCalledTimes(1); + expect(injectedClient.listModes).toHaveBeenCalledTimes(1); + expect(catalog.models.map((model) => model.id)).toEqual(["injected-model"]); + expect(catalog.modes).toEqual(injectedModes); + }); + + test("uses injected client fetchCatalog when available", async () => { + const injectedClient = { + provider: "codex", + capabilities: {}, + fetchCatalog: vi.fn(async () => ({ + models: [{ provider: "codex", id: "catalog-model", label: "Catalog Model" }], + modes: [{ id: "ask", label: "Ask" }], + })), + listModels: vi.fn(async () => []), + listModes: vi.fn(async () => []), + isAvailable: vi.fn(async () => true), + } satisfies Partial as AgentClient; + + const registry = buildProviderRegistry(logger); + const catalog = await registry.codex.fetchCatalog( + { cwd: "/tmp/catalog", force: false }, + injectedClient, + ); + + expect(injectedClient.fetchCatalog).toHaveBeenCalledTimes(1); + expect(injectedClient.listModels).not.toHaveBeenCalled(); + expect(injectedClient.listModes).not.toHaveBeenCalled(); + expect(catalog.models.map((model) => model.id)).toEqual(["catalog-model"]); + expect(catalog.modes.map((mode) => mode.id)).toEqual(["ask"]); + }); +}); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 9ecf23329d..dd32efbe3e 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -10,8 +10,10 @@ import type { AgentRuntimeInfo, AgentSession, AgentStreamEvent, + FetchCatalogOptions, ListModelsOptions, ListModesOptions, + ProviderCatalog, ResolveAgentCreateConfigInput, ResolveAgentCreateConfigResult, } from "./agent-sdk-types.js"; @@ -66,6 +68,11 @@ export interface ProviderDefinition extends AgentProviderDefinition { isCreateConfigUnattended: (input: AgentCreateConfigUnattendedInput) => boolean; fetchModels: (options: ListModelsOptions) => Promise; fetchModes: (options: ListModesOptions) => Promise; + /** + * Single catalog discovery call used by ProviderSnapshotManager. Should spawn + * at most one provider runtime process and return both models and modes. + */ + fetchCatalog: (options: FetchCatalogOptions, client?: AgentClient) => Promise; } export interface BuildProviderRegistryOptions { @@ -429,6 +436,17 @@ function wrapClientProvider( profileModelsAreAdditive, }), listModes: inner.listModes?.bind(inner), + fetchCatalog: inner.fetchCatalog + ? async (options) => { + const catalog = await inner.fetchCatalog!(options); + return { + models: mergeModels(provider, profileModels, additionalModels, catalog.models, { + profileModelsAreAdditive, + }), + modes: catalog.modes, + }; + } + : undefined, resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listImportableSessions: listImportableSessions @@ -473,6 +491,46 @@ function createRegistryEntry( resolved: ResolvedProvider, ): ProviderDefinition { const modelClient = resolved.createBaseClient(logger); + const hasReplacementModels = + resolved.profileModels.length > 0 && !resolved.profileModelsAreAdditive; + const replacementModels = hasReplacementModels + ? resolved.profileModels.map((model) => mapModel(provider, model)) + : []; + + const decorateModes = (modes: AgentMode[]): AgentMode[] => + modes.map((mode) => { + if (mode.icon && mode.colorTier) return mode; + const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id); + if (!definitionMode) return mode; + return Object.assign({}, mode, { + icon: mode.icon ?? definitionMode.icon, + colorTier: mode.colorTier ?? definitionMode.colorTier, + }); + }); + + const fetchModelsFromClient = async ( + options: ListModelsOptions, + catalogClient: AgentClient = modelClient, + ) => + mergeModels( + provider, + resolved.profileModels, + resolved.additionalModels, + await catalogClient.listModels(options), + { + profileModelsAreAdditive: resolved.profileModelsAreAdditive, + }, + ); + + const fetchModesFromClient = async ( + options: ListModesOptions, + catalogClient: AgentClient = modelClient, + ) => { + const modes = catalogClient.listModes + ? await catalogClient.listModes(options) + : resolved.definition.modes; + return decorateModes(modes); + }; return { ...resolved.definition, @@ -483,29 +541,42 @@ function createRegistryEntry( resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig, isCreateConfigUnattended: modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended, - fetchModels: async (options: ListModelsOptions) => - mergeModels( - provider, - resolved.profileModels, - resolved.additionalModels, - await modelClient.listModels(options), - { - profileModelsAreAdditive: resolved.profileModelsAreAdditive, - }, - ), - fetchModes: async (options: ListModesOptions) => { - const modes = modelClient.listModes - ? await modelClient.listModes(options) - : resolved.definition.modes; - return modes.map((mode) => { - if (mode.icon && mode.colorTier) return mode; - const definitionMode = resolved.definition.modes.find((d) => d.id === mode.id); - if (!definitionMode) return mode; - return Object.assign({}, mode, { - icon: mode.icon ?? definitionMode.icon, - colorTier: mode.colorTier ?? definitionMode.colorTier, - }); - }); + fetchModels: fetchModelsFromClient, + fetchModes: fetchModesFromClient, + fetchCatalog: async (options: FetchCatalogOptions, client?: AgentClient) => { + const catalogClient = client ?? modelClient; + if (hasReplacementModels) { + // Replacement models skip runtime model discovery, but additionalModels + // must still be merged on top. If modes are dynamic, probe for modes only; + // otherwise use static/empty modes with no runtime. + const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels); + if (!catalogClient.listModes) { + return { + models, + modes: decorateModes(resolved.definition.modes), + }; + } + return { + models, + modes: await fetchModesFromClient(options, catalogClient), + }; + } + + if (catalogClient.fetchCatalog) { + const catalog = await catalogClient.fetchCatalog(options); + return { + models: mergeModels(provider, [], resolved.additionalModels, catalog.models, { + profileModelsAreAdditive: true, + }), + modes: decorateModes(catalog.modes), + }; + } + + const [models, modes] = await Promise.all([ + fetchModelsFromClient(options, catalogClient), + fetchModesFromClient(options, catalogClient), + ]); + return { models, modes }; }, }; } 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 5453b92b0d..4cf99130e3 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -431,7 +431,7 @@ describe("ProviderSnapshotManager public surface", () => { } }); - test("getProviderDiagnostic returns the diagnostic from the injected client", async () => { + test("getProviderDiagnostic returns the diagnostic from the injected client and appends snapshot models/status", async () => { const getDiagnostic = vi.fn(async () => ({ diagnostic: "codex is ready" })); const client = createExtraClient("codex", { getDiagnostic }); const manager = new ProviderSnapshotManager({ @@ -440,14 +440,50 @@ describe("ProviderSnapshotManager public surface", () => { }); try { const result = await manager.getProviderDiagnostic("codex"); - expect(result).toEqual({ provider: "codex", diagnostic: "codex is ready" }); + expect(result.provider).toBe("codex"); + expect(result.diagnostic).toContain("codex is ready"); + expect(result.diagnostic).toContain("Models:"); + expect(result.diagnostic).toContain("Status:"); expect(getDiagnostic).toHaveBeenCalledTimes(1); } finally { manager.destroy(); } }); - test("getProviderDiagnostic falls back to a default message when the client has no getDiagnostic", async () => { + test("getProviderDiagnostic force-refreshes the snapshot via a single fetchCatalog call", async () => { + const catalogModels: AgentModelDefinition[] = [ + { provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }, + ]; + const catalogModes: AgentMode[] = [{ id: "agent", label: "Agent" }]; + const fetchCatalog = vi.fn(async () => ({ + models: catalogModels, + modes: catalogModes, + })); + const listModels = vi.fn(async () => [] as AgentModelDefinition[]); + const listModes = vi.fn(async () => [] as AgentMode[]); + const client = createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog, + listModels, + listModes, + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { codex: client }, + }); + try { + const result = await manager.getProviderDiagnostic("codex"); + expect(fetchCatalog).toHaveBeenCalledTimes(1); + expect(listModels).not.toHaveBeenCalled(); + expect(listModes).not.toHaveBeenCalled(); + expect(result.diagnostic).toContain("Models: 1"); + expect(result.diagnostic).toContain("Status: Ready"); + } finally { + manager.destroy(); + } + }); + + test("getProviderDiagnostic falls back to a default message when the client has no getDiagnostic and appends snapshot models/status", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { codex: createExtraClient("codex") }, @@ -456,6 +492,8 @@ describe("ProviderSnapshotManager public surface", () => { const result = await manager.getProviderDiagnostic("codex"); expect(result.provider).toBe("codex"); expect(result.diagnostic).toMatch(/no diagnostic/i); + expect(result.diagnostic).toContain("Models:"); + expect(result.diagnostic).toContain("Status:"); } finally { manager.destroy(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 054fcd8063..88b9eab2f4 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -27,6 +27,7 @@ import { type ProviderDefinition, } from "./provider-registry.js"; import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js"; +import { formatProviderDiagnostic } from "./providers/diagnostic-utils.js"; import type { MutableDaemonConfig } from "../daemon-config-store.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 30_000; @@ -312,13 +313,26 @@ export class ProviderSnapshotManager { } async getProviderDiagnostic(provider: AgentProvider): Promise { + const definition = this.requireProvider(provider); const client = this.providerClients[provider]; if (!client) { throw new Error(`Provider ${provider} is not configured`); } - const diagnostic = client.getDiagnostic + + // Force-refresh the snapshot so Models/Status come from the single catalog authority. + await this.refreshSnapshotForCwd({ cwd: homedir(), providers: [provider] }); + const entry = await this.getProvider({ cwd: homedir(), provider, wait: true }); + + const modelCount = entry.status === "ready" ? String(entry.models?.length ?? 0) : "—"; + const status = formatProviderStatus(entry); + + const baseDiagnostic = client.getDiagnostic ? (await client.getDiagnostic()).diagnostic - : "No diagnostic available for this provider."; + : formatProviderDiagnostic(definition.label ?? provider, [ + { label: "Diagnostic", value: "No diagnostic available" }, + ]); + + const diagnostic = `${baseDiagnostic}\n Models: ${modelCount}\n Status: ${status}`; return { provider, diagnostic }; } @@ -392,6 +406,7 @@ export class ProviderSnapshotManager { client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended, fetchModels: client.listModels.bind(client), fetchModes: client.listModes?.bind(client) ?? definition.fetchModes, + fetchCatalog: client.fetchCatalog?.bind(client) ?? definition.fetchCatalog, }; } @@ -644,11 +659,8 @@ export class ProviderSnapshotManager { return; } - const [models, modes] = await withTimeout( - Promise.all([ - definition.fetchModels({ cwd, force }), - definition.fetchModes({ cwd, force }), - ]), + const catalog = await withTimeout( + definition.fetchCatalog({ cwd, force }, client), this.refreshTimeoutMs, `Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`, ); @@ -657,8 +669,8 @@ export class ProviderSnapshotManager { ...base, status: "ready", enabled: true, - models, - modes, + models: catalog.models, + modes: catalog.modes, fetchedAt: new Date().toISOString(), }); } catch (error) { @@ -806,3 +818,10 @@ function toErrorMessage(error: unknown): string { } return "Unknown error"; } + +function formatProviderStatus(entry: ProviderSnapshotEntry): string { + if (entry.status === "ready") return "Ready"; + if (entry.status === "error") return `Error: ${entry.error ?? "Unknown error"}`; + if (entry.status === "unavailable") return "Unavailable"; + return "Loading"; +} diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 662acf3005..703c72ff56 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -81,6 +81,7 @@ import { type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, + type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, @@ -88,6 +89,7 @@ import { type ListModesOptions, type ListModelsOptions, type McpServerConfig, + type ProviderCatalog, type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; @@ -756,6 +758,36 @@ export class ACPAgentClient implements AgentClient { } } + async fetchCatalog(options: FetchCatalogOptions): Promise { + const { cwd } = options; + const probe = await this.spawnProcess(PROBE_ENV); + try { + const response = await this.runACPRequest(() => + probe.connection.newSession({ + cwd, + mcpServers: [], + }), + ); + const transformed = this.transformSessionResponse(response); + const models = deriveModelDefinitionsFromACP( + this.provider, + transformed.models, + transformed.configOptions, + ); + const modeInfo = deriveModesFromACP( + this.defaultModes, + transformed.modes, + transformed.configOptions, + ); + return { + models: this.modelTransformer ? this.modelTransformer(models) : models, + modes: modeInfo.modes, + }; + } finally { + await this.closeProbe(probe); + } + } + async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 16d07e336f..1c923c4c75 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -36,10 +36,8 @@ import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-defi import { buildBinaryDiagnosticRows, buildCommandResolutionDiagnosticRows, - formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, - toDiagnosticErrorMessage, } from "../diagnostic-utils.js"; import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js"; import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; @@ -76,12 +74,14 @@ import { type AgentTimelineItem, type AgentUsage, type AgentRuntimeInfo, + type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, type ListModelsOptions, type McpServerConfig, + type ProviderCatalog, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { @@ -1426,6 +1426,11 @@ export class ClaudeAgentClient implements AgentClient { return await getClaudeModelsWithSettings(this.logger, this.configDir); } + async fetchCatalog(_options: FetchCatalogOptions): Promise { + const models = await getClaudeModelsWithSettings(this.logger, this.configDir); + return { models, modes: DEFAULT_MODES }; + } + async listFeatures(config: AgentSessionConfig): Promise { const claudeConfig = this.assertConfig(config); return buildClaudeFeatures({ @@ -1477,28 +1482,9 @@ export class ClaudeAgentClient implements AgentClient { defaultBinary: "claude", }); const availability = await checkProviderLaunchAvailable(launch); - const available = availability.available; - const auth = available + const auth = availability.available ? await resolveClaudeAuth(launch, availability, this.runtimeSettings) : null; - let modelsValue = "Not checked"; - let status = formatDiagnosticStatus(available); - - if (available) { - try { - const models = await this.listModels({ - cwd: os.homedir(), - force: false, - }); - modelsValue = String(models.length); - } catch (error) { - modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; - status = formatDiagnosticStatus(available, { - source: "model fetch", - cause: error, - }); - } - } return { diagnostic: formatProviderDiagnostic("Claude Code", [ @@ -1507,8 +1493,6 @@ export class ClaudeAgentClient implements AgentClient { })), ...(await buildBinaryDiagnosticRows(launch, availability)), ...(auth ? [{ label: "Auth", value: auth }] : []), - { label: "Models", value: modelsValue }, - { label: "Status", value: status }, ]), }; } catch (error) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 0be6713f29..7c6aab4bc2 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -26,11 +26,13 @@ import { type AgentTimelineItem, type ToolCallTimelineItem, type AgentUsage, + type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, type ListModelsOptions, + type ProviderCatalog, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import type { Logger } from "pino"; @@ -84,13 +86,11 @@ import { } from "./provider-image-output.js"; import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js"; import { - formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, buildBinaryDiagnosticRows, buildCommandResolutionDiagnosticRows, resolveBinaryVersion, - toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; import { SETTING_APPLIES_NEXT_TURN_NOTICE } from "../provider-notices.js"; @@ -5592,6 +5592,11 @@ export class CodexAppServerAgentClient implements AgentClient { } } + async fetchCatalog(_options: FetchCatalogOptions): Promise { + const models = await this.listModels({ cwd: homedir(), force: false }); + return { models, modes: CODEX_MODES }; + } + async archiveNativeSession(handle: AgentPersistenceHandle): Promise { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; @@ -5645,34 +5650,12 @@ export class CodexAppServerAgentClient implements AgentClient { try { const launch = await resolveCodexLaunch(this.runtimeSettings); const availability = await checkCodexLaunchAvailable(launch); - const available = availability.available; const entries: Array<{ label: string; value: string }> = [ ...(await buildCommandResolutionDiagnosticRows(launch, { knownBinaryNames: ["codex"], })), ...(await buildBinaryDiagnosticRows(launch, availability)), ]; - let status = formatDiagnosticStatus(available); - - if (!available) { - entries.push({ label: "Models", value: "Not checked" }); - } else { - try { - const models = await this.listModels({ cwd: homedir(), force: false }); - entries.push({ label: "Models", value: String(models.length) }); - } catch (error) { - entries.push({ - label: "Models", - value: `Error - ${toDiagnosticErrorMessage(error)}`, - }); - status = formatDiagnosticStatus(available, { - source: "model fetch", - cause: error, - }); - } - } - - entries.push({ label: "Status", value: status }); return { diagnostic: formatProviderDiagnostic("Codex", entries), diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index 402a74381a..8107ce1cf2 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -1,5 +1,4 @@ import type { Logger } from "pino"; -import { homedir } from "node:os"; import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js"; @@ -16,12 +15,10 @@ import { type SessionStateResponse, } from "./acp-agent.js"; import { - formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, buildBinaryDiagnosticRows, buildCommandResolutionDiagnosticRows, - toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; const COPILOT_CAPABILITIES: AgentCapabilityFlags = { @@ -98,33 +95,6 @@ export class CopilotACPAgentClient extends ACPAgentClient { defaultBinary: "copilot", }); const availability = await checkProviderLaunchAvailable(launch); - const available = availability.available; - let modelsValue = "Not checked"; - let status = formatDiagnosticStatus(available); - - if (available) { - try { - const models = await this.listModels({ cwd: homedir(), force: false }); - modelsValue = String(models.length); - } catch (error) { - modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; - status = formatDiagnosticStatus(available, { - source: "model fetch", - cause: error, - }); - } - - if (!modelsValue.startsWith("Error -")) { - try { - await this.listModes({ cwd: homedir(), force: false }); - } catch (error) { - status = formatDiagnosticStatus(available, { - source: "mode fetch", - cause: error, - }); - } - } - } return { diagnostic: formatProviderDiagnostic("Copilot", [ @@ -132,8 +102,6 @@ export class CopilotACPAgentClient extends ACPAgentClient { knownBinaryNames: ["copilot"], })), ...(await buildBinaryDiagnosticRows(launch, availability)), - { label: "Models", value: modelsValue }, - { label: "Status", value: status }, ]), }; } catch (error) { diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index d70819b7d1..dc9d0c007b 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -1,8 +1,7 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js"; -import type { SpawnedACPProcess } from "./acp-agent.js"; describe("GenericACPAgentClient diagnostics", () => { test("probes npx-backed agent packages instead of npx itself", () => { @@ -17,45 +16,8 @@ describe("GenericACPAgentClient diagnostics", () => { }); }); - test("reports command, binary, ACP initialize, session, models, and modes", async () => { - class TestGenericACPAgentClient extends GenericACPAgentClient { - protected override async spawnProcess(): Promise { - return { - child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, - initialize: { - protocolVersion: 1, - agentInfo: { name: "Cursor Agent", version: "2026.05.09" }, - agentCapabilities: {}, - }, - connection: { - newSession: vi.fn().mockResolvedValue({ - sessionId: "session-1", - models: { - currentModelId: "composer-2[fast=true]", - availableModels: [ - { - modelId: "composer-2[fast=true]", - name: "Composer 2", - }, - ], - }, - modes: { - currentModeId: "ask", - availableModes: [ - { id: "agent", name: "Agent" }, - { id: "ask", name: "Ask" }, - ], - }, - configOptions: [], - }), - }, - } as SpawnedACPProcess; - } - - protected override async closeProbe(): Promise {} - } - - const client = new TestGenericACPAgentClient({ + test("reports command, binary, and version command without spawning ACP", async () => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command: [process.execPath, "acp"], providerId: "cursor", @@ -69,88 +31,10 @@ describe("GenericACPAgentClient diagnostics", () => { expect(diagnostic).toContain(`Configured command: ${process.execPath} acp`); expect(diagnostic).toContain(`Launcher binary: ${process.execPath}`); expect(diagnostic).toContain(`Version command: ${process.execPath} --version`); - expect(diagnostic).toContain("ACP initialize: ok (protocol 1, Cursor Agent 2026.05.09)"); - expect(diagnostic).toContain("ACP session/new: ok (session-1)"); - expect(diagnostic).toContain("Models: 1"); - expect(diagnostic).toContain("Modes: Agent, Ask"); - expect(diagnostic).toContain("Status: Available"); - }); - - test("counts models and modes exposed as ACP config options", async () => { - class ConfigOptionGenericACPAgentClient extends GenericACPAgentClient { - protected override async spawnProcess(): Promise { - return { - child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, - initialize: { - protocolVersion: 1, - agentInfo: { name: "Devin", version: "2026.5.6" }, - agentCapabilities: {}, - }, - connection: { - newSession: vi.fn().mockResolvedValue({ - sessionId: "session-1", - models: null, - modes: null, - configOptions: [ - { - id: "mode", - name: "Session Mode", - category: "mode", - type: "select", - currentValue: "ask", - options: [ - { value: "accept-edits", name: "Code" }, - { value: "ask", name: "Ask" }, - ], - }, - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: "swe-1-6-slow", - options: [{ value: "swe-1-6-slow", name: "SWE-1.6 Slow" }], - }, - ], - }), - }, - } as SpawnedACPProcess; - } - - protected override async closeProbe(): Promise {} - } - - const client = new ConfigOptionGenericACPAgentClient({ - logger: createTestLogger(), - command: [process.execPath, "acp"], - providerId: "devin", - label: "Devin", - }); - - const { diagnostic } = await client.getDiagnostic(); - - expect(diagnostic).toContain("Models: 1"); - expect(diagnostic).toContain("Modes: Code, Ask"); - }); - - test("reports ACP probe failures instead of falling back to no diagnostic", async () => { - class FailingGenericACPAgentClient extends GenericACPAgentClient { - protected override async spawnProcess(): Promise { - throw new Error("initialize timed out"); - } - } - - const client = new FailingGenericACPAgentClient({ - logger: createTestLogger(), - command: [process.execPath, "acp"], - providerId: "cursor", - label: "Cursor", - }); - - const { diagnostic } = await client.getDiagnostic(); - - expect(diagnostic).toContain("Cursor (ACP)"); - expect(diagnostic).toContain("ACP initialize: Error - initialize timed out"); - expect(diagnostic).toContain("Status: Error (ACP probe failed: initialize timed out)"); + expect(diagnostic).not.toContain("ACP initialize"); + expect(diagnostic).not.toContain("ACP session/new"); + expect(diagnostic).not.toContain("Models:"); + expect(diagnostic).not.toContain("Modes:"); + expect(diagnostic).not.toContain("Status:"); }); }); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index a9ff355cdf..b2269890df 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,27 +1,15 @@ -import { homedir } from "node:os"; import type { Logger } from "pino"; import { z } from "zod"; -import type { AgentCapabilityFlags, AgentProvider } from "../agent-sdk-types.js"; +import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; +import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js"; import { - ACPAgentClient, - DEFAULT_ACP_CAPABILITIES, - deriveModelDefinitionsFromACP, - deriveModesFromACP, - type SessionStateResponse, -} from "./acp-agent.js"; -import { - formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, buildBinaryDiagnosticRows, - toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; -const ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS = 8_000; -const ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS = 8_000; - export const GenericACPProviderParamsSchema = z .object({ supportsMcpServers: z.boolean().optional(), @@ -83,17 +71,7 @@ export class GenericACPAgentClient extends ACPAgentClient { try { const launch = await this.resolveConfiguredLaunch(); const availability = await checkProviderLaunchAvailable(launch); - const available = availability.available; const versionProbe = buildVersionProbeCommand(this.command); - const probeResult = available - ? await this.runDiagnosticACPProbe() - : { - status: formatDiagnosticStatus(false), - initialize: "Not checked", - session: "Not checked", - models: "Not checked", - modes: "Not checked", - }; return { diagnostic: formatProviderDiagnostic(providerName, [ @@ -111,11 +89,6 @@ export class GenericACPAgentClient extends ACPAgentClient { label: "Version command", value: formatCommand(versionProbe.command, versionProbe.args), }, - { label: "ACP initialize", value: probeResult.initialize }, - { label: "ACP session/new", value: probeResult.session }, - { label: "Models", value: probeResult.models }, - { label: "Modes", value: probeResult.modes }, - { label: "Status", value: probeResult.status }, ]), }; } catch (error) { @@ -131,58 +104,6 @@ export class GenericACPAgentClient extends ACPAgentClient { defaultBinary: this.command[0], }); } - - private async runDiagnosticACPProbe(): Promise { - let initializeValue = "Not checked"; - let sessionValue = "Not checked"; - - try { - const probe = await this.spawnProcess( - { - NO_BROWSER: "true", - NO_OPEN_BROWSER: "1", - GEMINI_CLI_NO_BROWSER: "true", - CI: "1", - }, - { - initializeTimeoutMs: ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS, - }, - ); - try { - initializeValue = formatInitializeResult(probe.initialize); - const response = await withTimeout( - probe.connection.newSession({ - cwd: homedir(), - mcpServers: [], - }), - ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS, - "ACP session/new", - ); - sessionValue = response.sessionId ? `ok (${response.sessionId})` : "ok"; - const transformed = this.transformSessionResponse(response); - return { - status: formatDiagnosticStatus(true), - initialize: initializeValue, - session: sessionValue, - ...summarizeSessionState(this.provider, transformed), - }; - } finally { - await this.closeProbe(probe); - } - } catch (error) { - return { - status: formatDiagnosticStatus(true, { - source: "ACP probe", - cause: error, - }), - initialize: formatProbeError(initializeValue, error), - session: - initializeValue === "Not checked" ? "Not checked" : formatProbeError(sessionValue, error), - models: "Not checked", - modes: "Not checked", - }; - } - } } function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags { @@ -197,14 +118,6 @@ function parseGenericACPProviderParams(params: unknown): GenericACPProviderParam return GenericACPProviderParamsSchema.parse(params ?? {}); } -interface ACPDiagnosticProbeResult { - status: string; - initialize: string; - session: string; - models: string; - modes: string; -} - export interface CommandInvocation { command: string; args: string[]; @@ -271,60 +184,3 @@ function takePackageSpecPrefix(args: string[]): string[] { } return prefix; } - -function formatInitializeResult(initialize: { - protocolVersion: number; - agentInfo?: unknown; -}): string { - const agentInfo = isAgentInfo(initialize.agentInfo) - ? `${initialize.agentInfo.name}${initialize.agentInfo.version ? ` ${initialize.agentInfo.version}` : ""}` - : "ok"; - return `ok (protocol ${initialize.protocolVersion}, ${agentInfo})`; -} - -function isAgentInfo(value: unknown): value is { name: string; version?: string } { - return ( - typeof value === "object" && - value !== null && - "name" in value && - typeof Reflect.get(value, "name") === "string" - ); -} - -function summarizeSessionState( - provider: AgentProvider, - response: SessionStateResponse, -): Pick { - const models = deriveModelDefinitionsFromACP(provider, response.models, response.configOptions); - const { modes } = deriveModesFromACP([], response.modes, response.configOptions); - return { - models: `${models.length}`, - modes: - modes.length > 0 ? modes.map((mode) => mode.label || mode.id).join(", ") : "none reported", - }; -} - -function formatProbeError(currentValue: string, error: unknown): string { - if (currentValue !== "Not checked") { - return currentValue; - } - return `Error - ${toDiagnosticErrorMessage(error)}`; -} - -async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { - let timeout: ReturnType | null = null; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timeout = setTimeout(() => { - reject(new Error(`${label} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - }), - ]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } -} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 24d35efaba..8cb6338643 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,4 +1,3 @@ -import { homedir } from "node:os"; import { type AssistantMessage as OpenCodeAssistantMessage, type Event as OpenCodeEvent, @@ -38,6 +37,7 @@ import { type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, + type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, @@ -47,6 +47,7 @@ import { type ListModelsOptions, type ListModesOptions, type McpServerConfig, + type ProviderCatalog, type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; @@ -67,7 +68,6 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display" import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { OpenCodeServerManager } from "./opencode/server-manager.js"; import { - formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, buildBinaryDiagnosticRows, @@ -1371,63 +1371,7 @@ export class OpenCodeAgentClient implements AgentClient { }); try { - // Background model discovery can be legitimately slow while OpenCode refreshes - // provider state, so allow longer than turn execution paths. - const response = await openCodeMetadataLimit(() => - withTimeout( - client.provider.list({ directory: options.cwd }), - OPENCODE_PROVIDER_LIST_TIMEOUT_MS, - `OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`, - ), - ); - - if (response.error) { - throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`); - } - - const providers = response.data; - if (!providers) { - return []; - } - - const connectedProviderIds = new Set(providers.connected); - - // Providers with source "api" are managed by the OpenCode console/subscription (e.g. Pi - // coding agent). They do not appear in `connected` (which only lists env/config providers) - // but are fully usable — OpenCode authenticates them internally via the console session. - const isAccessible = (provider: { id: string; source: string }): boolean => - connectedProviderIds.has(provider.id) || provider.source === "api"; - - // Fail fast if no providers are accessible at all - if (!providers.all.some(isAccessible)) { - throw new Error( - "OpenCode has no connected providers. Please authenticate with at least one provider " + - "(e.g., openai, anthropic), set appropriate environment variables (e.g., OPENAI_API_KEY), " + - "or log in to OpenCode Go via the console.", - ); - } - - const models: AgentModelDefinition[] = []; - this.modelContextWindows.clear(); - for (const provider of providers.all) { - if (!isAccessible(provider)) { - continue; - } - - for (const [modelId, model] of Object.entries(provider.models)) { - const definition = buildOpenCodeModelDefinition(provider, modelId, model); - const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model); - if (contextWindowMaxTokens !== undefined) { - this.modelContextWindows.set( - buildOpenCodeModelLookupKey(provider.id, modelId), - contextWindowMaxTokens, - ); - } - models.push(definition); - } - } - - return models; + return await this.fetchModelsFromClient(client, options.cwd); } finally { acquisition.release(); } @@ -1462,6 +1406,23 @@ export class OpenCodeAgentClient implements AgentClient { } } + async fetchCatalog(options: FetchCatalogOptions): Promise { + const acquisition = await this.runtime.acquireServer({ force: options.force }); + const { url } = acquisition.server; + const directory = options.cwd; + const client = this.runtime.createClient({ baseUrl: url, directory }); + + try { + const [models, modes] = await Promise.all([ + this.fetchModelsFromClient(client, directory), + this.fetchModesFromClient(client, directory), + ]); + return { models, modes }; + } finally { + acquisition.release(); + } + } + async listCommands(config: AgentSessionConfig): Promise { const openCodeConfig = this.assertConfig(config); const acquisition = await this.runtime.acquireServer({ force: false }); @@ -1555,17 +1516,6 @@ export class OpenCodeAgentClient implements AgentClient { defaultBinary: "opencode", }); const availability = await checkProviderLaunchAvailable(launch); - const available = availability.available; - let serverStatus = "Not running"; - let modelsValue = "Not checked"; - let status = formatDiagnosticStatus(available); - - try { - const { url } = await this.runtime.ensureServerRunning(); - serverStatus = `Running (${url})`; - } catch (error) { - serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`; - } let authValue = "Not checked"; const authCommand = availability.available @@ -1588,40 +1538,13 @@ export class OpenCodeAgentClient implements AgentClient { } } - if (available) { - try { - const models = await this.listModels({ cwd: homedir(), force: false }); - modelsValue = String(models.length); - } catch (error) { - modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; - status = formatDiagnosticStatus(available, { - source: "model fetch", - cause: error, - }); - } - - if (!modelsValue.startsWith("Error -")) { - try { - await this.listModes({ cwd: homedir(), force: false }); - } catch (error) { - status = formatDiagnosticStatus(available, { - source: "mode fetch", - cause: error, - }); - } - } - } - return { diagnostic: formatProviderDiagnostic("OpenCode", [ ...(await buildCommandResolutionDiagnosticRows(launch, { knownBinaryNames: ["opencode"], })), ...(await buildBinaryDiagnosticRows(launch, availability)), - { label: "Server", value: serverStatus }, { label: "Auth", value: authValue }, - { label: "Models", value: modelsValue }, - { label: "Status", value: status }, ]), }; } catch (error) { @@ -1630,6 +1553,83 @@ export class OpenCodeAgentClient implements AgentClient { }; } } + + private async fetchModelsFromClient( + client: OpencodeClient, + directory: string, + ): Promise { + const response = await openCodeMetadataLimit(() => + withTimeout( + client.provider.list({ directory }), + OPENCODE_PROVIDER_LIST_TIMEOUT_MS, + `OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`, + ), + ); + + if (response.error) { + throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`); + } + + const providers = response.data; + if (!providers) { + return []; + } + + const connectedProviderIds = new Set(providers.connected); + + const isAccessible = (provider: { id: string; source: string }): boolean => + connectedProviderIds.has(provider.id) || provider.source === "api"; + + if (!providers.all.some(isAccessible)) { + throw new Error( + "OpenCode has no connected providers. Please authenticate with at least one provider " + + "(e.g., openai, anthropic), set appropriate environment variables (e.g., OPENAI_API_KEY), " + + "or log in to OpenCode Go via the console.", + ); + } + + const models: AgentModelDefinition[] = []; + this.modelContextWindows.clear(); + for (const provider of providers.all) { + if (!isAccessible(provider)) { + continue; + } + + for (const [modelId, model] of Object.entries(provider.models)) { + const definition = buildOpenCodeModelDefinition(provider, modelId, model); + const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model); + if (contextWindowMaxTokens !== undefined) { + this.modelContextWindows.set( + buildOpenCodeModelLookupKey(provider.id, modelId), + contextWindowMaxTokens, + ); + } + models.push(definition); + } + } + + return models; + } + + private async fetchModesFromClient( + client: OpencodeClient, + directory: string, + ): Promise { + const response = await openCodeMetadataLimit(() => + withTimeout( + client.app.agents({ directory }), + 10_000, + "OpenCode app.agents timed out after 10s", + ), + ); + + if (response.error || !response.data) { + return DEFAULT_MODES; + } + + const discovered = response.data.filter(isSelectableOpenCodeAgent).map(mapOpenCodeAgentToMode); + return mergeOpenCodeModes(discovered); + } private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 1acadf96ea..eb4be8c97b 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -5,8 +5,6 @@ import { join } from "node:path"; import type { Logger } from "pino"; import { z } from "zod"; -import { withTimeout } from "../../../../utils/promise-timeout.js"; - import { type AgentCapabilityFlags, type AgentClient, @@ -28,12 +26,14 @@ import { type AgentSlashCommandKind, type AgentStreamEvent, type AgentUsage, + type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, type ListModesOptions, type ListModelsOptions, + type ProviderCatalog, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderTurn } from "../provider-runner.js"; @@ -48,7 +48,6 @@ import { composeSystemPromptParts } from "../../system-prompt.js"; import { buildBinaryDiagnosticRows, buildCommandResolutionDiagnosticRows, - formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, toDiagnosticErrorMessage, @@ -85,6 +84,7 @@ import { const PI_PROVIDER = "pi"; const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium"; const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; +const PI_CATALOG_REQUEST_TIMEOUT_MS = 120_000; const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree"; const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries"; const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE"; @@ -1964,18 +1964,26 @@ export class PiRpcAgentClient implements AgentClient { } async listModels(options: ListModelsOptions): Promise { + const catalog = await this.fetchCatalog(options); + return catalog.models; + } + + async listModes(_options: ListModesOptions): Promise { + return []; + } + + async fetchCatalog(options: FetchCatalogOptions): Promise { const runtimeSession = await this.runtime.startSession({ cwd: options.cwd }); try { - return transformPiModels((await runtimeSession.getAvailableModels()).map(mapPiModel)); + const models = transformPiModels( + (await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map(mapPiModel), + ); + return { models, modes: [] }; } finally { await runtimeSession.close(); } } - async listModes(_options: ListModesOptions): Promise { - return []; - } - async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { @@ -1999,30 +2007,9 @@ export class PiRpcAgentClient implements AgentClient { async isAvailable(): Promise { try { - return await withTimeout( - (async () => { - const launch = await this.resolvePiLaunch(); - const availability = await checkProviderLaunchAvailable(launch); - if (!availability.available) { - return false; - } - const runtimeSession = await this.runtime - .startSession({ cwd: homedir() }) - .catch(() => null); - if (!runtimeSession) { - return false; - } - try { - return (await runtimeSession.getAvailableModels()).length > 0; - } catch { - return false; - } finally { - await runtimeSession.close().catch(() => undefined); - } - })(), - 2000, - "Pi availability check timed out", - ); + const launch = await this.resolvePiLaunch(); + const availability = await checkProviderLaunchAvailable(launch); + return availability.available; } catch { return false; } @@ -2032,48 +2019,7 @@ export class PiRpcAgentClient implements AgentClient { try { const launch = await this.resolvePiLaunch(); const availability = await checkProviderLaunchAvailable(launch); - const available = availability.available; const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); - let modelsValue = "Not checked"; - let configuredProvidersValue = "none"; - let mcpToolsValue = "Not checked"; - let status = formatDiagnosticStatus(available); - - if (availability.available) { - const runtimeSession = await this.runtime - .startSession({ cwd: homedir() }) - .catch((error) => { - status = formatDiagnosticStatus(false, { - source: "startup", - cause: error, - }); - return null; - }); - if (runtimeSession) { - try { - const models = await runtimeSession.getAvailableModels(); - modelsValue = String(models.length); - const configuredProviders = Array.from( - new Set(models.map((model) => model.provider)), - ).sort(); - configuredProvidersValue = - configuredProviders.length > 0 ? configuredProviders.join(", ") : "none"; - const commands = await runtimeSession.getCommands(); - mcpToolsValue = commands.some(isPiMcpAdapterCommand) - ? "yes (pi-mcp-adapter loaded)" - : "no (install pi-mcp-adapter)"; - } catch (error) { - modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; - mcpToolsValue = `Error - ${toDiagnosticErrorMessage(error)}`; - status = formatDiagnosticStatus(available, { - source: "model fetch", - cause: error, - }); - } finally { - await runtimeSession.close().catch(() => undefined); - } - } - } return { diagnostic: formatProviderDiagnostic("Pi", [ @@ -2081,14 +2027,10 @@ export class PiRpcAgentClient implements AgentClient { knownBinaryNames: [launch.command], })), ...(await buildBinaryDiagnosticRows(launch, availability)), - { label: "Configured providers", value: configuredProvidersValue }, { label: "Auth config (~/.pi/agent/auth.json)", value: existsSync(authConfigPath) ? "found" : "not found", }, - { label: "Models", value: modelsValue }, - { label: "Paseo MCP tools", value: mcpToolsValue }, - { label: "Status", value: status }, ]), }; } catch (error) { diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 07af803a06..97300939b0 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -153,8 +153,10 @@ class PiCliRuntimeSession implements PiRuntimeSession { return data.messages ?? []; } - async getAvailableModels(): Promise { - const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] }; + async getAvailableModels(timeoutMs?: number): Promise { + const data = (await this.request({ type: "get_available_models" }, timeoutMs)) as { + models?: PiModel[]; + }; return data.models ?? []; } diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 0d813c12f2..2a91c810e9 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -42,7 +42,7 @@ export interface PiRuntimeSession { abort(): Promise; getState(): Promise; getMessages(): Promise; - getAvailableModels(): Promise; + getAvailableModels(timeoutMs?: number): Promise; setModel(provider: string, modelId: string): Promise; setThinkingLevel(level: string): Promise; getSessionStats(): Promise; diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 53121c23ad..76e0f4b7ff 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -138,7 +138,7 @@ export class FakePiSession implements PiRuntimeSession { return this.messages; } - async getAvailableModels(): Promise { + async getAvailableModels(_timeoutMs?: number): Promise { return this.models; } From be36a60cd501de12036a6a3a7d35985f9b117a9e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 22 Jun 2026 14:21:23 +0700 Subject: [PATCH 2/6] fix(server): preserve catalog profile models --- .../server/src/server/agent/provider-registry.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index dd32efbe3e..718192fc1b 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -565,9 +565,15 @@ function createRegistryEntry( if (catalogClient.fetchCatalog) { const catalog = await catalogClient.fetchCatalog(options); return { - models: mergeModels(provider, [], resolved.additionalModels, catalog.models, { - profileModelsAreAdditive: true, - }), + models: mergeModels( + provider, + resolved.profileModels, + resolved.additionalModels, + catalog.models, + { + profileModelsAreAdditive: resolved.profileModelsAreAdditive, + }, + ), modes: decorateModes(catalog.modes), }; } From 0bb5618db51241d4d28f303e4ee071f6835da97f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 23 Jun 2026 15:40:36 +0700 Subject: [PATCH 3/6] refactor: unify provider catalog discovery under AgentClient.fetchCatalog Remove listModels/listModes from AgentClient and fetchModels/fetchModes from ProviderDefinition. All provider runtime discovery now flows through a single fetchCatalog(options) => ProviderCatalog API. ProviderSnapshotManager.listModels/listModes remain as cached snapshot conveniences only. Provider implementations (acp, codex, opencode, pi, claude, mock) updated accordingly; agent-manager default model resolution now calls fetchCatalog. Reshape step toward issue pi-model-list-empty. --- docs/providers.md | 9 +- .../agent-manager-stream-coalescing.test.ts | 16 +-- .../src/server/agent/agent-manager.test.ts | 136 ++---------------- .../server/src/server/agent/agent-manager.ts | 4 +- .../src/server/agent/agent-sdk-types.ts | 9 +- .../src/server/agent/mcp-parity.e2e.test.ts | 2 +- .../server/agent/provider-registry.test.ts | 121 ++++++++-------- .../src/server/agent/provider-registry.ts | 99 ++++--------- .../agent/provider-snapshot-manager.test.ts | 111 +++++++------- .../server/agent/provider-snapshot-manager.ts | 4 +- .../server/agent/providers/acp-agent.test.ts | 68 ++++----- .../src/server/agent/providers/acp-agent.ts | 46 ------ .../agent/providers/claude/agent.test.ts | 4 +- .../server/agent/providers/claude/agent.ts | 8 +- .../agent/providers/claude/models.test.ts | 10 +- .../codex-app-server-agent.real.e2e.test.ts | 2 +- ...codex-app-server-agent.spawn-error.test.ts | 4 +- .../agent/providers/codex-app-server-agent.ts | 14 +- .../agent/providers/cursor-acp-agent.test.ts | 4 +- .../providers/mock-load-test-agent.test.ts | 2 +- .../agent/providers/mock-load-test-agent.ts | 15 +- .../agent/providers/mock-slow-provider.ts | 16 +-- .../opencode-agent.full-access.test.ts | 4 +- ...opencode-agent.list-models-timeout.test.ts | 10 +- .../agent/providers/opencode-agent.test.ts | 23 ++- .../server/agent/providers/opencode-agent.ts | 46 ------ .../server/agent/providers/pi/agent.test.ts | 23 +-- .../src/server/agent/providers/pi/agent.ts | 11 -- .../src/server/agent/rewind/rewind.test.ts | 11 +- 29 files changed, 271 insertions(+), 561 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index db0f026de1..e516a687c7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -34,7 +34,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text. -Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. +Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. @@ -336,14 +336,13 @@ interface AgentClient { overrides?: Partial, launchContext?: AgentLaunchContext, ): Promise; - listModels(options: ListModelsOptions): Promise; + fetchCatalog(options: FetchCatalogOptions): Promise; isAvailable(): Promise; // Optional: - listModes?(options: ListModesOptions): Promise; - listImportableSessions?( + listImportableSessions( options?: ListImportableSessionsOptions, ): Promise; - importSession?( + importSession( input: ImportProviderSessionInput, context: ImportProviderSessionContext, ): Promise; diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 1b4b01b8a0..44c34bddeb 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -11,7 +11,6 @@ import type { AgentCapabilityFlags, AgentClient, AgentLaunchContext, - AgentModelDefinition, AgentPersistenceHandle, AgentPromptInput, AgentProvider, @@ -206,19 +205,8 @@ class TestAgentClient implements AgentClient { return this.createSession(resolvedConfig); } - async listModels(): Promise { - return [ - { - provider: this.provider, - id: "test-model", - label: "Test Model", - isDefault: true, - }, - ]; - } - - async isAvailable(): Promise { - return true; + async fetchCatalog(): Promise { + return { models: true, modes: [] }; } getSession(cwd: string): TestAgentSession { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 502edc47f0..045490b9f9 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,5 +1,4 @@ import { expect, test, vi } from "vitest"; -import { spawn } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -99,100 +98,8 @@ class TestAgentClient implements AgentClient { return new TestAgentSession(config); } - async listModels() { - return [ - { - provider: "codex", - id: "gpt-5.4", - label: "GPT-5.4", - isDefault: true, - }, - { - provider: "codex", - id: "gpt-5.4-mini", - label: "GPT-5.4 Mini", - }, - { - provider: "codex", - id: "gpt-5.2-codex", - label: "GPT-5.2 Codex", - }, - ]; - } - - async resumeSession( - _handle: AgentPersistenceHandle, - config?: Partial, - _launchContext?: AgentLaunchContext, - ): Promise { - this.resumeOverrides.push(config); - return new TestAgentSession({ - provider: "codex", - cwd: config?.cwd ?? process.cwd(), - daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt, - }); - } -} - -class NativeArchiveRecordingClient extends TestAgentClient { - readonly archivedHandles: AgentPersistenceHandle[] = []; - readonly unarchivedHandles: AgentPersistenceHandle[] = []; - readArchivedAtDuringUnarchive: (() => Promise) | null = null; - archivedAtDuringUnarchive: string | null | undefined; - unarchiveFailure: Error | null = null; - - async archiveNativeSession(handle: AgentPersistenceHandle): Promise { - this.archivedHandles.push(handle); - } - - async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { - this.unarchivedHandles.push(handle); - if (this.readArchivedAtDuringUnarchive) { - this.archivedAtDuringUnarchive = await this.readArchivedAtDuringUnarchive(); - } - if (this.unarchiveFailure) { - throw this.unarchiveFailure; - } - } -} - -class EnvProbeAgentClient extends TestAgentClient { - probe: Promise<{ probe: string | null; agentId: string | null }> | null = null; - - override async createSession( - config: AgentSessionConfig, - launchContext?: AgentLaunchContext, - ): Promise { - const script = ` - process.stdout.write(JSON.stringify({ - probe: process.env.CHUNK14_PROBE ?? null, - agentId: process.env.PASEO_AGENT_ID ?? null - })); - `; - const child = spawn(process.execPath, ["-e", script], { - cwd: config.cwd, - env: { ...process.env, ...launchContext?.env }, - stdio: ["ignore", "pipe", "pipe"], - }); - this.probe = new Promise((resolve, reject) => { - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0) { - reject(new Error(`env probe exited ${code}: ${stderr}`)); - return; - } - resolve(JSON.parse(stdout) as { probe: string | null; agentId: string | null }); - }); - }); - return new TestAgentSession(config); + async fetchCatalog() { + return { models: new TestAgentSession(config), modes: [] }; } } @@ -667,8 +574,11 @@ test("setAgentMode persists the selected mode across session reload", async () = }); } - async listModels() { - return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }]; + async fetchCatalog() { + return { + models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }], + modes: [], + }; } } @@ -1454,32 +1364,8 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p return new TestAgentSession(config); } - async listModels() { - return [ - { - provider: "codex", - id: "gpt-5.4", - label: "GPT-5.4", - isDefault: true, - }, - ]; - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - launchContext?: AgentLaunchContext, - ): Promise { - this.lastResumeOverrides = overrides; - this.lastResumeLaunchContext = launchContext; - const metadata = (handle.metadata ?? {}) as Partial; - const merged: AgentSessionConfig = { - ...metadata, - ...overrides, - provider: "codex", - cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), - }; - return new TestAgentSession(merged); + async fetchCatalog() { + return { models: new TestAgentSession(merged), modes: [] }; } } @@ -5976,8 +5862,8 @@ class RecordingPersistedAgentsClient implements AgentClient { throw new Error(`unexpected resumeSession for ${this.provider}`); } - async listModels() { - return []; + async fetchCatalog() { + return { models: [], modes: [] }; } async listImportableSessions() { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 09aa33a63d..c27cdb170d 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3644,8 +3644,8 @@ export class AgentManager { const client = this.clients.get(normalized.provider); if (client) { try { - const models = await client.listModels({ cwd: normalized.cwd, force: false }); - const defaultModel = models.find((model) => model.isDefault) ?? models[0]; + const catalog = await client.fetchCatalog({ cwd: normalized.cwd, force: false }); + const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0]; if (defaultModel) { normalized.model = defaultModel.id; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index af13429f26..91752d2913 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -669,14 +669,13 @@ export interface AgentClient { overrides?: Partial, launchContext?: AgentLaunchContext, ): Promise; - listModels(options: ListModelsOptions): Promise; - listModes?(options: ListModesOptions): Promise; /** - * Discover models and modes together when the provider supports a single - * catalog probe. Implementations should spawn at most one runtime process. + * Discover models and modes together. Implementations may use one upstream + * process, separate upstream calls, static modes, or private helpers; callers + * outside the provider do not get separate runtime model/mode probes. * The registry is responsible for merging configured model overrides. */ - fetchCatalog?(options: FetchCatalogOptions): Promise; + fetchCatalog(options: FetchCatalogOptions): Promise; resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult; isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; listCommands?(config: AgentSessionConfig): Promise; diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 134c7a4ebe..fcb686566d 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -163,7 +163,7 @@ function createRecordingAgentClients(): Record { }, resumeSession: async (handle, overrides, launchContext) => await client.resumeSession(handle, overrides, launchContext), - listModels: async (options) => await client.listModels(options), + listModels: async (options) => await client.fetchCatalog(options), isAvailable: async () => await client.isAvailable(), }; if (client.listModes) { diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index e71b664f56..93cc12cdec 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import type { AgentClient, AgentModelDefinition, AgentMode } from "./agent-sdk-types.js"; +import type { + AgentClient, + AgentModelDefinition, + AgentMode, + ProviderCatalog, +} from "./agent-sdk-types.js"; const mockState = vi.hoisted(() => { interface ConstructorEntry { @@ -76,12 +81,11 @@ vi.mock("./providers/claude/agent.js", () => ({ throw new Error("not implemented"); } - async listModels(): Promise { - return mockState.runtimeModels.get(this.provider) ?? []; - } - - async listModes(): Promise<[]> { - return []; + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; } async isAvailable(): Promise { @@ -125,12 +129,11 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({ throw new Error("not implemented"); } - async listModels(): Promise { - return mockState.runtimeModels.get(this.provider) ?? []; - } - - async listModes(): Promise<[]> { - return []; + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; } async isAvailable(): Promise { @@ -176,12 +179,11 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({ throw new Error("not implemented"); } - async listModels(): Promise { - return mockState.runtimeModels.get(this.provider) ?? []; - } - - async listModes(): Promise<[]> { - return []; + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; } async isAvailable(): Promise { @@ -228,12 +230,11 @@ vi.mock("./providers/pi/agent.js", () => ({ throw new Error("not implemented"); } - async listModels(): Promise { - return mockState.runtimeModels.get(this.provider) ?? []; - } - - async listModes(): Promise<[]> { - return []; + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; } async isAvailable(): Promise { @@ -299,12 +300,11 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ throw new Error("not implemented"); } - async listModels(): Promise { - return mockState.runtimeModels.get(this.provider) ?? []; - } - - async listModes(): Promise<[]> { - return []; + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; } async isAvailable(): Promise { @@ -353,12 +353,11 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ throw new Error("not implemented"); } - async listModels(): Promise { - return mockState.runtimeModels.get(this.provider) ?? []; - } - - async listModes(): Promise<[]> { - return []; + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; } async isAvailable(): Promise { @@ -800,7 +799,7 @@ describe("model merging", () => { }, }); - const models = await registry.codex.fetchModels({ + const { models } = await registry.codex.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -835,7 +834,7 @@ describe("model merging", () => { }, }); - const models = await registry.codex.fetchModels({ + const { models } = await registry.codex.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -873,7 +872,7 @@ describe("model merging", () => { }, }); - const models = await registry.codex.fetchModels({ + const { models } = await registry.codex.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -907,7 +906,7 @@ describe("model merging", () => { }, }); - const models = await registry.codex.fetchModels({ + const { models } = await registry.codex.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -949,7 +948,7 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const { models } = await registry.claude.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -999,7 +998,7 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const { models } = await registry.claude.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -1046,7 +1045,7 @@ describe("model merging", () => { }, }); - const models = await registry.codex.fetchModels({ + const { models } = await registry.codex.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -1085,7 +1084,7 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const { models } = await registry.claude.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -1137,7 +1136,7 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const { models } = await registry.claude.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -1175,7 +1174,7 @@ describe("model merging", () => { ]); const registry = buildProviderRegistry(logger); - const models = await registry.claude.fetchModels({ + const { models } = await registry.claude.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -1190,7 +1189,7 @@ describe("model merging", () => { ]); }); - test("built-in createClient().listModels() honors profile model replacement (issue #579)", async () => { + test("built-in createClient().fetchCatalog() honors profile model replacement (issue #579)", async () => { mockState.runtimeModels.set("codex", [ { provider: "codex", @@ -1215,16 +1214,16 @@ describe("model merging", () => { }); const client = registry.codex.createClient(logger); - const models = await client.listModels({ + const catalog = await client.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); - expect(models.map((model) => model.id)).toEqual(["profile-fast"]); - expect(models.find((model) => model.isDefault)?.id).toBe("profile-fast"); + expect(catalog.models.map((model) => model.id)).toEqual(["profile-fast"]); + expect(catalog.models.find((model) => model.isDefault)?.id).toBe("profile-fast"); }); - test("built-in createClient().listModels() honors additionalModels default (issue #579)", async () => { + test("built-in createClient().fetchCatalog() honors additionalModels default (issue #579)", async () => { mockState.runtimeModels.set("claude", [ { provider: "claude", @@ -1249,12 +1248,12 @@ describe("model merging", () => { }); const client = registry.claude.createClient(logger); - const models = await client.listModels({ + const catalog = await client.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); - const defaultModel = models.find((model) => model.isDefault) ?? models[0]; + const defaultModel = catalog.models.find((model) => model.isDefault) ?? catalog.models[0]; expect(defaultModel?.id).toBe("profile-default"); }); @@ -1277,7 +1276,7 @@ describe("model merging", () => { }, }); - const models = await registry.claude.fetchModels({ + const { models } = await registry.claude.fetchCatalog({ cwd: "/tmp/registry-models", force: false, }); @@ -1288,7 +1287,7 @@ describe("model merging", () => { }); describe("fetchCatalog", () => { - test("returns merged models and modes from listModels/listModes fallback", async () => { + test("returns merged models and modes from fetchCatalog", async () => { mockState.runtimeModels.set("codex", [ { provider: "codex", id: "codex-runtime", label: "Codex Runtime" }, ]); @@ -1357,8 +1356,7 @@ describe("fetchCatalog", () => { const injectedClient = { provider: "codex", capabilities: {}, - listModels: vi.fn(async () => injectedModels), - listModes: vi.fn(async () => injectedModes), + fetchCatalog: vi.fn(async () => ({ models: injectedModels, modes: injectedModes })), isAvailable: vi.fn(async () => true), } satisfies Partial as AgentClient; @@ -1368,8 +1366,7 @@ describe("fetchCatalog", () => { injectedClient, ); - expect(injectedClient.listModels).toHaveBeenCalledTimes(1); - expect(injectedClient.listModes).toHaveBeenCalledTimes(1); + expect(injectedClient.fetchCatalog).toHaveBeenCalledTimes(1); expect(catalog.models.map((model) => model.id)).toEqual(["injected-model"]); expect(catalog.modes).toEqual(injectedModes); }); @@ -1382,8 +1379,6 @@ describe("fetchCatalog", () => { models: [{ provider: "codex", id: "catalog-model", label: "Catalog Model" }], modes: [{ id: "ask", label: "Ask" }], })), - listModels: vi.fn(async () => []), - listModes: vi.fn(async () => []), isAvailable: vi.fn(async () => true), } satisfies Partial as AgentClient; @@ -1394,8 +1389,6 @@ describe("fetchCatalog", () => { ); expect(injectedClient.fetchCatalog).toHaveBeenCalledTimes(1); - expect(injectedClient.listModels).not.toHaveBeenCalled(); - expect(injectedClient.listModes).not.toHaveBeenCalled(); expect(catalog.models.map((model) => model.id)).toEqual(["catalog-model"]); expect(catalog.modes.map((mode) => mode.id)).toEqual(["ask"]); }); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 718192fc1b..55e0ce3941 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -11,8 +11,6 @@ import type { AgentSession, AgentStreamEvent, FetchCatalogOptions, - ListModelsOptions, - ListModesOptions, ProviderCatalog, ResolveAgentCreateConfigInput, ResolveAgentCreateConfigResult, @@ -66,8 +64,6 @@ export interface ProviderDefinition extends AgentProviderDefinition { createClient: (logger: Logger) => AgentClient; resolveCreateConfig: (input: ResolveAgentCreateConfigInput) => ResolveAgentCreateConfigResult; isCreateConfigUnattended: (input: AgentCreateConfigUnattendedInput) => boolean; - fetchModels: (options: ListModelsOptions) => Promise; - fetchModes: (options: ListModesOptions) => Promise; /** * Single catalog discovery call used by ProviderSnapshotManager. Should spawn * at most one provider runtime process and return both models and modes. @@ -431,22 +427,15 @@ function wrapClientProvider( launchContext, ), ), - listModels: async (options) => - mergeModels(provider, profileModels, additionalModels, await inner.listModels(options), { - profileModelsAreAdditive, - }), - listModes: inner.listModes?.bind(inner), - fetchCatalog: inner.fetchCatalog - ? async (options) => { - const catalog = await inner.fetchCatalog!(options); - return { - models: mergeModels(provider, profileModels, additionalModels, catalog.models, { - profileModelsAreAdditive, - }), - modes: catalog.modes, - }; - } - : undefined, + fetchCatalog: async (options) => { + const catalog = await inner.fetchCatalog(options); + return { + models: mergeModels(provider, profileModels, additionalModels, catalog.models, { + profileModelsAreAdditive, + }), + modes: catalog.modes, + }; + }, resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listImportableSessions: listImportableSessions @@ -508,29 +497,7 @@ function createRegistryEntry( }); }); - const fetchModelsFromClient = async ( - options: ListModelsOptions, - catalogClient: AgentClient = modelClient, - ) => - mergeModels( - provider, - resolved.profileModels, - resolved.additionalModels, - await catalogClient.listModels(options), - { - profileModelsAreAdditive: resolved.profileModelsAreAdditive, - }, - ); - - const fetchModesFromClient = async ( - options: ListModesOptions, - catalogClient: AgentClient = modelClient, - ) => { - const modes = catalogClient.listModes - ? await catalogClient.listModes(options) - : resolved.definition.modes; - return decorateModes(modes); - }; + const hasStaticModes = resolved.definition.modes.length > 0; return { ...resolved.definition, @@ -541,48 +508,36 @@ function createRegistryEntry( resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig, isCreateConfigUnattended: modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended, - fetchModels: fetchModelsFromClient, - fetchModes: fetchModesFromClient, fetchCatalog: async (options: FetchCatalogOptions, client?: AgentClient) => { const catalogClient = client ?? modelClient; if (hasReplacementModels) { // Replacement models skip runtime model discovery, but additionalModels - // must still be merged on top. If modes are dynamic, probe for modes only; - // otherwise use static/empty modes with no runtime. + // must still be merged on top. If modes are dynamic, probe for modes via + // the single catalog API; otherwise use static/empty modes with no runtime. const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels); - if (!catalogClient.listModes) { + if (hasStaticModes) { return { models, modes: decorateModes(resolved.definition.modes), }; } - return { - models, - modes: await fetchModesFromClient(options, catalogClient), - }; - } - - if (catalogClient.fetchCatalog) { const catalog = await catalogClient.fetchCatalog(options); - return { - models: mergeModels( - provider, - resolved.profileModels, - resolved.additionalModels, - catalog.models, - { - profileModelsAreAdditive: resolved.profileModelsAreAdditive, - }, - ), - modes: decorateModes(catalog.modes), - }; + return { models, modes: decorateModes(catalog.modes) }; } - const [models, modes] = await Promise.all([ - fetchModelsFromClient(options, catalogClient), - fetchModesFromClient(options, catalogClient), - ]); - return { models, modes }; + const catalog = await catalogClient.fetchCatalog(options); + return { + models: mergeModels( + provider, + resolved.profileModels, + resolved.additionalModels, + catalog.models, + { + profileModelsAreAdditive: resolved.profileModelsAreAdditive, + }, + ), + modes: decorateModes(catalog.modes), + }; }, }; } 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 4cf99130e3..82b05a05a7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -7,7 +7,7 @@ import type { AgentMode, AgentModelDefinition, AgentProvider, - ListModelsOptions, + FetchCatalogOptions, ResolveAgentCreateConfigInput, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; @@ -38,8 +38,8 @@ function createExtraClient( async resumeSession() { throw new Error("not implemented"); }, - async listModels(_options: ListModelsOptions) { - return [] as AgentModelDefinition[]; + async fetchCatalog(_options: FetchCatalogOptions) { + return { models: [] as AgentModelDefinition[], modes: [] as AgentMode[] }; }, async isAvailable() { return false; @@ -107,7 +107,10 @@ describe("ProviderSnapshotManager public surface", () => { test("providerOverrides with enabled:false marks the provider as unavailable without probing", async () => { const isAvailable = vi.fn(async () => true); - const fetchModels = vi.fn(async () => [] as AgentModelDefinition[]); + const fetchCatalog = vi.fn(async () => ({ + models: [] as AgentModelDefinition[], + modes: [] as AgentMode[], + })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), providerOverrides: { @@ -118,7 +121,7 @@ describe("ProviderSnapshotManager public surface", () => { pi: { enabled: false }, }, extraClients: { - codex: createExtraClient("codex", { isAvailable, listModels: fetchModels }), + codex: createExtraClient("codex", { isAvailable, fetchCatalog }), }, }); try { @@ -126,7 +129,7 @@ describe("ProviderSnapshotManager public surface", () => { const codex = entries.find((entry) => entry.provider === "codex"); expect(codex).toMatchObject({ provider: "codex", enabled: false, status: "unavailable" }); expect(isAvailable).not.toHaveBeenCalled(); - expect(fetchModels).not.toHaveBeenCalled(); + expect(fetchCatalog).not.toHaveBeenCalled(); } finally { manager.destroy(); } @@ -161,18 +164,20 @@ describe("ProviderSnapshotManager public surface", () => { test("wait:true returns a warm provider without refreshing it", async () => { const cwd = "/tmp/project"; const isAvailable = vi.fn(async () => true); - const listModels = vi.fn(async () => [ - { - provider: "codex", - id: "gpt-5.4-mini", - label: "GPT 5.4 Mini", - }, - ]); - const listModes = vi.fn(async () => [] as AgentMode[]); + const fetchCatalog = vi.fn(async () => ({ + models: [ + { + provider: "codex", + id: "gpt-5.4-mini", + label: "GPT 5.4 Mini", + }, + ] as AgentModelDefinition[], + modes: [] as AgentMode[], + })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { - codex: createExtraClient("codex", { isAvailable, listModels, listModes }), + codex: createExtraClient("codex", { isAvailable, fetchCatalog }), }, }); const listener = vi.fn(); @@ -181,16 +186,14 @@ describe("ProviderSnapshotManager public surface", () => { const [first] = await manager.listProviders({ cwd, providers: ["codex"], wait: true }); expect(first).toMatchObject({ provider: "codex", status: "ready" }); expect(isAvailable).toHaveBeenCalledTimes(1); - expect(listModels).toHaveBeenCalledTimes(1); - expect(listModes).toHaveBeenCalledTimes(1); + expect(fetchCatalog).toHaveBeenCalledTimes(1); listener.mockClear(); const [second] = await manager.listProviders({ cwd, providers: ["codex"], wait: true }); expect(second).toEqual(first); expect(isAvailable).toHaveBeenCalledTimes(1); - expect(listModels).toHaveBeenCalledTimes(1); - expect(listModes).toHaveBeenCalledTimes(1); + expect(fetchCatalog).toHaveBeenCalledTimes(1); expect(listener).not.toHaveBeenCalled(); } finally { manager.destroy(); @@ -200,35 +203,37 @@ describe("ProviderSnapshotManager public surface", () => { test("explicit refresh re-probes only the requested warm provider", async () => { const cwd = "/tmp/project"; const isAvailableCodex = vi.fn(async () => true); - const listCodexModels = vi.fn(async () => [ - { - provider: "codex", - id: "gpt-5.4-mini", - label: "GPT 5.4 Mini", - }, - ]); - const listCodexModes = vi.fn(async () => [] as AgentMode[]); + const fetchCodexCatalog = vi.fn(async () => ({ + models: [ + { + provider: "codex", + id: "gpt-5.4-mini", + label: "GPT 5.4 Mini", + }, + ] as AgentModelDefinition[], + modes: [] as AgentMode[], + })); const isAvailableClaude = vi.fn(async () => true); - const listClaudeModels = vi.fn(async () => [ - { - provider: "claude", - id: "claude-opus-4.5", - label: "Claude Opus 4.5", - }, - ]); - const listClaudeModes = vi.fn(async () => [] as AgentMode[]); + const fetchClaudeCatalog = vi.fn(async () => ({ + models: [ + { + provider: "claude", + id: "claude-opus-4.5", + label: "Claude Opus 4.5", + }, + ] as AgentModelDefinition[], + modes: [] as AgentMode[], + })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { codex: createExtraClient("codex", { isAvailable: isAvailableCodex, - listModels: listCodexModels, - listModes: listCodexModes, + fetchCatalog: fetchCodexCatalog, }), claude: createExtraClient("claude", { isAvailable: isAvailableClaude, - listModels: listClaudeModels, - listModes: listClaudeModes, + fetchCatalog: fetchClaudeCatalog, }), }, }); @@ -237,11 +242,9 @@ describe("ProviderSnapshotManager public surface", () => { await manager.refreshSnapshotForCwd({ cwd, providers: ["codex"] }); expect(isAvailableCodex).toHaveBeenCalledTimes(2); - expect(listCodexModels).toHaveBeenCalledTimes(2); - expect(listCodexModes).toHaveBeenCalledTimes(2); + expect(fetchCodexCatalog).toHaveBeenCalledTimes(2); expect(isAvailableClaude).toHaveBeenCalledTimes(1); - expect(listClaudeModels).toHaveBeenCalledTimes(1); - expect(listClaudeModes).toHaveBeenCalledTimes(1); + expect(fetchClaudeCatalog).toHaveBeenCalledTimes(1); } finally { manager.destroy(); } @@ -459,13 +462,9 @@ describe("ProviderSnapshotManager public surface", () => { models: catalogModels, modes: catalogModes, })); - const listModels = vi.fn(async () => [] as AgentModelDefinition[]); - const listModes = vi.fn(async () => [] as AgentMode[]); const client = createExtraClient("codex", { isAvailable: async () => true, fetchCatalog, - listModels, - listModes, }); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), @@ -474,8 +473,6 @@ describe("ProviderSnapshotManager public surface", () => { try { const result = await manager.getProviderDiagnostic("codex"); expect(fetchCatalog).toHaveBeenCalledTimes(1); - expect(listModels).not.toHaveBeenCalled(); - expect(listModes).not.toHaveBeenCalled(); expect(result.diagnostic).toContain("Models: 1"); expect(result.diagnostic).toContain("Status: Ready"); } finally { @@ -547,8 +544,8 @@ describe("ProviderSnapshotManager public surface", () => { async isAvailable() { return true; }, - async listModes() { - return childModes; + async fetchCatalog() { + return { models: [] as AgentModelDefinition[], modes: childModes }; }, async resolveCreateConfig(input) { resolverInputs.push(input); @@ -562,8 +559,8 @@ describe("ProviderSnapshotManager public surface", () => { async isAvailable() { return true; }, - async listModes() { - return parentModes; + async fetchCatalog() { + return { models: [] as AgentModelDefinition[], modes: parentModes }; }, isCreateConfigUnattended(input) { return input.modeId === "parent-unattended"; @@ -625,8 +622,8 @@ describe("ProviderSnapshotManager public surface", () => { async isAvailable() { return true; }, - async listModes() { - return modes; + async fetchCatalog() { + return { models: [] as AgentModelDefinition[], modes }; }, async resolveCreateConfig(input) { resolverInputs.push(input); @@ -684,8 +681,8 @@ describe("ProviderSnapshotManager public surface", () => { async isAvailable() { return true; }, - async listModes() { - return modes; + async fetchCatalog() { + return { models: [] as AgentModelDefinition[], modes }; }, resolveCreateConfig: openCode.resolveCreateConfig.bind(openCode), isCreateConfigUnattended: openCode.isCreateConfigUnattended.bind(openCode), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 88b9eab2f4..9f2b8b5805 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -404,9 +404,7 @@ export class ProviderSnapshotManager { client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig, isCreateConfigUnattended: client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended, - fetchModels: client.listModels.bind(client), - fetchModes: client.listModes?.bind(client) ?? definition.fetchModes, - fetchCatalog: client.fetchCatalog?.bind(client) ?? definition.fetchCatalog, + fetchCatalog: client.fetchCatalog.bind(client), }; } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index f5d2701f8c..75f90f1ffb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1268,17 +1268,20 @@ describe("ACPAgentClient modelTransformer", () => { modelTransformer: transformPiModels, }); - await expect(client.listModels({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual([ - { - provider: "pi", - id: "openrouter/openai/gpt-4.1-mini", - label: "gpt-4.1-mini", - description: "openrouter/openai/gpt-4.1-mini", - isDefault: true, - thinkingOptions: undefined, - defaultThinkingOptionId: undefined, - }, - ]); + await expect(client.fetchCatalog({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual({ + models: [ + { + provider: "pi", + id: "openrouter/openai/gpt-4.1-mini", + label: "gpt-4.1-mini", + description: "openrouter/openai/gpt-4.1-mini", + isDefault: true, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }, + ], + modes: [], + }); }); }); @@ -1307,7 +1310,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => { protected override async closeProbe(): Promise {} } - test("applies sessionResponseTransformer before deriving list probe modes", async () => { + test("applies sessionResponseTransformer before deriving catalog modes", async () => { const client = new TestACPAgentClient({ provider: "claude-acp", logger: createTestLogger(), @@ -1322,18 +1325,21 @@ describe("ACPAgentClient sessionResponseTransformer", () => { }), }); - await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([ - { - id: "review", - label: "Review", - description: "After transform", - }, - ]); + await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({ + models: [], + modes: [ + { + id: "review", + label: "Review", + description: "After transform", + }, + ], + }); }); }); -describe("ACPAgentClient listModes", () => { - test("passes the requested cwd to list model and mode probes", async () => { +describe("ACPAgentClient fetchCatalog", () => { + test("passes the requested cwd to the catalog probe", async () => { const newSession = vi.fn().mockResolvedValue({ modes: null, models: null, configOptions: [] }); class TestACPAgentClient extends ACPAgentClient { @@ -1355,20 +1361,15 @@ describe("ACPAgentClient listModes", () => { defaultModes: [], }); - await client.listModels({ cwd: "/tmp/acp-model-cwd", force: false }); - await client.listModes({ cwd: "/tmp/acp-mode-cwd", force: false }); + await client.fetchCatalog({ cwd: "/tmp/acp-catalog-cwd", force: false }); - expect(newSession).toHaveBeenNthCalledWith(1, { - cwd: "/tmp/acp-model-cwd", - mcpServers: [], - }); - expect(newSession).toHaveBeenNthCalledWith(2, { - cwd: "/tmp/acp-mode-cwd", + expect(newSession).toHaveBeenCalledWith({ + cwd: "/tmp/acp-catalog-cwd", mcpServers: [], }); }); - test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => { + test("returns an empty modes array when no ACP modes are reported and fallback modes are empty", async () => { class TestACPAgentClient extends ACPAgentClient { protected override async spawnProcess(): Promise { return { @@ -1406,7 +1407,10 @@ describe("ACPAgentClient listModes", () => { defaultModes: [], }); - await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([]); + await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({ + models: [], + modes: [], + }); }); }); @@ -2159,7 +2163,7 @@ describe("ACPAgentClient probe cleanup", () => { terminateProcess: terminator.terminate, }); - await client.listModels({ cwd: "/tmp/acp-models", force: false }); + await client.fetchCatalog({ cwd: "/tmp/acp-models", force: false }); expect(terminator.terminated).toContain(child); expect(child.stdin.destroyed).toBe(true); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 703c72ff56..aafe5f3a27 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -86,8 +86,6 @@ import { type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, - type ListModesOptions, - type ListModelsOptions, type McpServerConfig, type ProviderCatalog, type ToolCallDetail, @@ -714,50 +712,6 @@ export class ACPAgentClient implements AgentClient { return session; } - async listModels(options: ListModelsOptions): Promise { - const { cwd } = options; - const probe = await this.spawnProcess(PROBE_ENV); - try { - const response = await this.runACPRequest(() => - probe.connection.newSession({ - cwd, - mcpServers: [], - }), - ); - const transformed = this.transformSessionResponse(response); - const models = deriveModelDefinitionsFromACP( - this.provider, - transformed.models, - transformed.configOptions, - ); - return this.modelTransformer ? this.modelTransformer(models) : models; - } finally { - await this.closeProbe(probe); - } - } - - async listModes(options: ListModesOptions): Promise { - const { cwd } = options; - const probe = await this.spawnProcess(PROBE_ENV); - try { - const response = await this.runACPRequest(() => - probe.connection.newSession({ - cwd, - mcpServers: [], - }), - ); - const transformed = this.transformSessionResponse(response); - const modeInfo = deriveModesFromACP( - this.defaultModes, - transformed.modes, - transformed.configOptions, - ); - return modeInfo.modes; - } finally { - await this.closeProbe(probe); - } - } - async fetchCatalog(options: FetchCatalogOptions): Promise { const { cwd } = options; const probe = await this.spawnProcess(PROBE_ENV); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index b156bb9616..aaa6ed0b96 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -406,7 +406,7 @@ describe("ClaudeAgentClient.listModels", () => { resolveBinary: async () => "/test/claude/bin", configDir: emptyConfigDir, }); - const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); + const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false }); expect(models.map((m) => m.id)).toEqual([ "claude-fable-5", @@ -441,7 +441,7 @@ describe("ClaudeAgentClient.listModels", () => { resolveBinary: async () => "/test/claude/bin", configDir: emptyConfigDir, }); - const models = await client.listModels({ cwd: "/tmp/claude-models", force: false }); + const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false }); const getThinkingIds = (modelId: string) => { return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id); }; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 1c923c4c75..a7b9bdad52 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -57,7 +57,6 @@ import { type AgentLaunchContext, type AgentMetadata, type AgentMode, - type AgentModelDefinition, type AgentPermissionRequest, type AgentPermissionRequestKind, type AgentPermissionResponse, @@ -79,7 +78,6 @@ import { type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, - type ListModelsOptions, type McpServerConfig, type ProviderCatalog, } from "../../agent-sdk-types.js"; @@ -1421,12 +1419,8 @@ export class ClaudeAgentClient implements AgentClient { }); } - async listModels(_options: ListModelsOptions): Promise { - // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. - return await getClaudeModelsWithSettings(this.logger, this.configDir); - } - async fetchCatalog(_options: FetchCatalogOptions): Promise { + // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. const models = await getClaudeModelsWithSettings(this.logger, this.configDir); return { models, modes: DEFAULT_MODES }; } diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 864e414f6f..6a3029614d 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -78,7 +78,7 @@ describe("ClaudeAgentClient.listModels", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); expect(models).toEqual([ ...getClaudeModels(), @@ -127,7 +127,7 @@ describe("ClaudeAgentClient.listModels", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); expect(models).toEqual(getClaudeModels()); }); @@ -137,7 +137,7 @@ describe("ClaudeAgentClient.listModels", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); expect(models).toEqual(getClaudeModels()); }); @@ -153,7 +153,7 @@ describe("ClaudeAgentClient.listModels", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); expect(models).toEqual(getClaudeModels()); }); @@ -169,7 +169,7 @@ describe("ClaudeAgentClient.listModels", () => { vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); const client = new ClaudeAgentClient({ logger: createTestLogger() }); - const models = await client.listModels({ cwd: os.tmpdir(), force: true }); + const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true }); expect(models.map((model) => model.id)).toEqual([ ...getClaudeModels().map((model) => model.id), diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts index 861467e018..0dcf7e7d40 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.real.e2e.test.ts @@ -26,7 +26,7 @@ describe("Codex app-server provider (real)", () => { test("lists models and runs a simple prompt", async () => { const client = createRealProviderClient("codex", createTestLogger()); const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")); - const models = await client.listModels({ cwd, force: false }); + const { models } = await client.fetchCatalog({ cwd, force: false }); expect(models.length).toBeGreaterThan(0); const session = await client.createSession({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index 0173328b21..3269e9ca73 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -21,7 +21,9 @@ describe("CodexAppServerAgentClient spawn error handling", () => { process.on("uncaughtException", onUncaught); try { - await expect(client.listModels({ cwd: "/tmp/codex-models", force: false })).rejects.toThrow(); + await expect( + client.fetchCatalog({ cwd: "/tmp/codex-models", force: false }), + ).rejects.toThrow(); // Drain microtask queue to ensure no deferred uncaught errors await new Promise((resolve) => setTimeout(resolve, 100)); expect(uncaughtErrors).toHaveLength(0); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 7c6aab4bc2..634044b30b 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -31,12 +31,10 @@ import { type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, - type ListModelsOptions, type ProviderCatalog, } from "../agent-sdk-types.js"; import { importSessionFromPersistence } from "../provider-session-import.js"; import type { Logger } from "pino"; -import { homedir } from "node:os"; import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -5561,7 +5559,12 @@ export class CodexAppServerAgentClient implements AgentClient { }); } - async listModels(_options: ListModelsOptions): Promise { + async fetchCatalog(_options: FetchCatalogOptions): Promise { + const models = await this.fetchModelsFromAppServer(); + return { models, modes: CODEX_MODES }; + } + + private async fetchModelsFromAppServer(): Promise { // Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored. const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger); @@ -5592,11 +5595,6 @@ export class CodexAppServerAgentClient implements AgentClient { } } - async fetchCatalog(_options: FetchCatalogOptions): Promise { - const models = await this.listModels({ cwd: homedir(), force: false }); - return { models, modes: CODEX_MODES }; - } - async archiveNativeSession(handle: AgentPersistenceHandle): Promise { const threadId = handle.nativeHandle ?? handle.sessionId; if (!threadId) return; diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index b2da3360c9..6d1aa34f72 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -45,7 +45,7 @@ describe("CursorACPAgentClient model discovery", () => { configOptions: [], }); - await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([ + await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([ { provider: "acp", id: "gpt-5.4[context=272k,reasoning=medium,fast=false]", @@ -65,6 +65,6 @@ describe("CursorACPAgentClient model discovery", () => { configOptions: [], }); - await expect(client.listModels({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([]); + await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([]); }); }); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 9149440931..1ae4f55027 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -33,7 +33,7 @@ describe("MockLoadTestAgentClient", () => { test("default model is a five minute foreground stream with token-rate intervals", async () => { const client = new MockLoadTestAgentClient(); - const models = await client.listModels({ cwd: "/tmp/mock-models", force: false }); + const { models } = await client.fetchCatalog({ cwd: "/tmp/mock-models", force: false }); expect(models[0]).toMatchObject({ id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 8b23b97544..2a59541015 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -20,11 +20,11 @@ import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem, + FetchCatalogOptions, ImportableProviderSession, ImportProviderSessionContext, ImportProviderSessionInput, - ListModesOptions, - ListModelsOptions, + ProviderCatalog, ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; @@ -531,12 +531,11 @@ export class MockLoadTestAgentClient implements AgentClient { }); } - async listModels(_options: ListModelsOptions): Promise { - return MODELS; - } - - async listModes(_options: ListModesOptions): Promise { - return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes; + async fetchCatalog(_options: FetchCatalogOptions): Promise { + return { + models: MODELS, + modes: getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes, + }; } async listImportableSessions(): Promise { diff --git a/packages/server/src/server/agent/providers/mock-slow-provider.ts b/packages/server/src/server/agent/providers/mock-slow-provider.ts index 5c2ba5a219..ef2ad42df7 100644 --- a/packages/server/src/server/agent/providers/mock-slow-provider.ts +++ b/packages/server/src/server/agent/providers/mock-slow-provider.ts @@ -2,14 +2,12 @@ import type { AgentCapabilityFlags, AgentClient, AgentLaunchContext, - AgentMode, - AgentModelDefinition, AgentPersistenceHandle, AgentProvider, AgentSession, AgentSessionConfig, - ListModelsOptions, - ListModesOptions, + FetchCatalogOptions, + ProviderCatalog, } from "../agent-sdk-types.js"; export const MOCK_SLOW_PROVIDER_ID = "mock-slow"; @@ -38,18 +36,14 @@ export class MockSlowProviderClient implements AgentClient { return process.env.PASEO_ENABLE_MOCK_SLOW === "true"; } - listModels(_options: ListModelsOptions): Promise { - return neverResolves(); - } - - listModes(_options: ListModesOptions): Promise { - return neverResolves(); + async fetchCatalog(_options: FetchCatalogOptions): Promise { + return neverResolves(); } async getDiagnostic(): Promise<{ diagnostic: string }> { return { diagnostic: - "Mock slow provider: dev-only. listModels() never resolves so the snapshot manager will time out.", + "Mock slow provider: dev-only. fetchCatalog() never resolves so the snapshot manager will time out.", }; } diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 4550b55a34..6732b2627a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -72,7 +72,7 @@ describe("OpenCode auto_accept feature", () => { }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - const modes = await client.listModes({ cwd: "/tmp/project", force: false }); + const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false }); expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]); }); @@ -81,7 +81,7 @@ describe("OpenCode auto_accept feature", () => { const { runtime } = mockOpenCodeClient({ agents: [] }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - const modes = await client.listModes({ cwd: "/tmp/project", force: false }); + const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false }); expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index 5f4dc215eb..204896237f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -41,7 +41,7 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se runtime.enqueueClient(openCodeClient); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false }); + const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }); await vi.advanceTimersByTimeAsync(15_000); @@ -68,7 +68,7 @@ test("passes explicit refresh force through server acquisition", async () => { const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - await client.listModels({ cwd: "/tmp/opencode-models", force: true }); + await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true }); expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]); }); @@ -99,7 +99,7 @@ test("includes models from api-source providers not in connected", async () => { runtime.enqueueClient(openCodeClient); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - const models = await client.listModels({ cwd: "/tmp/opencode-models", force: false }); + const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }); expect(models).toMatchObject([ { @@ -132,7 +132,7 @@ test("throws when no providers are accessible (neither connected nor api-source) const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); - await expect(client.listModels({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow( + await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow( "OpenCode has no connected providers", ); }); @@ -160,6 +160,6 @@ test("does not throw when only api-source providers are present with no connecte const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime }); await expect( - client.listModels({ cwd: "/tmp/opencode-models", force: false }), + client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }), ).resolves.toHaveLength(1); }); 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 b08bc20811..48f79273ba 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -263,7 +263,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 120_000); - test("listModels returns models with required fields", async () => { + test("fetchCatalog returns models with required fields", async () => { const runtime = new TestOpenCodeRuntime(); const openCodeClient = new TestOpenCodeClient(); openCodeClient.providerListResponse = { @@ -286,15 +286,24 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { ], }, }; + openCodeClient.appAgentsResponse = { + data: [ + { + name: "build", + mode: "primary", + hidden: false, + }, + ], + }; runtime.enqueueClient(openCodeClient); const client = new OpenCodeAgentClient(logger, undefined, { runtime }); const cwd = os.homedir(); - const models = await client.listModels({ cwd, force: false }); + const catalog = await client.fetchCatalog({ cwd, force: false }); - expect(Array.isArray(models)).toBe(true); - expect(models).toHaveLength(1); + expect(Array.isArray(catalog.models)).toBe(true); + expect(catalog.models).toHaveLength(1); - for (const model of models) { + for (const model of catalog.models) { expect(model.provider).toBe("opencode"); expect(typeof model.id).toBe("string"); expect(model.id.length).toBeGreaterThan(0); @@ -309,7 +318,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { }); expect(typeof model.metadata?.contextWindowMaxTokens).toBe("number"); } - expect(models[0]).toMatchObject({ + expect(catalog.models[0]).toMatchObject({ id: TEST_MODEL, label: "Big Pickle", metadata: { @@ -358,7 +367,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { const client = new OpenCodeAgentClient(logger, undefined, { runtime }); await Promise.all( Array.from({ length: 12 }, (_, index) => - client.listModels({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }), + client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }), ), ); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 8cb6338643..aec31ce7cc 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -44,8 +44,6 @@ import { type ListImportableSessionsOptions, type ResolveAgentCreateConfigInput, type ResolveAgentCreateConfigResult, - type ListModelsOptions, - type ListModesOptions, type McpServerConfig, type ProviderCatalog, type ToolCallDetail, @@ -1362,50 +1360,6 @@ export class OpenCodeAgentClient implements AgentClient { } } - async listModels(options: ListModelsOptions): Promise { - const acquisition = await this.runtime.acquireServer({ force: options.force }); - const { url } = acquisition.server; - const client = this.runtime.createClient({ - baseUrl: url, - directory: options.cwd, - }); - - try { - return await this.fetchModelsFromClient(client, options.cwd); - } finally { - acquisition.release(); - } - } - - async listModes(options: ListModesOptions): Promise { - const acquisition = await this.runtime.acquireServer({ force: options.force }); - const { url } = acquisition.server; - const directory = options.cwd; - const client = this.runtime.createClient({ baseUrl: url, directory }); - - try { - const response = await openCodeMetadataLimit(() => - withTimeout( - client.app.agents({ directory }), - 10_000, - "OpenCode app.agents timed out after 10s", - ), - ); - - if (response.error || !response.data) { - return DEFAULT_MODES; - } - - const discovered = response.data - .filter(isSelectableOpenCodeAgent) - .map(mapOpenCodeAgentToMode); - - return mergeOpenCodeModes(discovered); - } finally { - acquisition.release(); - } - } - async fetchCatalog(options: FetchCatalogOptions): Promise { const acquisition = await this.runtime.acquireServer({ force: options.force }); const { url } = acquisition.server; diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 305f055cdc..942bf1d140 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -858,10 +858,10 @@ describe("PiRpcAgentClient", () => { }); }); - test("lists models from a short-lived Pi session in the requested cwd", async () => { + test("discovers models from a short-lived Pi session in the requested cwd", async () => { const pi = new FakePi(); const client = createClient(pi); - const modelsPromise = client.listModels({ cwd: "/workspace/with-extension", force: false }); + const catalogPromise = client.fetchCatalog({ cwd: "/workspace/with-extension", force: false }); pi.latestSession().models = [ { provider: "openrouter", @@ -871,14 +871,17 @@ describe("PiRpcAgentClient", () => { }, ]; - await expect(modelsPromise).resolves.toMatchObject([ - { - provider: "pi", - id: "openrouter/google/gemini-2.5-flash-lite", - label: "gemini-2.5-flash-lite", - defaultThinkingOptionId: "medium", - }, - ]); + await expect(catalogPromise).resolves.toMatchObject({ + models: [ + { + provider: "pi", + id: "openrouter/google/gemini-2.5-flash-lite", + label: "gemini-2.5-flash-lite", + defaultThinkingOptionId: "medium", + }, + ], + modes: [], + }); expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" }); }); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index eb4be8c97b..bfd5a41b8f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -31,8 +31,6 @@ import { type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, - type ListModesOptions, - type ListModelsOptions, type ProviderCatalog, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; @@ -1963,15 +1961,6 @@ export class PiRpcAgentClient implements AgentClient { } } - async listModels(options: ListModelsOptions): Promise { - const catalog = await this.fetchCatalog(options); - return catalog.models; - } - - async listModes(_options: ListModesOptions): Promise { - return []; - } - async fetchCatalog(options: FetchCatalogOptions): Promise { const runtimeSession = await this.runtime.startSession({ cwd: options.cwd }); try { diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index c8eac014e5..6c664d312a 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -2,12 +2,7 @@ import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { AgentManager } from "../agent-manager.js"; -import type { - AgentClient, - AgentSession, - AgentSessionConfig, - ListModelsOptions, -} from "../agent-sdk-types.js"; +import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; import { FakeRewindSession, REWIND_TEST_CAPABILITIES } from "./test-rewind-session.js"; class FakeRewindClient implements AgentClient { @@ -24,8 +19,8 @@ class FakeRewindClient implements AgentClient { return this.session; } - async listModels(_options: ListModelsOptions) { - return []; + async fetchCatalog(_options: FetchCatalogOptions) { + return { models: [], modes: [] }; } async isAvailable() { From 257614d181e7dc456f6805aca1bb51263f7648c7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 23 Jun 2026 15:53:27 +0700 Subject: [PATCH 4/6] refactor: remove remaining provider listModels/listModes runtime API residue Migrate remaining AgentClient/provider-client implementations and tests to fetchCatalog. Remove obsolete ListModelsOptions/ListModesOptions interfaces. Update ProviderSnapshotManager.getProviderDiagnostic to materialize clients via ensureClient(provider, definition) so diagnostics self-heal the settings sheet instead of failing when providerClients[provider] is absent. Allowed to remain: ProviderSnapshotManager.listModels/listModes as cached snapshot readers; protocol/client legacy list_provider_models names; unrelated local helper in create-agent-mode. --- .../src/server/agent/agent-sdk-types.ts | 10 ----- .../src/server/agent/mcp-parity.e2e.test.ts | 5 +-- .../agent/provider-snapshot-manager.test.ts | 22 +++++++++- .../server/agent/provider-snapshot-manager.ts | 5 +-- .../agent/providers/claude/agent.test.ts | 2 +- .../agent/providers/claude/models.test.ts | 2 +- ...codex-app-server-agent.spawn-error.test.ts | 2 +- .../agent/providers/cursor-acp-agent.test.ts | 30 ++++++++------ ...opencode-agent.list-models-timeout.test.ts | 26 ++++++++---- .../src/server/daemon-client.e2e.test.ts | 4 +- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 4 +- .../server/src/server/loop-service.test.ts | 5 +-- .../src/server/schedule/service.test.ts | 11 +++-- .../src/server/session.workspaces.test.ts | 7 +++- .../server/test-utils/fake-agent-client.ts | 41 ++++++++++++------- .../workspace-same-cwd-isolation.e2e.test.ts | 30 +++++++------- 16 files changed, 117 insertions(+), 89 deletions(-) diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 91752d2913..42546aa2c2 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -636,16 +636,6 @@ export interface AgentSession { } | null; } -export interface ListModelsOptions { - cwd: string; - force: boolean; -} - -export interface ListModesOptions { - cwd: string; - force: boolean; -} - export interface FetchCatalogOptions { cwd: string; force: boolean; diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index fcb686566d..3981413d4b 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -163,12 +163,9 @@ function createRecordingAgentClients(): Record { }, resumeSession: async (handle, overrides, launchContext) => await client.resumeSession(handle, overrides, launchContext), - listModels: async (options) => await client.fetchCatalog(options), + fetchCatalog: async (options) => await client.fetchCatalog(options), isAvailable: async () => await client.isAvailable(), }; - if (client.listModes) { - wrappedClient.listModes = async (options) => await client.listModes!(options); - } if (client.resolveCreateConfig) { wrappedClient.resolveCreateConfig = (input) => client.resolveCreateConfig!(input); } 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 82b05a05a7..5a32c59c18 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -496,10 +496,28 @@ describe("ProviderSnapshotManager public surface", () => { } }); - test("getProviderDiagnostic throws when no client is configured for the provider", async () => { + test("getProviderDiagnostic materializes the client and proceeds for an unmaterialized configured provider", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + isDev: true, + extraClients: {}, + }); + try { + const result = await manager.getProviderDiagnostic("mock"); + expect(result.provider).toBe("mock"); + expect(result.diagnostic).toContain("Models:"); + expect(result.diagnostic).toContain("Status:"); + } finally { + manager.destroy(); + } + }); + + test("getProviderDiagnostic throws for an unknown provider", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); try { - await expect(manager.getProviderDiagnostic("codex")).rejects.toThrow(/not configured/); + await expect( + manager.getProviderDiagnostic("unknown-provider" as AgentProvider), + ).rejects.toThrow(/not configured/); } finally { manager.destroy(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 9f2b8b5805..e6b6513b95 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -314,10 +314,7 @@ export class ProviderSnapshotManager { async getProviderDiagnostic(provider: AgentProvider): Promise { const definition = this.requireProvider(provider); - const client = this.providerClients[provider]; - if (!client) { - throw new Error(`Provider ${provider} is not configured`); - } + const client = this.ensureClient(provider, definition); // Force-refresh the snapshot so Models/Status come from the single catalog authority. await this.refreshSnapshotForCwd({ cwd: homedir(), providers: [provider] }); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index aaa6ed0b96..02e93388b2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -395,7 +395,7 @@ describe("convertClaudeHistoryEntry", () => { // "interrupting message should produce coherent text without garbling from race condition" // in daemon.e2e.test.ts which exercises the full flow through the WebSocket API. -describe("ClaudeAgentClient.listModels", () => { +describe("ClaudeAgentClient.fetchCatalog", () => { const logger = createTestLogger(); test("returns hardcoded claude models", async () => { diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 6a3029614d..05468a3060 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -63,7 +63,7 @@ describe("getClaudeModels", () => { }); }); -describe("ClaudeAgentClient.listModels", () => { +describe("ClaudeAgentClient.fetchCatalog", () => { it("appends concrete models from Claude settings.json", async () => { const configDir = await createClaudeConfigDir({ model: "us.anthropic.claude-opus-4-7[1m]", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index 3269e9ca73..865c6c65b2 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -6,7 +6,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js"; describe("CodexAppServerAgentClient spawn error handling", () => { const logger = createTestLogger(); - test("listModels rejects gracefully when the codex binary does not exist", async () => { + test("fetchCatalog rejects gracefully when the codex binary does not exist", async () => { const client = new CodexAppServerAgentClient(logger, { command: { mode: "replace", diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index 6d1aa34f72..c745bc52a6 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -45,17 +45,20 @@ describe("CursorACPAgentClient model discovery", () => { configOptions: [], }); - await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([ - { - provider: "acp", - id: "gpt-5.4[context=272k,reasoning=medium,fast=false]", - label: "gpt-5.4", - description: undefined, - isDefault: true, - thinkingOptions: undefined, - defaultThinkingOptionId: undefined, - }, - ]); + await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({ + models: [ + { + provider: "acp", + id: "gpt-5.4[context=272k,reasoning=medium,fast=false]", + label: "gpt-5.4", + description: undefined, + isDefault: true, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }, + ], + modes: [], + }); }); test("does not fall back to cursor-agent models when ACP reports zero models", async () => { @@ -65,6 +68,9 @@ describe("CursorACPAgentClient model discovery", () => { configOptions: [], }); - await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual([]); + await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({ + models: [], + modes: [], + }); }); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index 204896237f..6c2a08f5e4 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -45,13 +45,15 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se await vi.advanceTimersByTimeAsync(15_000); - await expect(modelsPromise).resolves.toMatchObject([ - { - provider: "opencode", - id: "zai/glm-5.1", - label: "GLM 5.1", - }, - ]); + await expect(modelsPromise).resolves.toMatchObject({ + models: [ + { + provider: "opencode", + id: "zai/glm-5.1", + label: "GLM 5.1", + }, + ], + }); expect(openCodeClient.calls.providerList).toHaveLength(1); }); @@ -161,5 +163,13 @@ test("does not throw when only api-source providers are present with no connecte await expect( client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }), - ).resolves.toHaveLength(1); + ).resolves.toMatchObject({ + models: [ + { + provider: "opencode", + id: "pi/pi-model-1", + label: "Pi Model 1", + }, + ], + }); }); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 23bfbabd32..5caabdf37f 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -473,8 +473,8 @@ class NonPersistentReloadClient implements AgentClient { }); } - async listModels() { - return []; + async fetchCatalog() { + return { models: [], modes: [] }; } } diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 8140a6517c..17aeee8e3f 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -651,12 +651,12 @@ test( ); test( - "PiRpcAgentClient.listModels returns non-empty Pi model definitions", + "PiRpcAgentClient.fetchCatalog returns non-empty Pi model definitions", async () => { const client = createPiClient(); const cwd = tmpCwd("pi-list-models-"); try { - const models = await client.listModels({ cwd, force: false }); + const { models } = await client.fetchCatalog({ cwd, force: false }); expect(models.length).toBeGreaterThan(0); for (const model of models) { diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 2be498e960..c8bbf7110f 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -26,7 +26,6 @@ import type { AgentStreamEvent, AgentSlashCommand, AgentRuntimeInfo, - ListModelsOptions, AgentProvider, } from "./agent/agent-sdk-types.js"; import { AgentStorage } from "./agent/agent-storage.js"; @@ -93,8 +92,8 @@ class ScriptedAgentClient implements AgentClient { ); } - async listModels(_options?: ListModelsOptions): Promise { - return []; + async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> { + return { models: [], modes: [] }; } } diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index dc82e27aee..1bdb220e6b 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -18,7 +18,6 @@ import type { AgentSession, AgentSessionConfig, AgentStreamEvent, - ListModelsOptions, } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; @@ -365,8 +364,8 @@ describe("ScheduleService", () => { return new PromptEchoScheduleSession(); } - async listModels(_options: ListModelsOptions): Promise { - return []; + async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> { + return { models: [], modes: [] }; } async isAvailable(): Promise { @@ -548,8 +547,8 @@ describe("ScheduleService", () => { return session; } - async listModels(_options: ListModelsOptions): Promise { - return []; + async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> { + return { models: [], modes: [] }; } async isAvailable(): Promise { @@ -659,7 +658,7 @@ describe("ScheduleService", () => { return opencodeClient.createSession(...args); }, resumeSession: (...args) => opencodeClient.resumeSession(...args), - listModels: (...args) => opencodeClient.listModels(...args), + fetchCatalog: (...args) => opencodeClient.fetchCatalog(...args), isAvailable: () => opencodeClient.isAvailable(), } satisfies AgentClient; const manager = new AgentManager({ diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index ce86391116..5ce8b27ea8 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -491,8 +491,11 @@ class CreateAgentTestClient implements AgentClient { }); } - async listModels() { - return [{ provider: this.provider, id: "gpt-test", label: "GPT Test", isDefault: true }]; + async fetchCatalog() { + return { + models: [{ provider: this.provider, id: "gpt-test", label: "GPT Test", isDefault: true }], + modes: [], + }; } async isAvailable(): Promise { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index c29eeac015..f0a9793f3d 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -19,7 +19,7 @@ import type { AgentStreamEvent, AgentSlashCommand, AgentUsage, - ListModelsOptions, + FetchCatalogOptions, } from "../agent/agent-sdk-types.js"; import type { AgentPermissionRequest, AgentPermissionResponse } from "../agent/agent-sdk-types.js"; import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization"; @@ -1185,24 +1185,35 @@ class FakeAgentClient implements AgentClient { ); } - async listModels(_options: ListModelsOptions): Promise { + async fetchCatalog( + _options: FetchCatalogOptions, + ): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> { if (this.provider === "claude") { - return [ - { provider: this.provider, id: "haiku", label: "Haiku", isDefault: true }, - { provider: this.provider, id: "sonnet", label: "Sonnet", isDefault: false }, - ]; + return { + models: [ + { provider: this.provider, id: "haiku", label: "Haiku", isDefault: true }, + { provider: this.provider, id: "sonnet", label: "Sonnet", isDefault: false }, + ], + modes: [], + }; } if (this.provider === "codex") { - return [ - { - provider: this.provider, - id: "gpt-5.4-mini", - label: "gpt-5.4-mini", - isDefault: true, - }, - ]; + return { + models: [ + { + provider: this.provider, + id: "gpt-5.4-mini", + label: "gpt-5.4-mini", + isDefault: true, + }, + ], + modes: [], + }; } - return [{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true }]; + return { + models: [{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true }], + modes: [], + }; } async isAvailable(): Promise { diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index a790ec2165..da743dd32f 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -17,7 +17,6 @@ import type { AgentPersistenceHandle, AgentSession, AgentSessionConfig, - ListModelsOptions, } from "./agent/agent-sdk-types.js"; import { createPersistedProjectRecord, @@ -70,13 +69,9 @@ class SnapshotStormProviderClient implements AgentClient { throw new Error(`${this.provider} is only used for provider snapshot tests`); } - async listModels(_options: ListModelsOptions): Promise { + async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> { await new Promise((resolve) => setTimeout(resolve, this.delayMs)); - return this.models; - } - - async listModes(): Promise { - return []; + return { models: this.models, modes: [] }; } async isAvailable(): Promise { @@ -85,15 +80,18 @@ class SnapshotStormProviderClient implements AgentClient { } class MetadataMockLoadTestAgentClient extends MockLoadTestAgentClient { - override async listModels(_options: ListModelsOptions): Promise { - return [ - { - provider: "mock", - id: "gpt-5.4-mini", - label: "GPT 5.4 Mini", - isDefault: true, - }, - ]; + override async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> { + return { + models: [ + { + provider: "mock", + id: "gpt-5.4-mini", + label: "GPT 5.4 Mini", + isDefault: true, + }, + ], + modes: [], + }; } } From b87e0e62bf403c809aa76d4777efeb8d8c67e813 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 23 Jun 2026 16:09:41 +0700 Subject: [PATCH 5/6] test(server): repair test clients after fetchCatalog refactor - Restore TestAgentClient.fetchCatalog with proper model list and resumeSession. - Restore NativeArchiveRecordingClient and EnvProbeAgentClient removed during refactor. - Fix ResumeCaptureClient.fetchCatalog and resumeSession. - Fix stream-coalescing TestAgentClient.fetchCatalog shape and isAvailable. - Mock accessible OpenCode provider in full-access mode tests so fetchCatalog does not throw. --- .../agent-manager-stream-coalescing.test.ts | 17 ++- .../src/server/agent/agent-manager.test.ts | 127 +++++++++++++++++- .../opencode-agent.full-access.test.ts | 6 + 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 44c34bddeb..6db44fcdaf 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -20,6 +20,7 @@ import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem, + ProviderCatalog, } from "./agent-sdk-types.js"; /** @@ -206,7 +207,21 @@ class TestAgentClient implements AgentClient { } async fetchCatalog(): Promise { - return { models: true, modes: [] }; + return { + models: [ + { + provider: this.provider, + id: "test-model", + label: "Test Model", + isDefault: true, + }, + ], + modes: [], + }; + } + + async isAvailable(): Promise { + return true; } getSession(cwd: string): TestAgentSession { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 045490b9f9..dd7b7210fe 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from "vitest"; +import { spawn } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -99,7 +100,102 @@ class TestAgentClient implements AgentClient { } async fetchCatalog() { - return { models: new TestAgentSession(config), modes: [] }; + return { + models: [ + { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + isDefault: true, + }, + { + provider: "codex", + id: "gpt-5.4-mini", + label: "GPT-5.4 Mini", + }, + { + provider: "codex", + id: "gpt-5.2-codex", + label: "GPT-5.2 Codex", + }, + ], + modes: [], + }; + } + + async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { + this.resumeOverrides.push(config); + return new TestAgentSession({ + provider: "codex", + cwd: config?.cwd ?? process.cwd(), + daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt, + }); + } +} + +class NativeArchiveRecordingClient extends TestAgentClient { + readonly archivedHandles: AgentPersistenceHandle[] = []; + readonly unarchivedHandles: AgentPersistenceHandle[] = []; + readArchivedAtDuringUnarchive: (() => Promise) | null = null; + archivedAtDuringUnarchive: string | null | undefined; + unarchiveFailure: Error | null = null; + + async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + this.archivedHandles.push(handle); + } + + async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + this.unarchivedHandles.push(handle); + if (this.readArchivedAtDuringUnarchive) { + this.archivedAtDuringUnarchive = await this.readArchivedAtDuringUnarchive(); + } + if (this.unarchiveFailure) { + throw this.unarchiveFailure; + } + } +} + +class EnvProbeAgentClient extends TestAgentClient { + probe: Promise<{ probe: string | null; agentId: string | null }> | null = null; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + const script = ` + process.stdout.write(JSON.stringify({ + probe: process.env.CHUNK14_PROBE ?? null, + agentId: process.env.PASEO_AGENT_ID ?? null + })); + `; + const child = spawn(process.execPath, ["-e", script], { + cwd: config.cwd, + env: { ...process.env, ...launchContext?.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + this.probe = new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`env probe exited ${code}: ${stderr}`)); + return; + } + resolve(JSON.parse(stdout) as { probe: string | null; agentId: string | null }); + }); + }); + return new TestAgentSession(config); } } @@ -1365,7 +1461,34 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p } async fetchCatalog() { - return { models: new TestAgentSession(merged), modes: [] }; + return { + models: [ + { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + isDefault: true, + }, + ], + modes: [], + }; + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastResumeOverrides = overrides; + this.lastResumeLaunchContext = launchContext; + const metadata = (handle.metadata ?? {}) as Partial; + const merged: AgentSessionConfig = { + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }; + return new TestAgentSession(merged); } } diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 6732b2627a..6d8876444f 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -19,6 +19,12 @@ function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) { const openCodeClient = new TestOpenCodeClient(); openCodeClient.appAgentsResponse = { data: options.agents ?? [] }; openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()]; + openCodeClient.providerListResponse = { + data: { + connected: ["openai"], + all: [{ id: "openai", source: "env", models: {} }], + }, + }; runtime.enqueueClient(openCodeClient); return { openCodeClient, runtime }; From 7af48ada4658086709603e3075b1ea77bb646018 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 23 Jun 2026 16:17:08 +0700 Subject: [PATCH 6/6] refactor(app): update stable discovered models ref directly during render --- packages/app/src/components/provider-diagnostic-sheet.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index c805098456..61bc4b04ff 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -601,11 +601,9 @@ export function ProviderDiagnosticSheet({ const stableDiscoveredRef = useRef([]); const currentModels = providerEntry?.models; - useEffect(() => { - if (currentModels && currentModels.length > 0) { - stableDiscoveredRef.current = currentModels; - } - }, [currentModels]); + if (currentModels && currentModels.length > 0) { + stableDiscoveredRef.current = currentModels; + } const discoveredModels = useMemo(() => { if (currentModels && currentModels.length > 0) {