From 3176f844e788db680ad03e5491269bf06363d877 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 28 May 2026 18:25:09 +0800 Subject: [PATCH] Make MCP provider controls match the app (#1198) * Align MCP provider controls with app provider state * Deepen provider snapshot routing * Make provider snapshots the daemon authority * Move provider shutdown behind a generic AgentClient seam OpenCodeServerManager is now owned entirely by the OpenCode provider. ProviderSnapshotManager.shutdown() and provider-registry.shutdownProviders() materialize enabled clients and call an optional shutdown() per client; the OpenCode client forwards to its runtime. Other providers ignore it. Also wires providerSnapshotManager into the remaining Session-constructing tests that were missing it (server-tests CI failure). * Use platform-native cwd in change-event test --- .../src/server/agent/agent-sdk-types.ts | 35 + .../src/server/agent/create-agent-mode.ts | 37 +- .../src/server/agent/create-agent/create.ts | 154 +- .../src/server/agent/import-sessions.test.ts | 8 +- .../src/server/agent/import-sessions.ts | 8 +- .../src/server/agent/mcp-server.test.ts | 661 +++++-- .../server/src/server/agent/mcp-server.ts | 160 +- .../src/server/agent/model-resolver.test.ts | 151 -- .../server/src/server/agent/model-resolver.ts | 42 - .../src/server/agent/provider-registry.ts | 34 +- .../agent/provider-snapshot-manager.test.ts | 1737 +++++------------ .../server/agent/provider-snapshot-manager.ts | 302 ++- .../opencode-agent.full-access.test.ts | 38 + .../server/agent/providers/opencode-agent.ts | 61 + packages/server/src/server/bootstrap.ts | 30 +- .../server/src/server/persistence-hooks.ts | 31 +- packages/server/src/server/session.test.ts | 243 +-- packages/server/src/server/session.ts | 265 +-- .../session.workspace-git-watch.test.ts | 2 + ...on.workspace-resolution-invariants.test.ts | 2 + .../src/server/session.workspaces.test.ts | 8 + .../snapshot-mutation-ownership.test.ts | 2 + .../src/server/test-utils/session-stubs.ts | 103 +- .../websocket-server.notifications.test.ts | 5 +- .../websocket-server.relay-reconnect.test.ts | 14 +- .../server/src/server/websocket-server.ts | 50 +- .../server/src/server/wire-compat.test.ts | 2 + 27 files changed, 1731 insertions(+), 2454 deletions(-) delete mode 100644 packages/server/src/server/agent/model-resolver.test.ts delete mode 100644 packages/server/src/server/agent/model-resolver.ts diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 9d28a34d9..7227de4ea 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -48,6 +48,7 @@ export interface AgentMode { description?: string; icon?: string; colorTier?: string; + isUnattended?: boolean; } export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; @@ -93,6 +94,32 @@ export interface ProviderSnapshotEntry { defaultModeId?: string | null; } +export interface AgentCreateConfigParent { + provider: AgentProvider; + modeId: string | null; + isUnattended: boolean; +} + +export interface ResolveAgentCreateConfigInput { + provider: AgentProvider; + requestedMode: string | undefined; + featureValues: Record | undefined; + parent: AgentCreateConfigParent | null; + availableModes: AgentMode[] | undefined; +} + +export interface ResolveAgentCreateConfigResult { + modeId: string | undefined; + featureValues: Record | undefined; +} + +export interface AgentCreateConfigUnattendedInput { + modeId: string | null; + config: AgentSessionConfig; + features?: AgentFeature[]; + availableModes: AgentMode[]; +} + export interface AgentFeatureToggle { type: "toggle"; id: string; @@ -590,6 +617,8 @@ export interface AgentClient { ): Promise; listModels(options: ListModelsOptions): Promise; listModes?(options: ListModesOptions): Promise; + resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult; + isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; listCommands?(config: AgentSessionConfig): Promise; listFeatures?(config: AgentSessionConfig): Promise; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; @@ -604,4 +633,10 @@ export interface AgentClient { * Called when Paseo archives an agent so the provider's own UI reflects the same state. */ archiveNativeSession?(handle: AgentPersistenceHandle): Promise; + /** + * Release any provider-owned resources held by this client (background + * processes, sockets, cached subprocesses, etc.). Called when the daemon + * shuts down. Must be idempotent. + */ + shutdown?(): Promise; } diff --git a/packages/server/src/server/agent/create-agent-mode.ts b/packages/server/src/server/agent/create-agent-mode.ts index 375ffd063..ae9cc23ab 100644 --- a/packages/server/src/server/agent/create-agent-mode.ts +++ b/packages/server/src/server/agent/create-agent-mode.ts @@ -1,4 +1,10 @@ -import type { AgentProvider } from "./agent-sdk-types.js"; +import type { + AgentCreateConfigUnattendedInput, + AgentMode, + AgentProvider, + ResolveAgentCreateConfigInput, + ResolveAgentCreateConfigResult, +} from "./agent-sdk-types.js"; interface CreateAgentModeParent { provider: AgentProvider; @@ -55,3 +61,32 @@ export function resolveAndValidateCreateAgentMode( `cannot inherit mode '${parent.modeId ?? ""}' from caller (provider '${parent.provider}') for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`, ); } + +export function resolveDefaultAgentCreateConfig( + input: ResolveAgentCreateConfigInput, +): ResolveAgentCreateConfigResult { + const availableModeIds = input.availableModes?.map((mode) => mode.id); + return { + modeId: resolveAndValidateCreateAgentMode({ + requestedMode: input.requestedMode, + targetProvider: input.provider, + parent: input.parent, + availableModes: availableModeIds, + targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id, + }), + featureValues: input.featureValues, + }; +} + +export function isDefaultAgentCreateConfigUnattended( + input: AgentCreateConfigUnattendedInput, +): boolean { + if (input.modeId === null) { + return false; + } + return input.availableModes.some((mode) => mode.id === input.modeId && isUnattendedMode(mode)); +} + +function isUnattendedMode(mode: AgentMode): boolean { + return mode.isUnattended === true; +} diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 36bc48f01..8da367a49 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -16,17 +16,14 @@ import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../ import type { AgentManager, ManagedAgent } from "../agent-manager.js"; import { scheduleAgentMetadataGeneration } from "../agent-metadata-generator.js"; import type { - AgentProvider, AgentPromptContentBlock, AgentPromptInput, AgentRunOptions, AgentSessionConfig, } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; -import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { ProviderDefinition } from "../provider-registry.js"; +import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; -import { resolveAndValidateCreateAgentMode } from "../create-agent-mode.js"; import { resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel } from "../mcp-shared.js"; import { @@ -34,51 +31,6 @@ import { emitLiveTimelineItemIfAgentKnown, } from "../timeline-append.js"; -const OPENCODE_PROVIDER_ID = "opencode"; -const OPENCODE_BUILD_MODE_ID = "build"; -const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; -const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; - -function isOpenCodeLegacyFullAccessMode( - provider: AgentProvider, - modeId: string | undefined, -): boolean { - return provider === OPENCODE_PROVIDER_ID && modeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID; -} - -function withOpenCodeAutoAcceptFeature( - features: Record | undefined, - enabled: boolean, -): Record { - return { - ...features, - [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled, - }; -} - -function hasOpenCodeAutoAcceptFeature(agent: ManagedAgent): boolean { - if (agent.provider !== OPENCODE_PROVIDER_ID) { - return false; - } - return ( - agent.features?.some( - (feature) => - feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID && - (feature.value === true || feature.value === "true"), - ) === true || agent.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true - ); -} - -function isAgentInUnattendedState( - dependencies: CreateAgentCommandDependencies, - agent: ManagedAgent, -): boolean { - return ( - isParentInUnattendedMode(dependencies, agent.provider, agent.currentModeId) || - hasOpenCodeAutoAcceptFeature(agent) - ); -} - export interface CreateAgentWorkspace { workspaceId: string; } @@ -98,7 +50,7 @@ interface CreateAgentCommandDependencies { "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >; terminalManager?: TerminalManager | null; - providerRegistry?: Record | null; + providerSnapshotManager: ProviderSnapshotManager; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; } @@ -293,22 +245,14 @@ async function resolveMcpCreateAgent( initialPrompt: input.initialPrompt, }); - const parentForResolve = parentAgent - ? { - provider: parentAgent.provider, - modeId: parentAgent.currentModeId, - isUnattended: isAgentInUnattendedState(dependencies, parentAgent), - } - : null; - const { mode: resolvedMode, features: resolvedFeatures } = resolveCreateModeAndFeatures( - dependencies, - { + const { modeId: resolvedMode, featureValues: resolvedFeatures } = + await dependencies.providerSnapshotManager.resolveCreateConfig({ + cwd: resolvedCwd, provider, requestedMode: input.mode, - parent: parentForResolve, - features: input.features, - }, - ); + featureValues: input.features, + parent: parentAgent, + }); const labels = mergeLabels( input.callerAgentId, @@ -337,44 +281,6 @@ async function resolveMcpCreateAgent( }; } -function resolveCreateModeAndFeatures( - dependencies: CreateAgentCommandDependencies, - input: { - provider: AgentProvider; - requestedMode: string | undefined; - parent: { provider: AgentProvider; modeId: string | null; isUnattended: boolean } | null; - features: Record | undefined; - }, -): { mode: string | undefined; features: Record | undefined } { - const legacyOpenCodeFullAccess = isOpenCodeLegacyFullAccessMode( - input.provider, - input.requestedMode, - ); - const inheritsOpenCodeUnattended = - input.provider === OPENCODE_PROVIDER_ID && - input.requestedMode === undefined && - input.parent?.isUnattended === true; - const inheritsOpenCodeAutoAccept = - inheritsOpenCodeUnattended && input.features?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined; - const requestedMode = legacyOpenCodeFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode; - const features = - legacyOpenCodeFullAccess || inheritsOpenCodeAutoAccept - ? withOpenCodeAutoAcceptFeature(input.features, true) - : input.features; - const mode = - inheritsOpenCodeUnattended && requestedMode === undefined - ? OPENCODE_BUILD_MODE_ID - : resolveAndValidateCreateAgentMode({ - requestedMode, - targetProvider: input.provider, - parent: input.parent, - availableModes: getAvailableModeIds(dependencies, input.provider), - targetUnattendedMode: getUnattendedModeId(dependencies, input.provider), - }); - - return { mode, features }; -} - async function sendInitialPrompt( dependencies: CreateAgentCommandDependencies, resolved: ResolvedCreateAgent, @@ -565,47 +471,3 @@ function mergeLabels( }; return Object.keys(mergedLabels).length > 0 ? mergedLabels : undefined; } - -function getProviderModes( - dependencies: CreateAgentCommandDependencies, - provider: AgentProvider, -): ProviderDefinition["modes"] | undefined { - const fromRegistry = dependencies.providerRegistry?.[provider]; - if (fromRegistry) { - return fromRegistry.modes; - } - try { - return getAgentProviderDefinition(provider).modes; - } catch { - return undefined; - } -} - -function getAvailableModeIds( - dependencies: CreateAgentCommandDependencies, - provider: AgentProvider, -): string[] | undefined { - return getProviderModes(dependencies, provider)?.map((mode) => mode.id); -} - -function getUnattendedModeId( - dependencies: CreateAgentCommandDependencies, - provider: AgentProvider, -): string | undefined { - return getProviderModes(dependencies, provider)?.find((mode) => mode.isUnattended)?.id; -} - -function isParentInUnattendedMode( - dependencies: CreateAgentCommandDependencies, - provider: AgentProvider, - modeId: string | null, -): boolean { - if (modeId === null) { - return false; - } - const modes = getProviderModes(dependencies, provider); - if (!modes) { - return false; - } - return modes.some((mode) => mode.id === modeId && mode.isUnattended === true); -} diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index cfeef0501..d8125454d 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -208,7 +208,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import }), agentManager, agentStorage, - providerRegistry: { codex: { label: "Codex" } }, + providerSnapshotManager: { getProviderLabel: () => "Codex" }, }); expect(listImportablePersistedAgents).toHaveBeenCalledWith({ @@ -274,7 +274,7 @@ test("listImportableProviderSessions filters out metadata generation sessions", agentStorage: { list: async () => [], } satisfies Pick, - providerRegistry: { codex: { label: "Codex" } }, + providerSnapshotManager: { getProviderLabel: () => "Codex" }, }); expect(result.entries).toHaveLength(1); @@ -309,7 +309,7 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy agentStorage: { list: async () => [], } satisfies Pick, - providerRegistry: { pi: { label: "Pi" } }, + providerSnapshotManager: { getProviderLabel: () => "Pi" }, }); expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["pi-handle"]); @@ -326,7 +326,7 @@ test("listImportableProviderSessions rejects invalid since values", async () => agentStorage: { list: async () => [], } satisfies Pick, - providerRegistry: {}, + providerSnapshotManager: { getProviderLabel: () => "" }, }), ).rejects.toMatchObject( new ImportSessionsRequestError("invalid_since", "Invalid recent provider sessions since"), diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 6b295ef07..2606291eb 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -1,6 +1,6 @@ import type { z } from "zod"; import type { Logger } from "pino"; -import type { ProviderDefinition } from "./provider-registry.js"; +import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { @@ -49,7 +49,7 @@ export interface ListImportableProviderSessionsInput { request: FetchRecentProviderSessionsRequestMessage; agentManager: Pick; agentStorage: Pick; - providerRegistry: Record | undefined>; + providerSnapshotManager: Pick; } export interface ListImportableProviderSessionsResult { @@ -99,7 +99,7 @@ export function normalizeImportAgentRequest( export async function listImportableProviderSessions( input: ListImportableProviderSessionsInput, ): Promise { - const { request, agentManager, agentStorage, providerRegistry } = input; + const { request, agentManager, agentStorage, providerSnapshotManager } = input; const limit = request.limit ?? 20; const sinceTimestamp = parseRecentProviderSessionsSince(request.since); const providerFilter = request.providers ? new Set(request.providers) : undefined; @@ -140,7 +140,7 @@ export async function listImportableProviderSessions( .slice(0, limit) .map((descriptor) => toRecentProviderSessionDescriptorPayload(descriptor, { - providerLabel: providerRegistry[descriptor.provider]?.label ?? descriptor.provider, + providerLabel: providerSnapshotManager.getProviderLabel(descriptor.provider), }), ); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ba99577de..ec1c2ea09 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -12,7 +12,9 @@ import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; -import type { ProviderDefinition } from "./provider-registry.js"; +import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"; +import { resolveDefaultAgentCreateConfig } from "./create-agent-mode.js"; +import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js"; import { AgentListItemPayloadSchema, AgentSnapshotPayloadSchema, @@ -214,40 +216,188 @@ function createTerminalManagerStub(overrides: Partial = {}): Te } as unknown as TerminalManager; } -function createProviderDefinition(overrides: Partial): ProviderDefinition { - const provider = overrides.id ?? "claude"; +type ProviderSnapshotManagerStub = ReturnType; + +interface ConfigureProviderEntry { + provider: AgentProvider; + label?: string; + description?: string; + enabled?: boolean; + defaultModeId?: string; + modes?: AgentMode[]; +} + +// Builds a ProviderSnapshotEntry for tests that need to configure listProviders / +// getProvider directly. Mirrors the shape MCP server reads from the manager: +// status: "ready" for enabled+available, "unavailable" for disabled. +function buildSnapshotEntry(entry: ConfigureProviderEntry): ProviderSnapshotEntry { + const enabled = entry.enabled ?? true; + if (!enabled) { + return { + provider: entry.provider, + status: "unavailable", + enabled: false, + ...(entry.label !== undefined ? { label: entry.label } : {}), + ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(entry.defaultModeId !== undefined ? { defaultModeId: entry.defaultModeId } : {}), + modes: [], + }; + } return { - id: provider, - label: "Claude", - description: "Test provider", + provider: entry.provider, + status: "ready", enabled: true, - defaultModeId: "default", - modes: [], - createClient: vi.fn(() => ({ - provider, - capabilities: { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - createSession: async () => { - throw new Error("createSession is not used by this MCP provider test"); - }, - resumeSession: async () => { - throw new Error("resumeSession is not used by this MCP provider test"); - }, - listModels: vi.fn().mockResolvedValue([]), - isAvailable: vi.fn().mockResolvedValue(true), - })), - fetchModels: vi.fn().mockResolvedValue([]), - fetchModes: vi.fn().mockResolvedValue([]), - ...overrides, + ...(entry.label !== undefined ? { label: entry.label } : {}), + ...(entry.description !== undefined ? { description: entry.description } : {}), + ...(entry.defaultModeId !== undefined ? { defaultModeId: entry.defaultModeId } : {}), + modes: entry.modes ?? [], }; } +// Shared helper used by ~60 create_agent / update_agent / list_agents tests that +// only need a "normal" provider catalog (claude, codex, opencode) and the +// OpenCode resolveCreateConfig quirk: requestedMode="full-access" or an +// unattended parent maps to build mode + auto_accept feature. +// +// NOTE: This is NOT a registry. It directly configures the public stub surface. +// Per-test customization is done by overriding individual stub methods after +// calling this helper. +function configureOpenCodeProviderStub(stub: ProviderSnapshotManagerStub): void { + const claudeModes: AgentMode[] = [ + { id: "default", label: "Default", description: "Ask first" }, + { id: "bypassPermissions", label: "Bypass", description: "No prompts", isUnattended: true }, + ]; + const codexModes: AgentMode[] = [ + { id: "default", label: "Default", description: "Default" }, + { id: "auto", label: "Auto", description: "Auto", isUnattended: true }, + ]; + const opencodeModes: AgentMode[] = [ + { id: "build", label: "Build", description: "Can edit" }, + { id: "plan", label: "Plan", description: "Read-only" }, + ]; + const entries: ProviderSnapshotEntry[] = [ + buildSnapshotEntry({ + provider: "claude", + label: "Claude", + description: "Anthropic Claude", + defaultModeId: "default", + modes: claudeModes, + }), + buildSnapshotEntry({ + provider: "codex", + label: "Codex", + description: "OpenAI Codex", + defaultModeId: "default", + modes: codexModes, + }), + buildSnapshotEntry({ + provider: "opencode", + label: "OpenCode", + description: "OpenCode agent", + defaultModeId: "build", + modes: opencodeModes, + }), + ]; + const modesByProvider: Record = { + claude: claudeModes, + codex: codexModes, + opencode: opencodeModes, + }; + + stub.listRegisteredProviderIds.mockReturnValue(["claude", "codex", "opencode"]); + stub.hasProvider.mockImplementation((provider) => + Object.prototype.hasOwnProperty.call(modesByProvider, provider), + ); + stub.getProviderLabel.mockImplementation((provider) => { + const entry = entries.find((e) => e.provider === provider); + return entry?.label ?? provider; + }); + stub.listProviders.mockImplementation(async (input) => { + const opts = (input ?? {}) as { providers?: AgentProvider[] }; + if (!opts.providers) return entries; + const filter = new Set(opts.providers); + return entries.filter((e) => filter.has(e.provider)); + }); + stub.getProvider.mockImplementation(async (input) => { + const opts = input as { provider: AgentProvider }; + const entry = entries.find((e) => e.provider === opts.provider); + if (!entry) throw new Error(`Provider ${opts.provider} is not configured`); + return entry; + }); + stub.listModels.mockResolvedValue([]); + stub.listModes.mockImplementation(async (input) => { + const opts = input as { provider: AgentProvider }; + return modesByProvider[opts.provider] ?? []; + }); + stub.resolveCreateConfig.mockImplementation(async (input) => { + const opts = input as { + provider: AgentProvider; + requestedMode: string | undefined; + featureValues: Record | undefined; + parent: ManagedAgent | null; + }; + if (opts.provider === "opencode") { + if (opts.requestedMode === "full-access") { + return { + modeId: "build", + featureValues: { ...opts.featureValues, auto_accept: true }, + }; + } + // Cross-provider unattended inheritance: caller is in unattended mode and + // requested no explicit mode — map to build + auto_accept. + if (opts.requestedMode === undefined && opts.parent) { + const parentModes = modesByProvider[opts.parent.provider] ?? []; + const parentMode = parentModes.find((m) => m.id === opts.parent?.currentModeId); + if (parentMode?.isUnattended === true) { + return { + modeId: "build", + featureValues: { ...opts.featureValues, auto_accept: true }, + }; + } + } + } + const availableModes = modesByProvider[opts.provider] ?? []; + const parentModes = opts.parent ? (modesByProvider[opts.parent.provider] ?? []) : []; + const parentMode = opts.parent + ? parentModes.find((m) => m.id === opts.parent?.currentModeId) + : null; + return resolveDefaultAgentCreateConfig({ + provider: opts.provider, + requestedMode: opts.requestedMode, + featureValues: opts.featureValues, + parent: opts.parent + ? { + provider: opts.parent.provider, + modeId: opts.parent.currentModeId, + isUnattended: parentMode?.isUnattended === true, + } + : null, + availableModes, + }); + }); +} + +// Quick helper: returns a manager configured with the standard OpenCode catalog. +function createOpenCodeManager(): { + manager: ProviderSnapshotManagerStub["manager"]; + stub: ProviderSnapshotManagerStub; +} { + const stub = createProviderSnapshotManagerStub(); + configureOpenCodeProviderStub(stub); + return { manager: stub.manager, stub }; +} + +// Quick helper: returns a bare stub manager seam. Use when the test does not +// care about provider behavior at all (terminal tests, schema-only tests, +// stored-agent listing tests where the stored agent's provider just needs to +// be in listRegisteredProviderIds). +function createClaudeOnlyManager(): ProviderSnapshotManagerStub["manager"] { + const stub = createProviderSnapshotManagerStub(); + stub.listRegisteredProviderIds.mockReturnValue(["claude"]); + stub.hasProvider.mockImplementation((provider) => provider === "claude"); + return stub.manager; +} + function createStoredRecord(overrides: Partial = {}): StoredAgentRecord { const now = "2026-04-11T00:00:00.000Z"; return { @@ -439,6 +589,7 @@ describe("terminal MCP tools", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, terminalManager, logger, }); @@ -471,7 +622,12 @@ describe("create_agent MCP tool", () => { it("requires a concise title no longer than 60 characters", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); expect(tool).toBeDefined(); @@ -506,7 +662,12 @@ describe("create_agent MCP tool", () => { it("requires initialPrompt", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); const parsed = await tool.inputSchema.safeParseAsync({ cwd: existingCwd, @@ -533,7 +694,12 @@ describe("create_agent MCP tool", () => { config: { title: "Feature test", featureValues: { fast_mode: true } }, } as ManagedAgent); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); const input = { cwd: existingCwd, @@ -580,7 +746,12 @@ describe("create_agent MCP tool", () => { config: { title: "Mode test" }, } as ManagedAgent); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); const response = await tool.handler({ cwd: existingCwd, @@ -595,7 +766,12 @@ describe("create_agent MCP tool", () => { it("requires provider as provider/model and rejects the old model field", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); const missingProvider = await tool.inputSchema.safeParseAsync({ @@ -652,7 +828,12 @@ describe("create_agent MCP tool", () => { it("accepts optional worktree intent fields in create_agent input validation", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); const parsed = await tool.inputSchema.safeParseAsync({ @@ -671,7 +852,12 @@ describe("create_agent MCP tool", () => { it("accepts each create_worktree target mode", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_worktree"); for (const target of [ @@ -686,7 +872,12 @@ describe("create_agent MCP tool", () => { it("rejects create_worktree without a target", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_worktree"); const parsed = await tool.inputSchema.safeParseAsync({}); @@ -698,7 +889,12 @@ describe("create_agent MCP tool", () => { spies.agentManager.createAgent.mockRejectedValue( new Error("Working directory does not exist: /path/that/does/not/exist"), ); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); await expect( @@ -722,7 +918,12 @@ describe("create_agent MCP tool", () => { config: { title: "Fix auth bug" }, } as ManagedAgent); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); await tool.handler({ cwd: existingCwd, @@ -752,7 +953,12 @@ describe("create_agent MCP tool", () => { config: { title: "Fix auth" }, } as ManagedAgent); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); await tool.handler({ cwd: existingCwd, @@ -781,7 +987,12 @@ describe("create_agent MCP tool", () => { config: { title: "Config test", model: "claude-sonnet-4-20250514" }, } as ManagedAgent); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); await tool.handler({ cwd: existingCwd, @@ -843,6 +1054,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, @@ -921,6 +1133,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), workspaceGitService: workspaceGitService as unknown as Pick< @@ -1005,6 +1218,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }), workspaceGitService: workspaceGitService as unknown as Pick< @@ -1097,6 +1311,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, createPaseoWorktree, workspaceGitService: workspaceGitService as unknown as Pick< WorkspaceGitService, @@ -1165,6 +1380,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, @@ -1235,6 +1451,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), workspaceGitService: workspaceGitService as unknown as Pick< @@ -1319,6 +1536,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, paseoHome, createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }), workspaceGitService: workspaceGitService as unknown as Pick< @@ -1378,6 +1596,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, workspaceGitService: workspaceGitService as unknown as Pick< WorkspaceGitService, "getSnapshot" | "listWorktrees" @@ -1402,7 +1621,12 @@ describe("create_agent MCP tool", () => { it("accepts custom provider IDs in create_agent input validation", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); const parsed = await tool.inputSchema.safeParseAsync({ @@ -1439,6 +1663,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, callerAgentId: "voice-agent", resolveCallerContext: () => ({ childAgentDefaultLabels: { source: "voice" }, @@ -1491,6 +1716,7 @@ describe("create_agent MCP tool", () => { agentManager, agentStorage, callerAgentId: "parent-agent", + providerSnapshotManager: createOpenCodeManager().manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -1536,6 +1762,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -1559,7 +1786,12 @@ describe("create_agent MCP tool", () => { it("rejects an explicit mode that is not valid for the target provider", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); await expect( @@ -1576,6 +1808,61 @@ describe("create_agent MCP tool", () => { expect(spies.agentManager.createAgent).not.toHaveBeenCalled(); }); + it("validates create_agent modes against the shared provider snapshot", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.createAgent.mockResolvedValue({ + id: "child-agent", + cwd: existingCwd, + lifecycle: "idle", + currentModeId: "dynamic", + availableModes: [], + config: { title: "Child" }, + } as ManagedAgent); + const dynamicModes: AgentMode[] = [ + { id: "dynamic", label: "Dynamic", description: "Runtime mode" }, + ]; + const provStub = createProviderSnapshotManagerStub(); + provStub.listRegisteredProviderIds.mockReturnValue(["codex"]); + provStub.listProviders.mockResolvedValue([ + buildSnapshotEntry({ provider: "codex", label: "Codex", modes: dynamicModes }), + ]); + provStub.getProvider.mockImplementation(async ({ provider }: { provider: AgentProvider }) => + buildSnapshotEntry({ provider, label: "Codex", modes: dynamicModes }), + ); + provStub.listModes.mockResolvedValue(dynamicModes); + provStub.resolveCreateConfig.mockImplementation(async (input) => { + const opts = input as { requestedMode: string | undefined }; + return resolveDefaultAgentCreateConfig({ + provider: "codex", + requestedMode: opts.requestedMode, + featureValues: undefined, + parent: null, + availableModes: dynamicModes, + }); + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: provStub.manager, + logger, + }); + const tool = registeredTool(server, "create_agent"); + + await tool.handler({ + cwd: existingCwd, + title: "Dynamic mode", + provider: "codex/gpt-5.4", + settings: { modeId: "dynamic" }, + initialPrompt: "Do work", + }); + + expect(spies.agentManager.createAgent).toHaveBeenCalledWith( + expect.objectContaining({ modeId: "dynamic" }), + undefined, + undefined, + ); + }); + it("accepts legacy OpenCode full-access as build plus auto accept", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ @@ -1586,7 +1873,12 @@ describe("create_agent MCP tool", () => { availableModes: [], config: { title: "Child", featureValues: { auto_accept: true } }, } as ManagedAgent); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "create_agent"); await tool.handler({ @@ -1625,6 +1917,7 @@ describe("create_agent MCP tool", () => { agentManager, agentStorage, callerAgentId: "parent-agent", + providerSnapshotManager: createOpenCodeManager().manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -1654,6 +1947,7 @@ describe("create_agent MCP tool", () => { agentManager, agentStorage, callerAgentId: "parent-agent", + providerSnapshotManager: createOpenCodeManager().manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -1691,6 +1985,7 @@ describe("create_agent MCP tool", () => { agentManager, agentStorage, callerAgentId: "parent-agent", + providerSnapshotManager: createOpenCodeManager().manager, logger, }); const tool = registeredTool(server, "create_agent"); @@ -1727,6 +2022,7 @@ describe("create_agent MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, callerAgentId: "parent-agent", logger, }); @@ -1751,14 +2047,24 @@ describe("update_agent MCP tool", () => { it("does not register the replaced feature-specific MCP tool", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); expect(lookupTool(server, "set_agent_feature")).toBeUndefined(); }); it("updates runtime settings before metadata", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "update_agent"); const input = { agentId: "agent-1", @@ -1790,7 +2096,12 @@ describe("update_agent MCP tool", () => { it("reports success for a no-op update with neither metadata nor settings", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "update_agent"); const response = await tool.handler({ agentId: "agent-1" }); @@ -1806,7 +2117,12 @@ describe("update_agent MCP tool", () => { it("does not update metadata when runtime settings fail", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature")); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "update_agent"); await expect( @@ -1832,6 +2148,7 @@ describe("create_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { create } as unknown as ScheduleService, logger, }); @@ -1853,6 +2170,7 @@ describe("create_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { create } as unknown as ScheduleService, logger, }); @@ -1915,6 +2233,7 @@ describe("create_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { create } as unknown as ScheduleService, callerAgentId: "parent-agent", logger, @@ -1943,6 +2262,7 @@ describe("create_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { create } as unknown as ScheduleService, logger, }); @@ -1996,6 +2316,7 @@ describe("create_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { create } as unknown as ScheduleService, logger, }); @@ -2016,6 +2337,7 @@ describe("create_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { create } as unknown as ScheduleService, logger, }); @@ -2048,6 +2370,7 @@ describe("create_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { create } as unknown as ScheduleService, logger, }); @@ -2095,6 +2418,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2120,6 +2444,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2143,6 +2468,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2183,6 +2509,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2202,6 +2529,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2224,6 +2552,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2257,6 +2586,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2287,6 +2617,7 @@ describe("update_schedule MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { update } as unknown as ScheduleService, logger, }); @@ -2333,6 +2664,7 @@ describe("schedule_logs MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, scheduleService: { logs } as unknown as ScheduleService, logger, }); @@ -2349,6 +2681,7 @@ describe("schedule_logs MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, logger, }); const tool = registeredTool(server, "schedule_logs"); @@ -2364,25 +2697,28 @@ describe("provider listing MCP tool", () => { it("returns providers from the registry, including custom providers", async () => { const { agentManager, agentStorage } = createTestDeps(); - const providerRegistry = { - claude: createProviderDefinition({ - id: "claude", + const provStub = createProviderSnapshotManagerStub(); + provStub.listRegisteredProviderIds.mockReturnValue(["claude", "zai"]); + provStub.listProviders.mockResolvedValue([ + buildSnapshotEntry({ + provider: "claude", label: "Claude", + description: "Test provider", modes: [{ id: "default", label: "Default", description: "Built-in mode" }], }), - zai: createProviderDefinition({ - id: "zai", + buildSnapshotEntry({ + provider: "zai" as AgentProvider, label: "ZAI", description: "Custom Claude profile", defaultModeId: "default", modes: [{ id: "default", label: "Default", description: "Custom mode" }], }), - }; + ]); const server = await createAgentMcpServer({ agentManager, agentStorage, - providerRegistry, + providerSnapshotManager: provStub.manager, logger, }); const tool = registeredTool(server, "list_providers"); @@ -2414,27 +2750,52 @@ describe("provider listing MCP tool", () => { expect(modelVisibleText).toContain('"providers"'); }); + it("returns provider modes from the shared snapshot catalog", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const provStub = createProviderSnapshotManagerStub(); + provStub.listRegisteredProviderIds.mockReturnValue(["codex"]); + provStub.listProviders.mockResolvedValue([ + buildSnapshotEntry({ + provider: "codex", + label: "Codex", + modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime mode" }], + }), + ]); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: provStub.manager, + logger, + }); + const tool = registeredTool(server, "list_providers"); + + const response = await tool.handler({}); + + expect(response.structuredContent.providers).toEqual([ + expect.objectContaining({ + id: "codex", + modes: [{ id: "dynamic", label: "Dynamic", description: "Runtime 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", + const provStub = createProviderSnapshotManagerStub(); + provStub.listRegisteredProviderIds.mockReturnValue(["codex"]); + provStub.listProviders.mockResolvedValue([ + buildSnapshotEntry({ + provider: "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, + providerSnapshotManager: provStub.manager, logger, }); const tool = registeredTool(server, "list_providers"); @@ -2448,37 +2809,10 @@ describe("provider listing MCP tool", () => { description: "OpenAI coding agent", enabled: false, status: "unavailable", - modes: [{ id: "read-only", label: "Read Only", description: "No edits" }], + modes: [], }, ], }); - 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.handler({}); - - expect(providerRegistry.claude.createClient).toHaveBeenCalledTimes(1); - expect(isAvailable).toHaveBeenCalledTimes(1); }); }); @@ -2487,7 +2821,12 @@ describe("provider MCP tools", () => { it("does not register the replaced feature-specific provider discovery MCP tool", async () => { const { agentManager, agentStorage } = createTestDeps(); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); expect(lookupTool(server, "list_provider_features")).toBeUndefined(); }); @@ -2502,18 +2841,20 @@ describe("provider MCP tools", () => { value: false, }, ]); - const providerRegistry = { - codex: createProviderDefinition({ - id: "codex", - label: "Codex", - description: "OpenAI coding agent", - modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }], - }), - }; + const provStub = createProviderSnapshotManagerStub(); + provStub.listRegisteredProviderIds.mockReturnValue(["codex"]); + const codexEntry = buildSnapshotEntry({ + provider: "codex", + label: "Codex", + description: "OpenAI coding agent", + modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }], + }); + provStub.listProviders.mockResolvedValue([codexEntry]); + provStub.getProvider.mockResolvedValue(codexEntry); const server = await createAgentMcpServer({ agentManager, agentStorage, - providerRegistry, + providerSnapshotManager: provStub.manager, logger, }); const tool = registeredTool(server, "inspect_provider"); @@ -2561,26 +2902,13 @@ describe("provider MCP tools", () => { 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 provStub = createProviderSnapshotManagerStub(); + provStub.listRegisteredProviderIds.mockReturnValue(["codex"]); + provStub.listModels.mockRejectedValue(new Error("Provider 'codex' is disabled")); const server = await createAgentMcpServer({ agentManager, agentStorage, - providerRegistry, + providerSnapshotManager: provStub.manager, logger, }); const tool = registeredTool(server, "list_models"); @@ -2588,31 +2916,19 @@ describe("provider MCP tools", () => { await expect(tool.handler({ provider: "codex" })).rejects.toThrow( "Provider 'codex' is disabled", ); - expect(fetchModels).not.toHaveBeenCalled(); }); it("inspect_provider 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 provStub = createProviderSnapshotManagerStub(); + provStub.listRegisteredProviderIds.mockReturnValue(["codex"]); + provStub.getProvider.mockResolvedValue( + buildSnapshotEntry({ provider: "codex", label: "Codex", enabled: false }), + ); const server = await createAgentMcpServer({ agentManager, agentStorage, - providerRegistry, + providerSnapshotManager: provStub.manager, logger, }); const tool = registeredTool(server, "inspect_provider"); @@ -2620,7 +2936,6 @@ describe("provider MCP tools", () => { await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow( "Provider 'codex' is disabled", ); - expect(fetchModels).not.toHaveBeenCalled(); }); }); @@ -2633,6 +2948,7 @@ describe("speak MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, callerAgentId: "voice-agent-1", enableVoiceTools: true, resolveSpeakHandler: () => speak, @@ -2655,6 +2971,7 @@ describe("speak MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, callerAgentId: "voice-agent-2", enableVoiceTools: true, resolveSpeakHandler: () => null, @@ -2671,6 +2988,7 @@ describe("speak MCP tool", () => { const server = await createAgentMcpServer({ agentManager, agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, callerAgentId: "agent-no-voice", logger, }); @@ -2695,7 +3013,12 @@ describe("agent snapshot MCP serialization", () => { }), ]); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "list_agents"); const response = await tool.handler({}); const structured = z @@ -2746,9 +3069,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "get_agent_status"); const response = await tool.handler({ agentId: "archived-agent" }); @@ -2805,7 +3126,12 @@ describe("agent snapshot MCP serialization", () => { }), ); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "get_agent_status"); const response = await tool.handler({ agentId: "full-detail-agent" }); const snapshot = z.record(z.unknown()).parse(response.structuredContent.snapshot); @@ -2877,9 +3203,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "get_agent_status"); @@ -2921,9 +3245,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), callerAgentId: "caller-agent", }); const tool = registeredTool(server, "list_agents"); @@ -2974,9 +3296,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "list_agents"); const response = await tool.handler({ @@ -3015,9 +3335,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "list_agents"); const response = await tool.handler({ includeArchived: true }); @@ -3058,9 +3376,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "list_agents"); const response = await tool.handler({ cwd: REPO_CWD, includeArchived: true }); @@ -3134,7 +3450,12 @@ describe("agent snapshot MCP serialization", () => { }), ]); - const server = await createAgentMcpServer({ agentManager, agentStorage, logger }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); const tool = registeredTool(server, "list_agents"); const response = await tool.handler({}); @@ -3166,9 +3487,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "list_agents"); const response = await tool.handler({ includeArchived: true }); @@ -3206,9 +3525,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger, - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "get_agent_activity"); const response = await tool.handler({ agentId: "archived-activity-agent" }); @@ -3244,9 +3561,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger: createTestLogger(), - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "get_agent_activity"); const response = await tool.handler({ agentId: "live-activity-agent", limit: 1 }); @@ -3275,9 +3590,7 @@ describe("agent snapshot MCP serialization", () => { agentManager, agentStorage, logger: createTestLogger(), - providerRegistry: { - claude: createProviderDefinition({}), - }, + providerSnapshotManager: createClaudeOnlyManager(), }); const tool = registeredTool(server, "get_agent_activity"); const response = await tool.handler({ agentId: "live-activity-agent-2", limit: 2 }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 448724f65..2c4b354d2 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -9,7 +9,7 @@ import type { ServerRequest, } from "@modelcontextprotocol/sdk/types.js"; -import type { AgentProvider } from "./agent-sdk-types.js"; +import type { AgentMode, AgentProvider } from "./agent-sdk-types.js"; import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; import { AgentFeatureSchema, @@ -47,8 +47,7 @@ import { type ScheduleCadence, type UpdateScheduleInput, } from "@getpaseo/protocol/schedule/types"; -import type { ProviderDefinition } from "./provider-registry.js"; -import { resolveSnapshotCwd } from "./provider-snapshot-manager.js"; +import { resolveSnapshotCwd, type ProviderSnapshotManager } from "./provider-snapshot-manager.js"; import { AgentModelSchema, AgentProviderEnum, @@ -88,7 +87,7 @@ export interface AgentMcpServerOptions { terminalManager?: TerminalManager | null; getDaemonTcpPort?: () => number | null; scheduleService?: ScheduleService | null; - providerRegistry?: Record | null; + providerSnapshotManager: ProviderSnapshotManager; github?: GitHubService; workspaceGitService?: Pick< WorkspaceGitService, @@ -117,47 +116,6 @@ export interface AgentMcpServerOptions { logger: Logger; } -const CLAUDE_TO_CODEX_MODE: Record = { - plan: "read-only", - default: "auto", - acceptEdits: "auto", - bypassPermissions: "full-access", -}; - -const CODEX_TO_CLAUDE_MODE: Record = { - "read-only": "plan", - auto: "default", - "full-access": "bypassPermissions", -}; - -function mapModeAcrossProviders( - sourceMode: string, - sourceProvider: AgentProvider, - targetProvider: AgentProvider, -): string { - if (sourceProvider === targetProvider) { - return sourceMode; - } - - if (sourceProvider === "claude" && targetProvider === "codex") { - const mapped = CLAUDE_TO_CODEX_MODE[sourceMode]; - if (mapped) { - return mapped; - } - return "auto"; - } - - if (sourceProvider === "codex" && targetProvider === "claude") { - const mapped = CODEX_TO_CLAUDE_MODE[sourceMode]; - if (mapped) { - return mapped; - } - return "default"; - } - - return sourceMode; -} - function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult { if (result.structuredContent === undefined || result.content.length > 0) { return result; @@ -257,48 +215,34 @@ function resolveAgentListActivityTime(agent: AgentListItemPayload): number { ); } -function resolveRegisteredProviderIds( - agentManager: AgentManager, - providerRegistry: Record | null | undefined, -): AgentProvider[] { - return providerRegistry ? Object.keys(providerRegistry) : agentManager.getRegisteredProviderIds(); -} - interface ProviderSummary { id: AgentProvider; label: string; description: string; enabled: boolean; - modes: ProviderDefinition["modes"]; + modes: AgentMode[]; status: string; error?: string; } -async function resolveProviderSummary( - provider: ProviderDefinition, - logger: Logger, -): Promise { - const base = { - id: provider.id, - label: provider.label, - description: provider.description, - modes: provider.modes, +function toProviderSummary(entry: { + provider: AgentProvider; + label?: string; + description?: string; + enabled: boolean; + modes?: AgentMode[]; + status: string; + error?: string; +}): ProviderSummary { + return { + id: entry.provider, + label: entry.label ?? entry.provider, + description: entry.description ?? "", + enabled: entry.enabled, + modes: entry.modes ?? [], + status: entry.status === "ready" ? "available" : entry.status, + ...(entry.error ? { error: entry.error } : {}), }; - 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 { @@ -539,7 +483,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom agentStorage, terminalManager, scheduleService, - providerRegistry, + providerSnapshotManager, callerAgentId, resolveSpeakHandler, resolveCallerContext, @@ -639,13 +583,9 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom return { provider: resolvedProvider, cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd, - ...(callerAgent.currentModeId + ...(callerAgent.currentModeId && callerAgent.provider === resolvedProvider ? { - modeId: mapModeAcrossProviders( - callerAgent.currentModeId, - callerAgent.provider, - resolvedProvider, - ), + modeId: callerAgent.currentModeId, } : {}), ...(resolvedModel ? { model: resolvedModel } : {}), @@ -927,7 +867,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom paseoHome: options.paseoHome, workspaceGitService: options.workspaceGitService, terminalManager, - providerRegistry, + providerSnapshotManager, createPaseoWorktree: options.createPaseoWorktree, }, { @@ -1249,7 +1189,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const structuredSnapshot = buildStoredAgentPayload( record, - resolveRegisteredProviderIds(agentManager, providerRegistry), + providerSnapshotManager.listRegisteredProviderIds(), ); return { content: [], @@ -1296,7 +1236,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom ); const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); const storedRecords = await agentStorage.list(); - const registeredProviderIds = resolveRegisteredProviderIds(agentManager, providerRegistry); + const registeredProviderIds = providerSnapshotManager.listRegisteredProviderIds(); const storedAgents = storedRecords .filter((record) => !record.internal && !liveIds.has(record.id)) .filter((record) => includeArchived || !record.archivedAt) @@ -1941,10 +1881,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async () => { - const providers = await Promise.all( - Object.values(providerRegistry ?? {}).map((provider) => - resolveProviderSummary(provider, childLogger), - ), + const providers = (await providerSnapshotManager.listProviders({ wait: true })).map( + toProviderSummary, ); return { content: [], @@ -1967,19 +1905,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ provider }) => { - if (!providerRegistry) { - throw new Error("Provider registry is not configured"); - } - - const definition = providerRegistry[provider]; - 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 }); + const models = await providerSnapshotManager.listModels({ + cwd: resolveSnapshotCwd(), + provider, + wait: true, + }); return { content: [], structuredContent: ensureValidJson({ @@ -2014,21 +1944,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom defaultProvider: provider, }); const providerId = resolvedProviderModel.provider; - if (!providerRegistry) { - throw new Error("Provider registry is not configured"); - } - const definition = providerRegistry[providerId]; - if (!definition) { - throw new Error(`Provider ${providerId} is not configured`); - } - const summary = await resolveProviderSummary(definition, childLogger); - if (!definition.enabled) { + const resolvedCwd = resolveScopedCwd(cwd, { required: true }); + const entry = await providerSnapshotManager.getProvider({ + cwd: resolvedCwd, + provider: providerId, + wait: true, + }); + const summary = toProviderSummary(entry); + if (!entry.enabled) { throw new Error(`Provider '${providerId}' is disabled`); } - if (summary.status !== "available") { - throw new Error(summary.error ?? `Provider '${providerId}' is unavailable`); + if (entry.status !== "ready") { + throw new Error(entry.error ?? `Provider '${providerId}' is unavailable`); } - const resolvedCwd = resolveScopedCwd(cwd, { required: true }); const selectedModel = settings?.model ?? resolvedProviderModel.model; const features = await agentManager.listDraftFeatures({ provider: providerId, diff --git a/packages/server/src/server/agent/model-resolver.test.ts b/packages/server/src/server/agent/model-resolver.test.ts deleted file mode 100644 index 2c25517a7..000000000 --- a/packages/server/src/server/agent/model-resolver.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { resolveAgentModel } from "./model-resolver.js"; - -vi.mock("./provider-registry.js", () => ({ - buildProviderRegistry: vi.fn(), - isProviderEnabled: vi.fn((definition: { enabled: boolean }) => definition.enabled), -})); - -import { buildProviderRegistry } from "./provider-registry.js"; - -const mockedBuildProviderRegistry = vi.mocked(buildProviderRegistry); -const testLogger = createTestLogger(); -const testLoggerWarn = vi.spyOn(testLogger, "warn"); -type ProviderRegistryMock = ReturnType; - -function makeMockRegistry( - entries: Record }>, -): ProviderRegistryMock { - const registry: ProviderRegistryMock = Object.create(null); - for (const [key, val] of Object.entries(entries)) { - Reflect.set(registry, key, val); - } - return registry; -} - -describe("resolveAgentModel", () => { - beforeEach(() => { - mockedBuildProviderRegistry.mockReset(); - testLoggerWarn.mockClear(); - }); - - it("returns the trimmed requested model when provided", async () => { - mockedBuildProviderRegistry.mockReturnValue( - makeMockRegistry({ - claude: { enabled: true, fetchModels: vi.fn() }, - codex: { enabled: true, fetchModels: vi.fn() }, - opencode: { enabled: true, fetchModels: vi.fn() }, - }), - ); - - const result = await resolveAgentModel({ - provider: "codex", - requestedModel: " gpt-5.1 ", - cwd: "/tmp", - logger: testLogger, - }); - - expect(result).toBe("gpt-5.1"); - expect(mockedBuildProviderRegistry).toHaveBeenCalledWith(testLogger); - }); - - it("uses the default model from the provider catalog when no model specified", async () => { - const fetchModels = vi.fn().mockResolvedValue([ - { id: "claude-3.5-haiku", isDefault: false }, - { id: "claude-3.5-sonnet", isDefault: true }, - ]); - mockedBuildProviderRegistry.mockReturnValue( - makeMockRegistry({ - claude: { enabled: true, fetchModels }, - codex: { enabled: true, fetchModels: vi.fn() }, - opencode: { enabled: true, fetchModels: vi.fn() }, - }), - ); - - const result = await resolveAgentModel({ - provider: "claude", - cwd: "~/repo", - logger: testLogger, - }); - - expect(result).toBe("claude-3.5-sonnet"); - expect(fetchModels).toHaveBeenCalledWith({ - cwd: expect.stringMatching(/repo$/), - force: false, - }); - }); - - it("falls back to the first model when none are flagged as default", async () => { - const fetchModels = vi.fn().mockResolvedValue([ - { id: "model-a", isDefault: false }, - { id: "model-b", isDefault: false }, - ]); - mockedBuildProviderRegistry.mockReturnValue( - makeMockRegistry({ - claude: { enabled: true, fetchModels: vi.fn() }, - codex: { enabled: true, fetchModels }, - opencode: { enabled: true, fetchModels: vi.fn() }, - }), - ); - - const result = await resolveAgentModel({ provider: "codex", logger: testLogger }); - - expect(result).toBe("model-a"); - }); - - it("returns undefined when the catalog lookup fails", async () => { - const fetchModels = vi.fn().mockRejectedValue(new Error("boom")); - mockedBuildProviderRegistry.mockReturnValue( - makeMockRegistry({ - claude: { enabled: true, fetchModels: vi.fn() }, - codex: { enabled: true, fetchModels }, - opencode: { enabled: true, fetchModels: vi.fn() }, - }), - ); - - const result = await resolveAgentModel({ provider: "codex", logger: testLogger }); - - 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( - makeMockRegistry({ - claude: { enabled: true, fetchModels: vi.fn() }, - codex: { enabled: false, fetchModels }, - opencode: { enabled: true, fetchModels: vi.fn() }, - }), - ); - - 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( - makeMockRegistry({ - claude: { enabled: true, fetchModels: vi.fn() }, - codex: { enabled: false, fetchModels }, - opencode: { enabled: true, fetchModels: vi.fn() }, - }), - ); - - const result = await resolveAgentModel({ - provider: "codex", - requestedModel: "gpt-5.1", - logger: testLogger, - }); - - expect(result).toBeUndefined(); - expect(fetchModels).not.toHaveBeenCalled(); - expect(testLoggerWarn).toHaveBeenCalled(); - }); -}); diff --git a/packages/server/src/server/agent/model-resolver.ts b/packages/server/src/server/agent/model-resolver.ts deleted file mode 100644 index 27f8008d8..000000000 --- a/packages/server/src/server/agent/model-resolver.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { buildProviderRegistry } from "./provider-registry.js"; -import { resolveSnapshotCwd } from "./provider-snapshot-manager.js"; -import type { AgentProvider } from "./agent-sdk-types.js"; -import { expandTilde } from "../../utils/path.js"; -import type { Logger } from "pino"; - -interface ResolveAgentModelOptions { - provider: AgentProvider; - requestedModel?: string | null; - cwd?: string; - logger: Logger; -} - -export async function resolveAgentModel( - options: ResolveAgentModelOptions, -): Promise { - try { - const providerRegistry = buildProviderRegistry(options.logger); - 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, - }); - const preferred = models.find((model) => model.isDefault) ?? models[0]; - return preferred?.id; - } catch (error) { - options.logger.warn( - { err: error, provider: options.provider }, - "Failed to resolve default model", - ); - return undefined; - } -} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index b479f93e1..b474047fe 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -2,6 +2,7 @@ import type { Logger } from "pino"; import type { AgentClient, + AgentCreateConfigUnattendedInput, AgentMode, AgentModelDefinition, AgentPersistenceHandle, @@ -13,7 +14,13 @@ import type { ListModesOptions, ListPersistedAgentsOptions, PersistedAgentDescriptor, + ResolveAgentCreateConfigInput, + ResolveAgentCreateConfigResult, } from "./agent-sdk-types.js"; +import { + isDefaultAgentCreateConfigUnattended, + resolveDefaultAgentCreateConfig, +} from "./create-agent-mode.js"; import { normalizeAgentModelDefinition } from "./agent-sdk-types.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; import type { @@ -28,7 +35,6 @@ import { CopilotACPAgentClient } from "./providers/copilot-acp-agent.js"; import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; -import { OpenCodeServerManager } from "./providers/opencode/server-manager.js"; import { PiRpcAgentClient } from "./providers/pi/agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; @@ -57,6 +63,8 @@ export interface ProviderDefinition extends AgentProviderDefinition { */ derivedFromProviderId: string | null; createClient: (logger: Logger) => AgentClient; + resolveCreateConfig: (input: ResolveAgentCreateConfigInput) => ResolveAgentCreateConfigResult; + isCreateConfigUnattended: (input: AgentCreateConfigUnattendedInput) => boolean; fetchModels: (options: ListModelsOptions) => Promise; fetchModes: (options: ListModesOptions) => Promise; } @@ -414,6 +422,8 @@ function wrapClientProvider( profileModelsAreAdditive, }), listModes: inner.listModes?.bind(inner), + resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), + isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listPersistedAgents: listPersistedAgents ? async (options?: ListPersistedAgentsOptions) => (await listPersistedAgents(options)).map((descriptor) => @@ -438,6 +448,9 @@ function createRegistryEntry( derivedFromProviderId: resolved.derivedFromProviderId, createClient: (providerLogger: Logger) => createResolvedProviderClient(providerLogger, provider, resolved), + resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig, + isCreateConfigUnattended: + modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended, fetchModels: async (options: ListModelsOptions) => mergeModels( provider, @@ -673,5 +686,22 @@ export async function shutdownProviders( logger: Logger, options?: BuildProviderRegistryOptions, ): Promise { - await OpenCodeServerManager.getInstance(logger, options?.runtimeSettings?.opencode).shutdown(); + const clients = createAllClients(logger, options); + await shutdownAgentClients(Object.values(clients), logger); +} + +export async function shutdownAgentClients( + clients: Iterable, + logger: Logger, +): Promise { + await Promise.all( + Array.from(clients).map(async (client) => { + if (!client.shutdown) return; + try { + await client.shutdown(); + } catch (error) { + logger.warn({ err: error, provider: client.provider }, "Provider client shutdown failed"); + } + }), + ); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 92d73d8f0..508c6026a 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,46 +1,15 @@ -import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { AgentClient, - AgentMode, AgentModelDefinition, AgentProvider, - ListModesOptions, ListModelsOptions, - ProviderSnapshotEntry, } from "./agent-sdk-types.js"; -import type { ProviderDefinition } from "./provider-registry.js"; import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; -interface Deferred { - promise: Promise; - resolve: (value: T) => void; - reject: (reason?: unknown) => void; -} - -interface MockProviderOptions { - provider: AgentProvider; - enabled?: boolean; - label?: string; - description?: string; - defaultModeId?: string | null; - modes?: AgentMode[]; - isAvailable?: () => Promise; - fetchModels?: (cwd: string, force: boolean) => Promise; - fetchModes?: (cwd: string, force: boolean) => Promise; -} - -interface MockProviderHandle { - definition: ProviderDefinition; - createClient: ReturnType; - isAvailable: ReturnType; - fetchModels: ReturnType; - fetchModes: ReturnType; -} - const TEST_CAPABILITIES = { supportsStreaming: false, supportsSessionPersistence: false, @@ -50,1322 +19,486 @@ const TEST_CAPABILITIES = { supportsToolInvocations: false, } as const; -describe("ProviderSnapshotManager", () => { - const projectCwd = resolve("/tmp/project"); - const projectACwd = resolve("/tmp/project-a"); - const projectBCwd = resolve("/tmp/project-b"); +// Builds an AgentClient that can be injected via the public extraClients option. +// extraClients is the only injection surface the manager exposes for tests. +function createExtraClient( + provider: AgentProvider, + overrides: Partial = {}, +): AgentClient { + return { + provider, + capabilities: TEST_CAPABILITIES, + async createSession() { + throw new Error("not implemented"); + }, + async resumeSession() { + throw new Error("not implemented"); + }, + async listModels(_options: ListModelsOptions) { + return [] as AgentModelDefinition[]; + }, + async isAvailable() { + return false; + }, + ...overrides, + } satisfies AgentClient; +} - test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => { - const codexModels = deferred(); - const claudeModels = deferred(); - const { registry, handles } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => codexModels.promise, - }), - createMockProvider({ - provider: "claude", - fetchModels: async () => claudeModels.promise, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - const snapshot = manager.getSnapshot(projectCwd); - - expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "claude"]); - expect(getProviderEntry(snapshot, "claude")).toMatchObject({ - provider: "claude", - status: "loading", - label: "claude", - description: "claude test provider", - defaultModeId: null, - }); - expect(getProviderEntry(snapshot, "codex")).toMatchObject({ - provider: "codex", - status: "loading", - label: "codex", - description: "codex test provider", - defaultModeId: null, - }); - - await vi.waitFor(() => { - expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); - expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); - }); - - manager.destroy(); - codexModels.resolve([]); - claudeModels.resolve([]); +describe("ProviderSnapshotManager public surface", () => { + test("listRegisteredProviderIds includes the built-in providers", () => { + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + const ids = manager.listRegisteredProviderIds(); + expect(ids).toEqual(expect.arrayContaining(["claude", "codex", "opencode", "copilot", "pi"])); + } finally { + manager.destroy(); + } }); - test("after warmUp completes, getSnapshot returns ready entries with models", async () => { - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => [createModel("codex", "gpt-5.2")], - fetchModes: async () => [createMode("auto")], - }), - 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), "claude")?.status).toBe("ready"); - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); - }); - - const snapshot = manager.getSnapshot(projectCwd); - expect(getProviderEntry(snapshot, "codex")).toMatchObject({ - provider: "codex", - status: "ready", - models: [createModel("codex", "gpt-5.2")], - modes: [createMode("auto")], - label: "codex", - description: "codex test provider", - defaultModeId: null, - }); - expect(getProviderEntry(snapshot, "claude")).toMatchObject({ - provider: "claude", - status: "ready", - models: [createModel("claude", "sonnet")], - modes: [createMode("default")], - label: "claude", - description: "claude test provider", - defaultModeId: null, - }); - expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String)); - - manager.destroy(); - }); - - test("provider that fails isAvailable shows as unavailable", async () => { - const { registry, handles } = createRegistry([ - createMockProvider({ - provider: "codex", - isAvailable: async () => false, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(manager.getSnapshot(projectCwd)).toEqual([ - { - provider: "codex", - status: "unavailable", - enabled: true, - label: "codex", - description: "codex test provider", - defaultModeId: null, - }, - ]); - }); - - expect(handles.codex?.fetchModels).not.toHaveBeenCalled(); - expect(handles.codex?.fetchModes).not.toHaveBeenCalled(); - - manager.destroy(); - }); - - test("provider that fails fetchModels shows as error with error message", async () => { - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => { - throw new Error("model lookup failed"); - }, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(manager.getSnapshot(projectCwd)).toEqual([ - { - provider: "codex", - status: "error", - enabled: true, - error: "model lookup failed", - label: "codex", - description: "codex test provider", - defaultModeId: null, - }, - ]); - }); - - manager.destroy(); - }); - - test("change event fires for each provider as it resolves", async () => { - const codexModels = deferred(); - const claudeModels = deferred(); - const codexModes = deferred(); - const claudeModes = deferred(); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => codexModels.promise, - fetchModes: async () => codexModes.promise, - }), - createMockProvider({ - provider: "claude", - fetchModels: async () => claudeModels.promise, - fetchModes: async () => claudeModes.promise, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - const changes: Array<{ cwd: string; entries: ProviderSnapshotEntry[] }> = []; - const listener = (entries: ProviderSnapshotEntry[], cwd: string) => { - changes.push({ cwd, entries }); - }; - manager.on("change", listener); - - manager.getSnapshot(projectCwd); - - claudeModels.resolve([createModel("claude", "sonnet")]); - claudeModes.resolve([createMode("default")]); - - await vi.waitFor(() => { - expect(changes).toHaveLength(1); - }); - - expect(changes[0]?.cwd).toBe(projectCwd); - expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready"); - expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); - - codexModels.resolve([createModel("codex", "gpt-5.2")]); - codexModes.resolve([createMode("auto")]); - - await vi.waitFor(() => { - expect(changes).toHaveLength(2); - }); - - expect(getProviderEntry(changes[1]?.entries ?? [], "codex")?.status).toBe("ready"); - expect(getProviderEntry(changes[1]?.entries ?? [], "claude")?.status).toBe("ready"); - - manager.off("change", listener); - manager.destroy(); - }); - - test("refresh re-fetches and updates entries", async () => { - const codexFetchModels = vi - .fn<(options?: { cwd?: string }) => Promise>() - .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) - .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd) => codexFetchModels({ cwd }), - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.1", - ); - }); - - manager.refresh({ cwd: projectCwd }); - expect(manager.getSnapshot(projectCwd)).toEqual([ - { - provider: "codex", - status: "loading", - enabled: true, - label: "codex", - description: "codex test provider", - defaultModeId: null, + test("hasProvider reflects the built-in set and providerOverrides additions", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + "zai-claude": { extends: "claude", label: "ZAI", enabled: true }, }, - ]); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.2", - ); }); - - expect(codexFetchModels).toHaveBeenCalledTimes(2); - - manager.destroy(); + try { + expect(manager.hasProvider("claude")).toBe(true); + expect(manager.hasProvider("zai-claude")).toBe(true); + expect(manager.hasProvider("not-a-provider" as AgentProvider)).toBe(false); + } finally { + manager.destroy(); + } }); - test("refresh with providers only re-fetches matching providers", async () => { - const codexFetchModels = vi - .fn<() => Promise>() - .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) - .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); - const claudeFetchModels = vi - .fn<() => Promise>() - .mockResolvedValueOnce([createModel("claude", "sonnet-4")]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: codexFetchModels, - fetchModes: async () => [createMode("auto")], - }), - createMockProvider({ - provider: "claude", - fetchModels: claudeFetchModels, - fetchModes: async () => [createMode("default")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.1", - ); - expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( - "sonnet-4", - ); - }); - - manager.refresh({ cwd: projectCwd, providers: ["codex"] }); - - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading"); - expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({ - provider: "claude", - status: "ready", - models: [createModel("claude", "sonnet-4")], - }); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.2", - ); - }); - - expect(codexFetchModels).toHaveBeenCalledTimes(2); - expect(claudeFetchModels).toHaveBeenCalledTimes(1); - - manager.destroy(); - }); - - test("refresh treats an empty providers list as a full refresh", async () => { - const codexFetchModels = vi - .fn<() => Promise>() - .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) - .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); - const claudeFetchModels = vi - .fn<() => Promise>() - .mockResolvedValueOnce([createModel("claude", "sonnet-4")]) - .mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: codexFetchModels, - fetchModes: async () => [createMode("auto")], - }), - createMockProvider({ - provider: "claude", - fetchModels: claudeFetchModels, - 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"); - }); - - manager.refresh({ cwd: projectCwd, providers: [] }); - - expect(manager.getSnapshot(projectCwd)).toEqual([ - { - provider: "codex", - status: "loading", - enabled: true, - label: "codex", - description: "codex test provider", - defaultModeId: null, + test("getProviderLabel returns the override label when provided", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + "qwen-codex": { extends: "codex", label: "Qwen Code", enabled: true }, }, - { - provider: "claude", - status: "loading", - enabled: true, - label: "claude", - description: "claude test provider", - defaultModeId: null, - }, - ]); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.2", - ); - expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( - "sonnet-4.5", - ); }); - - expect(codexFetchModels).toHaveBeenCalledTimes(2); - expect(claudeFetchModels).toHaveBeenCalledTimes(2); - - manager.destroy(); + try { + expect(manager.getProviderLabel("qwen-codex")).toBe("Qwen Code"); + expect(manager.getProviderLabel("claude")).toBe("Claude"); + } finally { + manager.destroy(); + } }); - test("refresh ignores provider filters that are not in the registry", async () => { - const codexFetchModels = vi - .fn<() => Promise>() - .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: codexFetchModels, - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); - }); - - manager.refresh({ cwd: projectCwd, providers: ["zai"] }); - - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ - provider: "codex", - status: "ready", - models: [createModel("codex", "gpt-5.1")], - }); - expect(codexFetchModels).toHaveBeenCalledTimes(1); - - manager.destroy(); + test("getSnapshot returns loading entries for built-in providers before warmup", () => { + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + const snapshot = manager.getSnapshot("/tmp/project"); + const claude = snapshot.find((entry) => entry.provider === "claude"); + expect(claude?.status).toBe("loading"); + expect(claude?.label).toBe("Claude"); + expect(claude?.defaultModeId).toBe("default"); + } finally { + manager.destroy(); + } }); - test("explicit refresh bypasses an in-flight background warm-up", async () => { - const initialFetchModels = deferred(); - const explicitFetchModels = deferred(); - const { registry, handles } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: vi - .fn<() => Promise>() - .mockImplementationOnce(async () => initialFetchModels.promise) - .mockImplementationOnce(async () => explicitFetchModels.promise), - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - expect(manager.getSnapshot(projectCwd)).toEqual([ - { - provider: "codex", - status: "loading", - enabled: true, - label: "codex", - description: "codex test provider", - defaultModeId: null, + test("providerOverrides with enabled:false marks the provider as unavailable without probing", async () => { + const isAvailable = vi.fn(async () => true); + const fetchModels = vi.fn(async () => [] as AgentModelDefinition[]); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { + codex: createExtraClient("codex", { isAvailable, listModels: fetchModels }), }, - ]); - - await vi.waitFor(() => { - expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); }); + try { + const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true }); + const codex = entries.find((entry) => entry.provider === "codex"); + expect(codex).toMatchObject({ provider: "codex", enabled: false, status: "unavailable" }); + expect(isAvailable).not.toHaveBeenCalled(); + expect(fetchModels).not.toHaveBeenCalled(); + } finally { + manager.destroy(); + } + }); - const refreshPromise = manager.refresh({ cwd: projectCwd, providers: ["codex"] }); - - await vi.waitFor(() => { - expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + test("extraClients with isAvailable=false routes to unavailable without fetching", async () => { + const isAvailable = vi.fn().mockResolvedValue(false); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, }); - - explicitFetchModels.resolve([createModel("codex", "gpt-5.2")]); - await refreshPromise; - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", provider: "codex", - status: "ready", - models: [createModel("codex", "gpt-5.2")], - modes: [createMode("auto")], + wait: true, }); - }); - - initialFetchModels.resolve([createModel("codex", "stale-background-model")]); - - await Promise.resolve(); - - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.2", - ); - expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); - expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(2); - - manager.destroy(); + expect(entry.provider).toBe("codex"); + expect(entry.status).toBe("unavailable"); + expect(isAvailable).toHaveBeenCalledTimes(1); + } finally { + manager.destroy(); + } }); - test("warmUpSnapshotForCwd awaits an in-flight loading provider without force", async () => { - const loadingFetchModels = deferred(); - const fetchModels = vi - .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementation(async (_cwd, _force) => loadingFetchModels.promise); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd, force) => fetchModels(cwd, force), - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledTimes(1); + test("listProviders returns an entry per registered provider", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, }); - - const warmUpPromise = manager.warmUpSnapshotForCwd({ - cwd: projectCwd, - providers: ["codex"], - }); - - await Promise.resolve(); - - expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).toHaveBeenCalledWith(projectCwd, false); - expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true); - - loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]); - await warmUpPromise; - - expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true); - - manager.destroy(); + try { + const entries = await manager.listProviders({ cwd: "/tmp/project", wait: true }); + const providers = entries.map((entry) => entry.provider).sort(); + expect(providers).toEqual(["claude", "codex", "copilot", "opencode", "pi"]); + for (const entry of entries) { + expect(entry.enabled).toBe(false); + expect(entry.status).toBe("unavailable"); + } + } finally { + manager.destroy(); + } }); - test("settings refresh clears workspace scopes and refreshes only home immediately", async () => { - const fetchModels = vi - .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementation(async (_cwd, force) => [ - createModel("codex", force ? "refreshed" : "initial"), - ]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd, force) => fetchModels(cwd, force), - fetchModes: async () => [createMode("auto")], - }), - createMockProvider({ - provider: "claude", - fetchModels: async (cwd) => [createModel("claude", cwd)], - fetchModes: async () => [createMode("default")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectACwd); - manager.getSnapshot(projectBCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.status).toBe("ready"); - expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready"); + test("getProvider throws when the provider is not configured", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { codex: { enabled: false } }, }); - - await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - - expect(fetchModels.mock.calls).toEqual([ - [projectACwd, false], - [projectBCwd, false], - [homedir(), true], - ]); - - const homeSnapshot = manager.getSnapshot(); - expect(getProviderEntry(homeSnapshot, "codex")).toMatchObject({ - provider: "codex", - status: "ready", - models: [createModel("codex", "refreshed")], - }); - expect(fetchModels).toHaveBeenCalledTimes(3); - - const projectASnapshot = manager.getSnapshot(projectACwd); - expect(getProviderEntry(projectASnapshot, "codex")).toMatchObject({ - provider: "codex", - status: "loading", - }); - expect(getProviderEntry(projectASnapshot, "codex")?.models).toBeUndefined(); - expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready"); - - manager.destroy(); - }); - - test("settings refresh makes workspace reads refetch on demand", async () => { - const fetchModels = vi - .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementation(async (_cwd, force) => [ - createModel("codex", force ? "refreshed" : "initial"), - ]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd, force) => fetchModels(cwd, force), - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); - }); - - await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - - expect(fetchModels.mock.calls).toEqual([ - [projectCwd, false], - [homedir(), true], - ]); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "initial", - ); - }); - - manager.destroy(); - }); - - test("settings refresh prevents stale in-flight workspace loads from repopulating the cache", async () => { - const workspaceModels = deferred(); - const fetchModels = vi - .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementationOnce(async () => workspaceModels.promise) - .mockImplementationOnce(async (_cwd, force) => [ - createModel("codex", force ? "home-refreshed" : "unexpected"), - ]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd, force) => fetchModels(cwd, force), - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledTimes(1); - }); - - await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - workspaceModels.resolve([createModel("codex", "stale-workspace-model")]); - await Promise.resolve(); - - expect(getProviderEntry(manager.getSnapshot(), "codex")?.models?.[0]?.id).toBe( - "home-refreshed", - ); - const projectEntry = getProviderEntry(manager.getSnapshot(projectCwd), "codex"); - expect(projectEntry).toMatchObject({ - provider: "codex", - status: "loading", - }); - expect(projectEntry?.models).toBeUndefined(); - - manager.destroy(); - }); - - test("refresh marks a slow provider as error after the timeout", async () => { - const fetchModels = deferred(); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => fetchModels.promise, - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger(), { - refreshTimeoutMs: 5, - }); - - await manager.refresh({ cwd: projectCwd, providers: ["codex"] }); - - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ - provider: "codex", - status: "error", - error: "Timed out refreshing codex after 5ms", - }); - - manager.destroy(); - fetchModels.resolve([createModel("codex", "gpt-5.2")]); - }); - - test("warm getSnapshot keeps ready entries cached without probing again", async () => { - const fetchModels = vi - .fn<(cwd?: string) => Promise>() - .mockResolvedValue([createModel("codex", "gpt-5.1")]); - const { registry, handles } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd) => fetchModels(cwd), - fetchModes: async () => [createMode("auto")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.1", - ); - }); - - const firstWarmRead = manager.getSnapshot(projectCwd); - const secondWarmRead = manager.getSnapshot(projectCwd); - const thirdWarmRead = manager.getSnapshot(projectCwd); - - expect(getProviderEntry(firstWarmRead, "codex")).toMatchObject({ - provider: "codex", - status: "ready", - models: [createModel("codex", "gpt-5.1")], - modes: [createMode("auto")], - }); - expect(getProviderEntry(secondWarmRead, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); - expect(getProviderEntry(thirdWarmRead, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); - expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); - expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); - expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1); - - manager.destroy(); - }); - - test("warm error and unavailable entries stay cached until explicit refresh", async () => { - const unavailableFetchModels = vi - .fn<(cwd?: string) => Promise>() - .mockResolvedValue([createModel("codex", "gpt-5.2")]); - const unavailableIsAvailable = vi - .fn<() => Promise>() - .mockResolvedValueOnce(false) - .mockResolvedValueOnce(true); - const errorFetchModels = vi - .fn<(cwd?: string) => Promise>() - .mockRejectedValueOnce(new Error("model lookup failed")) - .mockResolvedValueOnce([createModel("claude", "sonnet")]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - isAvailable: unavailableIsAvailable, - fetchModels: async (cwd) => unavailableFetchModels(cwd), - fetchModes: async () => [createMode("auto")], - }), - createMockProvider({ - provider: "claude", - fetchModels: async (cwd) => errorFetchModels(cwd), - fetchModes: async () => [createMode("default")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe( - "unavailable", - ); - expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error"); - }); - - const firstWarmRead = manager.getSnapshot(projectCwd); - const secondWarmRead = manager.getSnapshot(projectCwd); - - expect(getProviderEntry(firstWarmRead, "codex")?.status).toBe("unavailable"); - expect(getProviderEntry(firstWarmRead, "claude")?.status).toBe("error"); - expect(getProviderEntry(secondWarmRead, "codex")?.status).toBe("unavailable"); - expect(getProviderEntry(secondWarmRead, "claude")?.status).toBe("error"); - expect(unavailableIsAvailable).toHaveBeenCalledTimes(1); - expect(errorFetchModels).toHaveBeenCalledTimes(1); - - await manager.refreshSnapshotForCwd({ cwd: projectCwd, providers: ["codex", "claude"] }); - - 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), "codex")?.models?.[0]?.id).toBe( - "gpt-5.2", - ); - expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( - "sonnet", - ); - expect(unavailableIsAvailable).toHaveBeenCalledTimes(2); - expect(errorFetchModels).toHaveBeenCalledTimes(2); - - manager.destroy(); - }); - - test("providers added after warm-up stay unprobed until explicit refresh", async () => { - const initial = createMockProvider({ - provider: "codex", - fetchModels: async () => [createModel("codex", "gpt-5.1")], - fetchModes: async () => [createMode("auto")], - }); - const added = createMockProvider({ - provider: "zai", - label: "Z.AI", - fetchModels: async () => [createModel("zai", "glm-4.6")], - fetchModes: async () => [createMode("plan")], - }); - const { registry } = createRegistry([initial]); - const { registry: nextRegistry, handles: nextHandles } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => [createModel("codex", "gpt-5.1")], - fetchModes: async () => [createMode("auto")], - }), - added, - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( - "gpt-5.1", - ); - }); - - manager.replaceRegistry(nextRegistry); - - const snapshot = manager.getSnapshot(projectCwd); - expect(getProviderEntry(snapshot, "zai")).toMatchObject({ - provider: "zai", - status: "unavailable", - enabled: true, - label: "Z.AI", - }); - expect(nextHandles.zai?.createClient).not.toHaveBeenCalled(); - expect(nextHandles.zai?.fetchModels).not.toHaveBeenCalled(); - - await manager.refreshSnapshotForCwd({ cwd: projectCwd, providers: ["zai"] }); - - expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({ - provider: "zai", - status: "ready", - models: [createModel("zai", "glm-4.6")], - modes: [createMode("plan")], - }); - expect(nextHandles.zai?.createClient).toHaveBeenCalledTimes(1); - expect(nextHandles.zai?.fetchModels).toHaveBeenCalledTimes(1); - - manager.destroy(); - }); - - test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { - const codexModels = deferred(); - const { registry, handles } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => codexModels.promise, - }), - createMockProvider({ - provider: "claude", - fetchModels: async () => [], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - manager.getSnapshot(projectCwd); - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); - expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); - expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1); - expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1); - }); - - codexModels.resolve([createModel("codex", "gpt-5.2")]); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); - }); - - manager.destroy(); - }); - - test("different cwd keys keep independent provider snapshot state", async () => { - const seenCwds: string[] = []; - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd) => { - seenCwds.push(cwd ?? "__missing__"); - return [createModel("codex", `model:${cwd}`)]; - }, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectACwd); - manager.getSnapshot(projectBCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.status).toBe("ready"); - expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready"); - }); - - expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe( - `model:${projectACwd}`, - ); - expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe( - `model:${projectBCwd}`, - ); - expect(seenCwds).toEqual([projectACwd, projectBCwd]); - - manager.destroy(); - }); - - test("missing cwd resolves to home and shares the explicit home cache entry", async () => { - const seenCwds: string[] = []; - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd) => { - seenCwds.push(cwd); - return [createModel("codex", cwd)]; - }, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(homedir()), "codex")?.status).toBe("ready"); - }); - - manager.getSnapshot(" "); - - expect(seenCwds).toEqual([homedir()]); - expect(getProviderEntry(manager.getSnapshot(), "codex")?.models?.[0]?.id).toBe(homedir()); - - manager.destroy(); - }); - - test("workspace cwd is normalized before provider model fetching", async () => { - const seenCwds: string[] = []; - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd) => { - seenCwds.push(cwd); - return [createModel("codex", cwd)]; - }, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot("~/paseo-provider-test/../paseo-provider-test/"); - manager.getSnapshot("relative-provider-test/.."); - - await vi.waitFor(() => { - expect(seenCwds).toHaveLength(2); - }); - - expect(seenCwds).toEqual([ - resolve(homedir(), "paseo-provider-test"), - resolve("relative-provider-test/.."), - ]); - - manager.destroy(); - }); - - test("workspace refresh refreshes only that cwd with force true", async () => { - const fetchModels = vi - .fn<(cwd: string, force: boolean) => Promise>() - .mockImplementation(async (_cwd, force) => [ - createModel("codex", force ? "refreshed" : "initial"), - ]); - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async (cwd, force) => fetchModels(cwd, force), - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectACwd); - manager.getSnapshot(projectBCwd); - - await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledTimes(2); - }); - - await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] }); - - expect(fetchModels.mock.calls).toEqual([ - [projectACwd, false], - [projectBCwd, false], - [projectACwd, true], - ]); - expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe( - "initial", - ); - - manager.destroy(); - }); - - test("replaceRegistry removes providers that were disabled at runtime", async () => { - const { registry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => [createModel("codex", "gpt-5.2")], - }), - createMockProvider({ - provider: "claude", - fetchModels: async () => [createModel("claude", "sonnet")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - const listener = vi.fn<(entries: ProviderSnapshotEntry[], cwd: string) => void>(); - manager.on("change", listener); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); - }); - - const { registry: nextRegistry } = createRegistry([ - createMockProvider({ - provider: "codex", - fetchModels: async () => [createModel("codex", "gpt-5.2")], - }), - ]); - manager.replaceRegistry(nextRegistry); - - expect(manager.getSnapshot(projectCwd).map((entry) => entry.provider)).toEqual(["codex"]); - expect(listener).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - provider: "codex", + try { + await expect( + manager.getProvider({ + cwd: "/tmp/project", + provider: "not-a-provider" as AgentProvider, + wait: true, }), - ]), - projectCwd, - ); - - manager.destroy(); + ).rejects.toThrow(/not configured/); + } finally { + manager.destroy(); + } }); - test("replaceRegistry updates warmed provider metadata without probing", async () => { - const original = createMockProvider({ - provider: "codex", - fetchModels: async () => [createModel("codex", "gpt-5.1")], - fetchModes: async () => [createMode("auto")], + test("listModels rejects when the provider is disabled", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { codex: { enabled: false } }, }); - const updated = createMockProvider({ - provider: "codex", - label: "Codex CLI", - description: "Updated provider description", - defaultModeId: "agent", - fetchModels: async () => [createModel("codex", "gpt-5.2")], - fetchModes: async () => [createMode("agent")], - }); - const { registry, handles } = createRegistry([original]); - const { registry: nextRegistry, handles: nextHandles } = createRegistry([updated]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); - }); - - manager.replaceRegistry(nextRegistry); - await Promise.resolve(); - - expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ - provider: "codex", - status: "ready", - models: [createModel("codex", "gpt-5.1")], - modes: [createMode("auto")], - label: "Codex CLI", - description: "Updated provider description", - defaultModeId: "agent", - }); - expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); - expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1); - expect(nextHandles.codex?.createClient).not.toHaveBeenCalled(); - expect(nextHandles.codex?.fetchModels).not.toHaveBeenCalled(); - expect(nextHandles.codex?.fetchModes).not.toHaveBeenCalled(); - - manager.destroy(); + try { + await expect( + manager.listModels({ cwd: "/tmp/project", provider: "codex", wait: true }), + ).rejects.toThrow(/disabled/); + } finally { + manager.destroy(); + } }); - test("snapshot includes user-defined providers from the registry", async () => { - const { registry } = createRegistry([ - createMockProvider({ provider: "claude" }), - createMockProvider({ - provider: "zai", - label: "ZAI", - description: "Custom Claude profile", - defaultModeId: "default", - fetchModes: async () => [createMode("default")], - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - manager.getSnapshot(projectCwd); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("ready"); + test("listModes rejects when the provider is disabled", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { codex: { enabled: false } }, }); - - expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({ - provider: "zai", - status: "ready", - label: "ZAI", - description: "Custom Claude profile", - defaultModeId: "default", - }); - - manager.destroy(); + try { + await expect( + manager.listModes({ cwd: "/tmp/project", provider: "codex", wait: true }), + ).rejects.toThrow(/disabled/); + } finally { + 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({ + test("resolveDefaultModel returns the requested model verbatim when provided", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { codex: { enabled: false } }, + }); + try { + const id = await manager.resolveDefaultModel({ 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()); - - const snapshot = manager.getSnapshot(projectCwd); - - expect(snapshot.map((entry) => entry.provider)).toEqual(["claude"]); - expect(getProviderEntry(snapshot, "zai")).toBeUndefined(); - - manager.destroy(); - }); - - test("snapshot entries include label and description from the registry", async () => { - const models = deferred(); - const modes = deferred(); - const { registry } = createRegistry([ - createMockProvider({ - provider: "zai", - label: "ZAI", - description: "Custom Claude profile", - defaultModeId: "plan", - fetchModels: async () => models.promise, - fetchModes: async () => modes.promise, - }), - ]); - const manager = new ProviderSnapshotManager(registry, createTestLogger()); - - expect(manager.getSnapshot(projectCwd)).toEqual([ - { - provider: "zai", - status: "loading", - enabled: true, - label: "ZAI", - description: "Custom Claude profile", - defaultModeId: "plan", - }, - ]); - - models.resolve([createModel("zai", "zai-fast")]); - modes.resolve([createMode("plan")]); - - await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({ - provider: "zai", - status: "ready", - label: "ZAI", - description: "Custom Claude profile", - defaultModeId: "plan", + requestedModel: "gpt-5.4", + cwd: "/tmp/project", }); - }); + expect(id).toBe("gpt-5.4"); + } finally { + manager.destroy(); + } + }); - manager.destroy(); + test("resolveDefaultModel returns undefined when the provider is disabled and no override is given", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { codex: { enabled: false } }, + }); + try { + const id = await manager.resolveDefaultModel({ provider: "codex", cwd: "/tmp/project" }); + expect(id).toBeUndefined(); + } finally { + manager.destroy(); + } + }); + + test("getProviderDiagnostic returns the diagnostic from the injected client", async () => { + const getDiagnostic = vi.fn(async () => ({ diagnostic: "codex is ready" })); + const client = createExtraClient("codex", { getDiagnostic }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { codex: client }, + }); + try { + const result = await manager.getProviderDiagnostic("codex"); + expect(result).toEqual({ provider: "codex", diagnostic: "codex is ready" }); + expect(getDiagnostic).toHaveBeenCalledTimes(1); + } finally { + manager.destroy(); + } + }); + + test("getProviderDiagnostic falls back to a default message when the client has no getDiagnostic", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { codex: createExtraClient("codex") }, + }); + try { + const result = await manager.getProviderDiagnostic("codex"); + expect(result.provider).toBe("codex"); + expect(result.diagnostic).toMatch(/no diagnostic/i); + } finally { + manager.destroy(); + } + }); + + test("getProviderDiagnostic throws when no client is configured for the provider", async () => { + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + await expect(manager.getProviderDiagnostic("codex")).rejects.toThrow(/not configured/); + } finally { + manager.destroy(); + } + }); + + test("getAgentManagerProviderState exposes extraClients verbatim", () => { + const codexClient = createExtraClient("codex"); + const claudeClient = createExtraClient("claude"); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { opencode: { enabled: false }, copilot: { enabled: false } }, + extraClients: { codex: codexClient, claude: claudeClient }, + }); + try { + const state = manager.getAgentManagerProviderState(); + expect(state.clients.codex).toBe(codexClient); + expect(state.clients.claude).toBe(claudeClient); + expect(state.providerDefinitions.opencode).toMatchObject({ enabled: false }); + expect(state.providerDefinitions.codex).toMatchObject({ enabled: true }); + } finally { + manager.destroy(); + } }); }); -function deferred(): Deferred { - let resolvePromise!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolvePromise = res; - reject = rej; +describe("ProviderSnapshotManager applyMutableProviderConfig", () => { + test("adds a derived provider and includes it in subsequent reads", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + expect(manager.hasProvider("zai-claude")).toBe(false); + + const state = manager.applyMutableProviderConfig({ + "zai-claude": { extends: "claude", label: "ZAI", enabled: true }, + }); + + expect(manager.hasProvider("zai-claude")).toBe(true); + expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true }); + expect(manager.listRegisteredProviderIds()).toContain("zai-claude"); + } finally { + manager.destroy(); + } }); - return { promise, resolve: resolvePromise, reject }; -} -function createRegistry(handles: MockProviderHandle[]): { - registry: Record; - handles: Record; -} { - return { - registry: Object.fromEntries( - handles.map((handle) => [handle.definition.id, handle.definition]), - ) as Record, - handles: Object.fromEntries(handles.map((handle) => [handle.definition.id, handle])) as Record< - AgentProvider, - MockProviderHandle - >, - }; -} + test("drops disabled built-in providers from clients while preserving providerDefinitions", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: true }, + codex: { enabled: true }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + const before = manager.getAgentManagerProviderState(); + expect(before.providerDefinitions.copilot).toMatchObject({ enabled: false }); + expect(before.clients.copilot).toBeUndefined(); -function createMockProvider(options: MockProviderOptions): MockProviderHandle { - const createClient = vi.fn(); - const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true); - const fetchModels = vi.fn( - async (listOptions: ListModelsOptions) => - options.fetchModels?.(listOptions.cwd, listOptions.force) ?? [ - createModel(options.provider, `${options.provider}-default`), - ], - ); - const fetchModes = vi.fn( - async (listOptions: ListModesOptions) => - options.fetchModes?.(listOptions.cwd, listOptions.force) ?? [ - createMode(`${options.provider}-mode`), - ], - ); + const state = manager.applyMutableProviderConfig({ codex: { enabled: false } }); + expect(state.providerDefinitions.codex).toMatchObject({ enabled: false }); + expect(state.clients.codex).toBeUndefined(); + expect(state.providerDefinitions.copilot).toMatchObject({ enabled: false }); + expect(state.clients.copilot).toBeUndefined(); + } finally { + manager.destroy(); + } + }); - 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: options.modes ?? [], - createClient: () => { - createClient(); - return { - provider: options.provider, - capabilities: TEST_CAPABILITIES, - async createSession() { - throw new Error("not implemented"); - }, - async resumeSession() { - throw new Error("not implemented"); - }, - async listModels(_options: ListModelsOptions) { - return []; - }, - async isAvailable() { - return isAvailable(); - }, - } satisfies AgentClient; - }, - fetchModels, - fetchModes, - } satisfies ProviderDefinition; + test("fires a change event on every primed snapshot cwd after applyMutableProviderConfig", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + const listener = vi.fn(); + manager.on("change", listener); - return { - definition, - createClient, - isAvailable, - fetchModels, - fetchModes, - }; -} + // Prime two distinct cwd snapshots. resolve() makes the keys platform- + // native so Windows ("D:\\tmp\\...") matches the assertion below. + const cwdA = resolve("/tmp/project-a"); + const cwdB = resolve("/tmp/project-b"); + manager.getSnapshot(cwdA); + manager.getSnapshot(cwdB); -function createModel(provider: AgentProvider, id: string): AgentModelDefinition { - return { - provider, - id, - label: id, - }; -} + listener.mockClear(); + manager.applyMutableProviderConfig({ + "zai-claude": { extends: "claude", label: "ZAI", enabled: true }, + }); -function createMode(id: string): AgentMode { - return { - id, - label: id, - }; -} + const cwds = listener.mock.calls.map((call) => call[1]).sort(); + expect(cwds).toEqual([cwdA, cwdB].sort()); + } finally { + manager.destroy(); + } + }); +}); -function getProviderEntry( - entries: ProviderSnapshotEntry[], - provider: AgentProvider, -): ProviderSnapshotEntry | undefined { - return entries.find((entry) => entry.provider === provider); -} +describe("ProviderSnapshotManager lifecycle", () => { + test("on/off attaches and detaches change listeners", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + const listener = vi.fn(); + manager.on("change", listener); + manager.getSnapshot("/tmp/project"); + manager.applyMutableProviderConfig({}); + const firstCallCount = listener.mock.calls.length; + expect(firstCallCount).toBeGreaterThan(0); + + manager.off("change", listener); + manager.applyMutableProviderConfig({}); + expect(listener.mock.calls.length).toBe(firstCallCount); + } finally { + manager.destroy(); + } + }); + + test("destroy clears snapshots and prevents further change emissions", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + const listener = vi.fn(); + manager.on("change", listener); + manager.getSnapshot("/tmp/project"); + manager.destroy(); + + listener.mockClear(); + manager.applyMutableProviderConfig({}); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("ProviderSnapshotManager cwd routing", () => { + test("different cwd keys produce independent snapshots", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + const a = manager.getSnapshot("/tmp/project-a"); + const b = manager.getSnapshot("/tmp/project-b"); + expect(a).not.toBe(b); + expect(a.map((entry) => entry.provider).sort()).toEqual( + b.map((entry) => entry.provider).sort(), + ); + } finally { + manager.destroy(); + } + }); + + test("getSnapshot called with no cwd resolves to the home snapshot key", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + const listener = vi.fn(); + manager.on("change", listener); + manager.getSnapshot(); + manager.applyMutableProviderConfig({}); + const cwds = listener.mock.calls.map((call) => call[1]); + // applyMutableProviderConfig emits change for each primed cwd; the home + // snapshot must be present. + expect(cwds.length).toBeGreaterThanOrEqual(1); + for (const cwd of cwds) { + expect(typeof cwd).toBe("string"); + expect(cwd.length).toBeGreaterThan(0); + } + } finally { + manager.destroy(); + } + }); +}); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 507170c41..1d2d2fe17 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -4,20 +4,91 @@ import { resolve } from "node:path"; import type { Logger } from "pino"; +import { expandTilde } from "../../utils/path.js"; import { withTimeout } from "../../utils/promise-timeout.js"; -import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"; -import type { ProviderDefinition } from "./provider-registry.js"; +import type { + AgentClient, + AgentMode, + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { ManagedAgent } from "./agent-manager.js"; +import type { WorkspaceGitService } from "../workspace-git-service.js"; +import type { + AgentProviderRuntimeSettingsMap, + ProviderOverride, +} from "./provider-launch-config.js"; +import { + buildProviderRegistry, + shutdownAgentClients, + type ProviderDefinition, +} from "./provider-registry.js"; +import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js"; +import type { MutableDaemonConfig } from "../daemon-config-store.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 30_000; type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; -interface ProviderSnapshotManagerOptions { + +export interface ProviderSnapshotManagerOptions { + logger: Logger; + runtimeSettings?: AgentProviderRuntimeSettingsMap; + providerOverrides?: Record; + workspaceGitService?: Pick; + isDev?: boolean; + extraClients?: Partial>; refreshTimeoutMs?: number; } + interface ProviderSnapshotRefreshOptions { cwd: string; providers?: AgentProvider[]; } + +interface ProviderSnapshotReadOptions { + cwd?: string | null; + providers?: AgentProvider[]; + wait?: boolean; +} + +interface ProviderSnapshotProviderOptions { + cwd?: string | null; + provider: AgentProvider; + wait?: boolean; +} + +interface ResolveProviderCreateConfigOptions { + cwd?: string | null; + provider: AgentProvider; + requestedMode: string | undefined; + featureValues: Record | undefined; + parent: ManagedAgent | null; +} + +export interface ResolvedProviderCreateConfig { + modeId: string | undefined; + featureValues: Record | undefined; +} + +interface ResolveDefaultModelOptions { + provider: AgentProvider; + requestedModel?: string | null; + cwd?: string; +} + +export interface ProviderDiagnosticResult { + provider: AgentProvider; + diagnostic: string; +} + +export interface AgentManagerProviderState { + providerDefinitions: Partial< + Record + >; + clients: Partial>; +} + interface ProviderLoadOptions { cwd: string; providers: AgentProvider[]; @@ -33,15 +104,27 @@ export class ProviderSnapshotManager { private readonly events = new EventEmitter(); private destroyed = false; private readonly refreshTimeoutMs: number; + private readonly logger: Logger; + private readonly workspaceGitService?: Pick; + private readonly isDev: boolean; + private readonly extraClients: Partial>; + private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; + private providerOverrides: Record | undefined; + private readonly baseProviderOverrides: Record | undefined; private providerRegistry: Record; + private providerClients: Record; - constructor( - providerRegistry: Record, - private readonly logger: Logger, - options: ProviderSnapshotManagerOptions = {}, - ) { - this.providerRegistry = providerRegistry; + constructor(options: ProviderSnapshotManagerOptions) { + this.logger = options.logger; + this.workspaceGitService = options.workspaceGitService; + this.isDev = options.isDev === true; + this.extraClients = options.extraClients ?? {}; + this.runtimeSettings = options.runtimeSettings; + this.providerOverrides = options.providerOverrides; + this.baseProviderOverrides = options.providerOverrides; this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; + this.providerRegistry = this.buildRegistry(); + this.providerClients = { ...this.extraClients } as Record; } getSnapshot(cwd?: string): ProviderSnapshotEntry[] { @@ -118,6 +201,145 @@ export class ProviderSnapshotManager { await this.refreshSnapshotForCwd(options); } + listRegisteredProviderIds(): AgentProvider[] { + return this.getProviderIds(); + } + + hasProvider(provider: AgentProvider): boolean { + return Object.prototype.hasOwnProperty.call(this.providerRegistry, provider); + } + + getProviderLabel(provider: AgentProvider): string { + return this.providerRegistry[provider]?.label ?? provider; + } + + getAgentManagerProviderState(): AgentManagerProviderState { + const providerDefinitions: AgentManagerProviderState["providerDefinitions"] = {}; + const clients: AgentManagerProviderState["clients"] = {}; + for (const [provider, definition] of Object.entries(this.providerRegistry)) { + providerDefinitions[provider] = { + enabled: definition.enabled, + derivedFromProviderId: definition.derivedFromProviderId, + }; + if (definition.enabled) { + clients[provider] = this.ensureClient(provider, definition); + } + } + for (const [provider, client] of Object.entries(this.extraClients)) { + if (client) { + clients[provider] = client; + } + } + return { providerDefinitions, clients }; + } + + private ensureClient(provider: AgentProvider, definition: ProviderDefinition): AgentClient { + const existing = this.providerClients[provider]; + if (existing) { + return existing; + } + const client = definition.createClient(this.logger); + this.providerClients[provider] = client; + return client; + } + + async listProviders(input: ProviderSnapshotReadOptions = {}): Promise { + const cwd = resolveSnapshotCwd(input.cwd); + if (input.wait) { + await this.warmUpSnapshotForCwd({ cwd, providers: input.providers }); + } + const providerFilter = input.providers ? new Set(input.providers) : null; + const entries = this.getSnapshot(cwd); + return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries; + } + + async getProvider(input: ProviderSnapshotProviderOptions): Promise { + const entry = (await this.listProviders({ ...input, providers: [input.provider] })).find( + (candidate) => candidate.provider === input.provider, + ); + if (!entry) { + throw new Error(`Provider ${input.provider} is not configured`); + } + return entry; + } + + async listModels(input: ProviderSnapshotProviderOptions): Promise { + const entry = await this.getReadyProvider(input); + return entry.models ?? []; + } + + async listModes(input: ProviderSnapshotProviderOptions): Promise { + const entry = await this.getReadyProvider(input); + return entry.modes ?? []; + } + + async resolveDefaultModel(input: ResolveDefaultModelOptions): Promise { + try { + const trimmed = input.requestedModel?.trim(); + if (trimmed) { + return trimmed; + } + const models = await this.listModels({ + provider: input.provider, + cwd: input.cwd ? expandTilde(input.cwd) : undefined, + wait: true, + }); + const preferred = models.find((model) => model.isDefault) ?? models[0]; + return preferred?.id; + } catch (error) { + this.logger.warn({ err: error, provider: input.provider }, "Failed to resolve default model"); + return undefined; + } + } + + async resolveCreateConfig( + input: ResolveProviderCreateConfigOptions, + ): Promise { + const entry = await this.getReadyProvider({ + cwd: input.cwd, + provider: input.provider, + wait: true, + }); + const definition = this.requireProvider(input.provider); + return definition.resolveCreateConfig({ + provider: input.provider, + requestedMode: input.requestedMode, + featureValues: input.featureValues, + parent: input.parent ? this.resolveParent(input.parent) : null, + availableModes: entry.modes ?? [], + }); + } + + async getProviderDiagnostic(provider: AgentProvider): Promise { + const client = this.providerClients[provider]; + if (!client) { + throw new Error(`Provider ${provider} is not configured`); + } + const diagnostic = client.getDiagnostic + ? (await client.getDiagnostic()).diagnostic + : "No diagnostic available for this provider."; + return { provider, diagnostic }; + } + + applyMutableProviderConfig( + mutableProviders: MutableDaemonConfig["providers"] | undefined, + ): AgentManagerProviderState { + this.providerOverrides = applyMutableProviderConfigToOverrides( + this.baseProviderOverrides, + mutableProviders, + ); + this.providerRegistry = this.buildRegistry(); + this.providerClients = { ...this.extraClients } as Record; + + for (const cwd of this.snapshots.keys()) { + this.providerLoads.delete(cwd); + this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); + this.emitChange(cwd); + } + + return this.getAgentManagerProviderState(); + } + on(event: "change", listener: ProviderSnapshotChangeListener): this { this.events.on(event, listener); return this; @@ -128,6 +350,17 @@ export class ProviderSnapshotManager { return this; } + async shutdown(): Promise { + // Materialize a client per enabled provider so provider-owned resources + // (background processes, sockets, etc.) get a chance to release even when + // a given provider hasn't been touched yet during this daemon's lifetime. + const state = this.getAgentManagerProviderState(); + const clients = Object.values(state.clients).filter( + (client): client is AgentClient => client !== undefined, + ); + await shutdownAgentClients(clients, this.logger); + } + destroy(): void { this.destroyed = true; this.events.removeAllListeners(); @@ -135,14 +368,51 @@ export class ProviderSnapshotManager { this.providerLoads.clear(); } - replaceRegistry(providerRegistry: Record): void { - this.providerRegistry = providerRegistry; + private buildRegistry(): Record { + return buildProviderRegistry(this.logger, { + runtimeSettings: this.runtimeSettings, + providerOverrides: this.providerOverrides, + workspaceGitService: this.workspaceGitService, + isDev: this.isDev, + }); + } - for (const cwd of this.snapshots.keys()) { - this.providerLoads.delete(cwd); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); - this.emitChange(cwd); + private resolveParent(parent: ManagedAgent) { + const definition = this.requireProvider(parent.provider); + return { + provider: parent.provider, + modeId: parent.currentModeId, + isUnattended: definition.isCreateConfigUnattended({ + modeId: parent.currentModeId, + config: parent.config, + features: parent.features, + availableModes: parent.availableModes ?? definition.modes ?? [], + }), + }; + } + + private async getReadyProvider( + input: ProviderSnapshotProviderOptions, + ): Promise { + const entry = await this.getProvider(input); + if (!entry.enabled) { + throw new Error(`Provider '${entry.provider}' is disabled`); } + if (entry.status === "ready") { + return entry; + } + if (entry.status === "error") { + throw new Error(entry.error ?? `Failed to load provider '${entry.provider}'`); + } + throw new Error(`Provider '${entry.provider}' is not available`); + } + + private requireProvider(provider: AgentProvider): ProviderDefinition { + const definition = this.providerRegistry[provider]; + if (!definition) { + throw new Error(`Provider ${provider} is not configured`); + } + return definition; } private createLoadingEntries(): Map { @@ -322,7 +592,7 @@ export class ProviderSnapshotManager { return; } - const client = definition.createClient(this.logger); + const client = this.ensureClient(provider, definition); const available = await withTimeout( client.isAvailable(), this.refreshTimeoutMs, diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 849b7b72a..5842ec572 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -125,6 +125,44 @@ describe("OpenCode auto_accept feature", () => { await session.close(); }); + test("resolves legacy full-access for provider-driven child creation", () => { + const client = new OpenCodeAgentClient(createTestLogger()); + + expect( + client.resolveCreateConfig({ + provider: "opencode", + requestedMode: "full-access", + featureValues: undefined, + parent: null, + availableModes: [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + ], + }), + ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); + }); + + test("inherits unattended callers as build plus auto accept", () => { + const client = new OpenCodeAgentClient(createTestLogger()); + + expect( + client.resolveCreateConfig({ + provider: "opencode", + requestedMode: undefined, + featureValues: undefined, + parent: { + provider: "claude", + modeId: "bypassPermissions", + isUnattended: true, + }, + availableModes: [ + { id: "build", label: "Build" }, + { id: "plan", label: "Plan" }, + ], + }), + ).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); + }); + test("auto-approves tool permissions when auto accept is enabled", async () => { const { openCodeClient, runtime } = mockOpenCodeClient({ events: [toolPermissionEvent(), idleEvent()], diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 0657355f3..4318c16e3 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -37,6 +37,8 @@ import { type AgentStreamEvent, type AgentTimelineItem, type AgentUsage, + type ResolveAgentCreateConfigInput, + type ResolveAgentCreateConfigResult, type ListModelsOptions, type ListModesOptions, type ListPersistedAgentsOptions, @@ -45,6 +47,10 @@ import { type ToolCallDetail, type ToolCallTimelineItem, } from "../agent-sdk-types.js"; +import { + isDefaultAgentCreateConfigUnattended, + resolveDefaultAgentCreateConfig, +} from "../create-agent-mode.js"; import { checkProviderLaunchAvailable, createProviderEnvSpec, @@ -111,6 +117,55 @@ function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean { return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true; } +function withOpenCodeAutoAcceptFeature( + featureValues: Record | undefined, + enabled: boolean, +): Record { + return { + ...featureValues, + [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled, + }; +} + +function resolveOpenCodeCreateConfig( + input: ResolveAgentCreateConfigInput, +): ResolveAgentCreateConfigResult { + const legacyFullAccess = input.requestedMode === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID; + const inheritsUnattended = + input.requestedMode === undefined && input.parent?.isUnattended === true; + const requestedMode = legacyFullAccess ? OPENCODE_BUILD_MODE_ID : input.requestedMode; + const featureValues = + legacyFullAccess || + (inheritsUnattended && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined) + ? withOpenCodeAutoAcceptFeature(input.featureValues, true) + : input.featureValues; + + if (inheritsUnattended && requestedMode === undefined) { + return { modeId: OPENCODE_BUILD_MODE_ID, featureValues }; + } + + const resolved = resolveDefaultAgentCreateConfig({ + ...input, + requestedMode, + featureValues, + }); + return { ...resolved, featureValues }; +} + +function isOpenCodeCreateConfigUnattended( + input: Parameters[0], +): boolean { + return ( + isDefaultAgentCreateConfigUnattended(input) || + input.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true || + input.features?.some( + (feature) => + feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID && + (feature.value === true || feature.value === "true"), + ) === true + ); +} + function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature { return { type: "toggle", @@ -1147,6 +1202,8 @@ class ProductionOpenCodeRuntime implements OpenCodeRuntime { export class OpenCodeAgentClient implements AgentClient { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; + readonly resolveCreateConfig = resolveOpenCodeCreateConfig; + readonly isCreateConfigUnattended = isOpenCodeCreateConfigUnattended; private readonly runtime: OpenCodeRuntime; private readonly logger: Logger; @@ -1403,6 +1460,10 @@ export class OpenCodeAgentClient implements AgentClient { return availability.available; } + async shutdown(): Promise { + await this.runtime.shutdown(); + } + async getDiagnostic(): Promise<{ diagnostic: string }> { try { const launch = await resolveProviderLaunch({ diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 76fa76e07..b9a2d3ff6 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -99,11 +99,7 @@ import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; -import { - buildProviderRegistry, - createClientsFromRegistry, - shutdownProviders, -} from "./agent/provider-registry.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; @@ -516,18 +512,19 @@ export async function createPaseoDaemon( github, }, }); - const providerRegistry = buildProviderRegistry(logger, { + const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" }); + const providerSnapshotManager = new ProviderSnapshotManager({ + logger: providerSnapshotLogger, runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, workspaceGitService, isDev: config.isDev === true, + extraClients: config.agentClients, }); + const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ - clients: { - ...createClientsFromRegistry(providerRegistry, logger), - ...config.agentClients, - }, - providerDefinitions: providerRegistry, + clients: initialAgentManagerState.clients, + providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, appendSystemPrompt: config.appendSystemPrompt, logger, @@ -670,7 +667,7 @@ export async function createPaseoDaemon( terminalManager, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), scheduleService, - providerRegistry, + providerSnapshotManager, github, workspaceGitService, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, @@ -927,9 +924,6 @@ export async function createPaseoDaemon( { finalTimeoutMs: config.dictationFinalTimeoutMs, }, - config.agentProviderSettings, - config.providerOverrides, - config.isDev === true, daemonVersion, (intent) => { try { @@ -953,6 +947,7 @@ export async function createPaseoDaemon( workspaceGitService, github, config.pushNotificationSender, + providerSnapshotManager, { listen: formatListenTarget(boundListenTarget ?? listenTarget), relay: { @@ -1021,10 +1016,7 @@ export async function createPaseoDaemon( await agentManager.flush().catch(() => undefined); detachAgentStoragePersistence(); await agentStorage.flush().catch(() => undefined); - await shutdownProviders(logger, { - runtimeSettings: config.agentProviderSettings, - providerOverrides: config.providerOverrides, - }); + await providerSnapshotManager.shutdown(); terminalManager.killAll(); speechService.stop(); await scheduleService.stop().catch(() => undefined); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index fb679ab2b..e2d99eaf3 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -5,7 +5,6 @@ import type { AgentSessionConfig, } from "./agent/agent-sdk-types.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; -import { buildProviderRegistry } from "./agent/provider-registry.js"; interface LoggerLike { child(bindings: Record): LoggerLike; @@ -24,18 +23,6 @@ interface BuildSessionConfigOptions { validProviders?: Iterable; } -type RegisteredProviders = ReturnType | Iterable; - -function isProviderRegistry( - registeredProviders: RegisteredProviders, -): registeredProviders is ReturnType { - return ( - typeof registeredProviders === "object" && - registeredProviders !== null && - !(Symbol.iterator in registeredProviders) - ); -} - /** * Attach AgentStorage persistence to an AgentManager instance so every * agent_state snapshot is flushed to disk. @@ -118,29 +105,15 @@ export function extractTimestamps(record: StoredAgentRecord): { }; } -function hasRegisteredProvider(registeredProviders: RegisteredProviders, value: string): boolean { - if (isProviderRegistry(registeredProviders)) { - return Object.prototype.hasOwnProperty.call(registeredProviders, value); - } - return new Set(registeredProviders).has(value); -} - -export function isRegisteredProvider( - providerRegistry: ReturnType, - value: string, -): boolean { - return hasRegisteredProvider(providerRegistry, value); -} - export function toAgentPersistenceHandle( - registeredProviders: RegisteredProviders, + registeredProviders: Iterable, handle: StoredAgentRecord["persistence"], ): AgentPersistenceHandle | null { if (!handle) { return null; } const provider = handle.provider; - if (!hasRegisteredProvider(registeredProviders, provider)) { + if (!new Set(registeredProviders).has(provider)) { return null; } if (!handle.sessionId) { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index b7d868ab0..72bed301b 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -13,15 +13,7 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { Session } from "./session.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; -import type { - AgentClient, - AgentMode, - AgentModelDefinition, - ListModesOptions, - ListModelsOptions, -} from "./agent/agent-sdk-types.js"; -import type { ProviderDefinition } from "./agent/provider-registry.js"; -import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { SessionOptions } from "./session.js"; import type { SpeechToTextProvider, @@ -83,7 +75,6 @@ interface SessionHandlerInternals { handleStashPopRequest(params: unknown): Promise; createPaseoWorktree(params: unknown): Promise; handleStartWorkspaceScriptRequest(params: unknown): Promise; - getProviderRegistry(): unknown; sttManager: { transcribe(audio: Buffer, format: string): Promise; }; @@ -143,46 +134,6 @@ interface Deferred { reject: (reason?: unknown) => void; } -const TEST_CAPABILITIES = { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, -} as const; - -function createTestProviderDefinition(overrides?: Partial): 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(): Deferred { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -357,7 +308,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { stt: options.stt ?? null, tts: null, terminalManager: options.terminalManager ?? null, - providerSnapshotManager: options.providerSnapshotManager, + providerSnapshotManager: + options.providerSnapshotManager ?? createProviderSnapshotManagerStub().manager, scriptRouteStore: options.scriptRouteStore, scriptRuntimeStore: options.scriptRuntimeStore, getDaemonTcpPort: options.getDaemonTcpPort, @@ -989,24 +941,10 @@ describe("session provider refresh cwd routing", () => { expect(refreshSettingsSnapshot).not.toHaveBeenCalled(); }); - test("provider snapshot requests pass cwd through to provider discovery", async () => { + test("get_providers_snapshot_request forwards cwd to the provider authority", async () => { const messages: unknown[] = []; const workspaceCwd = resolvePath("/tmp/session-provider-snapshot"); - const fetchModels = vi.fn(async (options: ListModelsOptions) => [ - { - provider: "codex" as const, - id: `model:${options.cwd}`, - label: `model:${options.cwd}`, - }, - ]); - const providerDefinition = createTestProviderDefinition({ - fetchModels, - fetchModes: vi.fn(async () => []), - }); - const providerSnapshotManager = new ProviderSnapshotManager( - { codex: providerDefinition }, - pino({ level: "silent" }), - ); + const { manager: providerSnapshotManager, getSnapshot } = createProviderSnapshotManagerStub(); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -1015,27 +953,24 @@ describe("session provider refresh cwd routing", () => { requestId: "snapshot-workspace", }); - await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledWith({ - cwd: workspaceCwd, - force: false, - }); - }); - - providerSnapshotManager.destroy(); + expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd); }); test("normalizes legacy model and mode list requests without cwd to home", async () => { const messages: unknown[] = []; - const session = createSessionForTest({ messages }); - const fetchModels = vi.fn(async () => []); - const fetchModes = vi.fn(async () => []); - asSessionInternals(session).getProviderRegistry = () => ({ - codex: createTestProviderDefinition({ - fetchModels, - fetchModes, - }), - }); + const { + manager: providerSnapshotManager, + getSnapshot, + warmUpSnapshotForCwd, + } = createProviderSnapshotManagerStub(); + getSnapshot.mockReturnValue([ + { + provider: "codex", + status: "loading", + enabled: true, + }, + ]); + const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ type: "list_provider_models_request", @@ -1048,8 +983,11 @@ describe("session provider refresh cwd routing", () => { requestId: "modes-home", }); - expect(fetchModels).toHaveBeenCalledWith({ cwd: homedir(), force: false }); - expect(fetchModes).toHaveBeenCalledWith({ cwd: homedir(), force: false }); + expect(getSnapshot).toHaveBeenCalledWith(homedir()); + expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({ + cwd: homedir(), + providers: ["codex"], + }); }); test("legacy model list request treats disabled snapshot entries as unavailable without warming", async () => { @@ -1114,128 +1052,47 @@ describe("session provider refresh cwd routing", () => { }); }); - test("legacy model and mode list fallback treats disabled registry definitions as unavailable without fetching", async () => { + test("list_provider_models_request awaits warmup and emits ready models", async () => { const messages: unknown[] = []; - const session = createSessionForTest({ messages }); - const fetchModels = vi.fn(async () => [ + const warmupDeferred = deferred(); + const { + manager: providerSnapshotManager, + getSnapshot, + warmUpSnapshotForCwd, + } = createProviderSnapshotManagerStub(); + getSnapshot.mockReturnValueOnce([ { - provider: "codex" as const, - id: "should-not-fetch", - label: "Should not fetch", + provider: "codex", + status: "loading", + enabled: true, }, ]); - const fetchModes = vi.fn(async () => [ + getSnapshot.mockReturnValue([ { - id: "should-not-fetch", - label: "Should not fetch", + provider: "codex", + status: "ready", + enabled: true, + models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }], + modes: [], + fetchedAt: "2026-05-28T00:00:00.000Z", }, ]); - asSessionInternals(session).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(); - const fetchModels = vi.fn( - async (options: ListModelsOptions): Promise => { - expect(options.cwd).toBe(homedir()); - return models.promise; - }, - ); - const fetchModes = vi.fn(async (_options: ListModesOptions): Promise => []); - const providerDefinition = createTestProviderDefinition({ - createClient: () => - ({ - provider: "codex", - capabilities: TEST_CAPABILITIES, - async createSession() { - throw new Error("not implemented"); - }, - async resumeSession() { - throw new Error("not implemented"); - }, - async listModels(options: ListModelsOptions) { - return fetchModels(options); - }, - async isAvailable() { - return true; - }, - }) satisfies AgentClient, - fetchModels, - fetchModes, - }); - const providerSnapshotManager = new ProviderSnapshotManager( - { codex: providerDefinition }, - pino({ level: "silent" }), - ); + warmUpSnapshotForCwd.mockReturnValue(warmupDeferred.promise); const session = createSessionForTest({ messages, providerSnapshotManager }); - providerSnapshotManager.getSnapshot(); - await vi.waitFor(() => { - expect(fetchModels).toHaveBeenCalledTimes(1); - }); - const responsePromise = session.handleMessage({ type: "list_provider_models_request", provider: "codex", requestId: "models-loading-home", }); - await Promise.resolve(); - - expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).toHaveBeenCalledWith({ cwd: homedir(), force: false }); - expect(fetchModels).not.toHaveBeenCalledWith({ cwd: homedir(), force: true }); - - models.resolve([ - { - provider: "codex", - id: "gpt-5.4", - label: "GPT-5.4", - }, - ]); + expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({ + cwd: homedir(), + providers: ["codex"], + }); + warmupDeferred.resolve(); await responsePromise; - expect(fetchModels).toHaveBeenCalledTimes(1); - expect(fetchModels).not.toHaveBeenCalledWith({ cwd: homedir(), force: true }); expect(messages).toContainEqual({ type: "list_provider_models_response", payload: { @@ -1248,12 +1105,10 @@ describe("session provider refresh cwd routing", () => { }, ], error: null, - fetchedAt: expect.any(String), + fetchedAt: "2026-05-28T00:00:00.000Z", requestId: "models-loading-home", }, }); - - providerSnapshotManager.destroy(); }); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bf4d31bd8..cd7c42ec4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -85,7 +85,6 @@ import type { ScriptHealthState } from "./script-health-monitor.js"; import { spawnWorkspaceScript } from "./worktree-bootstrap.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js"; -import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import type { @@ -94,11 +93,6 @@ import type { WorkspaceGitSnapshotOptions, } from "./workspace-git-service.js"; -import { buildProviderRegistry } from "./agent/provider-registry.js"; -import type { - AgentProviderRuntimeSettingsMap, - ProviderOverride, -} from "./agent/provider-launch-config.js"; import { AgentManager } from "./agent/agent-manager.js"; import { ProviderSnapshotManager, resolveSnapshotCwd } from "./agent/provider-snapshot-manager.js"; import type { @@ -587,7 +581,7 @@ export interface SessionOptions { sttLanguage?: string; tts: Resolvable; terminalManager: TerminalManager | null; - providerSnapshotManager?: ProviderSnapshotManager; + providerSnapshotManager: ProviderSnapshotManager; scriptRouteStore?: ScriptRouteStore; scriptRuntimeStore?: WorkspaceScriptRuntimeStore; workspaceSetupSnapshots?: Map; @@ -614,9 +608,6 @@ export interface SessionOptions { sttLanguage?: string; getSpeechReadiness?: () => SpeechReadinessSnapshot; }; - agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap; - providerOverrides?: Record; - isDev?: boolean; serverId?: string; daemonVersion?: string; daemonRuntimeConfig?: { @@ -794,7 +785,7 @@ export class Session { } | null = null; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; - private readonly providerSnapshotManager: ProviderSnapshotManager | null; + private readonly providerSnapshotManager: ProviderSnapshotManager; private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly scriptRouteStore: ScriptRouteStore | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; @@ -836,9 +827,6 @@ export class Session { private unregisterVoiceCallerContext?: (agentId: string) => void; private getSpeechReadiness?: () => SpeechReadinessSnapshot; private readonly sttLanguage: string; - private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; - private readonly providerOverrides: Record | undefined; - private readonly isDev: boolean; private readonly serverId: string | undefined; private readonly daemonVersion: string | undefined; private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"]; @@ -885,9 +873,6 @@ export class Session { voice, voiceBridge, dictation, - agentProviderRuntimeSettings, - providerOverrides, - isDev, serverId, daemonVersion, daemonRuntimeConfig, @@ -957,7 +942,7 @@ export class Session { this.terminalController.killTerminalsUnderPath(rootPath), logger: this.sessionLogger, }); - this.providerSnapshotManager = providerSnapshotManager ?? null; + this.providerSnapshotManager = providerSnapshotManager; this.scriptRouteStore = scriptRouteStore ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map(); @@ -968,9 +953,6 @@ export class Session { this.sttLanguage = sttLanguage ?? "en"; this.subscribeToOptionalManagers(); this.bindVoiceBridges({ voice, voiceBridge, dictation }); - this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; - this.providerOverrides = providerOverrides; - this.isDev = isDev === true; this.serverId = serverId; this.daemonVersion = daemonVersion; this.daemonRuntimeConfig = daemonRuntimeConfig; @@ -1219,27 +1201,25 @@ export class Session { */ private subscribeToOptionalManagers(): void { this.terminalController.start(); - if (this.providerSnapshotManager) { - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { - // COMPAT(providersSnapshot): keep provider visibility gating for older clients. - const visibleEntries = entries.filter((entry) => - this.isProviderVisibleToClient(entry.provider), - ); - const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd; - this.emit({ - type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: this.downgradeEntryModesForClient(visibleEntries), - generatedAt: new Date().toISOString(), - }, - }); - }; - this.providerSnapshotManager.on("change", handleProviderSnapshotChange); - this.unsubscribeProviderSnapshotEvents = () => { - this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); - }; - } + const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { + // COMPAT(providersSnapshot): keep provider visibility gating for older clients. + const visibleEntries = entries.filter((entry) => + this.isProviderVisibleToClient(entry.provider), + ); + const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd; + this.emit({ + type: "providers_snapshot_update", + payload: { + ...(snapshotCwd ? { cwd: snapshotCwd } : {}), + entries: this.downgradeEntryModesForClient(visibleEntries), + generatedAt: new Date().toISOString(), + }, + }); + }; + this.providerSnapshotManager.on("change", handleProviderSnapshotChange); + this.unsubscribeProviderSnapshotEvents = () => { + this.providerSnapshotManager.off("change", handleProviderSnapshotChange); + }; } private bindVoiceBridges(params: { @@ -1419,25 +1399,9 @@ export class Session { return payload; } - private getProviderRegistry(): ReturnType { - return buildProviderRegistry(this.sessionLogger, { - runtimeSettings: this.agentProviderRuntimeSettings, - providerOverrides: applyMutableProviderConfigToOverrides( - this.providerOverrides, - this.daemonConfigStore.get().providers, - ), - workspaceGitService: this.workspaceGitService, - isDev: this.isDev, - }); - } - - private getRegisteredProviderIds(): AgentProvider[] { - return Object.keys(this.getProviderRegistry()); - } - private buildStoredAgentPayload( record: StoredAgentRecord, - registeredProviderIds = this.getRegisteredProviderIds(), + registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds(), ): AgentSnapshotPayload { return buildStoredAgentPayload(record, registeredProviderIds); } @@ -3122,6 +3086,7 @@ export class Session { logger: this.sessionLogger, paseoHome: this.paseoHome, workspaceGitService: this.workspaceGitService, + providerSnapshotManager: this.providerSnapshotManager, }, { kind: "session", @@ -3378,11 +3343,11 @@ export class Session { if (!record) { throw new Error(`Agent not found: ${agentId}`); } - const providerRegistry = this.getProviderRegistry(); - if (!isStoredAgentProviderAvailable(record, Object.keys(providerRegistry))) { + const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds(); + if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) { throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); } - const handle = toAgentPersistenceHandle(providerRegistry, record.persistence); + const handle = toAgentPersistenceHandle(registeredProviderIds, record.persistence); if (!handle) { throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`); } @@ -3601,47 +3566,6 @@ export class Session { ): Promise { const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); const fetchedAt = new Date().toISOString(); - const manager = this.providerSnapshotManager; - - if (!manager) { - try { - 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, - }); - this.emit({ - type: "list_provider_models_response", - payload: { - provider: msg.provider, - models, - error: null, - fetchedAt, - requestId: msg.requestId, - }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, provider: msg.provider }, - `Failed to list models for ${msg.provider}`, - ); - this.emit({ - type: "list_provider_models_response", - payload: { - provider: msg.provider, - error: getErrorMessage(error), - fetchedAt, - requestId: msg.requestId, - }, - }); - } - return; - } const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); @@ -3698,53 +3622,14 @@ export class Session { ): Promise { const fetchedAt = new Date().toISOString(); const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); - const manager = this.providerSnapshotManager; - - if (manager) { - const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); - - if (!entry) { - this.emit({ - type: "list_provider_modes_response", - payload: { - provider: msg.provider, - error: `Unknown provider: ${msg.provider}`, - fetchedAt, - requestId: msg.requestId, - }, - }); - return; - } - - if (!entry.enabled) { - this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt); - return; - } - - if (entry.status === "ready") { - this.emit({ - type: "list_provider_modes_response", - payload: { - provider: msg.provider, - modes: this.downgradeModeIconsForClient(entry.modes ?? []), - error: null, - fetchedAt: entry.fetchedAt ?? fetchedAt, - requestId: msg.requestId, - }, - }); - return; - } - - const errorMessage = - entry.status === "error" - ? (entry.error ?? `Failed to list modes for ${msg.provider}`) - : `Provider ${msg.provider} is not available`; + const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); + if (!entry) { this.emit({ type: "list_provider_modes_response", payload: { provider: msg.provider, - error: errorMessage, + error: `Unknown provider: ${msg.provider}`, fetchedAt, requestId: msg.requestId, }, @@ -3752,42 +3637,39 @@ export class Session { return; } - try { - 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, - }); - this.emit({ - type: "list_provider_modes_response", - payload: { - provider: msg.provider, - modes: this.downgradeModeIconsForClient(modes), - error: null, - fetchedAt, - requestId: msg.requestId, - }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, provider: msg.provider }, - `Failed to list modes for ${msg.provider}`, - ); - this.emit({ - type: "list_provider_modes_response", - payload: { - provider: msg.provider, - error: getErrorMessage(error), - fetchedAt, - requestId: msg.requestId, - }, - }); + if (!entry.enabled) { + this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt); + return; } + + if (entry.status === "ready") { + this.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + modes: this.downgradeModeIconsForClient(entry.modes ?? []), + error: null, + fetchedAt: entry.fetchedAt ?? fetchedAt, + requestId: msg.requestId, + }, + }); + return; + } + + const errorMessage = + entry.status === "error" + ? (entry.error ?? `Failed to list modes for ${msg.provider}`) + : `Provider ${msg.provider} is not available`; + + this.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + error: errorMessage, + fetchedAt, + requestId: msg.requestId, + }, + }); } private async getProviderSnapshotEntryForRead( @@ -3795,10 +3677,6 @@ export class Session { provider: AgentProvider, ): Promise { const manager = this.providerSnapshotManager; - if (!manager) { - return undefined; - } - const findEntry = () => manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider); @@ -3983,10 +3861,8 @@ export class Session { ): Promise { // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. const entries = this.providerSnapshotManager - ? this.providerSnapshotManager - .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) - .filter((entry) => this.isProviderVisibleToClient(entry.provider)) - : []; + .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) + .filter((entry) => this.isProviderVisibleToClient(entry.provider)); this.emit({ type: "get_providers_snapshot_response", @@ -4002,12 +3878,12 @@ export class Session { msg: Extract, ): Promise { if (msg.cwd) { - await this.providerSnapshotManager?.refreshSnapshotForCwd({ + await this.providerSnapshotManager.refreshSnapshotForCwd({ cwd: expandTilde(msg.cwd), providers: msg.providers, }); } else { - await this.providerSnapshotManager?.refreshSettingsSnapshot({ + await this.providerSnapshotManager.refreshSettingsSnapshot({ providers: msg.providers, }); } @@ -4024,10 +3900,7 @@ export class Session { msg: Extract, ): Promise { try { - const client = this.getProviderRegistry()[msg.provider].createClient(this.sessionLogger); - const diagnostic = client.getDiagnostic - ? (await client.getDiagnostic()).diagnostic - : "No diagnostic available for this provider."; + const { diagnostic } = await this.providerSnapshotManager.getProviderDiagnostic(msg.provider); this.emit({ type: "provider_diagnostic_response", payload: { @@ -6018,7 +5891,7 @@ export class Session { // (excluding internal agents which are for ephemeral system tasks) const registryRecords = await this.agentStorage.list(); const liveIds = new Set(agentSnapshots.map((a) => a.id)); - const registeredProviderIds = this.getRegisteredProviderIds(); + const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds(); const persistedAgents = registryRecords .filter((record) => !liveIds.has(record.id) && !record.internal) .filter( @@ -6984,7 +6857,7 @@ export class Session { request, agentManager: this.agentManager, agentStorage: this.agentStorage, - providerRegistry: this.getProviderRegistry(), + providerSnapshotManager: this.providerSnapshotManager, }); this.emit({ type: "fetch_recent_provider_sessions_response", diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index be7cec138..94f9cc774 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -5,6 +5,7 @@ import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js import { ScriptRouteStore } from "./script-proxy.js"; import { Session, type SessionOptions } from "./session.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceGitListener, @@ -231,6 +232,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, scriptRouteStore: options?.scriptRouteStore, scriptRuntimeStore: options?.scriptRuntimeStore, diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index 324301908..e0785a513 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -12,6 +12,7 @@ import { Session, type SessionOptions } from "./session.js"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -162,6 +163,7 @@ function createHarness(input: { mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 8b4b80f7b..84dc8d851 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -38,6 +38,7 @@ import { asDaemonConfigStore, asTerminalManager, asSessionInternals, + createProviderSnapshotManagerStub, isSessionOutboundMessage, filterByType, findByType, @@ -510,6 +511,7 @@ function createSessionForWorkspaceTests( mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }), ); @@ -619,6 +621,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }), ); @@ -1000,6 +1003,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }), ); @@ -1165,6 +1169,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: asTerminalManager({ killTerminal, subscribeTerminalsChanged: () => () => {}, @@ -1353,6 +1358,7 @@ test("close_items_request archives stored agents that are not currently loaded", mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }), ); @@ -1502,6 +1508,7 @@ test("close_items_request continues after an archive failure", async () => { mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: asTerminalManager({ killTerminal: killTerminalBestEffort, subscribeTerminalsChanged: () => () => {}, @@ -2368,6 +2375,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto mcpBaseUrl: null, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }), ); diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index e8e22662c..ebf89f4ec 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -7,6 +7,7 @@ import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; interface SessionInternals { archiveAgentForClose(agentId: string): Promise<{ archivedAt: string }>; @@ -135,6 +136,7 @@ describe("snapshot mutation ownership boundary", () => { }, stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, }), ); diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index dae78d36b..d4c01fcb2 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,6 +1,18 @@ import { vi } from "vitest"; -import type { ProviderSnapshotEntry } from "../agent/agent-sdk-types.js"; +import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; + +import type { + AgentMode, + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "../agent/agent-sdk-types.js"; +import type { + AgentManagerProviderState, + ProviderDiagnosticResult, + ResolvedProviderCreateConfig, +} from "../agent/provider-snapshot-manager.js"; import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import type { SessionOptions } from "../session.js"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -130,28 +142,88 @@ export function findByType( // --------------------------------------------------------------------------- export interface ProviderSnapshotManagerSpies { - getSnapshot: ReturnType>; - refreshSnapshotForCwd: ReturnType>>; - refreshSettingsSnapshot: ReturnType>>; - warmUpSnapshotForCwd: ReturnType>>; + getSnapshot: ReturnType>; + refreshSnapshotForCwd: ReturnType>>; + refreshSettingsSnapshot: ReturnType>>; + warmUpSnapshotForCwd: ReturnType>>; + listRegisteredProviderIds: ReturnType>; + hasProvider: ReturnType>; + getProviderLabel: ReturnType>; + getAgentManagerProviderState: ReturnType>; + listProviders: ReturnType>>; + getProvider: ReturnType>>; + listModels: ReturnType>>; + listModes: ReturnType>>; + resolveCreateConfig: ReturnType>>; + resolveDefaultModel: ReturnType>>; + getProviderDiagnostic: ReturnType< + typeof vi.fn<[AgentProvider], Promise> + >; + applyMutableProviderConfig: ReturnType>; + destroy: ReturnType>; } export function createProviderSnapshotManagerStub(): { manager: ProviderSnapshotManager; } & ProviderSnapshotManagerSpies { - const getSnapshot = vi.fn<[], ProviderSnapshotEntry[]>(() => []); - const refreshSnapshotForCwd = vi.fn<[], Promise>(async () => {}); - const refreshSettingsSnapshot = vi.fn<[], Promise>(async () => {}); - const warmUpSnapshotForCwd = vi.fn<[], Promise>(async () => {}); + const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []); + const refreshSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); + const refreshSettingsSnapshot = vi.fn<[unknown], Promise>(async () => {}); + const warmUpSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); + const listRegisteredProviderIds = vi.fn<[], AgentProvider[]>(() => []); + const hasProvider = vi.fn<[AgentProvider], boolean>(() => false); + const getProviderLabel = vi.fn<[AgentProvider], string>((provider) => { + try { + return getAgentProviderDefinition(provider).label; + } catch { + return provider; + } + }); + const getAgentManagerProviderState = vi.fn<[], AgentManagerProviderState>(() => ({ + providerDefinitions: {}, + clients: {}, + })); + const listProviders = vi.fn<[unknown], Promise>(async () => []); + const getProvider = vi.fn<[unknown], Promise>(async () => { + throw new Error("createProviderSnapshotManagerStub: getProvider not stubbed"); + }); + const listModels = vi.fn<[unknown], Promise>(async () => []); + const listModes = vi.fn<[unknown], Promise>(async () => []); + const resolveCreateConfig = vi.fn<[unknown], Promise>(async () => ({ + modeId: undefined, + featureValues: undefined, + })); + const resolveDefaultModel = vi.fn<[unknown], Promise>(async () => undefined); + const getProviderDiagnostic = vi.fn<[AgentProvider], Promise>( + async (provider) => ({ provider, diagnostic: "No diagnostic available for this provider." }), + ); + const applyMutableProviderConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({ + providerDefinitions: {}, + clients: {}, + })); const on = vi.fn(); const off = vi.fn(); + const destroy = vi.fn<[], void>(); const stub = { getSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, warmUpSnapshotForCwd, + listRegisteredProviderIds, + hasProvider, + getProviderLabel, + getAgentManagerProviderState, + listProviders, + getProvider, + listModels, + listModes, + resolveCreateConfig, + resolveDefaultModel, + getProviderDiagnostic, + applyMutableProviderConfig, on, off, + destroy, }; on.mockImplementation(() => stub); off.mockImplementation(() => stub); @@ -162,5 +234,18 @@ export function createProviderSnapshotManagerStub(): { refreshSnapshotForCwd, refreshSettingsSnapshot, warmUpSnapshotForCwd, + listRegisteredProviderIds, + hasProvider, + getProviderLabel, + getAgentManagerProviderState, + listProviders, + getProvider, + listModels, + listModes, + resolveCreateConfig, + resolveDefaultModel, + getProviderDiagnostic, + applyMutableProviderConfig, + destroy, }; } diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 0cbb1748e..92a611797 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -10,6 +10,7 @@ import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; const wsModuleMock = vi.hoisted(() => { @@ -108,9 +109,6 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, undefined, - undefined, - undefined, - false, "1.2.3-test", undefined, undefined, @@ -138,6 +136,7 @@ function createServer(agentManagerOverrides?: Record) { undefined, undefined, pushNotifications, + createProviderSnapshotManagerStub().manager, ); return { server, agentManager, pushNotifications }; diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 8bdda0869..9d58281ed 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -10,6 +10,7 @@ import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { asUint8Array, decodeTerminalStreamFrame, @@ -230,9 +231,6 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu : undefined, undefined, undefined, - undefined, - undefined, - false, TEST_DAEMON_VERSION, undefined, undefined, @@ -251,6 +249,16 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu })), dispose: vi.fn(), }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + createProviderSnapshotManagerStub().manager, ); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 46e408e52..db1c78bbc 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -13,7 +13,6 @@ import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js"; import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; -import { applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, type WorkspaceSetupSnapshot, @@ -30,12 +29,7 @@ import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; -import type { - AgentProviderRuntimeSettingsMap, - ProviderOverride, -} from "./agent/provider-launch-config.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.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"; @@ -373,9 +367,6 @@ export class VoiceAssistantWebSocketServer { private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); private readonly workspaceSetupSnapshots = new Map(); - private agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; - private providerOverrides: Record | undefined; - private isDev!: boolean; private readonly providerSnapshotManager: ProviderSnapshotManager; private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null; private onBranchChanged!: @@ -404,9 +395,6 @@ export class VoiceAssistantWebSocketServer { dictation?: { finalTimeoutMs?: number; }, - agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, - providerOverrides?: Record, - isDev?: boolean, daemonVersion?: string, onLifecycleIntent?: (intent: SessionLifecycleIntent) => void, projectRegistry?: ProjectRegistry, @@ -428,6 +416,7 @@ export class VoiceAssistantWebSocketServer { workspaceGitService?: WorkspaceGitService, github?: GitHubService, pushNotificationSender?: PushNotificationSender, + providerSnapshotManager?: ProviderSnapshotManager, daemonRuntimeConfig?: { listen: string | null; relay: { @@ -470,9 +459,6 @@ export class VoiceAssistantWebSocketServer { speech, terminalManager, dictation, - agentProviderRuntimeSettings, - providerOverrides, - isDev, onLifecycleIntent, scriptRouteStore, scriptRuntimeStore, @@ -481,15 +467,10 @@ export class VoiceAssistantWebSocketServer { getDaemonTcpHost, resolveScriptHealth, }); - const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); - this.providerSnapshotManager = new ProviderSnapshotManager( - buildProviderRegistry(providerSnapshotLogger, { - runtimeSettings: this.agentProviderRuntimeSettings, - providerOverrides: this.providerOverrides, - isDev: this.isDev, - }), - providerSnapshotLogger, - ); + if (!providerSnapshotManager) { + throw new Error("providerSnapshotManager is required"); + } + this.providerSnapshotManager = providerSnapshotManager; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -498,18 +479,10 @@ export class VoiceAssistantWebSocketServer { this.publishSpeechReadiness(snapshot); }) ?? null; this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config) => { - this.providerOverrides = applyMutableProviderConfigToOverrides( - this.providerOverrides, + const nextAgentManagerState = this.providerSnapshotManager.applyMutableProviderConfig( config.providers, ); - 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.agentManager.updateProviderRegistry(nextAgentManagerState); this.broadcastDaemonConfigChanged(config); }); @@ -534,9 +507,6 @@ export class VoiceAssistantWebSocketServer { speech: SpeechService | null | undefined; terminalManager: TerminalManager | null | undefined; dictation: { finalTimeoutMs?: number } | undefined; - agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; - providerOverrides: Record | undefined; - isDev: boolean | undefined; onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined; scriptRouteStore: ScriptRouteStore | null | undefined; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined; @@ -550,9 +520,6 @@ export class VoiceAssistantWebSocketServer { this.speech = params.speech ?? null; this.terminalManager = params.terminalManager ?? null; this.dictation = params.dictation ?? null; - this.agentProviderRuntimeSettings = params.agentProviderRuntimeSettings; - this.providerOverrides = params.providerOverrides; - this.isDev = params.isDev === true; this.onLifecycleIntent = params.onLifecycleIntent ?? null; this.scriptRouteStore = params.scriptRouteStore ?? null; this.scriptRuntimeStore = params.scriptRuntimeStore ?? null; @@ -941,9 +908,6 @@ export class VoiceAssistantWebSocketServer { getSpeechReadiness: () => this.speech!.getReadiness(), } : undefined, - agentProviderRuntimeSettings: this.agentProviderRuntimeSettings, - providerOverrides: this.providerOverrides, - isDev: this.isDev, serverId: this.serverId, daemonVersion: this.daemonVersion, daemonRuntimeConfig: this.daemonRuntimeConfig, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 7f01c3ad4..506be11b0 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -11,6 +11,7 @@ import { type SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { Session, type SessionOptions } from "./session.js"; +import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import type { AgentTimelineRow } from "./agent/agent-manager.js"; import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js"; @@ -294,6 +295,7 @@ function createSessionForWireCompatTest(options?: { new EmptyDaemonConfigStore() as unknown as SessionOptions["daemonConfigStore"], stt: null, tts: null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, terminalManager: null, });