mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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>;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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}'`);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -473,8 +473,8 @@ class NonPersistentReloadClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listModels() {
|
||||
return [];
|
||||
async fetchCatalog() {
|
||||
return { models: [], modes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user