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/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 3172ebf558..61bc4b04ff 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -600,13 +600,20 @@ export function ProviderDiagnosticSheet({ const modelsRefreshing = isRefreshing || providerSnapshotRefreshing; const stableDiscoveredRef = useRef([]); - if (providerEntry?.models && providerEntry.models.length > 0) { - stableDiscoveredRef.current = providerEntry.models; + const currentModels = providerEntry?.models; + if (currentModels && currentModels.length > 0) { + stableDiscoveredRef.current = currentModels; } - const discoveredModels = - providerEntry?.models && providerEntry.models.length > 0 - ? providerEntry.models - : stableDiscoveredRef.current; + + 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-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 1b4b01b8a0..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 @@ -11,7 +11,6 @@ import type { AgentCapabilityFlags, AgentClient, AgentLaunchContext, - AgentModelDefinition, AgentPersistenceHandle, AgentPromptInput, AgentProvider, @@ -21,6 +20,7 @@ import type { AgentSessionConfig, AgentStreamEvent, AgentTimelineItem, + ProviderCatalog, } from "./agent-sdk-types.js"; /** @@ -206,15 +206,18 @@ class TestAgentClient implements AgentClient { return this.createSession(resolvedConfig); } - async listModels(): Promise { - return [ - { - provider: this.provider, - id: "test-model", - label: "Test Model", - isDefault: true, - }, - ]; + async fetchCatalog(): Promise { + return { + models: [ + { + provider: this.provider, + id: "test-model", + label: "Test Model", + isDefault: true, + }, + ], + modes: [], + }; } async isAvailable(): Promise { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 502edc47f0..dd7b7210fe 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -99,25 +99,28 @@ 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 fetchCatalog() { + 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( @@ -667,8 +670,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,15 +1460,18 @@ 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 fetchCatalog() { + return { + models: [ + { + provider: "codex", + id: "gpt-5.4", + label: "GPT-5.4", + isDefault: true, + }, + ], + modes: [], + }; } async resumeSession( @@ -5976,8 +5985,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 e411332b7f..42546aa2c2 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -636,14 +636,14 @@ export interface AgentSession { } | null; } -export interface ListModelsOptions { +export interface FetchCatalogOptions { cwd: string; force: boolean; } -export interface ListModesOptions { - cwd: string; - force: boolean; +export interface ProviderCatalog { + models: AgentModelDefinition[]; + modes: AgentMode[]; } export interface AgentClient { @@ -659,8 +659,13 @@ export interface AgentClient { overrides?: Partial, launchContext?: AgentLaunchContext, ): Promise; - listModels(options: ListModelsOptions): Promise; - listModes?(options: ListModesOptions): Promise; + /** + * 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; 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..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.listModels(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-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index b31ccd73f0..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 { AgentModelDefinition } 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, }); @@ -1286,3 +1285,111 @@ describe("model merging", () => { expect(models.find((model) => model.isDefault)?.id).toBe("MiniMax-M3"); }); }); + +describe("fetchCatalog", () => { + test("returns merged models and modes from fetchCatalog", 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: {}, + fetchCatalog: vi.fn(async () => ({ models: injectedModels, modes: 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.fetchCatalog).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" }], + })), + 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(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..55e0ce3941 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -10,8 +10,8 @@ import type { AgentRuntimeInfo, AgentSession, AgentStreamEvent, - ListModelsOptions, - ListModesOptions, + FetchCatalogOptions, + ProviderCatalog, ResolveAgentCreateConfigInput, ResolveAgentCreateConfigResult, } from "./agent-sdk-types.js"; @@ -64,8 +64,11 @@ 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. + */ + fetchCatalog: (options: FetchCatalogOptions, client?: AgentClient) => Promise; } export interface BuildProviderRegistryOptions { @@ -424,11 +427,15 @@ function wrapClientProvider( launchContext, ), ), - listModels: async (options) => - mergeModels(provider, profileModels, additionalModels, await inner.listModels(options), { - profileModelsAreAdditive, - }), - listModes: inner.listModes?.bind(inner), + 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 @@ -473,6 +480,24 @@ 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 hasStaticModes = resolved.definition.modes.length > 0; return { ...resolved.definition, @@ -483,29 +508,36 @@ 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, - }); - }); + 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 via + // the single catalog API; otherwise use static/empty modes with no runtime. + const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels); + if (hasStaticModes) { + return { + models, + modes: decorateModes(resolved.definition.modes), + }; + } + const catalog = await catalogClient.fetchCatalog(options); + return { models, modes: decorateModes(catalog.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 5453b92b0d..5a32c59c18 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(); } @@ -431,7 +434,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 +443,44 @@ 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 client = createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog, + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { codex: client }, + }); + try { + const result = await manager.getProviderDiagnostic("codex"); + expect(fetchCatalog).toHaveBeenCalledTimes(1); + 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,15 +489,35 @@ 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(); } }); - 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(); } @@ -509,8 +562,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); @@ -524,8 +577,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"; @@ -587,8 +640,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); @@ -646,8 +699,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 054fcd8063..e6b6513b95 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,23 @@ export class ProviderSnapshotManager { } async getProviderDiagnostic(provider: AgentProvider): Promise { - const client = this.providerClients[provider]; - if (!client) { - throw new Error(`Provider ${provider} is not configured`); - } - const diagnostic = client.getDiagnostic + const definition = this.requireProvider(provider); + 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] }); + 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 }; } @@ -390,8 +401,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), }; } @@ -644,11 +654,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 +664,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 +813,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.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 662acf3005..aafe5f3a27 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -81,13 +81,13 @@ import { type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, + type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, - type ListModesOptions, - type ListModelsOptions, type McpServerConfig, + type ProviderCatalog, type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; @@ -712,7 +712,7 @@ export class ACPAgentClient implements AgentClient { return session; } - async listModels(options: ListModelsOptions): Promise { + async fetchCatalog(options: FetchCatalogOptions): Promise { const { cwd } = options; const probe = await this.spawnProcess(PROBE_ENV); try { @@ -728,29 +728,15 @@ export class ACPAgentClient implements AgentClient { 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; + return { + models: this.modelTransformer ? this.modelTransformer(models) : models, + modes: modeInfo.modes, + }; } finally { await this.closeProbe(probe); } 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..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 () => { @@ -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 16d07e336f..a7b9bdad52 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"; @@ -59,7 +57,6 @@ import { type AgentLaunchContext, type AgentMetadata, type AgentMode, - type AgentModelDefinition, type AgentPermissionRequest, type AgentPermissionRequestKind, type AgentPermissionResponse, @@ -76,12 +73,13 @@ 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 { @@ -1421,9 +1419,10 @@ export class ClaudeAgentClient implements AgentClient { }); } - async listModels(_options: ListModelsOptions): Promise { + async fetchCatalog(_options: FetchCatalogOptions): Promise { // Claude exposes a global catalog here; cwd/force are intentionally irrelevant. - return await getClaudeModelsWithSettings(this.logger, this.configDir); + const models = await getClaudeModelsWithSettings(this.logger, this.configDir); + return { models, modes: DEFAULT_MODES }; } async listFeatures(config: AgentSessionConfig): Promise { @@ -1477,28 +1476,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 +1487,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/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 864e414f6f..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]", @@ -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..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", @@ -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 0be6713f29..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 @@ -26,15 +26,15 @@ 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"; -import { homedir } from "node:os"; import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -84,13 +84,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"; @@ -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); @@ -5645,34 +5648,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/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index b2da3360c9..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.listModels({ 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.listModels({ 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/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/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..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 }; @@ -72,7 +78,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 +87,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..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 @@ -41,17 +41,19 @@ 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); - 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); }); @@ -68,7 +70,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 +101,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 +134,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 +162,14 @@ 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 }), - ).resolves.toHaveLength(1); + client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }), + ).resolves.toMatchObject({ + models: [ + { + provider: "opencode", + id: "pi/pi-model-1", + label: "Pi Model 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 24d35efaba..aec31ce7cc 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,15 +37,15 @@ import { type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, + type FetchCatalogOptions, type ImportableProviderSession, type ImportProviderSessionContext, type ImportProviderSessionInput, type ListImportableSessionsOptions, type ResolveAgentCreateConfigInput, type ResolveAgentCreateConfigResult, - type ListModelsOptions, - type ListModesOptions, type McpServerConfig, + type ProviderCatalog, type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; @@ -67,7 +66,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, @@ -1362,101 +1360,18 @@ 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 { - // 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; - } finally { - acquisition.release(); - } - } - - async listModes(options: ListModesOptions): Promise { + 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 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); + const [models, modes] = await Promise.all([ + this.fetchModelsFromClient(client, directory), + this.fetchModesFromClient(client, directory), + ]); + return { models, modes }; } finally { acquisition.release(); } @@ -1555,17 +1470,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 +1492,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 +1507,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.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 1acadf96ea..bfd5a41b8f 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,12 @@ 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 +46,6 @@ import { composeSystemPromptParts } from "../../system-prompt.js"; import { buildBinaryDiagnosticRows, buildCommandResolutionDiagnosticRows, - formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, toDiagnosticErrorMessage, @@ -85,6 +82,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"; @@ -1963,19 +1961,18 @@ export class PiRpcAgentClient implements AgentClient { } } - async listModels(options: ListModelsOptions): Promise { + 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 +1996,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 +2008,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 +2016,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; } 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() { 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: [], + }; } }