Keep provider diagnostics and model lists in sync (#1660)

* fix(server): align provider catalog diagnostics

* fix(server): preserve catalog profile models

* refactor: unify provider catalog discovery under AgentClient.fetchCatalog

Remove listModels/listModes from AgentClient and fetchModels/fetchModes
from ProviderDefinition. All provider runtime discovery now flows through
a single fetchCatalog(options) => ProviderCatalog API.

ProviderSnapshotManager.listModels/listModes remain as cached snapshot
conveniences only. Provider implementations (acp, codex, opencode, pi,
claude, mock) updated accordingly; agent-manager default model resolution
now calls fetchCatalog.

Reshape step toward issue pi-model-list-empty.

* refactor: remove remaining provider listModels/listModes runtime API residue

Migrate remaining AgentClient/provider-client implementations and tests to
fetchCatalog. Remove obsolete ListModelsOptions/ListModesOptions interfaces.
Update ProviderSnapshotManager.getProviderDiagnostic to materialize clients
via ensureClient(provider, definition) so diagnostics self-heal the settings
sheet instead of failing when providerClients[provider] is absent.

Allowed to remain: ProviderSnapshotManager.listModels/listModes as cached
snapshot readers; protocol/client legacy list_provider_models names; unrelated
local helper in create-agent-mode.

* test(server): repair test clients after fetchCatalog refactor

- Restore TestAgentClient.fetchCatalog with proper model list and resumeSession.
- Restore NativeArchiveRecordingClient and EnvProbeAgentClient removed during refactor.
- Fix ResumeCaptureClient.fetchCatalog and resumeSession.
- Fix stream-coalescing TestAgentClient.fetchCatalog shape and isAvailable.
- Mock accessible OpenCode provider in full-access mode tests so fetchCatalog does not throw.

* refactor(app): update stable discovered models ref directly during render
This commit is contained in:
Mohamed Boudra
2026-06-23 17:55:47 +08:00
committed by GitHub
parent a397d411dd
commit 1970a14349
43 changed files with 782 additions and 978 deletions

View File

@@ -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<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
isAvailable(): Promise<boolean>;
// Optional:
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
listImportableSessions?(
listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
importSession?(
importSession(
input: ImportProviderSessionInput,
context: ImportProviderSessionContext,
): Promise<ImportedProviderSession>;

View File

@@ -600,13 +600,20 @@ export function ProviderDiagnosticSheet({
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
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(() => {

View File

@@ -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<AgentModelDefinition[]> {
return [
{
provider: this.provider,
id: "test-model",
label: "Test Model",
isDefault: true,
},
];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: [
{
provider: this.provider,
id: "test-model",
label: "Test Model",
isDefault: true,
},
],
modes: [],
};
}
async isAvailable(): Promise<boolean> {

View File

@@ -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() {

View File

@@ -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;
}

View File

@@ -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<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession>;
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
/**
* 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<ProviderCatalog>;
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;

View File

@@ -163,12 +163,9 @@ function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
},
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);
}

View File

@@ -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<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -125,12 +129,11 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -176,12 +179,11 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -228,12 +230,11 @@ vi.mock("./providers/pi/agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -299,12 +300,11 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -353,12 +353,11 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
@@ -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<AgentClient> 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<AgentClient> 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"]);
});
});

View File

@@ -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<AgentModelDefinition[]>;
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
/**
* 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<ProviderCatalog>;
}
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),
};
},
};
}

View File

@@ -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),

View File

@@ -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<ProviderDiagnosticResult> {
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";
}

View File

@@ -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<void> {}
}
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<SpawnedACPProcess> {
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);

View File

@@ -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<AgentModelDefinition[]> {
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
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<AgentMode[]> {
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);
}

View File

@@ -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);
};

View File

@@ -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<AgentModelDefinition[]> {
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
// 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<AgentFeature[]> {
@@ -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) {

View File

@@ -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),

View File

@@ -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({

View File

@@ -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);

View File

@@ -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<AgentModelDefinition[]> {
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
const models = await this.fetchModelsFromAppServer();
return { models, modes: CODEX_MODES };
}
private async fetchModelsFromAppServer(): Promise<AgentModelDefinition[]> {
// 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),

View File

@@ -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) {

View File

@@ -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: [],
});
});
});

View File

@@ -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<SpawnedACPProcess> {
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<void> {}
}
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<SpawnedACPProcess> {
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<void> {}
}
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<SpawnedACPProcess> {
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:");
});
});

View File

@@ -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<ACPDiagnosticProbeResult> {
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<ACPDiagnosticProbeResult, "models" | "modes"> {
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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}

View File

@@ -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,

View File

@@ -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<AgentModelDefinition[]> {
return MODELS;
}
async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
return {
models: MODELS,
modes: getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes,
};
}
async listImportableSessions(): Promise<ImportableProviderSession[]> {

View File

@@ -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<AgentModelDefinition[]> {
return neverResolves<AgentModelDefinition[]>();
}
listModes(_options: ListModesOptions): Promise<AgentMode[]> {
return neverResolves<AgentMode[]>();
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
return neverResolves<ProviderCatalog>();
}
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.",
};
}

View File

@@ -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"]);
});

View File

@@ -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",
},
],
});
});

View File

@@ -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 }),
),
);

View File

@@ -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<AgentModelDefinition[]> {
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<AgentMode[]> {
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
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<AgentModelDefinition[]> {
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<AgentMode[]> {
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}'`);

View File

@@ -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" });
});

View File

@@ -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<AgentModelDefinition[]> {
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
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<AgentMode[]> {
return [];
}
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
@@ -1999,30 +1996,9 @@ export class PiRpcAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> {
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) {

View File

@@ -153,8 +153,10 @@ class PiCliRuntimeSession implements PiRuntimeSession {
return data.messages ?? [];
}
async getAvailableModels(): Promise<PiModel[]> {
const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] };
async getAvailableModels(timeoutMs?: number): Promise<PiModel[]> {
const data = (await this.request({ type: "get_available_models" }, timeoutMs)) as {
models?: PiModel[];
};
return data.models ?? [];
}

View File

@@ -42,7 +42,7 @@ export interface PiRuntimeSession {
abort(): Promise<void>;
getState(): Promise<PiSessionState>;
getMessages(): Promise<PiAgentMessage[]>;
getAvailableModels(): Promise<PiModel[]>;
getAvailableModels(timeoutMs?: number): Promise<PiModel[]>;
setModel(provider: string, modelId: string): Promise<PiModel>;
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;

View File

@@ -138,7 +138,7 @@ export class FakePiSession implements PiRuntimeSession {
return this.messages;
}
async getAvailableModels(): Promise<PiModel[]> {
async getAvailableModels(_timeoutMs?: number): Promise<PiModel[]> {
return this.models;
}

View File

@@ -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() {

View File

@@ -473,8 +473,8 @@ class NonPersistentReloadClient implements AgentClient {
});
}
async listModels() {
return [];
async fetchCatalog() {
return { models: [], modes: [] };
}
}

View File

@@ -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) {

View File

@@ -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<AgentModelDefinition[]> {
return [];
async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> {
return { models: [], modes: [] };
}
}

View File

@@ -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<AgentModelDefinition[]> {
return [];
async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> {
return { models: [], modes: [] };
}
async isAvailable(): Promise<boolean> {
@@ -548,8 +547,8 @@ describe("ScheduleService", () => {
return session;
}
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return [];
async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> {
return { models: [], modes: [] };
}
async isAvailable(): Promise<boolean> {
@@ -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({

View File

@@ -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<boolean> {

View File

@@ -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<AgentModelDefinition[]> {
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<boolean> {

View File

@@ -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<AgentModelDefinition[]> {
async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> {
await new Promise((resolve) => setTimeout(resolve, this.delayMs));
return this.models;
}
async listModes(): Promise<AgentMode[]> {
return [];
return { models: this.models, modes: [] };
}
async isAvailable(): Promise<boolean> {
@@ -85,15 +80,18 @@ class SnapshotStormProviderClient implements AgentClient {
}
class MetadataMockLoadTestAgentClient extends MockLoadTestAgentClient {
override async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
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: [],
};
}
}