feat: enable/disable providers from settings
Adds a per-provider toggle in Settings → Providers, persisted via daemon config, so users can turn off providers they don't have installed (or don't want to use) without removing them from the registry. Disabled providers stay visible everywhere — settings, `paseo provider ls`, MCP `list_providers`, model/mode lists — marked as disabled rather than silently filtered. Availability probes are skipped for them, and attempts to launch an agent or fetch models/modes from a disabled provider return a clear "Provider '<id>' is disabled" error. Schema additions (`enabled` on `ProviderSnapshotEntry`, MCP provider summary) are backward-compatible: optional with a default of true so older clients continue to parse new daemon messages. CLI: `paseo provider ls` gains an ENABLED column; JSON output includes the new field.
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentProvider,
|
||||
AgentPersistenceHandle,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
@@ -18,6 +19,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -842,6 +844,119 @@ test("createAgent reports available providers when selected provider is unavaila
|
||||
);
|
||||
});
|
||||
|
||||
test("createAgent rejects a disabled provider without creating a session", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class DisabledCodexClient extends TestAgentClient {
|
||||
createSessionCalls = 0;
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.createSessionCalls += 1;
|
||||
return await super.createSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const disabledClient = new DisabledCodexClient();
|
||||
const providerDefinitions = {
|
||||
codex: {
|
||||
enabled: false,
|
||||
},
|
||||
} satisfies Partial<Record<AgentProvider, Pick<ProviderDefinition, "enabled">>>;
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: disabledClient,
|
||||
},
|
||||
providerDefinitions,
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
}),
|
||||
).rejects.toThrow("Provider 'codex' is disabled");
|
||||
expect(disabledClient.createSessionCalls).toBe(0);
|
||||
expect(await storage.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("updateProviderRegistry re-enables a previously disabled provider", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
providerDefinitions: {
|
||||
codex: { enabled: false },
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
|
||||
manager.updateProviderRegistry({
|
||||
providerDefinitions: { codex: { enabled: true } },
|
||||
clients: { codex: client },
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
expect(snapshot.config.provider).toBe("codex");
|
||||
});
|
||||
|
||||
test("updateProviderRegistry disables a previously enabled provider", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
providerDefinitions: {
|
||||
codex: { enabled: true },
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
manager.updateProviderRegistry({
|
||||
providerDefinitions: { codex: { enabled: false } },
|
||||
clients: { codex: client },
|
||||
});
|
||||
|
||||
await expect(manager.createAgent({ provider: "codex", cwd: workdir })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
});
|
||||
|
||||
test("updateProviderRegistry registers a previously unknown provider", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {},
|
||||
providerDefinitions: {},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(manager.getRegisteredProviderIds()).not.toContain("codex");
|
||||
|
||||
manager.updateProviderRegistry({
|
||||
providerDefinitions: { codex: { enabled: true } },
|
||||
clients: { codex: new TestAgentClient() },
|
||||
});
|
||||
|
||||
expect(manager.getRegisteredProviderIds()).toContain("codex");
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
expect(snapshot.config.provider).toBe("codex");
|
||||
});
|
||||
|
||||
test("createAgent passes explicit model strings through to the provider", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -113,8 +113,15 @@ interface AgentManagerRescueTimeouts {
|
||||
interruptSessionMs?: number;
|
||||
}
|
||||
|
||||
interface ProviderEnabledFlag {
|
||||
enabled: boolean;
|
||||
}
|
||||
type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
|
||||
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
||||
|
||||
export interface AgentManagerOptions {
|
||||
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
clients?: ProviderClientMap;
|
||||
providerDefinitions?: ProviderEnabledMap;
|
||||
idFactory?: () => string;
|
||||
registry?: AgentStorage;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
@@ -387,6 +394,7 @@ function buildExplicitTimelineSeedForRegister(
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
@@ -425,19 +433,32 @@ export class AgentManager {
|
||||
this.notifyForegroundTurnWaiters(agentId, event);
|
||||
},
|
||||
});
|
||||
if (options?.clients) {
|
||||
for (const [provider, client] of Object.entries(options.clients)) {
|
||||
if (client) {
|
||||
this.registerClient(provider as AgentProvider, client);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.updateProviderRegistry({
|
||||
providerDefinitions: options.providerDefinitions ?? {},
|
||||
clients: options.clients ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
registerClient(provider: AgentProvider, client: AgentClient): void {
|
||||
this.clients.set(provider, client);
|
||||
}
|
||||
|
||||
updateProviderRegistry(input: {
|
||||
providerDefinitions: ProviderEnabledMap;
|
||||
clients: ProviderClientMap;
|
||||
}): void {
|
||||
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
|
||||
if (definition) {
|
||||
this.providerEnabled.set(provider, definition.enabled);
|
||||
}
|
||||
}
|
||||
for (const [provider, client] of Object.entries(input.clients)) {
|
||||
if (client) {
|
||||
this.clients.set(provider, client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRegisteredProviderIds(): AgentProvider[] {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
@@ -718,6 +739,7 @@ export class AgentManager {
|
||||
...config.mcpServers,
|
||||
},
|
||||
};
|
||||
this.requireEnabledProvider(injectedConfig.provider);
|
||||
const normalizedConfig = await this.normalizeConfig(injectedConfig);
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const client = await this.requireAvailableClient({
|
||||
@@ -3082,7 +3104,7 @@ export class AgentManager {
|
||||
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
|
||||
const client = this.clients.get(options.provider);
|
||||
if (!client) {
|
||||
const configuredProviders = Array.from(this.clients.keys());
|
||||
const configuredProviders = this.getConfiguredProviderIds();
|
||||
throw new Error(
|
||||
`Unknown provider '${options.provider}'. Configured providers: ${formatProviderList(
|
||||
configuredProviders,
|
||||
@@ -3110,6 +3132,16 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
private requireEnabledProvider(provider: AgentProvider): void {
|
||||
if (this.providerEnabled.get(provider) === false) {
|
||||
throw new Error(`Provider '${provider}' is disabled`);
|
||||
}
|
||||
}
|
||||
|
||||
private getConfiguredProviderIds(): AgentProvider[] {
|
||||
return Array.from(new Set([...this.providerEnabled.keys(), ...this.clients.keys()]));
|
||||
}
|
||||
|
||||
private requireClient(provider: AgentProvider): AgentClient {
|
||||
const client = this.clients.get(provider);
|
||||
if (!client) {
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface AgentSelectOption {
|
||||
export interface ProviderSnapshotEntry {
|
||||
provider: AgentProvider;
|
||||
status: ProviderStatus;
|
||||
enabled: boolean;
|
||||
error?: string;
|
||||
models?: AgentModelDefinition[];
|
||||
modes?: AgentMode[];
|
||||
|
||||
@@ -146,6 +146,7 @@ function createProviderDefinition(overrides: Partial<ProviderDefinition>): Provi
|
||||
id: provider,
|
||||
label: "Claude",
|
||||
description: "Test provider",
|
||||
enabled: true,
|
||||
defaultModeId: "default",
|
||||
modes: [],
|
||||
createClient: vi.fn(() => ({
|
||||
@@ -1037,6 +1038,8 @@ describe("provider listing MCP tool", () => {
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Test provider",
|
||||
enabled: true,
|
||||
status: "available",
|
||||
modes: [{ id: "default", label: "Default", description: "Built-in mode" }],
|
||||
},
|
||||
@@ -1044,11 +1047,116 @@ describe("provider listing MCP tool", () => {
|
||||
id: "zai",
|
||||
label: "ZAI",
|
||||
status: "available",
|
||||
description: "Custom Claude profile",
|
||||
enabled: true,
|
||||
modes: [{ id: "default", label: "Default", description: "Custom mode" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns disabled providers with metadata without checking availability", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const baseProvider = createProviderDefinition({ id: "codex" });
|
||||
const client = baseProvider.createClient(logger);
|
||||
const isAvailable = vi.fn().mockResolvedValue(true);
|
||||
const createClient = vi.fn(() => ({ ...client, isAvailable }));
|
||||
const providerRegistry = {
|
||||
codex: createProviderDefinition({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
enabled: false,
|
||||
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
|
||||
createClient,
|
||||
}),
|
||||
};
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerRegistry,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.callback({});
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "OpenAI coding agent",
|
||||
enabled: false,
|
||||
status: "unavailable",
|
||||
modes: [{ id: "read-only", label: "Read Only", description: "No edits" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(isAvailable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks availability for enabled providers", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const baseProvider = createProviderDefinition({ id: "claude" });
|
||||
const client = baseProvider.createClient(logger);
|
||||
const isAvailable = vi.fn().mockResolvedValue(true);
|
||||
const providerRegistry = {
|
||||
claude: createProviderDefinition({
|
||||
createClient: vi.fn(() => ({ ...client, isAvailable })),
|
||||
}),
|
||||
};
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerRegistry,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
|
||||
await tool.callback({});
|
||||
|
||||
expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1);
|
||||
expect(isAvailable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("model listing MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("rejects disabled providers without fetching models", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const fetchModels = vi.fn().mockResolvedValue([
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "GPT-5.4",
|
||||
},
|
||||
]);
|
||||
const providerRegistry = {
|
||||
codex: createProviderDefinition({
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
enabled: false,
|
||||
fetchModels,
|
||||
}),
|
||||
};
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerRegistry,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "list_models");
|
||||
|
||||
await expect(tool.callback({ provider: "codex" })).rejects.toThrow(
|
||||
"Provider 'codex' is disabled",
|
||||
);
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("speak MCP tool", () => {
|
||||
|
||||
@@ -169,6 +169,43 @@ function resolveRegisteredProviderIds(
|
||||
: agentManager.getRegisteredProviderIds();
|
||||
}
|
||||
|
||||
interface ProviderSummary {
|
||||
id: AgentProvider;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
modes: ProviderDefinition["modes"];
|
||||
status: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function resolveProviderSummary(
|
||||
provider: ProviderDefinition,
|
||||
logger: Logger,
|
||||
): Promise<ProviderSummary> {
|
||||
const base = {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
description: provider.description,
|
||||
modes: provider.modes,
|
||||
};
|
||||
if (!provider.enabled) {
|
||||
return { ...base, enabled: false, status: "unavailable" };
|
||||
}
|
||||
try {
|
||||
const available = await provider.createClient(logger).isAvailable();
|
||||
return { ...base, enabled: true, status: available ? "available" : "unavailable" };
|
||||
} catch (availabilityError) {
|
||||
return {
|
||||
...base,
|
||||
enabled: true,
|
||||
status: "unavailable",
|
||||
error:
|
||||
availabilityError instanceof Error ? availabilityError.message : String(availabilityError),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number {
|
||||
const attentionDelta =
|
||||
Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false);
|
||||
@@ -1650,35 +1687,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
async () => {
|
||||
const providers = await Promise.all(
|
||||
Object.values(providerRegistry ?? {}).map(async (provider) => {
|
||||
let status = "unknown";
|
||||
let error: string | undefined;
|
||||
try {
|
||||
status = (await provider.createClient(childLogger).isAvailable())
|
||||
? "available"
|
||||
: "unavailable";
|
||||
} catch (availabilityError) {
|
||||
status = "unavailable";
|
||||
error =
|
||||
availabilityError instanceof Error
|
||||
? availabilityError.message
|
||||
: String(availabilityError);
|
||||
}
|
||||
const summary = {
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
status,
|
||||
modes: provider.modes.map((mode) => ({
|
||||
id: mode.id,
|
||||
label: mode.label,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
};
|
||||
if (error) {
|
||||
return Object.assign(summary, { error });
|
||||
}
|
||||
return summary;
|
||||
}),
|
||||
Object.values(providerRegistry ?? {}).map((provider) =>
|
||||
resolveProviderSummary(provider, childLogger),
|
||||
),
|
||||
);
|
||||
return {
|
||||
content: [],
|
||||
@@ -1709,6 +1720,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (!definition) {
|
||||
throw new Error(`Provider ${provider} is not configured`);
|
||||
}
|
||||
if (!definition.enabled) {
|
||||
throw new Error(`Provider '${provider}' is disabled`);
|
||||
}
|
||||
|
||||
const models = await definition.fetchModels({ cwd: resolveSnapshotCwd(), force: false });
|
||||
return {
|
||||
|
||||
@@ -32,6 +32,8 @@ export const ProviderSummarySchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
label: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
modes: z.array(ProviderModeSchema).nullish(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveAgentModel } from "./model-resolver.js";
|
||||
|
||||
vi.mock("./provider-registry.js", () => ({
|
||||
buildProviderRegistry: vi.fn(),
|
||||
isProviderEnabled: vi.fn((definition: { enabled: boolean }) => definition.enabled === true),
|
||||
}));
|
||||
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
@@ -21,6 +22,12 @@ describe("resolveAgentModel", () => {
|
||||
});
|
||||
|
||||
it("returns the trimmed requested model when provided", async () => {
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels: vi.fn() },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({
|
||||
provider: "codex",
|
||||
requestedModel: " gpt-5.1 ",
|
||||
@@ -29,7 +36,7 @@ describe("resolveAgentModel", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe("gpt-5.1");
|
||||
expect(mockedBuildProviderRegistry).not.toHaveBeenCalled();
|
||||
expect(mockedBuildProviderRegistry).toHaveBeenCalledWith(testLogger);
|
||||
});
|
||||
|
||||
it("uses the default model from the provider catalog when no model specified", async () => {
|
||||
@@ -38,9 +45,9 @@ describe("resolveAgentModel", () => {
|
||||
{ id: "claude-3.5-sonnet", isDefault: true },
|
||||
]);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { fetchModels },
|
||||
codex: { fetchModels: vi.fn() },
|
||||
opencode: { fetchModels: vi.fn() },
|
||||
claude: { enabled: true, fetchModels },
|
||||
codex: { enabled: true, fetchModels: vi.fn() },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({
|
||||
@@ -62,9 +69,9 @@ describe("resolveAgentModel", () => {
|
||||
{ id: "model-b", isDefault: false },
|
||||
]);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { fetchModels: vi.fn() },
|
||||
codex: { fetchModels },
|
||||
opencode: { fetchModels: vi.fn() },
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
@@ -75,9 +82,9 @@ describe("resolveAgentModel", () => {
|
||||
it("returns undefined when the catalog lookup fails", async () => {
|
||||
const fetchModels = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { fetchModels: vi.fn() },
|
||||
codex: { fetchModels },
|
||||
opencode: { fetchModels: vi.fn() },
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: true, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
@@ -85,4 +92,38 @@ describe("resolveAgentModel", () => {
|
||||
expect(result).toBeUndefined();
|
||||
expect(testLoggerWarn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined for a disabled provider without fetching default models", async () => {
|
||||
const fetchModels = vi.fn().mockResolvedValue([{ id: "model-a", isDefault: true }]);
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: false, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({ provider: "codex", logger: testLogger });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
expect(testLoggerWarn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined for a requested model from a disabled provider", async () => {
|
||||
const fetchModels = vi.fn();
|
||||
mockedBuildProviderRegistry.mockReturnValue({
|
||||
claude: { enabled: true, fetchModels: vi.fn() },
|
||||
codex: { enabled: false, fetchModels },
|
||||
opencode: { enabled: true, fetchModels: vi.fn() },
|
||||
} as unknown as ProviderRegistryMock);
|
||||
|
||||
const result = await resolveAgentModel({
|
||||
provider: "codex",
|
||||
requestedModel: "gpt-5.1",
|
||||
logger: testLogger,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
expect(testLoggerWarn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,14 +14,19 @@ interface ResolveAgentModelOptions {
|
||||
export async function resolveAgentModel(
|
||||
options: ResolveAgentModelOptions,
|
||||
): Promise<string | undefined> {
|
||||
const trimmed = options.requestedModel?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
try {
|
||||
const providerRegistry = buildProviderRegistry(options.logger);
|
||||
const models = await providerRegistry[options.provider].fetchModels({
|
||||
const providerDefinition = providerRegistry[options.provider];
|
||||
if (!providerDefinition.enabled) {
|
||||
throw new Error(`Provider '${options.provider}' is disabled`);
|
||||
}
|
||||
|
||||
const trimmed = options.requestedModel?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const models = await providerDefinition.fetchModels({
|
||||
cwd: resolveSnapshotCwd(options.cwd ? expandTilde(options.cwd) : undefined),
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -317,7 +317,11 @@ vi.mock("./providers/generic-acp-agent.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry } from "./provider-registry.js";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
buildProviderRegistry,
|
||||
createAllClients,
|
||||
} from "./provider-registry.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -458,7 +462,7 @@ test("custom provider without label throws", () => {
|
||||
).toThrowError("Custom provider 'zai' requires a label");
|
||||
});
|
||||
|
||||
test("enabled: false excludes provider from registry", () => {
|
||||
test("enabled: false keeps provider metadata in registry", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
@@ -467,7 +471,31 @@ test("enabled: false excludes provider from registry", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.claude).toBeUndefined();
|
||||
expect(registry.claude).toMatchObject({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
|
||||
defaultModeId: "default",
|
||||
enabled: false,
|
||||
});
|
||||
expect(registry.claude.modes).toEqual(
|
||||
AGENT_PROVIDER_DEFINITIONS.find((definition) => definition.id === "claude")?.modes,
|
||||
);
|
||||
expect(registry.codex.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("enabled: false still produces a client (enabled gate is enforced elsewhere)", () => {
|
||||
const clients = createAllClients(logger, {
|
||||
providerOverrides: {
|
||||
claude: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(clients.claude).toBeDefined();
|
||||
expect(mockState.constructorArgs.claude.length).toBeGreaterThan(0);
|
||||
expect(clients.codex).toBeDefined();
|
||||
});
|
||||
|
||||
test("provider override command can be PATH-resolved and still report available", async () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ export type { AgentProviderDefinition };
|
||||
export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
|
||||
|
||||
export interface ProviderDefinition extends AgentProviderDefinition {
|
||||
enabled: boolean;
|
||||
createClient: (logger: Logger) => AgentClient;
|
||||
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||
@@ -364,10 +365,9 @@ function createRegistryEntry(
|
||||
|
||||
return {
|
||||
...resolved.definition,
|
||||
createClient: (providerLogger: Logger) => {
|
||||
const inner = resolved.createBaseClient(providerLogger);
|
||||
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
|
||||
},
|
||||
enabled: resolved.enabled,
|
||||
createClient: (providerLogger: Logger) =>
|
||||
createResolvedProviderClient(providerLogger, provider, resolved),
|
||||
fetchModels: async (options: ListModelsOptions) =>
|
||||
mergeModels(
|
||||
provider,
|
||||
@@ -392,6 +392,15 @@ function createRegistryEntry(
|
||||
};
|
||||
}
|
||||
|
||||
function createResolvedProviderClient(
|
||||
logger: Logger,
|
||||
provider: AgentProvider,
|
||||
resolved: ResolvedProvider,
|
||||
): AgentClient {
|
||||
const inner = resolved.createBaseClient(logger);
|
||||
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
|
||||
}
|
||||
|
||||
function buildResolvedBuiltinProviders(
|
||||
providerOverrides: Record<string, ProviderOverride>,
|
||||
runtimeSettings: AgentProviderRuntimeSettingsMap | undefined,
|
||||
@@ -514,9 +523,10 @@ export function buildProviderRegistry(
|
||||
addDerivedProviders(resolvedProviders, providerOverrides);
|
||||
|
||||
return Object.fromEntries(
|
||||
[...resolvedProviders.entries()]
|
||||
.filter(([, resolved]) => resolved.enabled)
|
||||
.map(([provider, resolved]) => [provider, createRegistryEntry(logger, provider, resolved)]),
|
||||
[...resolvedProviders.entries()].map(([provider, resolved]) => [
|
||||
provider,
|
||||
createRegistryEntry(logger, provider, resolved),
|
||||
]),
|
||||
) as Record<AgentProvider, ProviderDefinition>;
|
||||
}
|
||||
|
||||
@@ -534,7 +544,13 @@ export function createAllClients(
|
||||
logger: Logger,
|
||||
options?: BuildProviderRegistryOptions,
|
||||
): Record<AgentProvider, AgentClient> {
|
||||
const registry = buildProviderRegistry(logger, options);
|
||||
return createClientsFromRegistry(buildProviderRegistry(logger, options), logger);
|
||||
}
|
||||
|
||||
export function createClientsFromRegistry(
|
||||
registry: Record<AgentProvider, ProviderDefinition>,
|
||||
logger: Logger,
|
||||
): Record<AgentProvider, AgentClient> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(registry).map(([provider, definition]) => [
|
||||
provider,
|
||||
|
||||
@@ -23,9 +23,11 @@ interface Deferred<T> {
|
||||
|
||||
interface MockProviderOptions {
|
||||
provider: AgentProvider;
|
||||
enabled?: boolean;
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultModeId?: string | null;
|
||||
modes?: AgentMode[];
|
||||
isAvailable?: () => Promise<boolean>;
|
||||
fetchModels?: (cwd: string, force: boolean) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd: string, force: boolean) => Promise<AgentMode[]>;
|
||||
@@ -33,6 +35,7 @@ interface MockProviderOptions {
|
||||
|
||||
interface MockProviderHandle {
|
||||
definition: ProviderDefinition;
|
||||
createClient: ReturnType<typeof vi.fn>;
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
fetchModels: ReturnType<typeof vi.fn>;
|
||||
fetchModes: ReturnType<typeof vi.fn>;
|
||||
@@ -158,6 +161,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
{
|
||||
provider: "codex",
|
||||
status: "unavailable",
|
||||
enabled: true,
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
@@ -189,6 +193,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
{
|
||||
provider: "codex",
|
||||
status: "error",
|
||||
enabled: true,
|
||||
error: "model lookup failed",
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
@@ -278,6 +283,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
@@ -385,6 +391,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
@@ -392,6 +399,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
{
|
||||
provider: "claude",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "claude",
|
||||
description: "claude test provider",
|
||||
defaultModeId: null,
|
||||
@@ -465,6 +473,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "codex",
|
||||
description: "codex test provider",
|
||||
defaultModeId: null,
|
||||
@@ -1120,6 +1129,72 @@ describe("ProviderSnapshotManager", () => {
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("disabled providers stay in the snapshot without probing or fetching", async () => {
|
||||
const disabledModels = [createModel("zai", "glm-4.6")];
|
||||
const disabledMode = createMode("plan");
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => [createModel("codex", "gpt-5.2")],
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "zai",
|
||||
enabled: false,
|
||||
label: "Z.AI",
|
||||
description: "Custom disabled Claude profile",
|
||||
defaultModeId: "plan",
|
||||
models: disabledModels,
|
||||
modes: [disabledMode],
|
||||
fetchModels: async () => [createModel("zai", "glm-4.6")],
|
||||
fetchModes: async () => [disabledMode],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: async () => [createModel("claude", "sonnet")],
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot(projectCwd);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("unavailable");
|
||||
});
|
||||
|
||||
const snapshot = manager.getSnapshot(projectCwd);
|
||||
expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "zai", "claude"]);
|
||||
const zaiEntry = getProviderEntry(snapshot, "zai");
|
||||
expect(zaiEntry).toMatchObject({
|
||||
provider: "zai",
|
||||
status: "unavailable",
|
||||
enabled: false,
|
||||
label: "Z.AI",
|
||||
description: "Custom disabled Claude profile",
|
||||
defaultModeId: "plan",
|
||||
});
|
||||
expect(zaiEntry?.models).toBeUndefined();
|
||||
expect(zaiEntry?.modes).toBeUndefined();
|
||||
|
||||
expect(handles.zai?.createClient).not.toHaveBeenCalled();
|
||||
expect(handles.zai?.isAvailable).not.toHaveBeenCalled();
|
||||
expect(handles.zai?.fetchModels).not.toHaveBeenCalled();
|
||||
expect(handles.zai?.fetchModes).not.toHaveBeenCalled();
|
||||
expect(handles.codex?.createClient).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
|
||||
expect(handles.claude?.createClient).toHaveBeenCalledTimes(1);
|
||||
expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(handles.claude?.fetchModes).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("enabled false providers are omitted when absent from the registry", () => {
|
||||
const { registry } = createRegistry([createMockProvider({ provider: "claude" })]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
@@ -1151,6 +1226,7 @@ describe("ProviderSnapshotManager", () => {
|
||||
{
|
||||
provider: "zai",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "ZAI",
|
||||
description: "Custom Claude profile",
|
||||
defaultModeId: "plan",
|
||||
@@ -1200,6 +1276,7 @@ function createRegistry(handles: MockProviderHandle[]): {
|
||||
}
|
||||
|
||||
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||
const createClient = vi.fn();
|
||||
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
|
||||
const fetchModels = vi.fn(
|
||||
async (listOptions: ListModelsOptions) =>
|
||||
@@ -1214,14 +1291,16 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||
],
|
||||
);
|
||||
|
||||
const definition: ProviderDefinition = {
|
||||
const definition = {
|
||||
id: options.provider,
|
||||
enabled: options.enabled ?? true,
|
||||
label: options.label ?? options.provider,
|
||||
description: options.description ?? `${options.provider} test provider`,
|
||||
defaultModeId: options.defaultModeId ?? null,
|
||||
modes: [],
|
||||
createClient: () =>
|
||||
({
|
||||
modes: options.modes ?? [],
|
||||
createClient: () => {
|
||||
createClient();
|
||||
return {
|
||||
provider: options.provider,
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
async createSession() {
|
||||
@@ -1236,13 +1315,15 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||
async isAvailable() {
|
||||
return isAvailable();
|
||||
},
|
||||
}) satisfies AgentClient,
|
||||
} satisfies AgentClient;
|
||||
},
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
};
|
||||
} satisfies ProviderDefinition;
|
||||
|
||||
return {
|
||||
definition,
|
||||
createClient,
|
||||
isAvailable,
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
|
||||
@@ -165,6 +165,7 @@ export class ProviderSnapshotManager {
|
||||
entries.set(provider, {
|
||||
provider,
|
||||
status: "loading",
|
||||
enabled: definition?.enabled ?? true,
|
||||
label: definition?.label,
|
||||
description: definition?.description,
|
||||
defaultModeId: definition?.defaultModeId ?? null,
|
||||
@@ -242,8 +243,27 @@ export class ProviderSnapshotManager {
|
||||
}): Promise<void> {
|
||||
const { cwd, provider, definition, load, force } = options;
|
||||
const snapshot = this.getOrCreateSnapshot(options.cwd);
|
||||
const base = {
|
||||
provider,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
};
|
||||
const setEntry = (entry: ProviderSnapshotEntry) => {
|
||||
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||
return false;
|
||||
}
|
||||
snapshot.set(provider, entry);
|
||||
this.emitChange(cwd);
|
||||
return true;
|
||||
};
|
||||
|
||||
try {
|
||||
if (!definition.enabled) {
|
||||
setEntry({ ...base, status: "unavailable", enabled: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = definition.createClient(this.logger);
|
||||
const available = await withTimeout(
|
||||
client.isAvailable(),
|
||||
@@ -251,17 +271,7 @@ export class ProviderSnapshotManager {
|
||||
`Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`,
|
||||
);
|
||||
if (!available) {
|
||||
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||
return;
|
||||
}
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
status: "unavailable",
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.emitChange(cwd);
|
||||
setEntry({ ...base, status: "unavailable", enabled: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -274,34 +284,24 @@ export class ProviderSnapshotManager {
|
||||
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
|
||||
);
|
||||
|
||||
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||
return;
|
||||
}
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
setEntry({
|
||||
...base,
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models,
|
||||
modes,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.emitChange(cwd);
|
||||
} catch (error) {
|
||||
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||
return;
|
||||
}
|
||||
snapshot.set(provider, {
|
||||
provider,
|
||||
const emitted = setEntry({
|
||||
...base,
|
||||
status: "error",
|
||||
enabled: true,
|
||||
error: toErrorMessage(error),
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
defaultModeId: definition.defaultModeId,
|
||||
});
|
||||
this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
|
||||
this.emitChange(cwd);
|
||||
if (emitted) {
|
||||
this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3019,7 +3019,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private appendUserContentArrayEvents(
|
||||
content: ClaudeContentChunk[],
|
||||
content: ReadonlyArray<unknown>,
|
||||
messageId: string | undefined,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
@@ -3507,7 +3507,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
|
||||
// for user entries. Both paths must produce equivalent user_message items.
|
||||
private mapBlocksToTimeline(
|
||||
content: string | ClaudeContentChunk[],
|
||||
content: string | ReadonlyArray<unknown>,
|
||||
options?: {
|
||||
textMessageType?: "assistant_message" | "user_message";
|
||||
suppressAssistantText?: boolean;
|
||||
@@ -3537,6 +3537,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message.
|
||||
const userTextParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!isClaudeContentChunk(block)) {
|
||||
continue;
|
||||
}
|
||||
this.mapBlockToTimeline(block, {
|
||||
items,
|
||||
userTextParts,
|
||||
|
||||
@@ -101,7 +101,7 @@ import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import {
|
||||
buildProviderRegistry,
|
||||
createAllClients,
|
||||
createClientsFromRegistry,
|
||||
shutdownProviders,
|
||||
} from "./agent/provider-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
@@ -434,25 +434,21 @@ export async function createPaseoDaemon(
|
||||
github,
|
||||
},
|
||||
});
|
||||
const agentManager = new AgentManager({
|
||||
clients: {
|
||||
...createAllClients(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
workspaceGitService,
|
||||
isDev: config.isDev === true,
|
||||
}),
|
||||
...config.agentClients,
|
||||
},
|
||||
registry: agentStorage,
|
||||
logger,
|
||||
});
|
||||
const providerRegistry = buildProviderRegistry(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
providerOverrides: config.providerOverrides,
|
||||
workspaceGitService,
|
||||
isDev: config.isDev === true,
|
||||
});
|
||||
const agentManager = new AgentManager({
|
||||
clients: {
|
||||
...createClientsFromRegistry(providerRegistry, logger),
|
||||
...config.agentClients,
|
||||
},
|
||||
providerDefinitions: providerRegistry,
|
||||
registry: agentStorage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||
logger,
|
||||
|
||||
@@ -52,6 +52,7 @@ export type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPermissionRequest,
|
||||
AgentTimelineItem,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
|
||||
// Agent activity curator for CLI logs
|
||||
|
||||
@@ -87,6 +87,37 @@ const TEST_CAPABILITIES = {
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
function createTestProviderDefinition(overrides?: Partial<ProviderDefinition>): ProviderDefinition {
|
||||
return {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex test provider",
|
||||
enabled: true,
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
createClient: () =>
|
||||
({
|
||||
provider: "codex",
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
async createSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async resumeSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async listModels() {
|
||||
return [];
|
||||
},
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
}) satisfies AgentClient,
|
||||
fetchModels: async () => [],
|
||||
fetchModes: async () => [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -364,10 +395,10 @@ describe("session provider refresh cwd routing", () => {
|
||||
const fetchModels = vi.fn(async () => []);
|
||||
const fetchModes = vi.fn(async () => []);
|
||||
(session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
|
||||
codex: {
|
||||
codex: createTestProviderDefinition({
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -385,6 +416,123 @@ describe("session provider refresh cwd routing", () => {
|
||||
expect(fetchModes).toHaveBeenCalledWith({ cwd: homedir(), force: false });
|
||||
});
|
||||
|
||||
test("legacy model list request treats disabled snapshot entries as unavailable without warming", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||
providerSnapshotManager.getSnapshot = vi.fn(() => [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
const session = createSessionForTest({ messages, providerSnapshotManager });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "list_provider_models_request",
|
||||
provider: "codex",
|
||||
requestId: "models-disabled",
|
||||
});
|
||||
|
||||
expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "list_provider_models_response",
|
||||
payload: {
|
||||
provider: "codex",
|
||||
error: "Provider codex is disabled",
|
||||
fetchedAt: expect.any(String),
|
||||
requestId: "models-disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy mode list request treats disabled snapshot entries as unavailable without warming", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||
providerSnapshotManager.getSnapshot = vi.fn(() => [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "loading",
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
const session = createSessionForTest({ messages, providerSnapshotManager });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "list_provider_modes_request",
|
||||
provider: "codex",
|
||||
requestId: "modes-disabled",
|
||||
});
|
||||
|
||||
expect(providerSnapshotManager.warmUpSnapshotForCwd).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "list_provider_modes_response",
|
||||
payload: {
|
||||
provider: "codex",
|
||||
error: "Provider codex is disabled",
|
||||
fetchedAt: expect.any(String),
|
||||
requestId: "modes-disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy model and mode list fallback treats disabled registry definitions as unavailable without fetching", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const session = createSessionForTest({ messages });
|
||||
const fetchModels = vi.fn(async () => [
|
||||
{
|
||||
provider: "codex" as const,
|
||||
id: "should-not-fetch",
|
||||
label: "Should not fetch",
|
||||
},
|
||||
]);
|
||||
const fetchModes = vi.fn(async () => [
|
||||
{
|
||||
id: "should-not-fetch",
|
||||
label: "Should not fetch",
|
||||
},
|
||||
]);
|
||||
(session as unknown as { getProviderRegistry: () => unknown }).getProviderRegistry = () => ({
|
||||
codex: createTestProviderDefinition({
|
||||
enabled: false,
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
}),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "list_provider_models_request",
|
||||
provider: "codex",
|
||||
requestId: "fallback-models-disabled",
|
||||
});
|
||||
await session.handleMessage({
|
||||
type: "list_provider_modes_request",
|
||||
provider: "codex",
|
||||
requestId: "fallback-modes-disabled",
|
||||
});
|
||||
|
||||
expect(fetchModels).not.toHaveBeenCalled();
|
||||
expect(fetchModes).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "list_provider_models_response",
|
||||
payload: {
|
||||
provider: "codex",
|
||||
error: "Provider codex is disabled",
|
||||
fetchedAt: expect.any(String),
|
||||
requestId: "fallback-models-disabled",
|
||||
},
|
||||
});
|
||||
expect(messages).toContainEqual({
|
||||
type: "list_provider_modes_response",
|
||||
payload: {
|
||||
provider: "codex",
|
||||
error: "Provider codex is disabled",
|
||||
fetchedAt: expect.any(String),
|
||||
requestId: "fallback-modes-disabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy model list request without cwd awaits loading snapshot without forced discovery", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const models = deferred<AgentModelDefinition[]>();
|
||||
@@ -395,12 +543,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
},
|
||||
);
|
||||
const fetchModes = vi.fn(async (_options: ListModesOptions): Promise<AgentMode[]> => []);
|
||||
const providerDefinition: ProviderDefinition = {
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
description: "Codex test provider",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
const providerDefinition = createTestProviderDefinition({
|
||||
createClient: () =>
|
||||
({
|
||||
provider: "codex",
|
||||
@@ -420,7 +563,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
}) satisfies AgentClient,
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
};
|
||||
});
|
||||
const providerSnapshotManager = new ProviderSnapshotManager(
|
||||
{ codex: providerDefinition },
|
||||
pino({ level: "silent" }),
|
||||
@@ -930,6 +1073,7 @@ describe("session checkout status handling", () => {
|
||||
cwd: "/tmp/service-worktree",
|
||||
isGit: true,
|
||||
repoRoot: "/tmp/service-worktree",
|
||||
mainRepoRoot: null,
|
||||
currentBranch: "feature/service",
|
||||
isDirty: true,
|
||||
baseRef: "main",
|
||||
|
||||
@@ -3195,6 +3195,25 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
private emitProviderDisabledResponse(
|
||||
kind: "models" | "modes",
|
||||
provider: AgentProvider,
|
||||
requestId: string,
|
||||
fetchedAt: string,
|
||||
): void {
|
||||
const payload = {
|
||||
provider,
|
||||
error: `Provider ${provider} is disabled`,
|
||||
fetchedAt,
|
||||
requestId,
|
||||
};
|
||||
if (kind === "models") {
|
||||
this.emit({ type: "list_provider_models_response", payload });
|
||||
} else {
|
||||
this.emit({ type: "list_provider_modes_response", payload });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleListProviderModelsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -3204,7 +3223,13 @@ export class Session {
|
||||
|
||||
if (!manager) {
|
||||
try {
|
||||
const models = await this.getProviderRegistry()[msg.provider].fetchModels({
|
||||
const definition = this.getProviderRegistry()[msg.provider];
|
||||
if (!definition.enabled) {
|
||||
this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
const models = await definition.fetchModels({
|
||||
cwd,
|
||||
force: false,
|
||||
});
|
||||
@@ -3251,6 +3276,11 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.enabled) {
|
||||
this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.status === "ready") {
|
||||
this.emit({
|
||||
type: "list_provider_models_response",
|
||||
@@ -3304,6 +3334,11 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entry.enabled) {
|
||||
this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.status === "ready") {
|
||||
this.emit({
|
||||
type: "list_provider_modes_response",
|
||||
@@ -3336,7 +3371,13 @@ export class Session {
|
||||
}
|
||||
|
||||
try {
|
||||
const modes = await this.getProviderRegistry()[msg.provider].fetchModes({
|
||||
const definition = this.getProviderRegistry()[msg.provider];
|
||||
if (!definition.enabled) {
|
||||
this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt);
|
||||
return;
|
||||
}
|
||||
|
||||
const modes = await definition.fetchModes({
|
||||
cwd,
|
||||
force: false,
|
||||
});
|
||||
@@ -3380,6 +3421,9 @@ export class Session {
|
||||
manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider);
|
||||
|
||||
let entry = findEntry();
|
||||
if (entry && !entry.enabled) {
|
||||
return entry;
|
||||
}
|
||||
if (!entry || entry.status === "loading") {
|
||||
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
|
||||
// a resolved answer rather than a loading placeholder.
|
||||
|
||||
@@ -36,7 +36,7 @@ import type {
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
import { buildProviderRegistry, createClientsFromRegistry } from "./agent/provider-registry.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
@@ -497,13 +497,14 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.providerOverrides,
|
||||
config.providers,
|
||||
);
|
||||
this.providerSnapshotManager.replaceRegistry(
|
||||
buildProviderRegistry(providerSnapshotLogger, {
|
||||
runtimeSettings: this.agentProviderRuntimeSettings,
|
||||
providerOverrides: this.providerOverrides,
|
||||
isDev: this.isDev,
|
||||
}),
|
||||
);
|
||||
const registry = buildProviderRegistry(providerSnapshotLogger, {
|
||||
runtimeSettings: this.agentProviderRuntimeSettings,
|
||||
providerOverrides: this.providerOverrides,
|
||||
isDev: this.isDev,
|
||||
});
|
||||
const clients = createClientsFromRegistry(registry, providerSnapshotLogger);
|
||||
this.providerSnapshotManager.replaceRegistry(registry);
|
||||
this.agentManager.updateProviderRegistry({ providerDefinitions: registry, clients });
|
||||
this.broadcastDaemonConfigChanged(config);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
GetProvidersSnapshotResponseMessageSchema,
|
||||
ProviderSnapshotEntrySchema,
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("provider snapshot message schemas", () => {
|
||||
test("defaults missing provider snapshot entry enabled state to true", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
label: "Codex",
|
||||
});
|
||||
|
||||
expect(parsed.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves disabled provider snapshot entries", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "claude",
|
||||
status: "unavailable",
|
||||
enabled: false,
|
||||
label: "Claude",
|
||||
});
|
||||
|
||||
expect(parsed.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves enabled provider snapshot entries", () => {
|
||||
const parsed = ProviderSnapshotEntrySchema.parse({
|
||||
provider: "opencode",
|
||||
status: "loading",
|
||||
enabled: true,
|
||||
label: "OpenCode",
|
||||
});
|
||||
|
||||
expect(parsed.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("defaults missing enabled state in providers snapshot response entries", () => {
|
||||
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
|
||||
type: "get_providers_snapshot_response",
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
label: "Codex",
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
status: "unavailable",
|
||||
enabled: false,
|
||||
label: "Claude",
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-04-24T00:00:00.000Z",
|
||||
requestId: "req-providers",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.entries.map((entry) => entry.enabled)).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("defaults missing enabled state in providers snapshot update entries", () => {
|
||||
const parsed = ProvidersSnapshotUpdateMessageSchema.parse({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
label: "Codex",
|
||||
},
|
||||
],
|
||||
generatedAt: "2026-04-24T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.entries[0]?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,6 @@ import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
ProviderSnapshotEntry,
|
||||
ProviderStatus,
|
||||
AgentRuntimeInfo,
|
||||
AgentTimelineItem,
|
||||
@@ -169,9 +168,10 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
|
||||
defaultThinkingOptionId: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProviderSnapshotEntrySchema: z.ZodType<ProviderSnapshotEntry> = z.object({
|
||||
export const ProviderSnapshotEntrySchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
status: ProviderStatusSchema,
|
||||
enabled: z.boolean().optional().default(true),
|
||||
error: z.string().optional(),
|
||||
models: z.array(AgentModelDefinitionSchema).optional(),
|
||||
modes: z.array(AgentModeSchema).optional(),
|
||||
|
||||
@@ -44,7 +44,7 @@ async function probeExecutable(executablePath: string): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
let pendingResolve: ((result: boolean) => void) | null = resolve;
|
||||
let started = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const settle = (result: boolean) => {
|
||||
if (!pendingResolve) {
|
||||
@@ -78,8 +78,8 @@ async function probeExecutable(executablePath: string): Promise<boolean> {
|
||||
return;
|
||||
}
|
||||
settle(false);
|
||||
}, PROBE_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}, PROBE_TIMEOUT_MS) as unknown as NodeJS.Timeout;
|
||||
timer.unref();
|
||||
|
||||
child.once("spawn", () => {
|
||||
started = true;
|
||||
|
||||
Reference in New Issue
Block a user