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:
Mohamed Boudra
2026-04-25 14:00:38 +07:00
parent de42858213
commit 4b3603604a
31 changed files with 1920 additions and 216 deletions
@@ -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", () => {
+43 -29
View File
@@ -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,
+10 -14
View File
@@ -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,
+1
View File
@@ -52,6 +52,7 @@ export type {
AgentCapabilityFlags,
AgentPermissionRequest,
AgentTimelineItem,
ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
// Agent activity curator for CLI logs
+153 -9
View File
@@ -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",
+46 -2
View File
@@ -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);
});
});
+2 -2
View File
@@ -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(),
+3 -3
View File
@@ -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;