diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts index 3713c7e6766a..d6dc569db2b6 100644 --- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts @@ -7,6 +7,8 @@ import { CommandId, MessageId, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ThreadId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; @@ -60,8 +62,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); const layerServerSettings = ServerSettings.layerTest({ - providers: { - cursor: { enabled: true }, + providerInstances: { + [ProviderInstanceId.make("cursor")]: { + driver: ProviderDriverKind.make("cursor"), + enabled: true, + }, }, }); const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts index 3cec0048ca34..b6b5eee97a0d 100644 --- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts @@ -8,6 +8,8 @@ import { MessageId, type OrchestrationV2ThreadProjection, ProjectId, + ProviderDriverKind, + ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; import * as Console from "effect/Console"; @@ -59,8 +61,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); const layerServerSettings = ServerSettings.layerTest({ - providers: { - grok: { enabled: true }, + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), + enabled: true, + }, }, }); const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index ab77e5e491a0..6be4c661e72a 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -59,13 +59,19 @@ const layerScannerTest = (input: ScannerTestInput) => Layer.provide( Layer.mergeAll( ServerSettings.layerTest({ - providers: { - claudeAgent: { homePath: input.claudeHomePath }, - codex: { homePath: input.codexHomePath }, + // Default slots point at the fixture homes; explicit test instances + // replace a slot with the same id. + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: input.claudeHomePath }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: input.codexHomePath }, + }, + ...input.providerInstances, }, - ...(input.providerInstances === undefined - ? {} - : { providerInstances: input.providerInstances }), }), ServerConfig.layerTest( input.claudeHomePath, @@ -533,7 +539,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + it.effect("uses explicit default-slot homes instead of the fixture homes", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index d67b5d72639c..0fe74152f1c3 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1102,16 +1102,15 @@ export const make = Effect.gen(function* () { instanceId: ProviderInstanceId.make(instanceId), config, })); + // The built-in default instance runs with default config when settings + // have no entry for it. if (!Object.hasOwn(settings.providerInstances, source)) { - const legacyInstance = { + const defaultInstance = { instanceId: ProviderInstanceId.make(source), - config: { - driver: ProviderDriverKind.make(source), - config: settings.providers[source], - }, + config: { driver: ProviderDriverKind.make(source) }, }; - if (resolveProviderInstanceEnabled(legacyInstance.config)) { - instances.push(legacyInstance); + if (resolveProviderInstanceEnabled(defaultInstance.config)) { + instances.push(defaultInstance); } } diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index da992744b36f..cc08d2b4a10e 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -467,6 +467,7 @@ export const AcpRegistryDriver: ProviderDriver decodeSettings({}), diff --git a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts index 50627de3d337..177aace04c57 100644 --- a/apps/server/src/provider/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/ProviderInstanceRegistryHydration.ts @@ -2,28 +2,10 @@ * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. * - * The server still reads two shapes: - * - * 1. `settings.providerInstances` — the new driver-agnostic map the - * registry expects. Keyed by `ProviderInstanceId`, values are - * `ProviderInstanceConfig` envelopes. - * 2. `settings.providers.` — the legacy single-instance-per-driver - * fields (`providers.codex`, `providers.claudeAgent`, …). These are - * the source of truth for every deployment that hasn't been migrated - * yet to an explicit `providerInstances` entry. - * - * This module bridges (2) into (1) and wires the resulting map into a - * mutable registry. For every built-in driver whose id is not already - * present in `providerInstances` (keyed on - * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a - * routing slug), we synthesize an envelope from the legacy field. The - * registry decodes both flavours through the same `configSchema` and ends - * up with one uniform `ProviderInstance` per entry. - * - * Explicit `providerInstances` entries always win — users can already - * override the legacy `providers.` blob by authoring a - * `providerInstances.codex` entry with a matching driver, and we don't - * want the synthesized envelope to silently stomp their config. + * `settings.providerInstances` is the source of truth. Every built-in driver + * with a default instance also runs at `defaultInstanceIdForDriver(kind)` + * when that slot has no entry, using the driver's default config, so a fresh + * install shows its built-in providers without writing settings first. * * Hot-reload * ---------- @@ -76,17 +58,9 @@ type ProviderInstanceRegistryHydrationEnv = | ServerConfig.ServerConfig; /** - * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. - * - * Strategy: - * 1. Copy all explicit `settings.providerInstances` entries verbatim. - * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key - * is *not* already in the explicit map, synthesize an entry from the - * matching legacy `settings.providers.` blob. - * - * The returned map is the input the registry consumes; pure & exported - * separately so the hydration logic can be exercised by unit tests - * without layering. + * Explicit `providerInstances` entries plus an implicit default instance for + * each built-in driver whose default slot is empty. Pure so the hydration + * rule can be tested without layers. */ export const deriveProviderInstanceConfigMap = ( settings: ServerSettings, @@ -94,28 +68,10 @@ export const deriveProviderInstanceConfigMap = ( const merged: Record = { ...settings.providerInstances }; for (const driver of BUILT_IN_DRIVERS) { + if (driver.metadata.hasDefaultInstance === false) continue; const instanceId = defaultInstanceIdForDriver(driver.driverKind); - if (instanceId in merged) { - // Explicit `providerInstances` entry for this slot — user-authored - // config always wins over the legacy mirror. - continue; - } - - // Only built-in drivers have a legacy mirror; the registry's - // `providers` struct is keyed on the same literal slug as - // `driverKind`. Access is dynamic (the driver kind is a branded string), - // but it's constrained to `keyof settings.providers` by the union of - // built-in driver kinds. - const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; - const legacyConfig = settings.providers[legacyKey]; - if (legacyConfig === undefined) { - continue; - } - - merged[instanceId] = { - driver: driver.driverKind, - config: legacyConfig, - }; + if (instanceId in merged) continue; + merged[instanceId] = { driver: driver.driverKind }; } return merged as ProviderInstanceConfigMap; diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index d5fc2b2a5809..11cab0e1d011 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -68,6 +68,17 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); +/** Default slots that settings mark disabled, so their probes never spawn. */ +const disabledDefaultSlots = ( + ...drivers: ReadonlyArray +): ContractServerSettings["providerInstances"] => + Object.fromEntries( + drivers.map((driver) => [ + ProviderInstanceId.make(driver), + { driver: ProviderDriverKind.make(driver), enabled: false }, + ]), + ); + const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); const decodeCodexSettings = Schema.decodeSync(CodexSettings); @@ -2663,24 +2674,18 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - // Disable every built-in probe that would otherwise spawn - // on the CI host. `enabled: false` short-circuits each - // driver's probe *before* it touches the spawner, so the - // test environment stays isolated from the dev - // machine's PATH. - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, // `providerInstances` keys are branded `ProviderInstanceId`; // the branded index signature rejects plain string literals // at the TS level even though the runtime schema happily // accepts + decodes them. Cast the patch to `unknown` so // the `Schema.decodeSync` below does the real validation. providerInstances: { + // Disable every built-in probe that would otherwise spawn + // on the CI host. `enabled: false` short-circuits each + // driver's probe *before* it touches the spawner, so the + // test environment stays isolated from the dev + // machine's PATH. + ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), // Matches the shape the user had in `.t3/dev/settings.json` // when the bug was reported: a custom enabled Codex instance // pointing at a binary the server has to actually spawn. @@ -2781,12 +2786,13 @@ it.layer( const mutableServerSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: true, binaryPath: firstMissing }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, + providerInstances: { + ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: firstMissing }, + }, }, }), ), @@ -2871,8 +2877,13 @@ it.layer( ), ); yield* serverSettings.updateSettings({ - providers: { - codex: { enabled: true, binaryPath: secondMissing }, + providerInstances: { + ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: secondMissing }, + }, }, }); // Start the lazy stream only after publishing. A watcher that did @@ -2900,14 +2911,8 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { enabled: false }, - claudeAgent: { enabled: false }, - cursor: { enabled: false }, - grok: { enabled: false }, - opencode: { enabled: false }, - }, providerInstances: { + ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), ghost_main: { driver: "ghostDriver", displayName: "A fork-only driver we don't ship", @@ -2971,14 +2976,7 @@ it.layer( const serverSettings = yield* makeMutableServerSettingsService( decodeServerSettings( deepMerge(encodedDefaultServerSettings, { - providers: { - codex: { - enabled: false, - }, - grok: { - enabled: false, - }, - }, + providerInstances: disabledDefaultSlots("codex", "grok"), }), ), ); diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts index d180b0e2dd99..ae2064fc85fa 100644 --- a/apps/server/src/provider/providerInstallation.test.ts +++ b/apps/server/src/provider/providerInstallation.test.ts @@ -147,7 +147,14 @@ describe("provider installation routing", () => { it.effect("keeps external installs manual without hiding shared install status", () => Effect.gen(function* () { const harness = yield* makeHarness({ - settings: { providers: { antigravity: { binaryPath: "/external/agy" } } }, + settings: { + providerInstances: { + [ProviderInstanceId.make("antigravity")]: { + driver: ProviderDriverKind.make("antigravity"), + config: { binaryPath: "/external/agy" }, + }, + }, + }, }); const start = yield* Effect.flip(harness.router.start({ instanceId })); const remove = yield* Effect.flip(harness.router.remove({ instanceId })); @@ -164,7 +171,14 @@ describe("provider installation routing", () => { const codexId = ProviderInstanceId.make("codex"); const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { providers: { codex: { setupMode: "managed" } } }, + settings: { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "managed" }, + }, + }, + }, }); assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); @@ -180,7 +194,14 @@ describe("provider installation routing", () => { const codexId = ProviderInstanceId.make("codex"); const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex"), codexId), - settings: { providers: { codex: { setupMode: "existing" } } }, + settings: { + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { setupMode: "existing" }, + }, + }, + }, }); assert.include( (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8c50ab6258ff..b6c0d99848ae 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -583,8 +583,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; - // `providerInstances` hydration merges `settings.providers.` - // with explicit `providerInstances` entries on boot. + // hydration adds their default instances to `providerInstances` on boot. Layer.provideMerge(ProviderInstanceRegistryHydration.layer), Layer.provideMerge( Layer.mergeAll( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 051f05331613..1611b7dfddb9 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -327,13 +327,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { it.effect("decodes nested settings patches", () => Effect.gen(function* () { - assert.deepEqual( - yield* decodeSettingsPatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), - { - providers: { codex: { binaryPath: "/tmp/codex" } }, - }, - ); - assert.deepEqual( yield* decodeSettingsPatch({ textGenerationModelSelection: { @@ -374,16 +367,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: "/usr/local/bin/codex", - homePath: "/Users/julius/.codex", - }, - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - customModels: ["claude-custom"], - }, - }, + observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" }, textGenerationModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, @@ -399,32 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { }); const next = yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: "/opt/homebrew/bin/codex", - }, - }, + observability: { otlpMetricsUrl: "http://localhost:4318/v1/metrics" }, textGenerationModelSelection: { options: [{ id: "fastMode", value: false }], }, }); - assert.deepEqual(next.providers.codex, { - enabled: true, - binaryPath: "/opt/homebrew/bin/codex", - homePath: "/Users/julius/.codex", - shadowHomePath: "", - launchArgs: "", - customModels: [], - }); - assert.deepEqual(next.providers.claudeAgent, { - enabled: true, - binaryPath: "/usr/local/bin/claude", - homePath: "", - customModels: ["claude-custom"], - launchArgs: "", - autoCompactWindow: "", - }); + assert.equal(next.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); + assert.equal(next.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); assert.deepEqual( next.textGenerationModelSelection, createModelSelection( @@ -521,18 +487,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { const changes = yield* serverSettings.subscribeChanges; yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: "/usr/local/bin/codex-next", - }, - }, + addProjectBaseDirectory: "~/next", }); const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); - assert.equal( - Option.getOrUndefined(firstChange)?.providers.codex.binaryPath, - "/usr/local/bin/codex-next", - ); + assert.equal(Option.getOrUndefined(firstChange)?.addProjectBaseDirectory, "~/next"); }), ).pipe(Effect.provide(layerServerSettings())), ); @@ -654,19 +613,18 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); it.effect( - "uses explicit provider instance enabled state over legacy provider enabled state", + "keeps a custom instance's selection when the driver's default instance is disabled", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const instanceId = ProviderInstanceId.make("claude_openrouter"); const next = yield* serverSettings.updateSettings({ - providers: { - claudeAgent: { + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), enabled: false, }, - }, - providerInstances: { [instanceId]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: true, @@ -700,6 +658,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { const fallbackInstanceId = ProviderInstanceId.make(fallbackId); const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; const providerInstances = { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, [fallbackInstanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, @@ -713,11 +675,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }; yield* serverSettings.updateSettings({ - providers: Object.fromEntries( - Object.keys(DEFAULT_SERVER_SETTINGS.providers).map( - (provider) => [provider, { enabled: false }] as const, - ), - ), providerInstances, textGenerationModelSelection: selection, }); @@ -754,7 +711,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { ).pipe(Effect.provide(layerServerSettings())), ); - it.effect("skips explicitly disabled instances when choosing a legacy fallback", () => + it.effect("skips explicitly disabled default instances when choosing a fallback", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const next = yield* serverSettings.updateSettings({ @@ -933,242 +890,188 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("enables previously used providers from sparse settings files", () => + it.effect("moves customized legacy provider settings into default instances on load", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}', + JSON.stringify({ + addProjectBaseDirectory: "~/Development", + providers: { + codex: { binaryPath: "/opt/guard/bin/codex" }, + claudeAgent: { enabled: false }, + cursor: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + }), ); - yield* recordProviderUsage("opencode"); const settings = yield* serverSettings.getSettings; - assert.isFalse(settings.providers.grok.enabled); - assert.isTrue(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); + assert.deepEqual(settings.providerInstances, { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { binaryPath: "/opt/guard/bin/codex" }, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + }); + // The file is rewritten once: instances persist and the retired map is gone. + const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); + assert.isUndefined(persisted.providers); + assert.equal(persisted.addProjectBaseDirectory, "~/Development"); + assert.deepEqual(persisted.providerInstances.codex, { + driver: "codex", + config: { binaryPath: "/opt/guard/bin/codex" }, + }); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves existing provider instances without explicit enabled flags", () => + it.effect("keeps an explicit default instance over the legacy blob for its driver", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}},"grok":{"driver":"grok","config":{}},"opencode_work":{"driver":"opencode","config":{"serverUrl":"http://127.0.0.1:4096"}},"opencode_unused":{"driver":"opencode","config":{}}}}', + JSON.stringify({ + providers: { codex: { binaryPath: "/legacy/codex" } }, + providerInstances: { + codex: { driver: "codex", enabled: true, config: { binaryPath: "/explicit/codex" } }, + }, + }), ); - yield* recordProviderUsage("cursor", "cursor_work"); - yield* recordProviderUsage("grok", null); - yield* recordProviderUsage("opencode", "opencode_work"); const settings = yield* serverSettings.getSettings; - assert.isTrue(settings.providers.cursor.enabled); - assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); - assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); - assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); - const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; - assert.isDefined(unused); - assert.isFalse(resolveProviderInstanceEnabled(unused)); + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { + driver: ProviderDriverKind.make("codex"), + enabled: true, + config: { binaryPath: "/explicit/codex" }, + }); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves explicit provider disables in existing settings files", () => + it.effect("enables previously used optional providers while migrating legacy settings", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providers":{"grok":{"enabled":false},"opencode":{"enabled":false},"cursor":{"enabled":false}},"providerInstances":{"grok":{"driver":"grok","enabled":false,"config":{}},"opencode":{"driver":"opencode","config":{"enabled":false}},"cursor":{"driver":"cursor","enabled":false,"config":{}}}}', + JSON.stringify({ + providers: { opencode: { serverUrl: "http://127.0.0.1:4096" }, cursor: {} }, + providerInstances: { + cursor_work: { driver: "cursor", config: {} }, + opencode_unused: { driver: "opencode", config: {} }, + }, + }), ); - yield* recordProviderUsage("grok"); yield* recordProviderUsage("opencode"); - yield* recordProviderUsage("cursor"); + yield* recordProviderUsage("grok", null); + yield* recordProviderUsage("cursor", "cursor_work"); const settings = yield* serverSettings.getSettings; - assert.isFalse(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); - assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); - assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("opencode")], { + driver: ProviderDriverKind.make("opencode"), + enabled: true, + config: { serverUrl: "http://127.0.0.1:4096" }, + }); + // Used without any legacy blob or instance: the slot is created enabled. + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); + // Using any cursor instance counts as opting into the driver. + assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); + const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; + assert.isDefined(unused); + assert.isFalse(resolveProviderInstanceEnabled(unused)); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("skips a disabled provider instance when picking the text generation fallback", () => + it.effect("keeps explicit legacy disables even when provider history shows use", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - // The Providers UI writes providerInstances only, so the legacy providers - // map decodes to defaults where codex is enabled and listed first. yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', + JSON.stringify({ providers: { grok: { enabled: false, binaryPath: "/opt/grok" } } }), ); + yield* recordProviderUsage("grok"); const settings = yield* serverSettings.getSettings; - assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("grok")], { + driver: ProviderDriverKind.make("grok"), + enabled: false, + config: { binaryPath: "/opt/grok" }, + }); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("keeps unused providers disabled in existing sparse settings files", () => + it.effect("runs provider history only for files that still carry the legacy map", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); - - const settings = yield* serverSettings.getSettings; - - assert.isFalse(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("preserves provider history when no settings file exists", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* recordProviderUsage("grok"); const settings = yield* serverSettings.getSettings; - assert.isTrue(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); + assert.deepEqual(settings.providerInstances, {}); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves provider history when the settings file is invalid", () => + it.effect("migrates legacy providers from an invalid file without rewriting it", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); - yield* recordProviderUsage("cursor"); + const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}'; + yield* fileSystem.writeFileString(serverConfig.settingsPath, raw); const settings = yield* serverSettings.getSettings; - assert.isTrue(settings.providers.cursor.enabled); - assert.isFalse(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); + // The readable provider settings still apply, but the file stays for the user to repair. + assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { + driver: ProviderDriverKind.make("codex"), + config: { binaryPath: "/x" }, + }); + assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("preserves valid provider flags when another settings field is invalid", () => + it.effect("skips a disabled provider instance when picking the text generation fallback", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; yield* fileSystem.writeFileString( serverConfig.settingsPath, - '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}', + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', ); - yield* recordProviderUsage("cursor"); - - const settings = yield* serverSettings.getSettings; - - assert.isFalse(settings.providers.cursor.enabled); - assert.isTrue(settings.providers.grok.enabled); - assert.isFalse(settings.providers.opencode.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("restores providers from persisted runtime sessions", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const sql = yield* SqlClient.SqlClient; - yield* sql` - INSERT INTO provider_session_runtime ( - thread_id, - provider_name, - provider_instance_id, - adapter_key, - status, - last_seen_at - ) - VALUES ( - ${"thread-opencode-runtime"}, - ${"opencode"}, - ${"opencode"}, - ${"opencode"}, - ${"ready"}, - ${"2026-08-25T00:00:00.000Z"} - ) - `; const settings = yield* serverSettings.getSettings; - assert.isFalse(settings.providers.grok.enabled); - assert.isTrue(settings.providers.opencode.enabled); - assert.isFalse(settings.providers.cursor.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("persists explicit disables after a provider has been used", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - yield* recordProviderUsage("grok"); - - assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled); - - const settings = yield* serverSettings.updateSettings({ - providers: { grok: { enabled: false } }, - }); - assert.isFalse(settings.providers.grok.enabled); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - assert.isFalse(JSON.parse(raw).providers.grok.enabled); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("persists explicit provider enables before their first use", () => - Effect.gen(function* () { - const serverConfig = yield* ServerConfig.ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - yield* serverSettings.updateSettings({ - providers: { - cursor: { enabled: true }, - grok: { enabled: true }, - opencode: { enabled: true }, - }, - }); - yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - const persisted = JSON.parse(raw); - assert.isTrue(persisted.providers.cursor.enabled); - assert.isTrue(persisted.providers.grok.enabled); - assert.isTrue(persisted.providers.opencode.enabled); + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("keeps optional providers disabled after a new installation writes settings", () => + it.effect("keeps a default-off provider's new instance disabled and sparse on disk", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - const initial = yield* serverSettings.getSettings; - assert.isFalse(initial.providers.grok.enabled); - assert.isFalse(initial.providers.opencode.enabled); - assert.isFalse(initial.providers.cursor.enabled); - const next = yield* serverSettings.updateSettings({ - addProjectBaseDirectory: "~/Development", providerInstances: { [ProviderInstanceId.make("grok")]: { driver: ProviderDriverKind.make("grok"), @@ -1177,18 +1080,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { }, }); - assert.isFalse(next.providers.grok.enabled); - assert.isFalse(next.providers.opencode.enabled); - assert.isFalse(next.providers.cursor.enabled); const grok = next.providerInstances[ProviderInstanceId.make("grok")]; assert.isDefined(grok); assert.isFalse(resolveProviderInstanceEnabled(grok)); - - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); - const persisted = JSON.parse(raw); - assert.isFalse(persisted.providers.cursor.enabled); - assert.isFalse(persisted.providers.grok.enabled); - assert.isFalse(persisted.providers.opencode.enabled); + const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); + assert.isUndefined(persisted.providers); assert.isUndefined(persisted.providerInstances.grok.enabled); }).pipe(Effect.provide(layerServerSettings())), ); @@ -1252,54 +1148,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("trims provider path settings when updates are applied", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: " /opt/homebrew/bin/codex ", - homePath: " ", - }, - claudeAgent: { - binaryPath: " /opt/homebrew/bin/claude ", - }, - opencode: { - binaryPath: " /opt/homebrew/bin/opencode ", - serverUrl: " http://127.0.0.1:4096 ", - serverPassword: " secret-password ", - }, - }, - }); - - assert.deepEqual(next.providers.codex, { - enabled: true, - binaryPath: "/opt/homebrew/bin/codex", - homePath: "", - shadowHomePath: "", - launchArgs: "", - customModels: [], - }); - assert.deepEqual(next.providers.claudeAgent, { - enabled: true, - binaryPath: "/opt/homebrew/bin/claude", - homePath: "", - customModels: [], - launchArgs: "", - autoCompactWindow: "", - }); - assert.deepEqual(next.providers.opencode, { - // OpenCode is disabled by default; this update only touches paths. - enabled: false, - binaryPath: "/opt/homebrew/bin/opencode", - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", - customModels: [], - }); - }).pipe(Effect.provide(layerServerSettings())), - ); - it.effect("trims observability settings when updates are applied", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; @@ -1322,51 +1170,20 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(layerServerSettings())), ); - it.effect("defaults blank binary paths to provider executables", () => - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsModule.ServerSettingsService; - - const next = yield* serverSettings.updateSettings({ - providers: { - codex: { - binaryPath: " ", - }, - claudeAgent: { - binaryPath: "", - }, - }, - }); - - assert.equal(next.providers.codex.binaryPath, "codex"); - assert.equal(next.providers.claudeAgent.binaryPath, "claude"); - }).pipe(Effect.provide(layerServerSettings())), - ); - - it.effect("writes non-default settings and explicit optional provider defaults to disk", () => + it.effect("writes only non-default settings to disk", () => Effect.gen(function* () { const serverSettings = yield* ServerSettingsModule.ServerSettingsService; const serverConfig = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; - const next = yield* serverSettings.updateSettings({ + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development", observability: { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, - providers: { - codex: { - binaryPath: "/opt/homebrew/bin/codex", - }, - opencode: { - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", - }, - }, automaticGitFetchInterval: Duration.seconds(10), }); - assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); - const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); assert.deepEqual(JSON.parse(raw), { addProjectBaseDirectory: "~/Development", @@ -1374,22 +1191,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", }, - providers: { - codex: { - binaryPath: "/opt/homebrew/bin/codex", - }, - cursor: { - enabled: false, - }, - grok: { - enabled: false, - }, - opencode: { - enabled: false, - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", - }, - }, backgroundActivity: { schemaVersion: 1, profile: "custom", diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 7cbcd895ac14..0695f673650d 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -23,6 +23,8 @@ import { type ProviderInstanceEnvironmentVariable, type UsageLimitSourceConfig, type ProviderInstanceMutation, + defaultInstanceIdForDriver, + isProviderDriverKind, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -384,69 +386,110 @@ const ServerSettingsJson = fromLenientJson( }), ); const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); -const PersistedOptionalProviderSettings = Schema.Struct({ - providers: Schema.optionalKey( - Schema.Struct({ - cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), - grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), - opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), - }), - ), -}); -const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit( - fromLenientJson(PersistedOptionalProviderSettings), +/** + * The retired `providers.` map, read without its old schemas. Before + * `providerInstances` existed each built-in driver had one blob there; it + * now only feeds `migrateLegacyProviderSettings`. + */ +const LegacyProviderSettingsJson = fromLenientJson( + Schema.Struct({ + providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), + }), ); +const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); -function restoreUsedProviders( +// Drivers that start disabled, so a session in the history means the user +// turned them on before the instance kept an explicit flag. +const HISTORY_RESTORED_DRIVERS: ReadonlySet = new Set( + ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)), +); + +/** + * Move each customized legacy `providers.` blob into the driver's + * default `providerInstances` slot. An explicit instance already in that slot + * wins. Blobs that match a fresh install (empty, or only an `enabled` flag + * equal to the driver default) carry nothing and are dropped. + * + * A cursor/grok/opencode slot without an `enabled` flag is enabled when + * provider history shows the driver was used, which is how those drivers + * were opted into before they had an explicit flag. Callers run this only + * while the settings file still has the retired map, so it happens once. + */ +function migrateLegacyProviderSettings( settings: ServerSettings, - persisted: typeof PersistedOptionalProviderSettings.Type, + legacyProviders: Readonly>, providerHistory: ReadonlyArray<{ readonly providerName: string; readonly providerInstanceId: string | null; }>, ): ServerSettings { - const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); - const usedProviderInstances = new Set( + // History rows are raw SQL text; compare them as strings. + const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); + const usedProviderInstances = new Set( providerHistory.map( ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, ), ); - const providerInstances = Object.fromEntries( - Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ - instanceId, + + const providerInstances: Record = {}; + for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array< + [ProviderInstanceId, ProviderInstanceConfig] + >) { + providerInstances[instanceId] = instance.enabled === undefined && - (instance.driver === "cursor" || - instance.driver === "grok" || - instance.driver === "opencode") && + HISTORY_RESTORED_DRIVERS.has(instance.driver) && usedProviderInstances.has(instanceId) ? { ...instance, enabled: true } - : instance, - ]), - ); + : instance; + } + + // History restores a never-configured cursor/grok/opencode slot too. + const legacyEntries = new Map(Object.entries(legacyProviders)); + for (const driver of HISTORY_RESTORED_DRIVERS) { + if (!legacyEntries.has(driver)) legacyEntries.set(driver, {}); + } + for (const [kind, blob] of legacyEntries) { + if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue; + if (Array.isArray(blob)) continue; + const driver = kind; + const instanceId = defaultInstanceIdForDriver(driver); + if (Object.hasOwn(providerInstances, instanceId)) continue; + + const { enabled: rawEnabled, ...config } = blob as Record; + const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined; + const enabled = + explicitEnabled ?? + (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined); + const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} }); + if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) { + continue; + } + providerInstances[instanceId] = { + driver, + ...(enabled === undefined ? {} : { enabled }), + config, + } satisfies ProviderInstanceConfig; + } return { ...settings, - providers: { - ...settings.providers, - cursor: { - ...settings.providers.cursor, - enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), - }, - grok: { - ...settings.providers.grok, - enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), - }, - opencode: { - ...settings.providers.opencode, - enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), - }, - }, providerInstances, }; } const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); +const TEXT_GENERATION_FALLBACK_DRIVERS = [ + "codex", + "claudeAgent", + "cursor", + "grok", + "muse", + "pi", + "opencode", + "antigravity", +].map((driver) => ProviderDriverKind.make(driver)); + /** ACP Registry instances reject every application text-generation operation. */ function selectionSupportsTextGeneration( settings: ServerSettings, @@ -463,14 +506,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - // Same precedence as isModelSelectionProviderEnabled: an explicit provider - // instance wins over the legacy providers map, which decodes to defaults - // (codex enabled) when the Providers UI has only written providerInstances. - const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { - const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; - return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); - }); - const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; + // The built-in default instances in preference order. A slot without an + // explicit instance uses its driver's default enabled state. + const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) => + resolveProviderInstanceEnabled( + settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} }, + ), + ); if (!fallback) { return settings; } @@ -497,17 +539,6 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "pullRequestMergeMethod", ]); -// Preserve both enabled states because provider history cannot recover a new opt-in. -const PERSISTED_SERVER_SETTINGS_DEFAULTS = { - ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, - grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, - opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, - }, -}; - function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { if (Array.isArray(current) || Array.isArray(defaults)) { return Equal.equals(current, defaults) ? undefined : current; @@ -671,7 +702,7 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { const sparseSettingsJson = yield* encodeServerSettingsJson( - stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, + stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, ); return yield* writeFileStringAtomically({ @@ -740,7 +771,7 @@ const make = Effect.gen(function* () { const loadSettingsFromDisk = Effect.gen(function* () { let settings = DEFAULT_SERVER_SETTINGS; - let persisted: typeof PersistedOptionalProviderSettings.Type = {}; + let legacyProviders: Readonly> | undefined; // A file that failed to decode must stay on disk for the user to repair; // the fold below only writes when it started from the file's real contents. let settingsFileTrusted = true; @@ -748,12 +779,12 @@ const make = Effect.gen(function* () { if (yield* readConfigExists) { const raw = yield* readRawConfig; const decoded = decodeServerSettingsJsonExit(raw); - const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); - if (persistedSettings._tag === "Success") { - persisted = persistedSettings.value; + const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); + if (legacySettings._tag === "Success") { + legacyProviders = legacySettings.value.providers; } - if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { - const failure = decoded._tag === "Failure" ? decoded : persistedSettings; + if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { + const failure = decoded._tag === "Failure" ? decoded : legacySettings; settingsFileTrusted = false; if (failure._tag === "Failure") { yield* Effect.logWarning("failed to parse settings.json, using defaults", { @@ -817,14 +848,18 @@ const make = Effect.gen(function* () { ); const loaded = foldProviderInstanceEnabledFlags( - restoreUsedProviders(settings, persisted, providerHistory), + legacyProviders === undefined + ? settings + : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory), ); const folded = settingsFileTrusted ? foldLegacyProjectSettings(loaded, legacyProjectRows) : loaded; // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; - if (migrated !== loaded) { + // A file still carrying the retired `providers` map is rewritten once so + // the migrated instances persist and the old key disappears. + if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) { yield* writeSettingsAtomically(migrated); } return migrated; diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5a493cc28053..95819d285236 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -2272,7 +2272,7 @@ it.layer( ), ); - it.effect("resolves the legacy Codex default instance", () => + it.effect("resolves the Codex default slot", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -2283,18 +2283,22 @@ it.layer( env: undefined, }); - expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/); }).pipe( Effect.provide( ServerSettings.ServerSettingsService.layerTest({ - providerInstances: {}, - providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: "~/.codex-default" }, + }, + }, }), ), ), ); - it.effect("resolves the legacy Claude default instance", () => + it.effect("resolves the Claude default slot", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -2305,18 +2309,22 @@ it.layer( env: undefined, }); - expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/); }).pipe( Effect.provide( ServerSettings.ServerSettingsService.layerTest({ - providerInstances: {}, - providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: "~/.claude-default" }, + }, + }, }), ), ), ); - it.effect("prefers an explicit default instance over legacy provider settings", () => + it.effect("resolves an empty Codex default slot with default config", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -2324,26 +2332,16 @@ it.layer( serverSettings, path, rawProviderInstanceId: "codex", - env: undefined, + env: { CODEX_HOME: "/inherited/codex-home" }, }); - expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + expect(environment.CODEX_HOME).toBe("/inherited/codex-home"); }).pipe( - Effect.provide( - ServerSettings.ServerSettingsService.layerTest({ - providers: { codex: { homePath: "~/.codex-legacy" } }, - providerInstances: { - [ProviderInstanceId.make("codex")]: { - driver: "codex", - config: { homePath: "~/.codex-explicit" }, - }, - }, - }), - ), + Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })), ), ); - it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + it.effect("keeps unknown provider instance ids unavailable after default-slot hydration", () => Effect.gen(function* () { const path = yield* Path.Path; const serverSettings = yield* ServerSettings.ServerSettingsService; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index ab5607df41f0..52e98f0eaf7d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -73,9 +73,15 @@ const setup = Effect.gen(function* () { home, transcript: NodePath.join(transcriptDir, "session.jsonl"), settings: { - providers: { - claudeAgent: { homePath: NodePath.join(home, "claude") }, - codex: { homePath: NodePath.join(home, "codex") }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: NodePath.join(home, "claude") }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: NodePath.join(home, "codex") }, + }, }, }, }; @@ -227,98 +233,86 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { } describe("UsageService", () => { - it.live.each([ - { explicitDefault: true, label: "explicit" }, - { explicitDefault: false, label: "legacy" }, - ])( - "reads shared managed $label default and disabled extra account history once", - ({ explicitDefault }) => - Effect.gen(function* () { - const { home, settings } = yield* setup; - const summary = yield* Effect.gen(function* () { - for (const [id, output] of [ - ["codex", 17], - ["codex-personal", 23], - ] as const) { - const sessions = NodePath.join(home, "shared-codex", "sessions"); - yield* Effect.promise(async () => { - await NodeFSP.mkdir(sessions, { recursive: true }); - await NodeFSP.writeFile( - NodePath.join(sessions, `${id}-rollout.jsonl`), - [ - { type: "session_meta", payload: { id } }, - { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, - { - type: "event_msg", - timestamp: "2026-08-01T10:00:00Z", - payload: { - type: "token_count", - info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, - }, + it.live("reads shared managed default and disabled extra account history once", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const summary = yield* Effect.gen(function* () { + for (const [id, output] of [ + ["codex", 17], + ["codex-personal", 23], + ] as const) { + const sessions = NodePath.join(home, "shared-codex", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(sessions, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(sessions, `${id}-rollout.jsonl`), + [ + { type: "session_meta", payload: { id } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, }, - ] - .map((line) => encodeUnknownJsonString(line)) - .join("\n") + "\n", - ); - }); - } - const service = yield* UsageService.make; - return yield* service.readSummary(WINDOW); - }).pipe( - // Scoped inside the state directory, so pending cache writes land - // before it is removed. - Effect.scoped, - Effect.provide( - layerService({ - prefix: "usage-managed-accounts", - home, - settings: { - ...settings, - providers: { - ...settings.providers, - codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, }, - providerInstances: { - ...(explicitDefault - ? { - [ProviderInstanceId.make("codex")]: { - driver: ProviderDriverKind.make("codex"), - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - }, - }, - } - : {}), - [ProviderInstanceId.make("codex-personal")]: { - driver: ProviderDriverKind.make("codex"), - enabled: false, - config: { - setupMode: "managed", - homePath: NodePath.join(home, "shared-codex"), - shadowHomePath: NodePath.join(home, "personal-shadow"), - }, - environment: [ - { - name: "CODEX_HOME", - value: NodePath.join(home, "ignored-environment"), - sensitive: false, - }, - ], + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + } + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + // Scoped inside the state directory, so pending cache writes land + // before it is removed. + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-managed-accounts", + home, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { + setupMode: "managed", + homePath: NodePath.join(home, "shared-codex"), + shadowHomePath: NodePath.join(home, "personal-shadow"), }, + environment: [ + { + name: "CODEX_HOME", + value: NodePath.join(home, "ignored-environment"), + sensitive: false, + }, + ], }, }, - }), - ), - ); - assert.strictEqual(totalOutputTokens(summary), 40); - assert.strictEqual( - summary.sources.filter( - (source) => source.fingerprint.provider === "codex" && source.status === "ok", - ).length, - 1, - ); - }).pipe(Effect.scoped), + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 40); + assert.strictEqual( + summary.sources.filter( + (source) => source.fingerprint.provider === "codex" && source.status === "ok", + ).length, + 1, + ); + }).pipe(Effect.scoped), ); it.live("omits Cursor account usage when no file login is saved", () => Effect.gen(function* () { @@ -788,6 +782,7 @@ describe("UsageService", () => { settings: { ...settings, providerInstances: { + ...settings.providerInstances, [ProviderInstanceId.make("claude-work")]: { driver: ProviderDriverKind.make("claudeAgent"), enabled: false, @@ -847,7 +842,7 @@ describe("UsageService", () => { ); it.live( - "uses explicit account settings before environment and legacy homes, then refreshes them", + "uses explicit account settings before environment and default homes, then refreshes them", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -884,6 +879,7 @@ describe("UsageService", () => { ); yield* settingsService.updateSettings({ providerInstances: { + ...settings.providerInstances, [ProviderInstanceId.make("claudeAgent")]: { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: "" }, @@ -912,6 +908,7 @@ describe("UsageService", () => { settings: { ...settings, providerInstances: { + ...settings.providerInstances, [ProviderInstanceId.make("claudeAgent")]: { driver: ProviderDriverKind.make("claudeAgent"), config: { homePath: configured }, @@ -1271,7 +1268,15 @@ describe("UsageService", () => { layerService({ prefix: "usage-service-cleanup-test", home, - settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } }, + settings: { + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: alias }, + }, + }, + }, ratesDocument: { "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, }, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 4ff2bbce9ef6..7b4269287f4b 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -351,18 +351,15 @@ export const make = Effect.gen(function* () { }> = []; const seen = new Set(); for (const driver of ["claudeAgent", "codex", "grok"] as const) { - // Disabled accounts still have history. Explicit default slots replace - // the legacy settings, just as they do in the provider registry. + // Disabled accounts still have history. An unconfigured default slot + // runs with default config, just as it does in the provider registry. const instances: Array< Pick & { instanceId: ProviderInstanceId } > = Object.entries(settings.providerInstances) .filter(([, instance]) => instance.driver === driver) .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); if (!Object.hasOwn(settings.providerInstances, driver)) { - instances.push({ - config: settings.providers[driver], - instanceId: ProviderInstanceId.make(driver), - }); + instances.push({ instanceId: ProviderInstanceId.make(driver) }); } for (const instance of instances) { const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 9a4549efc9fa..7607c2c7ddb4 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -898,8 +898,7 @@ function OnboardingCodexSetup({ const settings = serverConfig.settings; const instance = settings.providerInstances[instanceId] ?? { driver: ProviderDriverKind.make("codex"), - enabled: settings.providers.codex.enabled, - config: createdAccount ? { enabled: true, setupMode: "managed" } : settings.providers.codex, + config: createdAccount ? { enabled: true, setupMode: "managed" } : {}, }; const mode = readCodexSetupMode(instance.config); const existingChosen = @@ -915,8 +914,6 @@ function OnboardingCodexSetup({ patch: buildProviderInstanceUpdatePatch({ settings, instanceId, - driver: ProviderDriverKind.make("codex"), - isDefault: instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), instance: { ...instance, enabled: true, diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index e923a081b6e4..91116cb0b6f0 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -7,7 +7,6 @@ import { CheckIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { - DEFAULT_UNIFIED_SETTINGS, type AcpRegistrySearchAgent, AuthProvidersManageScope, ProviderInstanceId, @@ -21,7 +20,6 @@ import { useEnvironmentSettings, usePersistEnvironmentProviderInstanceMutation, } from "../../hooks/useSettings"; -import * as Equal from "effect/Equal"; import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; @@ -139,15 +137,12 @@ export function AddProviderInstanceDialog({ const [isSaving, setIsSaving] = useState(false); const [createdInstanceId, setCreatedInstanceId] = useState(null); - const existingIds = useMemo(() => { - const ids = new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]); - const defaults = DEFAULT_UNIFIED_SETTINGS.providers as Record; - // Reserve configured legacy slots too, so adding an account cannot replace them. - for (const [kind, config] of Object.entries(settings.providers ?? {})) { - if (!Equal.equals(config, defaults[kind])) ids.add(kind); - } - return ids; - }, [settings.providerInstances, settings.providers]); + // Codex and Claude run at their default slots before they are configured, so + // those ids stay reserved; other unconfigured default slots are free to take. + const existingIds = useMemo( + () => new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]), + [settings.providerInstances], + ); const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION; const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND; diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index bcce41229b2e..35a4bcbd33a9 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -276,12 +276,13 @@ describe("EnvironmentProviderSettings routing", () => { it("keeps legacy provider configuration visible when disabled", () => { settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, - providers: { - ...DEFAULT_UNIFIED_SETTINGS.providers, - grok: { - ...DEFAULT_UNIFIED_SETTINGS.providers.grok, + providerInstances: { + [ProviderInstanceId.make("grok")]: { + driver: ProviderDriverKind.make("grok"), enabled: false, - binaryPath: "/custom/grok", + config: { + binaryPath: "/custom/grok", + }, }, }, }; @@ -516,9 +517,8 @@ describe("EnvironmentProviderSettings routing", () => { const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? []; expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId }); - expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providers"]); - expect(resetPatch).not.toHaveProperty("favorites"); - expect(resetPatch).not.toHaveProperty("providerModelPreferences"); + // Removing the instance is the whole reset; shared preferences stay untouched. + expect(resetPatch ?? {}).toEqual({}); }); it("updates one provider instance without sending a stale whole map", async () => { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index dd471b4d14e8..461277127ac8 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -147,6 +147,7 @@ function providerConfigString(config: unknown, key: string): string | null { const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ provider: definition.driverKind, + hasDefaultInstance: definition.hasDefaultInstance !== false, })); function configuredBinaryPath(config: unknown): string { @@ -757,57 +758,29 @@ export function EnvironmentProviderSettings({ ); for (const providerSettings of visibleProviderSettings) { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const legacyProviders = settings.providers as Record; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings - >; const driver = providerSettings.provider; const defaultInstanceId = defaultInstanceIdForDriver(driver); const explicitInstance = settings.providerInstances?.[defaultInstanceId]; - // A remote device may run a server version whose settings predate this - // driver, so the legacy mirror can be absent. Without either an explicit - // instance or a legacy blob there is nothing to render for the slot. - const legacyConfig = legacyProviders[providerSettings.provider]; - const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; - // The envelope is the single enabled flag: keep the legacy in-config - // flag out of the synthesized blob, or an explicit `enabled: false` - // would keep winning over the envelope and the Switch could never - // turn a default-off provider on. - const synthesizedInstance = (): ProviderInstanceConfig | undefined => { - if (legacyConfig === undefined) { - return undefined; - } - const { enabled: legacyEnabled, ...legacyConfigRest } = legacyConfig; - return { - driver, - enabled: legacyEnabled, - config: legacyConfigRest, - } satisfies ProviderInstanceConfig; - }; - const effectiveInstance: ProviderInstanceConfig | undefined = - explicitInstance ?? synthesizedInstance(); - // Only the default slot depends on the legacy blob; custom instances for - // the driver must still render even when the slot has nothing to show. - if (effectiveInstance !== undefined) { - const isDirty = - explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); - if ( - driver === "codex" || + // An unconfigured default slot runs with the driver's default config. + const effectiveInstance: ProviderInstanceConfig = explicitInstance ?? { driver }; + const isDirty = explicitInstance !== undefined; + // Drivers without a default instance list only their configured instances. + const hasDefaultSlot = providerSettings.hasDefaultInstance || explicitInstance !== undefined; + if ( + hasDefaultSlot && + (driver === "codex" || driver === "claudeAgent" || isDirty || resolveProviderInstanceEnabled(effectiveInstance) || - defaultInstanceId === targetInstanceId - ) { - rows.push({ - instanceId: defaultInstanceId, - instance: effectiveInstance, - driver, - isDefault: true, - isDirty, - }); - } + defaultInstanceId === targetInstanceId) + ) { + rows.push({ + instanceId: defaultInstanceId, + instance: effectiveInstance, + driver, + isDefault: true, + isDirty, + }); } for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { if (id === defaultInstanceId) continue; @@ -847,8 +820,6 @@ export function EnvironmentProviderSettings({ settings, instanceId: row.instanceId, instance: next, - driver: row.driver, - isDefault: row.isDefault, textGenerationModelSelection: options?.textGenerationModelSelection, }); const result = await persistProviderInstance( @@ -947,23 +918,10 @@ export function EnvironmentProviderSettings({ }; const resetDefaultInstance = async (driverKind: ProviderDriverKind) => { - type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; - const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - const defaultLegacyProvider = defaultLegacyProviders[driverKind]; - if (defaultLegacyProvider === undefined) return; - const result = await persistProviderInstance( - { operation: "remove", instanceId: defaultInstanceId }, - { - providers: { - ...settings.providers, - [driverKind]: defaultLegacyProvider, - } as typeof settings.providers, - }, - ); + const result = await persistProviderInstance({ + operation: "remove", + instanceId: defaultInstanceIdForDriver(driverKind), + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index 3f9d233ed278..fe26ce239393 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -190,57 +190,27 @@ describe("formatDiagnosticsDescription", () => { }); describe("buildProviderInstanceUpdatePatch", () => { - it("promotes an edited default provider into providerInstances and resets the legacy provider", () => { + it("replaces one instance and keeps the others", () => { const instanceId = ProviderInstanceId.make("codex"); - const nextInstance = { + const otherId = ProviderInstanceId.make("codex_personal"); + const other = { driver: ProviderDriverKind.make("codex"), - enabled: true, - config: { - binaryPath: "/opt/t3/codex", - }, + config: { homePath: "/Users/example/.codex-personal" }, } satisfies ProviderInstanceConfig; - - const patch = buildProviderInstanceUpdatePatch({ - settings: { - ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "/legacy/codex", - }, - }, - }, - instanceId, - instance: nextInstance, - driver: ProviderDriverKind.make("codex"), - isDefault: true, - }); - - expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); - expect(patch.providers?.codex).toEqual(DEFAULT_SERVER_SETTINGS.providers.codex); - }); - - it("updates custom instances without touching legacy provider settings", () => { - const instanceId = ProviderInstanceId.make("codex_personal"); const nextInstance = { driver: ProviderDriverKind.make("codex"), enabled: true, - config: { - homePath: "/Users/example/.codex-personal", - }, + config: { binaryPath: "/opt/t3/codex" }, } satisfies ProviderInstanceConfig; const patch = buildProviderInstanceUpdatePatch({ - settings: DEFAULT_SERVER_SETTINGS, + settings: { providerInstances: { [otherId]: other } }, instanceId, instance: nextInstance, - driver: ProviderDriverKind.make("codex"), - isDefault: false, }); - expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); - expect(patch.providers).toBeUndefined(); + expect(patch.providerInstances).toEqual({ [otherId]: other, [instanceId]: nextInstance }); + expect(patch).not.toHaveProperty("textGenerationModelSelection"); }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index d5c6359c1c9f..232357b5482f 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -1,7 +1,6 @@ import type { BackgroundActivityProfile, BackgroundActivitySettings, - ProviderDriverKind, ProviderInstanceConfig, PreviewViewportSetting, ProviderInstanceId, @@ -255,30 +254,14 @@ export function formatDiagnosticsDescription(input: { } export function buildProviderInstanceUpdatePatch(input: { - readonly settings: Pick; + readonly settings: Pick; readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; - readonly driver: ProviderDriverKind; - readonly isDefault: boolean; readonly textGenerationModelSelection?: | ServerSettings["textGenerationModelSelection"] | undefined; }): Partial { - type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; - const legacyProviderDefaults = DEFAULT_UNIFIED_SETTINGS.providers as Record< - string, - LegacyProviderSettings | undefined - >; - const legacyProviderDefault = input.isDefault ? legacyProviderDefaults[input.driver] : undefined; return { - ...(legacyProviderDefault !== undefined - ? { - providers: { - ...input.settings.providers, - [input.driver]: legacyProviderDefault, - } as ServerSettings["providers"], - } - : {}), providerInstances: { ...input.settings.providerInstances, [input.instanceId]: input.instance, diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index bcd9e59519bc..cf1bf389dd19 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -636,14 +636,11 @@ describe("instance-scoped model selection", () => { const nativeModel = "gemini-3.1-pro"; const settings: UnifiedSettings = { ...DEFAULT_UNIFIED_SETTINGS, - providers: { - ...DEFAULT_UNIFIED_SETTINGS.providers, - antigravity: { - ...DEFAULT_UNIFIED_SETTINGS.providers.antigravity, - customModels: ["api-only-model"], - }, - }, providerInstances: { + [ProviderInstanceId.make("antigravity")]: { + driver, + config: { customModels: ["api-only-model"] }, + }, [customId]: { driver, config: { customModels: ["unknown-model"] } }, }, }; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 91e7251f35fe..1ffd3b4be920 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -66,15 +66,7 @@ function readInstanceCustomModels( return readCustomModelEntries(value); } } - const defaultInstanceId = defaultInstanceIdForDriver(driverKind); - if (instanceId !== defaultInstanceId) { - return []; - } - const legacyProviders = settings.providers as Record< - string, - { readonly customModels: ReadonlyArray } | undefined - >; - return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []); + return []; } export interface AppModelOption { @@ -228,11 +220,8 @@ function getAppModelOptions( * come from the instance's own `entry.models` snapshot (rather than the * first-matching-kind fallback in `getProviderModels`), so each custom * instance gets the precise model list its driver reported. Custom model - * slugs come from the instance's own `providerInstances[id].config.customModels` - * when present, falling back to the legacy per-kind - * `settings.providers[driverKind].customModels` bucket for default - * instances only. This keeps two instances of the same kind from leaking - * custom slugs into each other. Custom rows reported by the server are + * slugs come from the instance's own `providerInstances[id].config.customModels`, + * so two instances of the same kind never leak custom slugs into each other. Custom rows reported by the server are * ignored so a slug removed in Settings disappears without waiting for the * next provider probe. */ diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts index b0b3a4d57515..17cb5ca836ab 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.test.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -196,11 +196,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + }, }, }, }, @@ -221,11 +222,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { provider, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - claudeAgent: { - ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, - binaryPath: "/opt/Claude Tools/$current/claude", + providerInstances: { + [ProviderInstanceId.make("claude")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { + binaryPath: "/opt/Claude Tools/$current/claude", + }, }, }, }, @@ -246,11 +248,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath, + }, }, }, }, @@ -265,11 +268,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "/opt/codex\\work/codex", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "/opt/codex\\work/codex", + }, }, }, }, @@ -284,11 +288,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "C:\\Tools\\codex.exe", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "C:\\Tools\\codex.exe", + }, }, }, }, @@ -303,11 +308,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { readyCodex, { ...DEFAULT_SERVER_SETTINGS, - providers: { - ...DEFAULT_SERVER_SETTINGS.providers, - codex: { - ...DEFAULT_SERVER_SETTINGS.providers.codex, - binaryPath: "/opt/Codex Tools/codex", + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { + binaryPath: "/opt/Codex Tools/codex", + }, }, }, }, diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts index 08dd69bd3b03..ae75ace7668b 100644 --- a/apps/web/src/onboarding/providerReadiness.logic.ts +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -112,17 +112,13 @@ export function resolveOnboardingProviderLoginCommand( const instance = settings.providerInstances[provider.instanceId]; if (provider.driver === "claudeAgent") { - const config = decodeClaudeSettings( - instance ? (instance.config ?? {}) : settings.providers.claudeAgent, - ); + const config = decodeClaudeSettings(instance?.config ?? {}); const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; } if (provider.driver === "codex") { - const config = decodeCodexSettings( - instance ? (instance.config ?? {}) : settings.providers.codex, - ); + const config = decodeCodexSettings(instance?.config ?? {}); const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 4ff23880ade6..236b4363a026 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -143,7 +143,6 @@ describe("applyProviderInstanceSettings", () => { enabled: false, }, }, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -158,7 +157,6 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -175,7 +173,6 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -197,7 +194,6 @@ describe("applyProviderInstanceSettings", () => { enabled: false, }, }, - providers: {} as never, }); expect(entry?.enabled).toBe(false); @@ -213,26 +209,22 @@ describe("applyProviderInstanceSettings", () => { ]); const [entry] = applyProviderInstanceSettings(entries, { providerInstances: {}, - providers: {} as never, }); expect(entry?.isDefault).toBe(true); expect(entry?.enabled).toBe(false); }); - it("uses legacy settings for a built-in default instance", () => { + it("uses the driver default for an unconfigured built-in default instance", () => { const entries = deriveProviderInstanceEntries([ - provider({ - provider: ProviderDriverKind.make("codex"), - instanceId: "codex", - }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), + provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), ]); - const [entry] = applyProviderInstanceSettings(entries, { - providerInstances: {}, - providers: { codex: { enabled: false } } as never, - }); + const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} }); - expect(entry?.enabled).toBe(false); + // Settings decide over a stale probe: Codex starts on, Grok starts off. + expect(codex?.enabled).toBe(true); + expect(grok?.enabled).toBe(false); }); }); @@ -261,7 +253,6 @@ describe("deriveProviderEntriesByEnvironment", () => { agentId, [snapshot], { - providers: {} as never, providerInstances: { [instanceId]: { driver: ProviderDriverKind.make("acpRegistry"), @@ -629,7 +620,6 @@ describe("provider icon metadata", () => { const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; const brandedEntries = applyProviderInstanceSettings(snapshots, { providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, - providers: {} as never, }); expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); @@ -642,7 +632,6 @@ describe("provider icon metadata", () => { config: { ...registryConfig, source: "local", commandPath: "dsh" }, }, }, - providers: {} as never, }); expect(localEntry?.acpRegistryAgentId).toBeUndefined(); expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); @@ -651,7 +640,6 @@ describe("provider icon metadata", () => { const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, - providers: {} as never, }); expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 60630c5f826b..d6c6c5e2a945 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -15,6 +15,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, + isUnconfiguredDefaultInstanceEnabled, resolveProviderInstanceEnabled, type ModelSelection, type ProviderDriverKind, @@ -153,11 +154,7 @@ export function deriveProviderInstanceEntries( */ export function deriveProviderEntriesByEnvironment( providersByEnvironment: Iterable< - readonly [ - string, - ReadonlyArray, - Pick?, - ] + readonly [string, ReadonlyArray, Pick?] >, ): ReadonlyMap> { const byEnvironment = new Map>(); @@ -178,30 +175,23 @@ export function deriveProviderEntriesByEnvironment( * settings write, so picker visibility must follow settings rather than waiting * for probe reconciliation. * - * Only built-in default instances have a legacy `providers` entry. Every - * other instance exists through `providerInstances`; if it is absent there, - * its streamed snapshot is stale (for example immediately after deletion) - * and is treated as disabled. + * A built-in default instance without a `providerInstances` entry keeps its + * streamed state, since the server runs it with default config. Any other + * instance missing from `providerInstances` is stale (for example + * immediately after deletion) and is treated as disabled. */ export function applyProviderInstanceSettings( entries: ReadonlyArray, - settings: Pick, + settings: Pick, ): ReadonlyArray { - const legacyProviders = settings.providers as Readonly< - Record - >; - return entries.map((entry) => { const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) ? settings.providerInstances[entry.instanceId] : undefined; - const legacyProvider = Object.hasOwn(legacyProviders, entry.driverKind) - ? legacyProviders[entry.driverKind] - : undefined; const enabled = explicitInstance ? resolveProviderInstanceEnabled(explicitInstance) - : entry.isDefault && legacyProvider - ? (legacyProvider.enabled ?? entry.enabled) + : entry.isDefault + ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId) : false; if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { return enabled === entry.enabled ? entry : { ...entry, enabled }; diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index d11c0e367d1f..e0100be873b3 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -103,8 +103,9 @@ produce catches them. ## Where a driver plugs in -- **Contracts:** settings schema and patch, default model, and display name in - [`packages/contracts`](../../packages/contracts/src). New providers are off by default. +- **Contracts:** settings schema, default model, and display name in + [`packages/contracts`](../../packages/contracts/src). Instances store that config in + `providerInstances`. New providers are off by default. - **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 742066e9efa0..58e8fa5d9658 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -208,17 +208,6 @@ describe("custom model settings", () => { { slug: "named", name: "Named", capabilities }, ]); }); - - it("accepts entries at the settings patch boundary", () => { - expect( - decodeServerSettingsPatch({ - providers: { codex: { customModels: [{ slug: "x", capabilities }] } }, - }).providers?.codex?.customModels, - ).toEqual([{ slug: "x", capabilities }]); - expect(() => - decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }), - ).toThrow(); - }); }); describe("ClaudeSettings auto-compaction", () => { @@ -239,15 +228,6 @@ describe("ClaudeSettings auto-compaction", () => { expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow(); }, ); - - it("rejects an unsupported threshold at the settings patch boundary", () => { - expect(() => - decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }), - ).toThrow(); - expect( - decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }), - ).toBeDefined(); - }); }); describe("ClientSettings notifications", () => { @@ -752,9 +732,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("decodes a fully empty config (legacy on-disk shape) without complaint", () => { const decoded = decodeServerSettings({}); expect(decoded.providerInstances).toEqual({}); - // Legacy `providers` struct is still hydrated with its per-driver defaults - // so existing call sites keep working through the migration. - expect(decoded.providers.codex.enabled).toBe(true); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { @@ -803,7 +780,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { describe("provider enabled defaults", () => { it("keeps Muse disabled until a configured instance opts in", () => { const muse = ProviderDriverKind.make("muse"); - expect(decodeServerSettings({}).providers.muse.enabled).toBe(false); expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true); expect( @@ -812,26 +788,13 @@ describe("provider enabled defaults", () => { }); it("enables only the stable bindings by default", () => { - const decoded = decodeServerSettings({}); - expect(decoded.providers.codex.enabled).toBe(true); - expect(decoded.providers.claudeAgent.enabled).toBe(true); - expect(decoded.providers.cursor.enabled).toBe(false); - expect(decoded.providers.grok.enabled).toBe(false); - expect(decoded.providers.opencode.enabled).toBe(false); - }); - - it("keeps Cursor enabled when an existing user explicitly opted in", () => { - const cursor = ProviderDriverKind.make("cursor"); - const cursorId = ProviderInstanceId.make("cursor"); - const decoded = decodeServerSettings({ - providers: { cursor: { enabled: true } }, - providerInstances: { - [cursorId]: { driver: cursor, enabled: true, config: {} }, - }, - }); - - expect(decoded.providers.cursor.enabled).toBe(true); - expect(resolveProviderInstanceEnabled(decoded.providerInstances[cursorId]!)).toBe(true); + const enabledByDefault = (driver: string) => + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make(driver), config: {} }); + expect(enabledByDefault("codex")).toBe(true); + expect(enabledByDefault("claudeAgent")).toBe(true); + for (const driver of ["cursor", "grok", "muse", "pi", "opencode", "antigravity"]) { + expect(enabledByDefault(driver)).toBe(false); + } }); it("resolves instance enabled state with explicit false winning", () => { @@ -903,42 +866,6 @@ describe("ServerSettings worktree defaults", () => { }); }); -describe("ServerSettings Cursor legacy settings", () => { - it("preserves V1 Cursor CLI settings when reading and writing shared settings", () => { - const decoded = decodeServerSettings({ - providers: { - cursor: { - enabled: true, - binaryPath: "cursor-agent", - apiEndpoint: "http://127.0.0.1:3774", - }, - }, - }); - - expect(decoded.providers.cursor.enabled).toBe(true); - expect(encodeServerSettings(decoded).providers?.cursor).toMatchObject({ - binaryPath: "cursor-agent", - apiEndpoint: "http://127.0.0.1:3774", - }); - }); - - it("ignores obsolete Cursor CLI settings in patches", () => { - const patch = decodeServerSettingsPatch({ - providers: { - cursor: { - enabled: true, - binaryPath: "cursor-agent", - apiEndpoint: "http://127.0.0.1:3774", - }, - }, - }); - - expect(patch.providers?.cursor?.enabled).toBe(true); - expect(patch.providers?.cursor).not.toHaveProperty("binaryPath"); - expect(patch.providers?.cursor).not.toHaveProperty("apiEndpoint"); - }); -}); - describe("ServerSettings.sourceControlWritingStyle", () => { it("defaults all style settings for legacy configs", () => { const settings = decodeServerSettings({}); @@ -1011,13 +938,6 @@ describe("ServerSettingsPatch string normalization", () => { observability: { otlpTracesUrl: " http://localhost:4318/v1/traces ", }, - providers: { - codex: { - binaryPath: " /opt/homebrew/bin/codex ", - homePath: " ~/.codex ", - launchArgs: " --strict-config --enable foo ", - }, - }, providerInstances: { codex_personal: { driver: " codex ", @@ -1030,9 +950,6 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.addProjectBaseDirectory).toBe("~/Development"); expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); - expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); - expect(patch.providers?.codex?.homePath).toBe("~/.codex"); - expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); @@ -1049,19 +966,9 @@ describe("ServerSettingsPatch string normalization", () => { const encoded = encodeServerSettings({ ...defaultSettings, addProjectBaseDirectory: " ~/Development ", - providers: { - ...defaultSettings.providers, - codex: { - ...defaultSettings.providers.codex, - binaryPath: " /opt/homebrew/bin/codex ", - launchArgs: " --strict-config ", - }, - }, }); expect(encoded.addProjectBaseDirectory).toBe("~/Development"); - expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); - expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b93a1d354746..9cce40c70ce2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -40,7 +40,7 @@ import { import { ProviderInstanceConfig, ProviderInstanceId, - type ProviderDriverKind, + ProviderDriverKind, } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; @@ -1457,22 +1457,6 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), - // Legacy single-instance-per-driver settings. Continues to be the source - // of truth until `providerInstances` (below) lands per-driver migration - // shims and the server starts hydrating instances from it. Driver-specific - // schemas live here for the duration of the migration; once each driver - // owns its config in its own package, this struct shrinks to nothing and - // is removed entirely. - providers: Schema.Struct({ - codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - muse: MuseSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob // is `Schema.Unknown` at this layer so envelopes with unknown drivers @@ -1512,8 +1496,8 @@ export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerS /** * Read the legacy `enabled` flag embedded in a provider instance config * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single - * flag going forward; this reader exists for legacy `providers.` - * blobs and old settings files that still carry the flag in-config. + * flag going forward; this reader exists for old settings files that still + * carry the flag in-config. */ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => { if (config === null || typeof config !== "object" || Array.isArray(config)) { @@ -1524,18 +1508,34 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un }; /** - * Default enabled state for a built-in driver when neither the envelope nor - * the config blob carries a flag. Derived from the driver's settings schema - * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays - * the single source of truth. Unknown (fork) drivers default to enabled. + * Built-in drivers that stay off until the user turns them on. Matches the + * `enabled` decoding default of each driver's settings schema. */ -const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { - const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< - string, - { readonly enabled?: boolean } | undefined - >; - return legacyDefaults[driver]?.enabled ?? true; -}; +const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( + ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) => + ProviderDriverKind.make(driver), + ), +); + +/** Built-in drivers whose default instance runs before the user configures it. */ +const DEFAULT_ENABLED_DEFAULT_INSTANCES: ReadonlySet = new Set( + ["codex", "claudeAgent"].map((instanceId) => ProviderInstanceId.make(instanceId)), +); + +/** + * Whether the built-in default instance at `instanceId` is enabled while + * settings have no `providerInstances` entry for it. Only Codex and Claude + * start on; any other id without an entry has no running instance. + */ +export const isUnconfiguredDefaultInstanceEnabled = (instanceId: ProviderInstanceId): boolean => + DEFAULT_ENABLED_DEFAULT_INSTANCES.has(instanceId); + +/** + * Default enabled state for a driver when neither the envelope nor the config + * blob carries a flag. Unknown (fork) drivers default to enabled. + */ +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => + !DEFAULT_DISABLED_PROVIDER_DRIVERS.has(driver); /** * Resolve whether a configured provider instance is enabled. An explicit @@ -1608,70 +1608,6 @@ const ModelSelectionPatch = Schema.Struct({ options: Schema.optionalKey(ProviderOptionSelections), }); -const CodexSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - homePath: Schema.optionalKey(TrimmedString), - shadowHomePath: Schema.optionalKey(TrimmedString), - launchArgs: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const ClaudeSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - homePath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), - launchArgs: Schema.optionalKey(TrimmedString), - // Validated at the patch boundary so a typo fails the one update with a - // schema error instead of a generic whole-settings failure. - autoCompactWindow: Schema.optionalKey( - TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)), - ), -}); - -const CursorSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const GrokSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const AntigravitySettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - authMethod: Schema.optionalKey(AntigravityAuthMethod), - apiKey: Schema.optionalKey(TrimmedString), - gcpProject: Schema.optionalKey(TrimmedString), - gcpLocation: Schema.optionalKey(TrimmedString), - binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const MuseSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const PiSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - launchArgs: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - -const OpenCodeSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - serverUrl: Schema.optionalKey(TrimmedString), - serverPassword: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), -}); - export const ServerSettingsPatch = Schema.Struct({ worktreeCleanup: Schema.optionalKey( Schema.NullOr( @@ -1791,18 +1727,6 @@ export const ServerSettingsPatch = Schema.Struct({ tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)), }), ), - providers: Schema.optionalKey( - Schema.Struct({ - codex: Schema.optionalKey(CodexSettingsPatch), - claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), - cursor: Schema.optionalKey(CursorSettingsPatch), - grok: Schema.optionalKey(GrokSettingsPatch), - muse: Schema.optionalKey(MuseSettingsPatch), - pi: Schema.optionalKey(PiSettingsPatch), - opencode: Schema.optionalKey(OpenCodeSettingsPatch), - antigravity: Schema.optionalKey(AntigravitySettingsPatch), - }), - ), // Whole-map replacement for the new instance config. Patching individual // entries is intentionally out of scope: the map is small, and partial // patches risk leaving driver-specific config in a half-merged state. diff --git a/packages/provider-core/src/server/driver.ts b/packages/provider-core/src/server/driver.ts index ed91c24d8833..88e46b937d13 100644 --- a/packages/provider-core/src/server/driver.ts +++ b/packages/provider-core/src/server/driver.ts @@ -58,6 +58,12 @@ export interface ProviderDriverMetadata { * rejects multi-instance configurations with a clear error. */ readonly supportsMultipleInstances?: boolean; + /** + * Whether the server runs an instance at `defaultInstanceIdForDriver(kind)` + * even when settings have no entry for it. Defaults to `true`; drivers that + * need user input before they can run (such as the ACP Registry) opt out. + */ + readonly hasDefaultInstance?: boolean; } export type ProviderWorkspaceSnapshot = ServerProvider & @@ -188,11 +194,8 @@ export interface ProviderDriver { */ readonly configSchema: Schema.Codec; /** - * Default config payload used when the legacy - * `ServerSettings.providers.` entry is empty or when the driver - * is auto-bootstrapped without user configuration. Returning a typed - * default keeps the migration path simple — no special-casing needed - * to construct a "blank" instance. + * Config used when an instance has no config blob, such as the default + * instance of a driver the user never configured. */ readonly defaultConfig: () => Config; /** diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index dcde2af1f30a..9c19ab91a61b 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, PROJECT_SCOPED_SERVER_SETTING_KEYS, ProjectId, + ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; @@ -78,7 +79,12 @@ describe("resolveProjectSettings", () => { it("keeps the environment text generation model when the override's provider is disabled", () => { const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - providers: { claudeAgent: { enabled: false } }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, + }, projectSettingsOverrides: { [projectId]: { textGenerationModelSelection: disabledSelection }, }, @@ -128,7 +134,12 @@ describe("resolveProjectSettings", () => { it("keeps the environment default model when the override's provider is disabled", () => { const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { - providers: { claudeAgent: { enabled: false } }, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + }, + }, projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } }, }); const resolved = resolveProjectSettings(settings, projectId); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 5e66b0ebe332..99b0f2c336c1 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -1,13 +1,12 @@ import { - isProviderDriverKind, isProviderAvailable, + isUnconfiguredDefaultInstanceEnabled, resolveProviderInstanceEnabled, isProviderTextGenerationCapable, type ModelSelection, type ProjectId, type ProjectScopedServerSettingKey, type ProjectSettingsOverrides, - type ProviderDriverKind, type ServerProvider, ServerSettings, type ServerSettingsPatch, @@ -59,14 +58,6 @@ export function resolveProjectAutoPull( ); } -type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; - -const getLegacyProviderSettings = ( - settings: ServerSettings, - provider: ProviderDriverKind, -): LegacyProviderSettings | undefined => - (settings.providers as Record)[provider]; - export function isModelSelectionProviderEnabled( settings: ServerSettings, selection: ModelSelection, @@ -76,10 +67,7 @@ export function isModelSelectionProviderEnabled( return resolveProviderInstanceEnabled(instanceConfig); } - return ( - isProviderDriverKind(selection.instanceId) && - getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true - ); + return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); } export function resolveSourceControlWriterModelSelection(