| author | |
| committer | |
| log | 2c1160783ea4b4dcc1c1f1ab04639ffc0aaa289d |
| tree | 5ac0dbc9259df0bfabebc8f285ddab66e3360ad9 |
| parent | 9eea28bacd27b2ee6d5183e195c397bba8b515a0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>32 files changed, 602 insertions(+), 1073 deletions(-)
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+7-2| ... | ... | @@ -7,6 +7,8 @@ import { |
| 7 | 7 | CommandId, |
| 8 | 8 | MessageId, |
| 9 | 9 | ProjectId, |
| 10 | ProviderDriverKind, | |
| 11 | ProviderInstanceId, | |
| 10 | 12 | ThreadId, |
| 11 | 13 | type OrchestrationV2ThreadProjection, |
| 12 | 14 | } from "@t3tools/contracts"; |
| ... | ... | @@ -60,8 +62,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( |
| 60 | 62 | const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); |
| 61 | 63 | |
| 62 | 64 | const layerServerSettings = ServerSettings.layerTest({ |
| 63 | providers: { | |
| 64 | cursor: { enabled: true }, | |
| 65 | providerInstances: { | |
| 66 | [ProviderInstanceId.make("cursor")]: { | |
| 67 | driver: ProviderDriverKind.make("cursor"), | |
| 68 | enabled: true, | |
| 69 | }, | |
| 65 | 70 | }, |
| 66 | 71 | }); |
| 67 | 72 | const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( |
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+7-2| ... | ... | @@ -8,6 +8,8 @@ import { |
| 8 | 8 | MessageId, |
| 9 | 9 | type OrchestrationV2ThreadProjection, |
| 10 | 10 | ProjectId, |
| 11 | ProviderDriverKind, | |
| 12 | ProviderInstanceId, | |
| 11 | 13 | ThreadId, |
| 12 | 14 | } from "@t3tools/contracts"; |
| 13 | 15 | import * as Console from "effect/Console"; |
| ... | ... | @@ -59,8 +61,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( |
| 59 | 61 | const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); |
| 60 | 62 | |
| 61 | 63 | const layerServerSettings = ServerSettings.layerTest({ |
| 62 | providers: { | |
| 63 | grok: { enabled: true }, | |
| 64 | providerInstances: { | |
| 65 | [ProviderInstanceId.make("grok")]: { | |
| 66 | driver: ProviderDriverKind.make("grok"), | |
| 67 | enabled: true, | |
| 68 | }, | |
| 64 | 69 | }, |
| 65 | 70 | }); |
| 66 | 71 | const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( |
apps/server/src/project/AgentSessionScanner.test.ts+13-7| ... | ... | @@ -59,13 +59,19 @@ const layerScannerTest = (input: ScannerTestInput) => |
| 59 | 59 | Layer.provide( |
| 60 | 60 | Layer.mergeAll( |
| 61 | 61 | ServerSettings.layerTest({ |
| 62 | providers: { | |
| 63 | claudeAgent: { homePath: input.claudeHomePath }, | |
| 64 | codex: { homePath: input.codexHomePath }, | |
| 62 | // Default slots point at the fixture homes; explicit test instances | |
| 63 | // replace a slot with the same id. | |
| 64 | providerInstances: { | |
| 65 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 66 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 67 | config: { homePath: input.claudeHomePath }, | |
| 68 | }, | |
| 69 | [ProviderInstanceId.make("codex")]: { | |
| 70 | driver: ProviderDriverKind.make("codex"), | |
| 71 | config: { homePath: input.codexHomePath }, | |
| 72 | }, | |
| 73 | ...input.providerInstances, | |
| 65 | 74 | }, |
| 66 | ...(input.providerInstances === undefined | |
| 67 | ? {} | |
| 68 | : { providerInstances: input.providerInstances }), | |
| 69 | 75 | }), |
| 70 | 76 | ServerConfig.layerTest( |
| 71 | 77 | input.claudeHomePath, |
| ... | ... | @@ -533,7 +539,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { |
| 533 | 539 | }), |
| 534 | 540 | ); |
| 535 | 541 | |
| 536 | it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => | |
| 542 | it.effect("uses explicit default-slot homes instead of the fixture homes", () => | |
| 537 | 543 | Effect.gen(function* () { |
| 538 | 544 | const path = yield* Path.Path; |
| 539 | 545 | const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); |
apps/server/src/project/AgentSessionScanner.ts+6-7| ... | ... | @@ -1102,16 +1102,15 @@ export const make = Effect.gen(function* () { |
| 1102 | 1102 | instanceId: ProviderInstanceId.make(instanceId), |
| 1103 | 1103 | config, |
| 1104 | 1104 | })); |
| 1105 | // The built-in default instance runs with default config when settings | |
| 1106 | // have no entry for it. | |
| 1105 | 1107 | if (!Object.hasOwn(settings.providerInstances, source)) { |
| 1106 | const legacyInstance = { | |
| 1108 | const defaultInstance = { | |
| 1107 | 1109 | instanceId: ProviderInstanceId.make(source), |
| 1108 | config: { | |
| 1109 | driver: ProviderDriverKind.make(source), | |
| 1110 | config: settings.providers[source], | |
| 1111 | }, | |
| 1110 | config: { driver: ProviderDriverKind.make(source) }, | |
| 1112 | 1111 | }; |
| 1113 | if (resolveProviderInstanceEnabled(legacyInstance.config)) { | |
| 1114 | instances.push(legacyInstance); | |
| 1112 | if (resolveProviderInstanceEnabled(defaultInstance.config)) { | |
| 1113 | instances.push(defaultInstance); | |
| 1115 | 1114 | } |
| 1116 | 1115 | } |
| 1117 | 1116 |
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+1| ... | ... | @@ -467,6 +467,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 467 | 467 | metadata: { |
| 468 | 468 | displayName: "ACP Registry", |
| 469 | 469 | supportsMultipleInstances: true, |
| 470 | hasDefaultInstance: false, | |
| 470 | 471 | }, |
| 471 | 472 | configSchema: AcpRegistrySettings, |
| 472 | 473 | defaultConfig: () => decodeSettings({}), |
apps/server/src/provider/ProviderInstanceRegistryHydration.ts+10-54| ... | ... | @@ -2,28 +2,10 @@ |
| 2 | 2 | * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` |
| 3 | 3 | * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. |
| 4 | 4 | * |
| 5 | * The server still reads two shapes: | |
| 6 | * | |
| 7 | * 1. `settings.providerInstances` — the new driver-agnostic map the | |
| 8 | * registry expects. Keyed by `ProviderInstanceId`, values are | |
| 9 | * `ProviderInstanceConfig` envelopes. | |
| 10 | * 2. `settings.providers.<kind>` — the legacy single-instance-per-driver | |
| 11 | * fields (`providers.codex`, `providers.claudeAgent`, …). These are | |
| 12 | * the source of truth for every deployment that hasn't been migrated | |
| 13 | * yet to an explicit `providerInstances` entry. | |
| 14 | * | |
| 15 | * This module bridges (2) into (1) and wires the resulting map into a | |
| 16 | * mutable registry. For every built-in driver whose id is not already | |
| 17 | * present in `providerInstances` (keyed on | |
| 18 | * `defaultInstanceIdForDriver(driverKind)` — literally the driver kind as a | |
| 19 | * routing slug), we synthesize an envelope from the legacy field. The | |
| 20 | * registry decodes both flavours through the same `configSchema` and ends | |
| 21 | * up with one uniform `ProviderInstance` per entry. | |
| 22 | * | |
| 23 | * Explicit `providerInstances` entries always win — users can already | |
| 24 | * override the legacy `providers.<kind>` blob by authoring a | |
| 25 | * `providerInstances.codex` entry with a matching driver, and we don't | |
| 26 | * want the synthesized envelope to silently stomp their config. | |
| 5 | * `settings.providerInstances` is the source of truth. Every built-in driver | |
| 6 | * with a default instance also runs at `defaultInstanceIdForDriver(kind)` | |
| 7 | * when that slot has no entry, using the driver's default config, so a fresh | |
| 8 | * install shows its built-in providers without writing settings first. | |
| 27 | 9 | * |
| 28 | 10 | * Hot-reload |
| 29 | 11 | * ---------- |
| ... | ... | @@ -76,17 +58,9 @@ type ProviderInstanceRegistryHydrationEnv = |
| 76 | 58 | | ServerConfig.ServerConfig; |
| 77 | 59 | |
| 78 | 60 | /** |
| 79 | * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. | |
| 80 | * | |
| 81 | * Strategy: | |
| 82 | * 1. Copy all explicit `settings.providerInstances` entries verbatim. | |
| 83 | * 2. For each built-in driver whose `defaultInstanceIdForDriver(id)` key | |
| 84 | * is *not* already in the explicit map, synthesize an entry from the | |
| 85 | * matching legacy `settings.providers.<kind>` blob. | |
| 86 | * | |
| 87 | * The returned map is the input the registry consumes; pure & exported | |
| 88 | * separately so the hydration logic can be exercised by unit tests | |
| 89 | * without layering. | |
| 61 | * Explicit `providerInstances` entries plus an implicit default instance for | |
| 62 | * each built-in driver whose default slot is empty. Pure so the hydration | |
| 63 | * rule can be tested without layers. | |
| 90 | 64 | */ |
| 91 | 65 | export const deriveProviderInstanceConfigMap = ( |
| 92 | 66 | settings: ServerSettings, |
| ... | ... | @@ -94,28 +68,10 @@ export const deriveProviderInstanceConfigMap = ( |
| 94 | 68 | const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances }; |
| 95 | 69 | |
| 96 | 70 | for (const driver of BUILT_IN_DRIVERS) { |
| 71 | if (driver.metadata.hasDefaultInstance === false) continue; | |
| 97 | 72 | const instanceId = defaultInstanceIdForDriver(driver.driverKind); |
| 98 | if (instanceId in merged) { | |
| 99 | // Explicit `providerInstances` entry for this slot — user-authored | |
| 100 | // config always wins over the legacy mirror. | |
| 101 | continue; | |
| 102 | } | |
| 103 | ||
| 104 | // Only built-in drivers have a legacy mirror; the registry's | |
| 105 | // `providers` struct is keyed on the same literal slug as | |
| 106 | // `driverKind`. Access is dynamic (the driver kind is a branded string), | |
| 107 | // but it's constrained to `keyof settings.providers` by the union of | |
| 108 | // built-in driver kinds. | |
| 109 | const legacyKey = driver.driverKind as keyof ServerSettings["providers"]; | |
| 110 | const legacyConfig = settings.providers[legacyKey]; | |
| 111 | if (legacyConfig === undefined) { | |
| 112 | continue; | |
| 113 | } | |
| 114 | ||
| 115 | merged[instanceId] = { | |
| 116 | driver: driver.driverKind, | |
| 117 | config: legacyConfig, | |
| 118 | }; | |
| 73 | if (instanceId in merged) continue; | |
| 74 | merged[instanceId] = { driver: driver.driverKind }; | |
| 119 | 75 | } |
| 120 | 76 | |
| 121 | 77 | return merged as ProviderInstanceConfigMap; |
apps/server/src/provider/ProviderRegistry.test.ts+33-35| ... | ... | @@ -68,6 +68,17 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings); |
| 68 | 68 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 69 | 69 | const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); |
| 70 | 70 | |
| 71 | /** Default slots that settings mark disabled, so their probes never spawn. */ | |
| 72 | const disabledDefaultSlots = ( | |
| 73 | ...drivers: ReadonlyArray<string> | |
| 74 | ): ContractServerSettings["providerInstances"] => | |
| 75 | Object.fromEntries( | |
| 76 | drivers.map((driver) => [ | |
| 77 | ProviderInstanceId.make(driver), | |
| 78 | { driver: ProviderDriverKind.make(driver), enabled: false }, | |
| 79 | ]), | |
| 80 | ); | |
| 81 | ||
| 71 | 82 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); |
| 72 | 83 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); |
| 73 | 84 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| ... | ... | @@ -2663,24 +2674,18 @@ it.layer( |
| 2663 | 2674 | const serverSettings = yield* makeMutableServerSettingsService( |
| 2664 | 2675 | decodeServerSettings( |
| 2665 | 2676 | deepMerge(encodedDefaultServerSettings, { |
| 2666 | providers: { | |
| 2667 | // Disable every built-in probe that would otherwise spawn | |
| 2668 | // on the CI host. `enabled: false` short-circuits each | |
| 2669 | // driver's probe *before* it touches the spawner, so the | |
| 2670 | // test environment stays isolated from the dev | |
| 2671 | // machine's PATH. | |
| 2672 | codex: { enabled: false }, | |
| 2673 | claudeAgent: { enabled: false }, | |
| 2674 | cursor: { enabled: false }, | |
| 2675 | grok: { enabled: false }, | |
| 2676 | opencode: { enabled: false }, | |
| 2677 | }, | |
| 2678 | 2677 | // `providerInstances` keys are branded `ProviderInstanceId`; |
| 2679 | 2678 | // the branded index signature rejects plain string literals |
| 2680 | 2679 | // at the TS level even though the runtime schema happily |
| 2681 | 2680 | // accepts + decodes them. Cast the patch to `unknown` so |
| 2682 | 2681 | // the `Schema.decodeSync` below does the real validation. |
| 2683 | 2682 | providerInstances: { |
| 2683 | // Disable every built-in probe that would otherwise spawn | |
| 2684 | // on the CI host. `enabled: false` short-circuits each | |
| 2685 | // driver's probe *before* it touches the spawner, so the | |
| 2686 | // test environment stays isolated from the dev | |
| 2687 | // machine's PATH. | |
| 2688 | ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), | |
| 2684 | 2689 | // Matches the shape the user had in `.t3/dev/settings.json` |
| 2685 | 2690 | // when the bug was reported: a custom enabled Codex instance |
| 2686 | 2691 | // pointing at a binary the server has to actually spawn. |
| ... | ... | @@ -2781,12 +2786,13 @@ it.layer( |
| 2781 | 2786 | const mutableServerSettings = yield* makeMutableServerSettingsService( |
| 2782 | 2787 | decodeServerSettings( |
| 2783 | 2788 | deepMerge(encodedDefaultServerSettings, { |
| 2784 | providers: { | |
| 2785 | codex: { enabled: true, binaryPath: firstMissing }, | |
| 2786 | claudeAgent: { enabled: false }, | |
| 2787 | cursor: { enabled: false }, | |
| 2788 | grok: { enabled: false }, | |
| 2789 | opencode: { enabled: false }, | |
| 2789 | providerInstances: { | |
| 2790 | ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), | |
| 2791 | [ProviderInstanceId.make("codex")]: { | |
| 2792 | driver: ProviderDriverKind.make("codex"), | |
| 2793 | enabled: true, | |
| 2794 | config: { binaryPath: firstMissing }, | |
| 2795 | }, | |
| 2790 | 2796 | }, |
| 2791 | 2797 | }), |
| 2792 | 2798 | ), |
| ... | ... | @@ -2871,8 +2877,13 @@ it.layer( |
| 2871 | 2877 | ), |
| 2872 | 2878 | ); |
| 2873 | 2879 | yield* serverSettings.updateSettings({ |
| 2874 | providers: { | |
| 2875 | codex: { enabled: true, binaryPath: secondMissing }, | |
| 2880 | providerInstances: { | |
| 2881 | ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), | |
| 2882 | [ProviderInstanceId.make("codex")]: { | |
| 2883 | driver: ProviderDriverKind.make("codex"), | |
| 2884 | enabled: true, | |
| 2885 | config: { binaryPath: secondMissing }, | |
| 2886 | }, | |
| 2876 | 2887 | }, |
| 2877 | 2888 | }); |
| 2878 | 2889 | // Start the lazy stream only after publishing. A watcher that did |
| ... | ... | @@ -2900,14 +2911,8 @@ it.layer( |
| 2900 | 2911 | const serverSettings = yield* makeMutableServerSettingsService( |
| 2901 | 2912 | decodeServerSettings( |
| 2902 | 2913 | deepMerge(encodedDefaultServerSettings, { |
| 2903 | providers: { | |
| 2904 | codex: { enabled: false }, | |
| 2905 | claudeAgent: { enabled: false }, | |
| 2906 | cursor: { enabled: false }, | |
| 2907 | grok: { enabled: false }, | |
| 2908 | opencode: { enabled: false }, | |
| 2909 | }, | |
| 2910 | 2914 | providerInstances: { |
| 2915 | ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), | |
| 2911 | 2916 | ghost_main: { |
| 2912 | 2917 | driver: "ghostDriver", |
| 2913 | 2918 | displayName: "A fork-only driver we don't ship", |
| ... | ... | @@ -2971,14 +2976,7 @@ it.layer( |
| 2971 | 2976 | const serverSettings = yield* makeMutableServerSettingsService( |
| 2972 | 2977 | decodeServerSettings( |
| 2973 | 2978 | deepMerge(encodedDefaultServerSettings, { |
| 2974 | providers: { | |
| 2975 | codex: { | |
| 2976 | enabled: false, | |
| 2977 | }, | |
| 2978 | grok: { | |
| 2979 | enabled: false, | |
| 2980 | }, | |
| 2981 | }, | |
| 2979 | providerInstances: disabledDefaultSlots("codex", "grok"), | |
| 2982 | 2980 | }), |
| 2983 | 2981 | ), |
| 2984 | 2982 | ); |
apps/server/src/provider/providerInstallation.test.ts+24-3| ... | ... | @@ -147,7 +147,14 @@ describe("provider installation routing", () => { |
| 147 | 147 | it.effect("keeps external installs manual without hiding shared install status", () => |
| 148 | 148 | Effect.gen(function* () { |
| 149 | 149 | const harness = yield* makeHarness({ |
| 150 | settings: { providers: { antigravity: { binaryPath: "/external/agy" } } }, | |
| 150 | settings: { | |
| 151 | providerInstances: { | |
| 152 | [ProviderInstanceId.make("antigravity")]: { | |
| 153 | driver: ProviderDriverKind.make("antigravity"), | |
| 154 | config: { binaryPath: "/external/agy" }, | |
| 155 | }, | |
| 156 | }, | |
| 157 | }, | |
| 151 | 158 | }); |
| 152 | 159 | const start = yield* Effect.flip(harness.router.start({ instanceId })); |
| 153 | 160 | const remove = yield* Effect.flip(harness.router.remove({ instanceId })); |
| ... | ... | @@ -164,7 +171,14 @@ describe("provider installation routing", () => { |
| 164 | 171 | const codexId = ProviderInstanceId.make("codex"); |
| 165 | 172 | const harness = yield* makeHarness({ |
| 166 | 173 | instance: instance(ProviderDriverKind.make("codex"), codexId), |
| 167 | settings: { providers: { codex: { setupMode: "managed" } } }, | |
| 174 | settings: { | |
| 175 | providerInstances: { | |
| 176 | [ProviderInstanceId.make("codex")]: { | |
| 177 | driver: ProviderDriverKind.make("codex"), | |
| 178 | config: { setupMode: "managed" }, | |
| 179 | }, | |
| 180 | }, | |
| 181 | }, | |
| 168 | 182 | }); |
| 169 | 183 | assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); |
| 170 | 184 | yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); |
| ... | ... | @@ -180,7 +194,14 @@ describe("provider installation routing", () => { |
| 180 | 194 | const codexId = ProviderInstanceId.make("codex"); |
| 181 | 195 | const harness = yield* makeHarness({ |
| 182 | 196 | instance: instance(ProviderDriverKind.make("codex"), codexId), |
| 183 | settings: { providers: { codex: { setupMode: "existing" } } }, | |
| 197 | settings: { | |
| 198 | providerInstances: { | |
| 199 | [ProviderInstanceId.make("codex")]: { | |
| 200 | driver: ProviderDriverKind.make("codex"), | |
| 201 | config: { setupMode: "existing" }, | |
| 202 | }, | |
| 203 | }, | |
| 204 | }, | |
| 184 | 205 | }); |
| 185 | 206 | assert.include( |
| 186 | 207 | (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, |
apps/server/src/server.ts+1-2| ... | ... | @@ -583,8 +583,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 583 | 583 | // The instance registry is the new routing keystone — text generation, |
| 584 | 584 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` |
| 585 | 585 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; |
| 586 | // `providerInstances` hydration merges `settings.providers.<kind>` | |
| 587 | // with explicit `providerInstances` entries on boot. | |
| 586 | // hydration adds their default instances to `providerInstances` on boot. | |
| 588 | 587 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), |
| 589 | 588 | Layer.provideMerge( |
| 590 | 589 | Layer.mergeAll( |
apps/server/src/serverSettings.test.ts+107-306| ... | ... | @@ -327,13 +327,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 327 | 327 | |
| 328 | 328 | it.effect("decodes nested settings patches", () => |
| 329 | 329 | Effect.gen(function* () { |
| 330 | assert.deepEqual( | |
| 331 | yield* decodeSettingsPatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), | |
| 332 | { | |
| 333 | providers: { codex: { binaryPath: "/tmp/codex" } }, | |
| 334 | }, | |
| 335 | ); | |
| 336 | ||
| 337 | 330 | assert.deepEqual( |
| 338 | 331 | yield* decodeSettingsPatch({ |
| 339 | 332 | textGenerationModelSelection: { |
| ... | ... | @@ -374,16 +367,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 374 | 367 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 375 | 368 | |
| 376 | 369 | yield* serverSettings.updateSettings({ |
| 377 | providers: { | |
| 378 | codex: { | |
| 379 | binaryPath: "/usr/local/bin/codex", | |
| 380 | homePath: "/Users/julius/.codex", | |
| 381 | }, | |
| 382 | claudeAgent: { | |
| 383 | binaryPath: "/usr/local/bin/claude", | |
| 384 | customModels: ["claude-custom"], | |
| 385 | }, | |
| 386 | }, | |
| 370 | observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" }, | |
| 387 | 371 | textGenerationModelSelection: { |
| 388 | 372 | instanceId: ProviderInstanceId.make("codex"), |
| 389 | 373 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| ... | ... | @@ -399,32 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 399 | 383 | }); |
| 400 | 384 | |
| 401 | 385 | const next = yield* serverSettings.updateSettings({ |
| 402 | providers: { | |
| 403 | codex: { | |
| 404 | binaryPath: "/opt/homebrew/bin/codex", | |
| 405 | }, | |
| 406 | }, | |
| 386 | observability: { otlpMetricsUrl: "http://localhost:4318/v1/metrics" }, | |
| 407 | 387 | textGenerationModelSelection: { |
| 408 | 388 | options: [{ id: "fastMode", value: false }], |
| 409 | 389 | }, |
| 410 | 390 | }); |
| 411 | 391 | |
| 412 | assert.deepEqual(next.providers.codex, { | |
| 413 | enabled: true, | |
| 414 | binaryPath: "/opt/homebrew/bin/codex", | |
| 415 | homePath: "/Users/julius/.codex", | |
| 416 | shadowHomePath: "", | |
| 417 | launchArgs: "", | |
| 418 | customModels: [], | |
| 419 | }); | |
| 420 | assert.deepEqual(next.providers.claudeAgent, { | |
| 421 | enabled: true, | |
| 422 | binaryPath: "/usr/local/bin/claude", | |
| 423 | homePath: "", | |
| 424 | customModels: ["claude-custom"], | |
| 425 | launchArgs: "", | |
| 426 | autoCompactWindow: "", | |
| 427 | }); | |
| 392 | assert.equal(next.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); | |
| 393 | assert.equal(next.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); | |
| 428 | 394 | assert.deepEqual( |
| 429 | 395 | next.textGenerationModelSelection, |
| 430 | 396 | createModelSelection( |
| ... | ... | @@ -521,18 +487,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 521 | 487 | const changes = yield* serverSettings.subscribeChanges; |
| 522 | 488 | |
| 523 | 489 | yield* serverSettings.updateSettings({ |
| 524 | providers: { | |
| 525 | codex: { | |
| 526 | binaryPath: "/usr/local/bin/codex-next", | |
| 527 | }, | |
| 528 | }, | |
| 490 | addProjectBaseDirectory: "~/next", | |
| 529 | 491 | }); |
| 530 | 492 | |
| 531 | 493 | const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); |
| 532 | assert.equal( | |
| 533 | Option.getOrUndefined(firstChange)?.providers.codex.binaryPath, | |
| 534 | "/usr/local/bin/codex-next", | |
| 535 | ); | |
| 494 | assert.equal(Option.getOrUndefined(firstChange)?.addProjectBaseDirectory, "~/next"); | |
| 536 | 495 | }), |
| 537 | 496 | ).pipe(Effect.provide(layerServerSettings())), |
| 538 | 497 | ); |
| ... | ... | @@ -654,19 +613,18 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 654 | 613 | ); |
| 655 | 614 | |
| 656 | 615 | it.effect( |
| 657 | "uses explicit provider instance enabled state over legacy provider enabled state", | |
| 616 | "keeps a custom instance's selection when the driver's default instance is disabled", | |
| 658 | 617 | () => |
| 659 | 618 | Effect.gen(function* () { |
| 660 | 619 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 661 | 620 | const instanceId = ProviderInstanceId.make("claude_openrouter"); |
| 662 | 621 | |
| 663 | 622 | const next = yield* serverSettings.updateSettings({ |
| 664 | providers: { | |
| 665 | claudeAgent: { | |
| 623 | providerInstances: { | |
| 624 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 625 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 666 | 626 | enabled: false, |
| 667 | 627 | }, |
| 668 | }, | |
| 669 | providerInstances: { | |
| 670 | 628 | [instanceId]: { |
| 671 | 629 | driver: ProviderDriverKind.make("claudeAgent"), |
| 672 | 630 | enabled: true, |
| ... | ... | @@ -700,6 +658,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 700 | 658 | const fallbackInstanceId = ProviderInstanceId.make(fallbackId); |
| 701 | 659 | const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; |
| 702 | 660 | const providerInstances = { |
| 661 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 662 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 663 | enabled: false, | |
| 664 | }, | |
| 703 | 665 | [fallbackInstanceId]: { |
| 704 | 666 | driver: ProviderDriverKind.make("codex"), |
| 705 | 667 | enabled: true, |
| ... | ... | @@ -713,11 +675,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 713 | 675 | }; |
| 714 | 676 | |
| 715 | 677 | yield* serverSettings.updateSettings({ |
| 716 | providers: Object.fromEntries( | |
| 717 | Object.keys(DEFAULT_SERVER_SETTINGS.providers).map( | |
| 718 | (provider) => [provider, { enabled: false }] as const, | |
| 719 | ), | |
| 720 | ), | |
| 721 | 678 | providerInstances, |
| 722 | 679 | textGenerationModelSelection: selection, |
| 723 | 680 | }); |
| ... | ... | @@ -754,7 +711,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 754 | 711 | ).pipe(Effect.provide(layerServerSettings())), |
| 755 | 712 | ); |
| 756 | 713 | |
| 757 | it.effect("skips explicitly disabled instances when choosing a legacy fallback", () => | |
| 714 | it.effect("skips explicitly disabled default instances when choosing a fallback", () => | |
| 758 | 715 | Effect.gen(function* () { |
| 759 | 716 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 760 | 717 | const next = yield* serverSettings.updateSettings({ |
| ... | ... | @@ -933,242 +890,188 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 933 | 890 | }).pipe(Effect.provide(layerServerSettings())), |
| 934 | 891 | ); |
| 935 | 892 | |
| 936 | it.effect("enables previously used providers from sparse settings files", () => | |
| 893 | it.effect("moves customized legacy provider settings into default instances on load", () => | |
| 937 | 894 | Effect.gen(function* () { |
| 938 | 895 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 939 | 896 | const fileSystem = yield* FileSystem.FileSystem; |
| 940 | 897 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 941 | 898 | yield* fileSystem.writeFileString( |
| 942 | 899 | serverConfig.settingsPath, |
| 943 | '{"providers":{"opencode":{"serverUrl":"http://127.0.0.1:4096"}}}', | |
| 900 | JSON.stringify({ | |
| 901 | addProjectBaseDirectory: "~/Development", | |
| 902 | providers: { | |
| 903 | codex: { binaryPath: "/opt/guard/bin/codex" }, | |
| 904 | claudeAgent: { enabled: false }, | |
| 905 | cursor: { enabled: false }, | |
| 906 | grok: { enabled: false }, | |
| 907 | opencode: { enabled: false }, | |
| 908 | }, | |
| 909 | }), | |
| 944 | 910 | ); |
| 945 | yield* recordProviderUsage("opencode"); | |
| 946 | 911 | |
| 947 | 912 | const settings = yield* serverSettings.getSettings; |
| 948 | 913 | |
| 949 | assert.isFalse(settings.providers.grok.enabled); | |
| 950 | assert.isTrue(settings.providers.opencode.enabled); | |
| 951 | assert.isFalse(settings.providers.cursor.enabled); | |
| 952 | assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); | |
| 914 | assert.deepEqual(settings.providerInstances, { | |
| 915 | [ProviderInstanceId.make("codex")]: { | |
| 916 | driver: ProviderDriverKind.make("codex"), | |
| 917 | config: { binaryPath: "/opt/guard/bin/codex" }, | |
| 918 | }, | |
| 919 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 920 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 921 | enabled: false, | |
| 922 | config: {}, | |
| 923 | }, | |
| 924 | }); | |
| 925 | // The file is rewritten once: instances persist and the retired map is gone. | |
| 926 | const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); | |
| 927 | assert.isUndefined(persisted.providers); | |
| 928 | assert.equal(persisted.addProjectBaseDirectory, "~/Development"); | |
| 929 | assert.deepEqual(persisted.providerInstances.codex, { | |
| 930 | driver: "codex", | |
| 931 | config: { binaryPath: "/opt/guard/bin/codex" }, | |
| 932 | }); | |
| 953 | 933 | }).pipe(Effect.provide(layerServerSettings())), |
| 954 | 934 | ); |
| 955 | 935 | |
| 956 | it.effect("preserves existing provider instances without explicit enabled flags", () => | |
| 936 | it.effect("keeps an explicit default instance over the legacy blob for its driver", () => | |
| 957 | 937 | Effect.gen(function* () { |
| 958 | 938 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 959 | 939 | const fileSystem = yield* FileSystem.FileSystem; |
| 960 | 940 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 961 | 941 | yield* fileSystem.writeFileString( |
| 962 | 942 | serverConfig.settingsPath, |
| 963 | '{"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":{}}}}', | |
| 943 | JSON.stringify({ | |
| 944 | providers: { codex: { binaryPath: "/legacy/codex" } }, | |
| 945 | providerInstances: { | |
| 946 | codex: { driver: "codex", enabled: true, config: { binaryPath: "/explicit/codex" } }, | |
| 947 | }, | |
| 948 | }), | |
| 964 | 949 | ); |
| 965 | yield* recordProviderUsage("cursor", "cursor_work"); | |
| 966 | yield* recordProviderUsage("grok", null); | |
| 967 | yield* recordProviderUsage("opencode", "opencode_work"); | |
| 968 | 950 | |
| 969 | 951 | const settings = yield* serverSettings.getSettings; |
| 970 | 952 | |
| 971 | assert.isTrue(settings.providers.cursor.enabled); | |
| 972 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); | |
| 973 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); | |
| 974 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); | |
| 975 | const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; | |
| 976 | assert.isDefined(unused); | |
| 977 | assert.isFalse(resolveProviderInstanceEnabled(unused)); | |
| 953 | assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { | |
| 954 | driver: ProviderDriverKind.make("codex"), | |
| 955 | enabled: true, | |
| 956 | config: { binaryPath: "/explicit/codex" }, | |
| 957 | }); | |
| 978 | 958 | }).pipe(Effect.provide(layerServerSettings())), |
| 979 | 959 | ); |
| 980 | 960 | |
| 981 | it.effect("preserves explicit provider disables in existing settings files", () => | |
| 961 | it.effect("enables previously used optional providers while migrating legacy settings", () => | |
| 982 | 962 | Effect.gen(function* () { |
| 983 | 963 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 984 | 964 | const fileSystem = yield* FileSystem.FileSystem; |
| 985 | 965 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 986 | 966 | yield* fileSystem.writeFileString( |
| 987 | 967 | serverConfig.settingsPath, |
| 988 | '{"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":{}}}}', | |
| 968 | JSON.stringify({ | |
| 969 | providers: { opencode: { serverUrl: "http://127.0.0.1:4096" }, cursor: {} }, | |
| 970 | providerInstances: { | |
| 971 | cursor_work: { driver: "cursor", config: {} }, | |
| 972 | opencode_unused: { driver: "opencode", config: {} }, | |
| 973 | }, | |
| 974 | }), | |
| 989 | 975 | ); |
| 990 | yield* recordProviderUsage("grok"); | |
| 991 | 976 | yield* recordProviderUsage("opencode"); |
| 992 | yield* recordProviderUsage("cursor"); | |
| 977 | yield* recordProviderUsage("grok", null); | |
| 978 | yield* recordProviderUsage("cursor", "cursor_work"); | |
| 993 | 979 | |
| 994 | 980 | const settings = yield* serverSettings.getSettings; |
| 995 | 981 | |
| 996 | assert.isFalse(settings.providers.grok.enabled); | |
| 997 | assert.isFalse(settings.providers.opencode.enabled); | |
| 998 | assert.isFalse(settings.providers.cursor.enabled); | |
| 999 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); | |
| 1000 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); | |
| 1001 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); | |
| 982 | assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("opencode")], { | |
| 983 | driver: ProviderDriverKind.make("opencode"), | |
| 984 | enabled: true, | |
| 985 | config: { serverUrl: "http://127.0.0.1:4096" }, | |
| 986 | }); | |
| 987 | // Used without any legacy blob or instance: the slot is created enabled. | |
| 988 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); | |
| 989 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); | |
| 990 | // Using any cursor instance counts as opting into the driver. | |
| 991 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); | |
| 992 | const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; | |
| 993 | assert.isDefined(unused); | |
| 994 | assert.isFalse(resolveProviderInstanceEnabled(unused)); | |
| 1002 | 995 | }).pipe(Effect.provide(layerServerSettings())), |
| 1003 | 996 | ); |
| 1004 | 997 | |
| 1005 | it.effect("skips a disabled provider instance when picking the text generation fallback", () => | |
| 998 | it.effect("keeps explicit legacy disables even when provider history shows use", () => | |
| 1006 | 999 | Effect.gen(function* () { |
| 1007 | 1000 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1008 | 1001 | const fileSystem = yield* FileSystem.FileSystem; |
| 1009 | 1002 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1010 | // The Providers UI writes providerInstances only, so the legacy providers | |
| 1011 | // map decodes to defaults where codex is enabled and listed first. | |
| 1012 | 1003 | yield* fileSystem.writeFileString( |
| 1013 | 1004 | serverConfig.settingsPath, |
| 1014 | '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', | |
| 1005 | JSON.stringify({ providers: { grok: { enabled: false, binaryPath: "/opt/grok" } } }), | |
| 1015 | 1006 | ); |
| 1007 | yield* recordProviderUsage("grok"); | |
| 1016 | 1008 | |
| 1017 | 1009 | const settings = yield* serverSettings.getSettings; |
| 1018 | 1010 | |
| 1019 | assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); | |
| 1011 | assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("grok")], { | |
| 1012 | driver: ProviderDriverKind.make("grok"), | |
| 1013 | enabled: false, | |
| 1014 | config: { binaryPath: "/opt/grok" }, | |
| 1015 | }); | |
| 1020 | 1016 | }).pipe(Effect.provide(layerServerSettings())), |
| 1021 | 1017 | ); |
| 1022 | 1018 | |
| 1023 | it.effect("keeps unused providers disabled in existing sparse settings files", () => | |
| 1019 | it.effect("runs provider history only for files that still carry the legacy map", () => | |
| 1024 | 1020 | Effect.gen(function* () { |
| 1025 | 1021 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1026 | 1022 | const fileSystem = yield* FileSystem.FileSystem; |
| 1027 | 1023 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1028 | 1024 | yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); |
| 1029 | ||
| 1030 | const settings = yield* serverSettings.getSettings; | |
| 1031 | ||
| 1032 | assert.isFalse(settings.providers.grok.enabled); | |
| 1033 | assert.isFalse(settings.providers.opencode.enabled); | |
| 1034 | assert.isFalse(settings.providers.cursor.enabled); | |
| 1035 | }).pipe(Effect.provide(layerServerSettings())), | |
| 1036 | ); | |
| 1037 | ||
| 1038 | it.effect("preserves provider history when no settings file exists", () => | |
| 1039 | Effect.gen(function* () { | |
| 1040 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1041 | 1025 | yield* recordProviderUsage("grok"); |
| 1042 | 1026 | |
| 1043 | 1027 | const settings = yield* serverSettings.getSettings; |
| 1044 | 1028 | |
| 1045 | assert.isTrue(settings.providers.grok.enabled); | |
| 1046 | assert.isFalse(settings.providers.opencode.enabled); | |
| 1047 | assert.isFalse(settings.providers.cursor.enabled); | |
| 1029 | assert.deepEqual(settings.providerInstances, {}); | |
| 1048 | 1030 | }).pipe(Effect.provide(layerServerSettings())), |
| 1049 | 1031 | ); |
| 1050 | 1032 | |
| 1051 | it.effect("preserves provider history when the settings file is invalid", () => | |
| 1033 | it.effect("migrates legacy providers from an invalid file without rewriting it", () => | |
| 1052 | 1034 | Effect.gen(function* () { |
| 1053 | 1035 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1054 | 1036 | const fileSystem = yield* FileSystem.FileSystem; |
| 1055 | 1037 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1056 | yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); | |
| 1057 | yield* recordProviderUsage("cursor"); | |
| 1038 | const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}'; | |
| 1039 | yield* fileSystem.writeFileString(serverConfig.settingsPath, raw); | |
| 1058 | 1040 | |
| 1059 | 1041 | const settings = yield* serverSettings.getSettings; |
| 1060 | 1042 | |
| 1061 | assert.isTrue(settings.providers.cursor.enabled); | |
| 1062 | assert.isFalse(settings.providers.grok.enabled); | |
| 1063 | assert.isFalse(settings.providers.opencode.enabled); | |
| 1043 | // The readable provider settings still apply, but the file stays for the user to repair. | |
| 1044 | assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { | |
| 1045 | driver: ProviderDriverKind.make("codex"), | |
| 1046 | config: { binaryPath: "/x" }, | |
| 1047 | }); | |
| 1048 | assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw); | |
| 1064 | 1049 | }).pipe(Effect.provide(layerServerSettings())), |
| 1065 | 1050 | ); |
| 1066 | 1051 | |
| 1067 | it.effect("preserves valid provider flags when another settings field is invalid", () => | |
| 1052 | it.effect("skips a disabled provider instance when picking the text generation fallback", () => | |
| 1068 | 1053 | Effect.gen(function* () { |
| 1069 | 1054 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1070 | 1055 | const fileSystem = yield* FileSystem.FileSystem; |
| 1071 | 1056 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1072 | 1057 | yield* fileSystem.writeFileString( |
| 1073 | 1058 | serverConfig.settingsPath, |
| 1074 | '{"addProjectBaseDirectory":42,"providers":{"cursor":{"enabled":false},"grok":{"enabled":true}}}', | |
| 1059 | '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', | |
| 1075 | 1060 | ); |
| 1076 | yield* recordProviderUsage("cursor"); | |
| 1077 | ||
| 1078 | const settings = yield* serverSettings.getSettings; | |
| 1079 | ||
| 1080 | assert.isFalse(settings.providers.cursor.enabled); | |
| 1081 | assert.isTrue(settings.providers.grok.enabled); | |
| 1082 | assert.isFalse(settings.providers.opencode.enabled); | |
| 1083 | }).pipe(Effect.provide(layerServerSettings())), | |
| 1084 | ); | |
| 1085 | ||
| 1086 | it.effect("restores providers from persisted runtime sessions", () => | |
| 1087 | Effect.gen(function* () { | |
| 1088 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1089 | const sql = yield* SqlClient.SqlClient; | |
| 1090 | yield* sql` | |
| 1091 | INSERT INTO provider_session_runtime ( | |
| 1092 | thread_id, | |
| 1093 | provider_name, | |
| 1094 | provider_instance_id, | |
| 1095 | adapter_key, | |
| 1096 | status, | |
| 1097 | last_seen_at | |
| 1098 | ) | |
| 1099 | VALUES ( | |
| 1100 | ${"thread-opencode-runtime"}, | |
| 1101 | ${"opencode"}, | |
| 1102 | ${"opencode"}, | |
| 1103 | ${"opencode"}, | |
| 1104 | ${"ready"}, | |
| 1105 | ${"2026-08-25T00:00:00.000Z"} | |
| 1106 | ) | |
| 1107 | `; | |
| 1108 | 1061 | |
| 1109 | 1062 | const settings = yield* serverSettings.getSettings; |
| 1110 | 1063 | |
| 1111 | assert.isFalse(settings.providers.grok.enabled); | |
| 1112 | assert.isTrue(settings.providers.opencode.enabled); | |
| 1113 | assert.isFalse(settings.providers.cursor.enabled); | |
| 1114 | }).pipe(Effect.provide(layerServerSettings())), | |
| 1115 | ); | |
| 1116 | ||
| 1117 | it.effect("persists explicit disables after a provider has been used", () => | |
| 1118 | Effect.gen(function* () { | |
| 1119 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 1120 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1121 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1122 | yield* recordProviderUsage("grok"); | |
| 1123 | ||
| 1124 | assert.isTrue((yield* serverSettings.getSettings).providers.grok.enabled); | |
| 1125 | ||
| 1126 | const settings = yield* serverSettings.updateSettings({ | |
| 1127 | providers: { grok: { enabled: false } }, | |
| 1128 | }); | |
| 1129 | assert.isFalse(settings.providers.grok.enabled); | |
| 1130 | ||
| 1131 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | |
| 1132 | assert.isFalse(JSON.parse(raw).providers.grok.enabled); | |
| 1133 | }).pipe(Effect.provide(layerServerSettings())), | |
| 1134 | ); | |
| 1135 | ||
| 1136 | it.effect("persists explicit provider enables before their first use", () => | |
| 1137 | Effect.gen(function* () { | |
| 1138 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 1139 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1140 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1141 | ||
| 1142 | yield* serverSettings.updateSettings({ | |
| 1143 | providers: { | |
| 1144 | cursor: { enabled: true }, | |
| 1145 | grok: { enabled: true }, | |
| 1146 | opencode: { enabled: true }, | |
| 1147 | }, | |
| 1148 | }); | |
| 1149 | yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/Development" }); | |
| 1150 | ||
| 1151 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | |
| 1152 | const persisted = JSON.parse(raw); | |
| 1153 | assert.isTrue(persisted.providers.cursor.enabled); | |
| 1154 | assert.isTrue(persisted.providers.grok.enabled); | |
| 1155 | assert.isTrue(persisted.providers.opencode.enabled); | |
| 1064 | assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); | |
| 1156 | 1065 | }).pipe(Effect.provide(layerServerSettings())), |
| 1157 | 1066 | ); |
| 1158 | 1067 | |
| 1159 | it.effect("keeps optional providers disabled after a new installation writes settings", () => | |
| 1068 | it.effect("keeps a default-off provider's new instance disabled and sparse on disk", () => | |
| 1160 | 1069 | Effect.gen(function* () { |
| 1161 | 1070 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1162 | 1071 | const fileSystem = yield* FileSystem.FileSystem; |
| 1163 | 1072 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1164 | 1073 | |
| 1165 | const initial = yield* serverSettings.getSettings; | |
| 1166 | assert.isFalse(initial.providers.grok.enabled); | |
| 1167 | assert.isFalse(initial.providers.opencode.enabled); | |
| 1168 | assert.isFalse(initial.providers.cursor.enabled); | |
| 1169 | ||
| 1170 | 1074 | const next = yield* serverSettings.updateSettings({ |
| 1171 | addProjectBaseDirectory: "~/Development", | |
| 1172 | 1075 | providerInstances: { |
| 1173 | 1076 | [ProviderInstanceId.make("grok")]: { |
| 1174 | 1077 | driver: ProviderDriverKind.make("grok"), |
| ... | ... | @@ -1177,18 +1080,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1177 | 1080 | }, |
| 1178 | 1081 | }); |
| 1179 | 1082 | |
| 1180 | assert.isFalse(next.providers.grok.enabled); | |
| 1181 | assert.isFalse(next.providers.opencode.enabled); | |
| 1182 | assert.isFalse(next.providers.cursor.enabled); | |
| 1183 | 1083 | const grok = next.providerInstances[ProviderInstanceId.make("grok")]; |
| 1184 | 1084 | assert.isDefined(grok); |
| 1185 | 1085 | assert.isFalse(resolveProviderInstanceEnabled(grok)); |
| 1186 | ||
| 1187 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | |
| 1188 | const persisted = JSON.parse(raw); | |
| 1189 | assert.isFalse(persisted.providers.cursor.enabled); | |
| 1190 | assert.isFalse(persisted.providers.grok.enabled); | |
| 1191 | assert.isFalse(persisted.providers.opencode.enabled); | |
| 1086 | const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); | |
| 1087 | assert.isUndefined(persisted.providers); | |
| 1192 | 1088 | assert.isUndefined(persisted.providerInstances.grok.enabled); |
| 1193 | 1089 | }).pipe(Effect.provide(layerServerSettings())), |
| 1194 | 1090 | ); |
| ... | ... | @@ -1252,54 +1148,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1252 | 1148 | }).pipe(Effect.provide(layerServerSettings())), |
| 1253 | 1149 | ); |
| 1254 | 1150 | |
| 1255 | it.effect("trims provider path settings when updates are applied", () => | |
| 1256 | Effect.gen(function* () { | |
| 1257 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1258 | ||
| 1259 | const next = yield* serverSettings.updateSettings({ | |
| 1260 | providers: { | |
| 1261 | codex: { | |
| 1262 | binaryPath: " /opt/homebrew/bin/codex ", | |
| 1263 | homePath: " ", | |
| 1264 | }, | |
| 1265 | claudeAgent: { | |
| 1266 | binaryPath: " /opt/homebrew/bin/claude ", | |
| 1267 | }, | |
| 1268 | opencode: { | |
| 1269 | binaryPath: " /opt/homebrew/bin/opencode ", | |
| 1270 | serverUrl: " http://127.0.0.1:4096 ", | |
| 1271 | serverPassword: " secret-password ", | |
| 1272 | }, | |
| 1273 | }, | |
| 1274 | }); | |
| 1275 | ||
| 1276 | assert.deepEqual(next.providers.codex, { | |
| 1277 | enabled: true, | |
| 1278 | binaryPath: "/opt/homebrew/bin/codex", | |
| 1279 | homePath: "", | |
| 1280 | shadowHomePath: "", | |
| 1281 | launchArgs: "", | |
| 1282 | customModels: [], | |
| 1283 | }); | |
| 1284 | assert.deepEqual(next.providers.claudeAgent, { | |
| 1285 | enabled: true, | |
| 1286 | binaryPath: "/opt/homebrew/bin/claude", | |
| 1287 | homePath: "", | |
| 1288 | customModels: [], | |
| 1289 | launchArgs: "", | |
| 1290 | autoCompactWindow: "", | |
| 1291 | }); | |
| 1292 | assert.deepEqual(next.providers.opencode, { | |
| 1293 | // OpenCode is disabled by default; this update only touches paths. | |
| 1294 | enabled: false, | |
| 1295 | binaryPath: "/opt/homebrew/bin/opencode", | |
| 1296 | serverUrl: "http://127.0.0.1:4096", | |
| 1297 | serverPassword: "secret-password", | |
| 1298 | customModels: [], | |
| 1299 | }); | |
| 1300 | }).pipe(Effect.provide(layerServerSettings())), | |
| 1301 | ); | |
| 1302 | ||
| 1303 | 1151 | it.effect("trims observability settings when updates are applied", () => |
| 1304 | 1152 | Effect.gen(function* () { |
| 1305 | 1153 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| ... | ... | @@ -1322,51 +1170,20 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1322 | 1170 | }).pipe(Effect.provide(layerServerSettings())), |
| 1323 | 1171 | ); |
| 1324 | 1172 | |
| 1325 | it.effect("defaults blank binary paths to provider executables", () => | |
| 1326 | Effect.gen(function* () { | |
| 1327 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | |
| 1328 | ||
| 1329 | const next = yield* serverSettings.updateSettings({ | |
| 1330 | providers: { | |
| 1331 | codex: { | |
| 1332 | binaryPath: " ", | |
| 1333 | }, | |
| 1334 | claudeAgent: { | |
| 1335 | binaryPath: "", | |
| 1336 | }, | |
| 1337 | }, | |
| 1338 | }); | |
| 1339 | ||
| 1340 | assert.equal(next.providers.codex.binaryPath, "codex"); | |
| 1341 | assert.equal(next.providers.claudeAgent.binaryPath, "claude"); | |
| 1342 | }).pipe(Effect.provide(layerServerSettings())), | |
| 1343 | ); | |
| 1344 | ||
| 1345 | it.effect("writes non-default settings and explicit optional provider defaults to disk", () => | |
| 1173 | it.effect("writes only non-default settings to disk", () => | |
| 1346 | 1174 | Effect.gen(function* () { |
| 1347 | 1175 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1348 | 1176 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1349 | 1177 | const fileSystem = yield* FileSystem.FileSystem; |
| 1350 | const next = yield* serverSettings.updateSettings({ | |
| 1178 | yield* serverSettings.updateSettings({ | |
| 1351 | 1179 | addProjectBaseDirectory: "~/Development", |
| 1352 | 1180 | observability: { |
| 1353 | 1181 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 1354 | 1182 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 1355 | 1183 | }, |
| 1356 | providers: { | |
| 1357 | codex: { | |
| 1358 | binaryPath: "/opt/homebrew/bin/codex", | |
| 1359 | }, | |
| 1360 | opencode: { | |
| 1361 | serverUrl: "http://127.0.0.1:4096", | |
| 1362 | serverPassword: "secret-password", | |
| 1363 | }, | |
| 1364 | }, | |
| 1365 | 1184 | automaticGitFetchInterval: Duration.seconds(10), |
| 1366 | 1185 | }); |
| 1367 | 1186 | |
| 1368 | assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); | |
| 1369 | ||
| 1370 | 1187 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); |
| 1371 | 1188 | assert.deepEqual(JSON.parse(raw), { |
| 1372 | 1189 | addProjectBaseDirectory: "~/Development", |
| ... | ... | @@ -1374,22 +1191,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1374 | 1191 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 1375 | 1192 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 1376 | 1193 | }, |
| 1377 | providers: { | |
| 1378 | codex: { | |
| 1379 | binaryPath: "/opt/homebrew/bin/codex", | |
| 1380 | }, | |
| 1381 | cursor: { | |
| 1382 | enabled: false, | |
| 1383 | }, | |
| 1384 | grok: { | |
| 1385 | enabled: false, | |
| 1386 | }, | |
| 1387 | opencode: { | |
| 1388 | enabled: false, | |
| 1389 | serverUrl: "http://127.0.0.1:4096", | |
| 1390 | serverPassword: "secret-password", | |
| 1391 | }, | |
| 1392 | }, | |
| 1393 | 1194 | backgroundActivity: { |
| 1394 | 1195 | schemaVersion: 1, |
| 1395 | 1196 | profile: "custom", |
apps/server/src/serverSettings.ts+102-67| ... | ... | @@ -23,6 +23,8 @@ import { |
| 23 | 23 | type ProviderInstanceEnvironmentVariable, |
| 24 | 24 | type UsageLimitSourceConfig, |
| 25 | 25 | type ProviderInstanceMutation, |
| 26 | defaultInstanceIdForDriver, | |
| 27 | isProviderDriverKind, | |
| 26 | 28 | ProviderDriverKind, |
| 27 | 29 | ProviderInstanceId, |
| 28 | 30 | resolveProviderInstanceEnabled, |
| ... | ... | @@ -384,69 +386,110 @@ const ServerSettingsJson = fromLenientJson( |
| 384 | 386 | }), |
| 385 | 387 | ); |
| 386 | 388 | const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); |
| 387 | const PersistedOptionalProviderSettings = Schema.Struct({ | |
| 388 | providers: Schema.optionalKey( | |
| 389 | Schema.Struct({ | |
| 390 | cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), | |
| 391 | grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), | |
| 392 | opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), | |
| 393 | }), | |
| 394 | ), | |
| 395 | }); | |
| 396 | const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit( | |
| 397 | fromLenientJson(PersistedOptionalProviderSettings), | |
| 389 | /** | |
| 390 | * The retired `providers.<kind>` map, read without its old schemas. Before | |
| 391 | * `providerInstances` existed each built-in driver had one blob there; it | |
| 392 | * now only feeds `migrateLegacyProviderSettings`. | |
| 393 | */ | |
| 394 | const LegacyProviderSettingsJson = fromLenientJson( | |
| 395 | Schema.Struct({ | |
| 396 | providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), | |
| 397 | }), | |
| 398 | 398 | ); |
| 399 | const decodeLegacyProviderSettingsJsonExit = Schema.decodeUnknownExit(LegacyProviderSettingsJson); | |
| 399 | 400 | |
| 400 | function restoreUsedProviders( | |
| 401 | // Drivers that start disabled, so a session in the history means the user | |
| 402 | // turned them on before the instance kept an explicit flag. | |
| 403 | const HISTORY_RESTORED_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set( | |
| 404 | ["cursor", "grok", "opencode"].map((driver) => ProviderDriverKind.make(driver)), | |
| 405 | ); | |
| 406 | ||
| 407 | /** | |
| 408 | * Move each customized legacy `providers.<kind>` blob into the driver's | |
| 409 | * default `providerInstances` slot. An explicit instance already in that slot | |
| 410 | * wins. Blobs that match a fresh install (empty, or only an `enabled` flag | |
| 411 | * equal to the driver default) carry nothing and are dropped. | |
| 412 | * | |
| 413 | * A cursor/grok/opencode slot without an `enabled` flag is enabled when | |
| 414 | * provider history shows the driver was used, which is how those drivers | |
| 415 | * were opted into before they had an explicit flag. Callers run this only | |
| 416 | * while the settings file still has the retired map, so it happens once. | |
| 417 | */ | |
| 418 | function migrateLegacyProviderSettings( | |
| 401 | 419 | settings: ServerSettings, |
| 402 | persisted: typeof PersistedOptionalProviderSettings.Type, | |
| 420 | legacyProviders: Readonly<Record<string, unknown>>, | |
| 403 | 421 | providerHistory: ReadonlyArray<{ |
| 404 | 422 | readonly providerName: string; |
| 405 | 423 | readonly providerInstanceId: string | null; |
| 406 | 424 | }>, |
| 407 | 425 | ): ServerSettings { |
| 408 | const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); | |
| 409 | const usedProviderInstances = new Set( | |
| 426 | // History rows are raw SQL text; compare them as strings. | |
| 427 | const usedProviders = new Set<string>(providerHistory.map(({ providerName }) => providerName)); | |
| 428 | const usedProviderInstances = new Set<string>( | |
| 410 | 429 | providerHistory.map( |
| 411 | 430 | ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, |
| 412 | 431 | ), |
| 413 | 432 | ); |
| 414 | const providerInstances = Object.fromEntries( | |
| 415 | Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ | |
| 416 | instanceId, | |
| 433 | ||
| 434 | const providerInstances: Record<ProviderInstanceId, ProviderInstanceConfig> = {}; | |
| 435 | for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array< | |
| 436 | [ProviderInstanceId, ProviderInstanceConfig] | |
| 437 | >) { | |
| 438 | providerInstances[instanceId] = | |
| 417 | 439 | instance.enabled === undefined && |
| 418 | (instance.driver === "cursor" || | |
| 419 | instance.driver === "grok" || | |
| 420 | instance.driver === "opencode") && | |
| 440 | HISTORY_RESTORED_DRIVERS.has(instance.driver) && | |
| 421 | 441 | usedProviderInstances.has(instanceId) |
| 422 | 442 | ? { ...instance, enabled: true } |
| 423 | : instance, | |
| 424 | ]), | |
| 425 | ); | |
| 443 | : instance; | |
| 444 | } | |
| 445 | ||
| 446 | // History restores a never-configured cursor/grok/opencode slot too. | |
| 447 | const legacyEntries = new Map(Object.entries(legacyProviders)); | |
| 448 | for (const driver of HISTORY_RESTORED_DRIVERS) { | |
| 449 | if (!legacyEntries.has(driver)) legacyEntries.set(driver, {}); | |
| 450 | } | |
| 451 | for (const [kind, blob] of legacyEntries) { | |
| 452 | if (!isProviderDriverKind(kind) || blob === null || typeof blob !== "object") continue; | |
| 453 | if (Array.isArray(blob)) continue; | |
| 454 | const driver = kind; | |
| 455 | const instanceId = defaultInstanceIdForDriver(driver); | |
| 456 | if (Object.hasOwn(providerInstances, instanceId)) continue; | |
| 457 | ||
| 458 | const { enabled: rawEnabled, ...config } = blob as Record<string, unknown>; | |
| 459 | const explicitEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined; | |
| 460 | const enabled = | |
| 461 | explicitEnabled ?? | |
| 462 | (HISTORY_RESTORED_DRIVERS.has(driver) && usedProviders.has(driver) ? true : undefined); | |
| 463 | const driverDefault = resolveProviderInstanceEnabled({ driver, config: {} }); | |
| 464 | if (Object.keys(config).length === 0 && (enabled === undefined || enabled === driverDefault)) { | |
| 465 | continue; | |
| 466 | } | |
| 467 | providerInstances[instanceId] = { | |
| 468 | driver, | |
| 469 | ...(enabled === undefined ? {} : { enabled }), | |
| 470 | config, | |
| 471 | } satisfies ProviderInstanceConfig; | |
| 472 | } | |
| 426 | 473 | |
| 427 | 474 | return { |
| 428 | 475 | ...settings, |
| 429 | providers: { | |
| 430 | ...settings.providers, | |
| 431 | cursor: { | |
| 432 | ...settings.providers.cursor, | |
| 433 | enabled: persisted.providers?.cursor?.enabled ?? usedProviders.has("cursor"), | |
| 434 | }, | |
| 435 | grok: { | |
| 436 | ...settings.providers.grok, | |
| 437 | enabled: persisted.providers?.grok?.enabled ?? usedProviders.has("grok"), | |
| 438 | }, | |
| 439 | opencode: { | |
| 440 | ...settings.providers.opencode, | |
| 441 | enabled: persisted.providers?.opencode?.enabled ?? usedProviders.has("opencode"), | |
| 442 | }, | |
| 443 | }, | |
| 444 | 476 | providerInstances, |
| 445 | 477 | }; |
| 446 | 478 | } |
| 447 | 479 | |
| 448 | 480 | const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); |
| 449 | 481 | |
| 482 | const TEXT_GENERATION_FALLBACK_DRIVERS = [ | |
| 483 | "codex", | |
| 484 | "claudeAgent", | |
| 485 | "cursor", | |
| 486 | "grok", | |
| 487 | "muse", | |
| 488 | "pi", | |
| 489 | "opencode", | |
| 490 | "antigravity", | |
| 491 | ].map((driver) => ProviderDriverKind.make(driver)); | |
| 492 | ||
| 450 | 493 | /** ACP Registry instances reject every application text-generation operation. */ |
| 451 | 494 | function selectionSupportsTextGeneration( |
| 452 | 495 | settings: ServerSettings, |
| ... | ... | @@ -463,14 +506,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings |
| 463 | 506 | } |
| 464 | 507 | |
| 465 | 508 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| 466 | // Same precedence as isModelSelectionProviderEnabled: an explicit provider | |
| 467 | // instance wins over the legacy providers map, which decodes to defaults | |
| 468 | // (codex enabled) when the Providers UI has only written providerInstances. | |
| 469 | const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { | |
| 470 | const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; | |
| 471 | return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); | |
| 472 | }); | |
| 473 | const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; | |
| 509 | // The built-in default instances in preference order. A slot without an | |
| 510 | // explicit instance uses its driver's default enabled state. | |
| 511 | const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) => | |
| 512 | resolveProviderInstanceEnabled( | |
| 513 | settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} }, | |
| 514 | ), | |
| 515 | ); | |
| 474 | 516 | if (!fallback) { |
| 475 | 517 | return settings; |
| 476 | 518 | } |
| ... | ... | @@ -497,17 +539,6 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ |
| 497 | 539 | "pullRequestMergeMethod", |
| 498 | 540 | ]); |
| 499 | 541 | |
| 500 | // Preserve both enabled states because provider history cannot recover a new opt-in. | |
| 501 | const PERSISTED_SERVER_SETTINGS_DEFAULTS = { | |
| 502 | ...DEFAULT_SERVER_SETTINGS, | |
| 503 | providers: { | |
| 504 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 505 | cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined }, | |
| 506 | grok: { ...DEFAULT_SERVER_SETTINGS.providers.grok, enabled: undefined }, | |
| 507 | opencode: { ...DEFAULT_SERVER_SETTINGS.providers.opencode, enabled: undefined }, | |
| 508 | }, | |
| 509 | }; | |
| 510 | ||
| 511 | 542 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { |
| 512 | 543 | if (Array.isArray(current) || Array.isArray(defaults)) { |
| 513 | 544 | return Equal.equals(current, defaults) ? undefined : current; |
| ... | ... | @@ -671,7 +702,7 @@ const make = Effect.gen(function* () { |
| 671 | 702 | const writeSettingsAtomically = Effect.fnUntraced( |
| 672 | 703 | function* (settings: ServerSettings) { |
| 673 | 704 | const sparseSettingsJson = yield* encodeServerSettingsJson( |
| 674 | stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, | |
| 705 | stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, | |
| 675 | 706 | ); |
| 676 | 707 | |
| 677 | 708 | return yield* writeFileStringAtomically({ |
| ... | ... | @@ -740,7 +771,7 @@ const make = Effect.gen(function* () { |
| 740 | 771 | |
| 741 | 772 | const loadSettingsFromDisk = Effect.gen(function* () { |
| 742 | 773 | let settings = DEFAULT_SERVER_SETTINGS; |
| 743 | let persisted: typeof PersistedOptionalProviderSettings.Type = {}; | |
| 774 | let legacyProviders: Readonly<Record<string, unknown>> | undefined; | |
| 744 | 775 | // A file that failed to decode must stay on disk for the user to repair; |
| 745 | 776 | // the fold below only writes when it started from the file's real contents. |
| 746 | 777 | let settingsFileTrusted = true; |
| ... | ... | @@ -748,12 +779,12 @@ const make = Effect.gen(function* () { |
| 748 | 779 | if (yield* readConfigExists) { |
| 749 | 780 | const raw = yield* readRawConfig; |
| 750 | 781 | const decoded = decodeServerSettingsJsonExit(raw); |
| 751 | const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); | |
| 752 | if (persistedSettings._tag === "Success") { | |
| 753 | persisted = persistedSettings.value; | |
| 782 | const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); | |
| 783 | if (legacySettings._tag === "Success") { | |
| 784 | legacyProviders = legacySettings.value.providers; | |
| 754 | 785 | } |
| 755 | if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { | |
| 756 | const failure = decoded._tag === "Failure" ? decoded : persistedSettings; | |
| 786 | if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { | |
| 787 | const failure = decoded._tag === "Failure" ? decoded : legacySettings; | |
| 757 | 788 | settingsFileTrusted = false; |
| 758 | 789 | if (failure._tag === "Failure") { |
| 759 | 790 | yield* Effect.logWarning("failed to parse settings.json, using defaults", { |
| ... | ... | @@ -817,14 +848,18 @@ const make = Effect.gen(function* () { |
| 817 | 848 | ); |
| 818 | 849 | |
| 819 | 850 | const loaded = foldProviderInstanceEnabledFlags( |
| 820 | restoreUsedProviders(settings, persisted, providerHistory), | |
| 851 | legacyProviders === undefined | |
| 852 | ? settings | |
| 853 | : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory), | |
| 821 | 854 | ); |
| 822 | 855 | const folded = settingsFileTrusted |
| 823 | 856 | ? foldLegacyProjectSettings(loaded, legacyProjectRows) |
| 824 | 857 | : loaded; |
| 825 | 858 | // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. |
| 826 | 859 | const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; |
| 827 | if (migrated !== loaded) { | |
| 860 | // A file still carrying the retired `providers` map is rewritten once so | |
| 861 | // the migrated instances persist and the old key disappears. | |
| 862 | if (migrated !== loaded || (settingsFileTrusted && legacyProviders !== undefined)) { | |
| 828 | 863 | yield* writeSettingsAtomically(migrated); |
| 829 | 864 | } |
| 830 | 865 | return migrated; |
apps/server/src/terminal/Manager.test.ts+21-23| ... | ... | @@ -2272,7 +2272,7 @@ it.layer( |
| 2272 | 2272 | ), |
| 2273 | 2273 | ); |
| 2274 | 2274 | |
| 2275 | it.effect("resolves the legacy Codex default instance", () => | |
| 2275 | it.effect("resolves the Codex default slot", () => | |
| 2276 | 2276 | Effect.gen(function* () { |
| 2277 | 2277 | const path = yield* Path.Path; |
| 2278 | 2278 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | ... | @@ -2283,18 +2283,22 @@ it.layer( |
| 2283 | 2283 | env: undefined, |
| 2284 | 2284 | }); |
| 2285 | 2285 | |
| 2286 | expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); | |
| 2286 | expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/); | |
| 2287 | 2287 | }).pipe( |
| 2288 | 2288 | Effect.provide( |
| 2289 | 2289 | ServerSettings.ServerSettingsService.layerTest({ |
| 2290 | providerInstances: {}, | |
| 2291 | providers: { codex: { homePath: "~/.codex-legacy" } }, | |
| 2290 | providerInstances: { | |
| 2291 | [ProviderInstanceId.make("codex")]: { | |
| 2292 | driver: ProviderDriverKind.make("codex"), | |
| 2293 | config: { homePath: "~/.codex-default" }, | |
| 2294 | }, | |
| 2295 | }, | |
| 2292 | 2296 | }), |
| 2293 | 2297 | ), |
| 2294 | 2298 | ), |
| 2295 | 2299 | ); |
| 2296 | 2300 | |
| 2297 | it.effect("resolves the legacy Claude default instance", () => | |
| 2301 | it.effect("resolves the Claude default slot", () => | |
| 2298 | 2302 | Effect.gen(function* () { |
| 2299 | 2303 | const path = yield* Path.Path; |
| 2300 | 2304 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | ... | @@ -2305,18 +2309,22 @@ it.layer( |
| 2305 | 2309 | env: undefined, |
| 2306 | 2310 | }); |
| 2307 | 2311 | |
| 2308 | expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); | |
| 2312 | expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-default$/); | |
| 2309 | 2313 | }).pipe( |
| 2310 | 2314 | Effect.provide( |
| 2311 | 2315 | ServerSettings.ServerSettingsService.layerTest({ |
| 2312 | providerInstances: {}, | |
| 2313 | providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, | |
| 2316 | providerInstances: { | |
| 2317 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 2318 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 2319 | config: { homePath: "~/.claude-default" }, | |
| 2320 | }, | |
| 2321 | }, | |
| 2314 | 2322 | }), |
| 2315 | 2323 | ), |
| 2316 | 2324 | ), |
| 2317 | 2325 | ); |
| 2318 | 2326 | |
| 2319 | it.effect("prefers an explicit default instance over legacy provider settings", () => | |
| 2327 | it.effect("resolves an empty Codex default slot with default config", () => | |
| 2320 | 2328 | Effect.gen(function* () { |
| 2321 | 2329 | const path = yield* Path.Path; |
| 2322 | 2330 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | ... | @@ -2324,26 +2332,16 @@ it.layer( |
| 2324 | 2332 | serverSettings, |
| 2325 | 2333 | path, |
| 2326 | 2334 | rawProviderInstanceId: "codex", |
| 2327 | env: undefined, | |
| 2335 | env: { CODEX_HOME: "/inherited/codex-home" }, | |
| 2328 | 2336 | }); |
| 2329 | 2337 | |
| 2330 | expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); | |
| 2338 | expect(environment.CODEX_HOME).toBe("/inherited/codex-home"); | |
| 2331 | 2339 | }).pipe( |
| 2332 | Effect.provide( | |
| 2333 | ServerSettings.ServerSettingsService.layerTest({ | |
| 2334 | providers: { codex: { homePath: "~/.codex-legacy" } }, | |
| 2335 | providerInstances: { | |
| 2336 | [ProviderInstanceId.make("codex")]: { | |
| 2337 | driver: "codex", | |
| 2338 | config: { homePath: "~/.codex-explicit" }, | |
| 2339 | }, | |
| 2340 | }, | |
| 2341 | }), | |
| 2342 | ), | |
| 2340 | Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })), | |
| 2343 | 2341 | ), |
| 2344 | 2342 | ); |
| 2345 | 2343 | |
| 2346 | it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => | |
| 2344 | it.effect("keeps unknown provider instance ids unavailable after default-slot hydration", () => | |
| 2347 | 2345 | Effect.gen(function* () { |
| 2348 | 2346 | const path = yield* Path.Path; |
| 2349 | 2347 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
apps/server/src/usage/UsageService.test.ts+97-92| ... | ... | @@ -73,9 +73,15 @@ const setup = Effect.gen(function* () { |
| 73 | 73 | home, |
| 74 | 74 | transcript: NodePath.join(transcriptDir, "session.jsonl"), |
| 75 | 75 | settings: { |
| 76 | providers: { | |
| 77 | claudeAgent: { homePath: NodePath.join(home, "claude") }, | |
| 78 | codex: { homePath: NodePath.join(home, "codex") }, | |
| 76 | providerInstances: { | |
| 77 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 78 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 79 | config: { homePath: NodePath.join(home, "claude") }, | |
| 80 | }, | |
| 81 | [ProviderInstanceId.make("codex")]: { | |
| 82 | driver: ProviderDriverKind.make("codex"), | |
| 83 | config: { homePath: NodePath.join(home, "codex") }, | |
| 84 | }, | |
| 79 | 85 | }, |
| 80 | 86 | }, |
| 81 | 87 | }; |
| ... | ... | @@ -227,98 +233,86 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { |
| 227 | 233 | } |
| 228 | 234 | |
| 229 | 235 | describe("UsageService", () => { |
| 230 | it.live.each([ | |
| 231 | { explicitDefault: true, label: "explicit" }, | |
| 232 | { explicitDefault: false, label: "legacy" }, | |
| 233 | ])( | |
| 234 | "reads shared managed $label default and disabled extra account history once", | |
| 235 | ({ explicitDefault }) => | |
| 236 | Effect.gen(function* () { | |
| 237 | const { home, settings } = yield* setup; | |
| 238 | const summary = yield* Effect.gen(function* () { | |
| 239 | for (const [id, output] of [ | |
| 240 | ["codex", 17], | |
| 241 | ["codex-personal", 23], | |
| 242 | ] as const) { | |
| 243 | const sessions = NodePath.join(home, "shared-codex", "sessions"); | |
| 244 | yield* Effect.promise(async () => { | |
| 245 | await NodeFSP.mkdir(sessions, { recursive: true }); | |
| 246 | await NodeFSP.writeFile( | |
| 247 | NodePath.join(sessions, `${id}-rollout.jsonl`), | |
| 248 | [ | |
| 249 | { type: "session_meta", payload: { id } }, | |
| 250 | { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, | |
| 251 | { | |
| 252 | type: "event_msg", | |
| 253 | timestamp: "2026-08-01T10:00:00Z", | |
| 254 | payload: { | |
| 255 | type: "token_count", | |
| 256 | info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, | |
| 257 | }, | |
| 236 | it.live("reads shared managed default and disabled extra account history once", () => | |
| 237 | Effect.gen(function* () { | |
| 238 | const { home, settings } = yield* setup; | |
| 239 | const summary = yield* Effect.gen(function* () { | |
| 240 | for (const [id, output] of [ | |
| 241 | ["codex", 17], | |
| 242 | ["codex-personal", 23], | |
| 243 | ] as const) { | |
| 244 | const sessions = NodePath.join(home, "shared-codex", "sessions"); | |
| 245 | yield* Effect.promise(async () => { | |
| 246 | await NodeFSP.mkdir(sessions, { recursive: true }); | |
| 247 | await NodeFSP.writeFile( | |
| 248 | NodePath.join(sessions, `${id}-rollout.jsonl`), | |
| 249 | [ | |
| 250 | { type: "session_meta", payload: { id } }, | |
| 251 | { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, | |
| 252 | { | |
| 253 | type: "event_msg", | |
| 254 | timestamp: "2026-08-01T10:00:00Z", | |
| 255 | payload: { | |
| 256 | type: "token_count", | |
| 257 | info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, | |
| 258 | 258 | }, |
| 259 | ] | |
| 260 | .map((line) => encodeUnknownJsonString(line)) | |
| 261 | .join("\n") + "\n", | |
| 262 | ); | |
| 263 | }); | |
| 264 | } | |
| 265 | const service = yield* UsageService.make; | |
| 266 | return yield* service.readSummary(WINDOW); | |
| 267 | }).pipe( | |
| 268 | // Scoped inside the state directory, so pending cache writes land | |
| 269 | // before it is removed. | |
| 270 | Effect.scoped, | |
| 271 | Effect.provide( | |
| 272 | layerService({ | |
| 273 | prefix: "usage-managed-accounts", | |
| 274 | home, | |
| 275 | settings: { | |
| 276 | ...settings, | |
| 277 | providers: { | |
| 278 | ...settings.providers, | |
| 279 | codex: { setupMode: "managed", homePath: NodePath.join(home, "shared-codex") }, | |
| 280 | 259 | }, |
| 281 | providerInstances: { | |
| 282 | ...(explicitDefault | |
| 283 | ? { | |
| 284 | [ProviderInstanceId.make("codex")]: { | |
| 285 | driver: ProviderDriverKind.make("codex"), | |
| 286 | config: { | |
| 287 | setupMode: "managed", | |
| 288 | homePath: NodePath.join(home, "shared-codex"), | |
| 289 | }, | |
| 290 | }, | |
| 291 | } | |
| 292 | : {}), | |
| 293 | [ProviderInstanceId.make("codex-personal")]: { | |
| 294 | driver: ProviderDriverKind.make("codex"), | |
| 295 | enabled: false, | |
| 296 | config: { | |
| 297 | setupMode: "managed", | |
| 298 | homePath: NodePath.join(home, "shared-codex"), | |
| 299 | shadowHomePath: NodePath.join(home, "personal-shadow"), | |
| 300 | }, | |
| 301 | environment: [ | |
| 302 | { | |
| 303 | name: "CODEX_HOME", | |
| 304 | value: NodePath.join(home, "ignored-environment"), | |
| 305 | sensitive: false, | |
| 306 | }, | |
| 307 | ], | |
| 260 | ] | |
| 261 | .map((line) => encodeUnknownJsonString(line)) | |
| 262 | .join("\n") + "\n", | |
| 263 | ); | |
| 264 | }); | |
| 265 | } | |
| 266 | const service = yield* UsageService.make; | |
| 267 | return yield* service.readSummary(WINDOW); | |
| 268 | }).pipe( | |
| 269 | // Scoped inside the state directory, so pending cache writes land | |
| 270 | // before it is removed. | |
| 271 | Effect.scoped, | |
| 272 | Effect.provide( | |
| 273 | layerService({ | |
| 274 | prefix: "usage-managed-accounts", | |
| 275 | home, | |
| 276 | settings: { | |
| 277 | ...settings, | |
| 278 | providerInstances: { | |
| 279 | ...settings.providerInstances, | |
| 280 | [ProviderInstanceId.make("codex")]: { | |
| 281 | driver: ProviderDriverKind.make("codex"), | |
| 282 | config: { | |
| 283 | setupMode: "managed", | |
| 284 | homePath: NodePath.join(home, "shared-codex"), | |
| 285 | }, | |
| 286 | }, | |
| 287 | [ProviderInstanceId.make("codex-personal")]: { | |
| 288 | driver: ProviderDriverKind.make("codex"), | |
| 289 | enabled: false, | |
| 290 | config: { | |
| 291 | setupMode: "managed", | |
| 292 | homePath: NodePath.join(home, "shared-codex"), | |
| 293 | shadowHomePath: NodePath.join(home, "personal-shadow"), | |
| 308 | 294 | }, |
| 295 | environment: [ | |
| 296 | { | |
| 297 | name: "CODEX_HOME", | |
| 298 | value: NodePath.join(home, "ignored-environment"), | |
| 299 | sensitive: false, | |
| 300 | }, | |
| 301 | ], | |
| 309 | 302 | }, |
| 310 | 303 | }, |
| 311 | }), | |
| 312 | ), | |
| 313 | ); | |
| 314 | assert.strictEqual(totalOutputTokens(summary), 40); | |
| 315 | assert.strictEqual( | |
| 316 | summary.sources.filter( | |
| 317 | (source) => source.fingerprint.provider === "codex" && source.status === "ok", | |
| 318 | ).length, | |
| 319 | 1, | |
| 320 | ); | |
| 321 | }).pipe(Effect.scoped), | |
| 304 | }, | |
| 305 | }), | |
| 306 | ), | |
| 307 | ); | |
| 308 | assert.strictEqual(totalOutputTokens(summary), 40); | |
| 309 | assert.strictEqual( | |
| 310 | summary.sources.filter( | |
| 311 | (source) => source.fingerprint.provider === "codex" && source.status === "ok", | |
| 312 | ).length, | |
| 313 | 1, | |
| 314 | ); | |
| 315 | }).pipe(Effect.scoped), | |
| 322 | 316 | ); |
| 323 | 317 | it.live("omits Cursor account usage when no file login is saved", () => |
| 324 | 318 | Effect.gen(function* () { |
| ... | ... | @@ -788,6 +782,7 @@ describe("UsageService", () => { |
| 788 | 782 | settings: { |
| 789 | 783 | ...settings, |
| 790 | 784 | providerInstances: { |
| 785 | ...settings.providerInstances, | |
| 791 | 786 | [ProviderInstanceId.make("claude-work")]: { |
| 792 | 787 | driver: ProviderDriverKind.make("claudeAgent"), |
| 793 | 788 | enabled: false, |
| ... | ... | @@ -847,7 +842,7 @@ describe("UsageService", () => { |
| 847 | 842 | ); |
| 848 | 843 | |
| 849 | 844 | it.live( |
| 850 | "uses explicit account settings before environment and legacy homes, then refreshes them", | |
| 845 | "uses explicit account settings before environment and default homes, then refreshes them", | |
| 851 | 846 | () => |
| 852 | 847 | Effect.gen(function* () { |
| 853 | 848 | const { transcript, settings, home } = yield* setup; |
| ... | ... | @@ -884,6 +879,7 @@ describe("UsageService", () => { |
| 884 | 879 | ); |
| 885 | 880 | yield* settingsService.updateSettings({ |
| 886 | 881 | providerInstances: { |
| 882 | ...settings.providerInstances, | |
| 887 | 883 | [ProviderInstanceId.make("claudeAgent")]: { |
| 888 | 884 | driver: ProviderDriverKind.make("claudeAgent"), |
| 889 | 885 | config: { homePath: "" }, |
| ... | ... | @@ -912,6 +908,7 @@ describe("UsageService", () => { |
| 912 | 908 | settings: { |
| 913 | 909 | ...settings, |
| 914 | 910 | providerInstances: { |
| 911 | ...settings.providerInstances, | |
| 915 | 912 | [ProviderInstanceId.make("claudeAgent")]: { |
| 916 | 913 | driver: ProviderDriverKind.make("claudeAgent"), |
| 917 | 914 | config: { homePath: configured }, |
| ... | ... | @@ -1271,7 +1268,15 @@ describe("UsageService", () => { |
| 1271 | 1268 | layerService({ |
| 1272 | 1269 | prefix: "usage-service-cleanup-test", |
| 1273 | 1270 | home, |
| 1274 | settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } }, | |
| 1271 | settings: { | |
| 1272 | providerInstances: { | |
| 1273 | ...settings.providerInstances, | |
| 1274 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 1275 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 1276 | config: { homePath: alias }, | |
| 1277 | }, | |
| 1278 | }, | |
| 1279 | }, | |
| 1275 | 1280 | ratesDocument: { |
| 1276 | 1281 | "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, |
| 1277 | 1282 | }, |
apps/server/src/usage/UsageService.ts+3-6| ... | ... | @@ -351,18 +351,15 @@ export const make = Effect.gen(function* () { |
| 351 | 351 | }> = []; |
| 352 | 352 | const seen = new Set<string>(); |
| 353 | 353 | for (const driver of ["claudeAgent", "codex", "grok"] as const) { |
| 354 | // Disabled accounts still have history. Explicit default slots replace | |
| 355 | // the legacy settings, just as they do in the provider registry. | |
| 354 | // Disabled accounts still have history. An unconfigured default slot | |
| 355 | // runs with default config, just as it does in the provider registry. | |
| 356 | 356 | const instances: Array< |
| 357 | 357 | Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId } |
| 358 | 358 | > = Object.entries(settings.providerInstances) |
| 359 | 359 | .filter(([, instance]) => instance.driver === driver) |
| 360 | 360 | .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); |
| 361 | 361 | if (!Object.hasOwn(settings.providerInstances, driver)) { |
| 362 | instances.push({ | |
| 363 | config: settings.providers[driver], | |
| 364 | instanceId: ProviderInstanceId.make(driver), | |
| 365 | }); | |
| 362 | instances.push({ instanceId: ProviderInstanceId.make(driver) }); | |
| 366 | 363 | } |
| 367 | 364 | for (const instance of instances) { |
| 368 | 365 | const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); |
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-4| ... | ... | @@ -898,8 +898,7 @@ function OnboardingCodexSetup({ |
| 898 | 898 | const settings = serverConfig.settings; |
| 899 | 899 | const instance = settings.providerInstances[instanceId] ?? { |
| 900 | 900 | driver: ProviderDriverKind.make("codex"), |
| 901 | enabled: settings.providers.codex.enabled, | |
| 902 | config: createdAccount ? { enabled: true, setupMode: "managed" } : settings.providers.codex, | |
| 901 | config: createdAccount ? { enabled: true, setupMode: "managed" } : {}, | |
| 903 | 902 | }; |
| 904 | 903 | const mode = readCodexSetupMode(instance.config); |
| 905 | 904 | const existingChosen = |
| ... | ... | @@ -915,8 +914,6 @@ function OnboardingCodexSetup({ |
| 915 | 914 | patch: buildProviderInstanceUpdatePatch({ |
| 916 | 915 | settings, |
| 917 | 916 | instanceId, |
| 918 | driver: ProviderDriverKind.make("codex"), | |
| 919 | isDefault: instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), | |
| 920 | 917 | instance: { |
| 921 | 918 | ...instance, |
| 922 | 919 | enabled: true, |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+6-11| ... | ... | @@ -7,7 +7,6 @@ import { CheckIcon } from "lucide-react"; |
| 7 | 7 | import { useMemo, useState } from "react"; |
| 8 | 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 9 | 9 | import { |
| 10 | DEFAULT_UNIFIED_SETTINGS, | |
| 11 | 10 | type AcpRegistrySearchAgent, |
| 12 | 11 | AuthProvidersManageScope, |
| 13 | 12 | ProviderInstanceId, |
| ... | ... | @@ -21,7 +20,6 @@ import { |
| 21 | 20 | useEnvironmentSettings, |
| 22 | 21 | usePersistEnvironmentProviderInstanceMutation, |
| 23 | 22 | } from "../../hooks/useSettings"; |
| 24 | import * as Equal from "effect/Equal"; | |
| 25 | 23 | |
| 26 | 24 | import { cn } from "../../lib/utils"; |
| 27 | 25 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| ... | ... | @@ -139,15 +137,12 @@ export function AddProviderInstanceDialog({ |
| 139 | 137 | const [isSaving, setIsSaving] = useState(false); |
| 140 | 138 | const [createdInstanceId, setCreatedInstanceId] = useState<ProviderInstanceId | null>(null); |
| 141 | 139 | |
| 142 | const existingIds = useMemo(() => { | |
| 143 | const ids = new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]); | |
| 144 | const defaults = DEFAULT_UNIFIED_SETTINGS.providers as Record<string, unknown>; | |
| 145 | // Reserve configured legacy slots too, so adding an account cannot replace them. | |
| 146 | for (const [kind, config] of Object.entries(settings.providers ?? {})) { | |
| 147 | if (!Equal.equals(config, defaults[kind])) ids.add(kind); | |
| 148 | } | |
| 149 | return ids; | |
| 150 | }, [settings.providerInstances, settings.providers]); | |
| 140 | // Codex and Claude run at their default slots before they are configured, so | |
| 141 | // those ids stay reserved; other unconfigured default slots are free to take. | |
| 142 | const existingIds = useMemo( | |
| 143 | () => new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]), | |
| 144 | [settings.providerInstances], | |
| 145 | ); | |
| 151 | 146 | |
| 152 | 147 | const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION; |
| 153 | 148 | const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND; |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+8-8| ... | ... | @@ -276,12 +276,13 @@ describe("EnvironmentProviderSettings routing", () => { |
| 276 | 276 | it("keeps legacy provider configuration visible when disabled", () => { |
| 277 | 277 | settingsState.value = { |
| 278 | 278 | ...DEFAULT_UNIFIED_SETTINGS, |
| 279 | providers: { | |
| 280 | ...DEFAULT_UNIFIED_SETTINGS.providers, | |
| 281 | grok: { | |
| 282 | ...DEFAULT_UNIFIED_SETTINGS.providers.grok, | |
| 279 | providerInstances: { | |
| 280 | [ProviderInstanceId.make("grok")]: { | |
| 281 | driver: ProviderDriverKind.make("grok"), | |
| 283 | 282 | enabled: false, |
| 284 | binaryPath: "/custom/grok", | |
| 283 | config: { | |
| 284 | binaryPath: "/custom/grok", | |
| 285 | }, | |
| 285 | 286 | }, |
| 286 | 287 | }, |
| 287 | 288 | }; |
| ... | ... | @@ -516,9 +517,8 @@ describe("EnvironmentProviderSettings routing", () => { |
| 516 | 517 | |
| 517 | 518 | const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? []; |
| 518 | 519 | expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId }); |
| 519 | expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providers"]); | |
| 520 | expect(resetPatch).not.toHaveProperty("favorites"); | |
| 521 | expect(resetPatch).not.toHaveProperty("providerModelPreferences"); | |
| 520 | // Removing the instance is the whole reset; shared preferences stay untouched. | |
| 521 | expect(resetPatch ?? {}).toEqual({}); | |
| 522 | 522 | }); |
| 523 | 523 | |
| 524 | 524 | it("updates one provider instance without sending a stale whole map", async () => { |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+22-64| ... | ... | @@ -147,6 +147,7 @@ function providerConfigString(config: unknown, key: string): string | null { |
| 147 | 147 | |
| 148 | 148 | const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ |
| 149 | 149 | provider: definition.driverKind, |
| 150 | hasDefaultInstance: definition.hasDefaultInstance !== false, | |
| 150 | 151 | })); |
| 151 | 152 | |
| 152 | 153 | function configuredBinaryPath(config: unknown): string { |
| ... | ... | @@ -757,57 +758,29 @@ export function EnvironmentProviderSettings({ |
| 757 | 758 | ); |
| 758 | 759 | |
| 759 | 760 | for (const providerSettings of visibleProviderSettings) { |
| 760 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 761 | const legacyProviders = settings.providers as Record<string, LegacyProviderSettings>; | |
| 762 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 763 | string, | |
| 764 | LegacyProviderSettings | |
| 765 | >; | |
| 766 | 761 | const driver = providerSettings.provider; |
| 767 | 762 | const defaultInstanceId = defaultInstanceIdForDriver(driver); |
| 768 | 763 | const explicitInstance = settings.providerInstances?.[defaultInstanceId]; |
| 769 | // A remote device may run a server version whose settings predate this | |
| 770 | // driver, so the legacy mirror can be absent. Without either an explicit | |
| 771 | // instance or a legacy blob there is nothing to render for the slot. | |
| 772 | const legacyConfig = legacyProviders[providerSettings.provider]; | |
| 773 | const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; | |
| 774 | // The envelope is the single enabled flag: keep the legacy in-config | |
| 775 | // flag out of the synthesized blob, or an explicit `enabled: false` | |
| 776 | // would keep winning over the envelope and the Switch could never | |
| 777 | // turn a default-off provider on. | |
| 778 | const synthesizedInstance = (): ProviderInstanceConfig | undefined => { | |
| 779 | if (legacyConfig === undefined) { | |
| 780 | return undefined; | |
| 781 | } | |
| 782 | const { enabled: legacyEnabled, ...legacyConfigRest } = legacyConfig; | |
| 783 | return { | |
| 784 | driver, | |
| 785 | enabled: legacyEnabled, | |
| 786 | config: legacyConfigRest, | |
| 787 | } satisfies ProviderInstanceConfig; | |
| 788 | }; | |
| 789 | const effectiveInstance: ProviderInstanceConfig | undefined = | |
| 790 | explicitInstance ?? synthesizedInstance(); | |
| 791 | // Only the default slot depends on the legacy blob; custom instances for | |
| 792 | // the driver must still render even when the slot has nothing to show. | |
| 793 | if (effectiveInstance !== undefined) { | |
| 794 | const isDirty = | |
| 795 | explicitInstance !== undefined || !Equal.equals(legacyConfig, defaultLegacyConfig); | |
| 796 | if ( | |
| 797 | driver === "codex" || | |
| 764 | // An unconfigured default slot runs with the driver's default config. | |
| 765 | const effectiveInstance: ProviderInstanceConfig = explicitInstance ?? { driver }; | |
| 766 | const isDirty = explicitInstance !== undefined; | |
| 767 | // Drivers without a default instance list only their configured instances. | |
| 768 | const hasDefaultSlot = providerSettings.hasDefaultInstance || explicitInstance !== undefined; | |
| 769 | if ( | |
| 770 | hasDefaultSlot && | |
| 771 | (driver === "codex" || | |
| 798 | 772 | driver === "claudeAgent" || |
| 799 | 773 | isDirty || |
| 800 | 774 | resolveProviderInstanceEnabled(effectiveInstance) || |
| 801 | defaultInstanceId === targetInstanceId | |
| 802 | ) { | |
| 803 | rows.push({ | |
| 804 | instanceId: defaultInstanceId, | |
| 805 | instance: effectiveInstance, | |
| 806 | driver, | |
| 807 | isDefault: true, | |
| 808 | isDirty, | |
| 809 | }); | |
| 810 | } | |
| 775 | defaultInstanceId === targetInstanceId) | |
| 776 | ) { | |
| 777 | rows.push({ | |
| 778 | instanceId: defaultInstanceId, | |
| 779 | instance: effectiveInstance, | |
| 780 | driver, | |
| 781 | isDefault: true, | |
| 782 | isDirty, | |
| 783 | }); | |
| 811 | 784 | } |
| 812 | 785 | for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { |
| 813 | 786 | if (id === defaultInstanceId) continue; |
| ... | ... | @@ -847,8 +820,6 @@ export function EnvironmentProviderSettings({ |
| 847 | 820 | settings, |
| 848 | 821 | instanceId: row.instanceId, |
| 849 | 822 | instance: next, |
| 850 | driver: row.driver, | |
| 851 | isDefault: row.isDefault, | |
| 852 | 823 | textGenerationModelSelection: options?.textGenerationModelSelection, |
| 853 | 824 | }); |
| 854 | 825 | const result = await persistProviderInstance( |
| ... | ... | @@ -947,23 +918,10 @@ export function EnvironmentProviderSettings({ |
| 947 | 918 | }; |
| 948 | 919 | |
| 949 | 920 | const resetDefaultInstance = async (driverKind: ProviderDriverKind) => { |
| 950 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | |
| 951 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 952 | string, | |
| 953 | LegacyProviderSettings | undefined | |
| 954 | >; | |
| 955 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | |
| 956 | const defaultLegacyProvider = defaultLegacyProviders[driverKind]; | |
| 957 | if (defaultLegacyProvider === undefined) return; | |
| 958 | const result = await persistProviderInstance( | |
| 959 | { operation: "remove", instanceId: defaultInstanceId }, | |
| 960 | { | |
| 961 | providers: { | |
| 962 | ...settings.providers, | |
| 963 | [driverKind]: defaultLegacyProvider, | |
| 964 | } as typeof settings.providers, | |
| 965 | }, | |
| 966 | ); | |
| 921 | const result = await persistProviderInstance({ | |
| 922 | operation: "remove", | |
| 923 | instanceId: defaultInstanceIdForDriver(driverKind), | |
| 924 | }); | |
| 967 | 925 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 968 | 926 | const error = squashAtomCommandFailure(result); |
| 969 | 927 | toastManager.add({ |
apps/web/src/components/settings/SettingsPanels.logic.test.ts+8-38| ... | ... | @@ -190,57 +190,27 @@ describe("formatDiagnosticsDescription", () => { |
| 190 | 190 | }); |
| 191 | 191 | |
| 192 | 192 | describe("buildProviderInstanceUpdatePatch", () => { |
| 193 | it("promotes an edited default provider into providerInstances and resets the legacy provider", () => { | |
| 193 | it("replaces one instance and keeps the others", () => { | |
| 194 | 194 | const instanceId = ProviderInstanceId.make("codex"); |
| 195 | const nextInstance = { | |
| 195 | const otherId = ProviderInstanceId.make("codex_personal"); | |
| 196 | const other = { | |
| 196 | 197 | driver: ProviderDriverKind.make("codex"), |
| 197 | enabled: true, | |
| 198 | config: { | |
| 199 | binaryPath: "/opt/t3/codex", | |
| 200 | }, | |
| 198 | config: { homePath: "/Users/example/.codex-personal" }, | |
| 201 | 199 | } satisfies ProviderInstanceConfig; |
| 202 | ||
| 203 | const patch = buildProviderInstanceUpdatePatch({ | |
| 204 | settings: { | |
| 205 | ...DEFAULT_SERVER_SETTINGS, | |
| 206 | providers: { | |
| 207 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 208 | codex: { | |
| 209 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 210 | binaryPath: "/legacy/codex", | |
| 211 | }, | |
| 212 | }, | |
| 213 | }, | |
| 214 | instanceId, | |
| 215 | instance: nextInstance, | |
| 216 | driver: ProviderDriverKind.make("codex"), | |
| 217 | isDefault: true, | |
| 218 | }); | |
| 219 | ||
| 220 | expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); | |
| 221 | expect(patch.providers?.codex).toEqual(DEFAULT_SERVER_SETTINGS.providers.codex); | |
| 222 | }); | |
| 223 | ||
| 224 | it("updates custom instances without touching legacy provider settings", () => { | |
| 225 | const instanceId = ProviderInstanceId.make("codex_personal"); | |
| 226 | 200 | const nextInstance = { |
| 227 | 201 | driver: ProviderDriverKind.make("codex"), |
| 228 | 202 | enabled: true, |
| 229 | config: { | |
| 230 | homePath: "/Users/example/.codex-personal", | |
| 231 | }, | |
| 203 | config: { binaryPath: "/opt/t3/codex" }, | |
| 232 | 204 | } satisfies ProviderInstanceConfig; |
| 233 | 205 | |
| 234 | 206 | const patch = buildProviderInstanceUpdatePatch({ |
| 235 | settings: DEFAULT_SERVER_SETTINGS, | |
| 207 | settings: { providerInstances: { [otherId]: other } }, | |
| 236 | 208 | instanceId, |
| 237 | 209 | instance: nextInstance, |
| 238 | driver: ProviderDriverKind.make("codex"), | |
| 239 | isDefault: false, | |
| 240 | 210 | }); |
| 241 | 211 | |
| 242 | expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); | |
| 243 | expect(patch.providers).toBeUndefined(); | |
| 212 | expect(patch.providerInstances).toEqual({ [otherId]: other, [instanceId]: nextInstance }); | |
| 213 | expect(patch).not.toHaveProperty("textGenerationModelSelection"); | |
| 244 | 214 | }); |
| 245 | 215 | }); |
| 246 | 216 |
apps/web/src/components/settings/SettingsPanels.logic.ts+1-18| ... | ... | @@ -1,7 +1,6 @@ |
| 1 | 1 | import type { |
| 2 | 2 | BackgroundActivityProfile, |
| 3 | 3 | BackgroundActivitySettings, |
| 4 | ProviderDriverKind, | |
| 5 | 4 | ProviderInstanceConfig, |
| 6 | 5 | PreviewViewportSetting, |
| 7 | 6 | ProviderInstanceId, |
| ... | ... | @@ -255,30 +254,14 @@ export function formatDiagnosticsDescription(input: { |
| 255 | 254 | } |
| 256 | 255 | |
| 257 | 256 | export function buildProviderInstanceUpdatePatch(input: { |
| 258 | readonly settings: Pick<ServerSettings, "providers" | "providerInstances">; | |
| 257 | readonly settings: Pick<ServerSettings, "providerInstances">; | |
| 259 | 258 | readonly instanceId: ProviderInstanceId; |
| 260 | 259 | readonly instance: ProviderInstanceConfig; |
| 261 | readonly driver: ProviderDriverKind; | |
| 262 | readonly isDefault: boolean; | |
| 263 | 260 | readonly textGenerationModelSelection?: |
| 264 | 261 | | ServerSettings["textGenerationModelSelection"] |
| 265 | 262 | | undefined; |
| 266 | 263 | }): Partial<UnifiedSettings> { |
| 267 | type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; | |
| 268 | const legacyProviderDefaults = DEFAULT_UNIFIED_SETTINGS.providers as Record< | |
| 269 | string, | |
| 270 | LegacyProviderSettings | undefined | |
| 271 | >; | |
| 272 | const legacyProviderDefault = input.isDefault ? legacyProviderDefaults[input.driver] : undefined; | |
| 273 | 264 | return { |
| 274 | ...(legacyProviderDefault !== undefined | |
| 275 | ? { | |
| 276 | providers: { | |
| 277 | ...input.settings.providers, | |
| 278 | [input.driver]: legacyProviderDefault, | |
| 279 | } as ServerSettings["providers"], | |
| 280 | } | |
| 281 | : {}), | |
| 282 | 265 | providerInstances: { |
| 283 | 266 | ...input.settings.providerInstances, |
| 284 | 267 | [input.instanceId]: input.instance, |
apps/web/src/modelSelection.test.ts+4-7| ... | ... | @@ -636,14 +636,11 @@ describe("instance-scoped model selection", () => { |
| 636 | 636 | const nativeModel = "gemini-3.1-pro"; |
| 637 | 637 | const settings: UnifiedSettings = { |
| 638 | 638 | ...DEFAULT_UNIFIED_SETTINGS, |
| 639 | providers: { | |
| 640 | ...DEFAULT_UNIFIED_SETTINGS.providers, | |
| 641 | antigravity: { | |
| 642 | ...DEFAULT_UNIFIED_SETTINGS.providers.antigravity, | |
| 643 | customModels: ["api-only-model"], | |
| 644 | }, | |
| 645 | }, | |
| 646 | 639 | providerInstances: { |
| 640 | [ProviderInstanceId.make("antigravity")]: { | |
| 641 | driver, | |
| 642 | config: { customModels: ["api-only-model"] }, | |
| 643 | }, | |
| 647 | 644 | [customId]: { driver, config: { customModels: ["unknown-model"] } }, |
| 648 | 645 | }, |
| 649 | 646 | }; |
apps/web/src/modelSelection.ts+3-14| ... | ... | @@ -66,15 +66,7 @@ function readInstanceCustomModels( |
| 66 | 66 | return readCustomModelEntries(value); |
| 67 | 67 | } |
| 68 | 68 | } |
| 69 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | |
| 70 | if (instanceId !== defaultInstanceId) { | |
| 71 | return []; | |
| 72 | } | |
| 73 | const legacyProviders = settings.providers as Record< | |
| 74 | string, | |
| 75 | { readonly customModels: ReadonlyArray<unknown> } | undefined | |
| 76 | >; | |
| 77 | return readCustomModelEntries(legacyProviders[driverKind]?.customModels ?? []); | |
| 69 | return []; | |
| 78 | 70 | } |
| 79 | 71 | |
| 80 | 72 | export interface AppModelOption { |
| ... | ... | @@ -228,11 +220,8 @@ function getAppModelOptions( |
| 228 | 220 | * come from the instance's own `entry.models` snapshot (rather than the |
| 229 | 221 | * first-matching-kind fallback in `getProviderModels`), so each custom |
| 230 | 222 | * instance gets the precise model list its driver reported. Custom model |
| 231 | * slugs come from the instance's own `providerInstances[id].config.customModels` | |
| 232 | * when present, falling back to the legacy per-kind | |
| 233 | * `settings.providers[driverKind].customModels` bucket for default | |
| 234 | * instances only. This keeps two instances of the same kind from leaking | |
| 235 | * custom slugs into each other. Custom rows reported by the server are | |
| 223 | * slugs come from the instance's own `providerInstances[id].config.customModels`, | |
| 224 | * so two instances of the same kind never leak custom slugs into each other. Custom rows reported by the server are | |
| 236 | 225 | * ignored so a slug removed in Settings disappears without waiting for the |
| 237 | 226 | * next provider probe. |
| 238 | 227 | */ |
apps/web/src/onboarding/providerReadiness.logic.test.ts+36-30| ... | ... | @@ -196,11 +196,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 196 | 196 | readyCodex, |
| 197 | 197 | { |
| 198 | 198 | ...DEFAULT_SERVER_SETTINGS, |
| 199 | providers: { | |
| 200 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 201 | codex: { | |
| 202 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 203 | binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", | |
| 199 | providerInstances: { | |
| 200 | [ProviderInstanceId.make("codex")]: { | |
| 201 | driver: ProviderDriverKind.make("codex"), | |
| 202 | config: { | |
| 203 | binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", | |
| 204 | }, | |
| 204 | 205 | }, |
| 205 | 206 | }, |
| 206 | 207 | }, |
| ... | ... | @@ -221,11 +222,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 221 | 222 | provider, |
| 222 | 223 | { |
| 223 | 224 | ...DEFAULT_SERVER_SETTINGS, |
| 224 | providers: { | |
| 225 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 226 | claudeAgent: { | |
| 227 | ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | |
| 228 | binaryPath: "/opt/Claude Tools/$current/claude", | |
| 225 | providerInstances: { | |
| 226 | [ProviderInstanceId.make("claude")]: { | |
| 227 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 228 | config: { | |
| 229 | binaryPath: "/opt/Claude Tools/$current/claude", | |
| 230 | }, | |
| 229 | 231 | }, |
| 230 | 232 | }, |
| 231 | 233 | }, |
| ... | ... | @@ -246,11 +248,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 246 | 248 | readyCodex, |
| 247 | 249 | { |
| 248 | 250 | ...DEFAULT_SERVER_SETTINGS, |
| 249 | providers: { | |
| 250 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 251 | codex: { | |
| 252 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 253 | binaryPath, | |
| 251 | providerInstances: { | |
| 252 | [ProviderInstanceId.make("codex")]: { | |
| 253 | driver: ProviderDriverKind.make("codex"), | |
| 254 | config: { | |
| 255 | binaryPath, | |
| 256 | }, | |
| 254 | 257 | }, |
| 255 | 258 | }, |
| 256 | 259 | }, |
| ... | ... | @@ -265,11 +268,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 265 | 268 | readyCodex, |
| 266 | 269 | { |
| 267 | 270 | ...DEFAULT_SERVER_SETTINGS, |
| 268 | providers: { | |
| 269 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 270 | codex: { | |
| 271 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 272 | binaryPath: "/opt/codex\\work/codex", | |
| 271 | providerInstances: { | |
| 272 | [ProviderInstanceId.make("codex")]: { | |
| 273 | driver: ProviderDriverKind.make("codex"), | |
| 274 | config: { | |
| 275 | binaryPath: "/opt/codex\\work/codex", | |
| 276 | }, | |
| 273 | 277 | }, |
| 274 | 278 | }, |
| 275 | 279 | }, |
| ... | ... | @@ -284,11 +288,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 284 | 288 | readyCodex, |
| 285 | 289 | { |
| 286 | 290 | ...DEFAULT_SERVER_SETTINGS, |
| 287 | providers: { | |
| 288 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 289 | codex: { | |
| 290 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 291 | binaryPath: "C:\\Tools\\codex.exe", | |
| 291 | providerInstances: { | |
| 292 | [ProviderInstanceId.make("codex")]: { | |
| 293 | driver: ProviderDriverKind.make("codex"), | |
| 294 | config: { | |
| 295 | binaryPath: "C:\\Tools\\codex.exe", | |
| 296 | }, | |
| 292 | 297 | }, |
| 293 | 298 | }, |
| 294 | 299 | }, |
| ... | ... | @@ -303,11 +308,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 303 | 308 | readyCodex, |
| 304 | 309 | { |
| 305 | 310 | ...DEFAULT_SERVER_SETTINGS, |
| 306 | providers: { | |
| 307 | ...DEFAULT_SERVER_SETTINGS.providers, | |
| 308 | codex: { | |
| 309 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 310 | binaryPath: "/opt/Codex Tools/codex", | |
| 311 | providerInstances: { | |
| 312 | [ProviderInstanceId.make("codex")]: { | |
| 313 | driver: ProviderDriverKind.make("codex"), | |
| 314 | config: { | |
| 315 | binaryPath: "/opt/Codex Tools/codex", | |
| 316 | }, | |
| 311 | 317 | }, |
| 312 | 318 | }, |
| 313 | 319 | }, |
apps/web/src/onboarding/providerReadiness.logic.ts+2-6| ... | ... | @@ -112,17 +112,13 @@ export function resolveOnboardingProviderLoginCommand( |
| 112 | 112 | const instance = settings.providerInstances[provider.instanceId]; |
| 113 | 113 | |
| 114 | 114 | if (provider.driver === "claudeAgent") { |
| 115 | const config = decodeClaudeSettings( | |
| 116 | instance ? (instance.config ?? {}) : settings.providers.claudeAgent, | |
| 117 | ); | |
| 115 | const config = decodeClaudeSettings(instance?.config ?? {}); | |
| 118 | 116 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; |
| 119 | 117 | return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; |
| 120 | 118 | } |
| 121 | 119 | |
| 122 | 120 | if (provider.driver === "codex") { |
| 123 | const config = decodeCodexSettings( | |
| 124 | instance ? (instance.config ?? {}) : settings.providers.codex, | |
| 125 | ); | |
| 121 | const config = decodeCodexSettings(instance?.config ?? {}); | |
| 126 | 122 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; |
| 127 | 123 | return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; |
| 128 | 124 | } |
apps/web/src/providerInstances.test.ts+7-19| ... | ... | @@ -143,7 +143,6 @@ describe("applyProviderInstanceSettings", () => { |
| 143 | 143 | enabled: false, |
| 144 | 144 | }, |
| 145 | 145 | }, |
| 146 | providers: {} as never, | |
| 147 | 146 | }); |
| 148 | 147 | |
| 149 | 148 | expect(entry?.enabled).toBe(false); |
| ... | ... | @@ -158,7 +157,6 @@ describe("applyProviderInstanceSettings", () => { |
| 158 | 157 | ]); |
| 159 | 158 | const [entry] = applyProviderInstanceSettings(entries, { |
| 160 | 159 | providerInstances: {}, |
| 161 | providers: {} as never, | |
| 162 | 160 | }); |
| 163 | 161 | |
| 164 | 162 | expect(entry?.enabled).toBe(false); |
| ... | ... | @@ -175,7 +173,6 @@ describe("applyProviderInstanceSettings", () => { |
| 175 | 173 | ]); |
| 176 | 174 | const [entry] = applyProviderInstanceSettings(entries, { |
| 177 | 175 | providerInstances: {}, |
| 178 | providers: {} as never, | |
| 179 | 176 | }); |
| 180 | 177 | |
| 181 | 178 | expect(entry?.enabled).toBe(false); |
| ... | ... | @@ -197,7 +194,6 @@ describe("applyProviderInstanceSettings", () => { |
| 197 | 194 | enabled: false, |
| 198 | 195 | }, |
| 199 | 196 | }, |
| 200 | providers: {} as never, | |
| 201 | 197 | }); |
| 202 | 198 | |
| 203 | 199 | expect(entry?.enabled).toBe(false); |
| ... | ... | @@ -213,26 +209,22 @@ describe("applyProviderInstanceSettings", () => { |
| 213 | 209 | ]); |
| 214 | 210 | const [entry] = applyProviderInstanceSettings(entries, { |
| 215 | 211 | providerInstances: {}, |
| 216 | providers: {} as never, | |
| 217 | 212 | }); |
| 218 | 213 | |
| 219 | 214 | expect(entry?.isDefault).toBe(true); |
| 220 | 215 | expect(entry?.enabled).toBe(false); |
| 221 | 216 | }); |
| 222 | 217 | |
| 223 | it("uses legacy settings for a built-in default instance", () => { | |
| 218 | it("uses the driver default for an unconfigured built-in default instance", () => { | |
| 224 | 219 | const entries = deriveProviderInstanceEntries([ |
| 225 | provider({ | |
| 226 | provider: ProviderDriverKind.make("codex"), | |
| 227 | instanceId: "codex", | |
| 228 | }), | |
| 220 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), | |
| 221 | provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), | |
| 229 | 222 | ]); |
| 230 | const [entry] = applyProviderInstanceSettings(entries, { | |
| 231 | providerInstances: {}, | |
| 232 | providers: { codex: { enabled: false } } as never, | |
| 233 | }); | |
| 223 | const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} }); | |
| 234 | 224 | |
| 235 | expect(entry?.enabled).toBe(false); | |
| 225 | // Settings decide over a stale probe: Codex starts on, Grok starts off. | |
| 226 | expect(codex?.enabled).toBe(true); | |
| 227 | expect(grok?.enabled).toBe(false); | |
| 236 | 228 | }); |
| 237 | 229 | }); |
| 238 | 230 | |
| ... | ... | @@ -261,7 +253,6 @@ describe("deriveProviderEntriesByEnvironment", () => { |
| 261 | 253 | agentId, |
| 262 | 254 | [snapshot], |
| 263 | 255 | { |
| 264 | providers: {} as never, | |
| 265 | 256 | providerInstances: { |
| 266 | 257 | [instanceId]: { |
| 267 | 258 | driver: ProviderDriverKind.make("acpRegistry"), |
| ... | ... | @@ -629,7 +620,6 @@ describe("provider icon metadata", () => { |
| 629 | 620 | const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; |
| 630 | 621 | const brandedEntries = applyProviderInstanceSettings(snapshots, { |
| 631 | 622 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, |
| 632 | providers: {} as never, | |
| 633 | 623 | }); |
| 634 | 624 | expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); |
| 635 | 625 | expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); |
| ... | ... | @@ -642,7 +632,6 @@ describe("provider icon metadata", () => { |
| 642 | 632 | config: { ...registryConfig, source: "local", commandPath: "dsh" }, |
| 643 | 633 | }, |
| 644 | 634 | }, |
| 645 | providers: {} as never, | |
| 646 | 635 | }); |
| 647 | 636 | expect(localEntry?.acpRegistryAgentId).toBeUndefined(); |
| 648 | 637 | expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); |
| ... | ... | @@ -651,7 +640,6 @@ describe("provider icon metadata", () => { |
| 651 | 640 | |
| 652 | 641 | const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { |
| 653 | 642 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, |
| 654 | providers: {} as never, | |
| 655 | 643 | }); |
| 656 | 644 | expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); |
| 657 | 645 | expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); |
apps/web/src/providerInstances.ts+9-19| ... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 | import { |
| 16 | 16 | DEFAULT_MODEL_BY_PROVIDER, |
| 17 | 17 | defaultInstanceIdForDriver, |
| 18 | isUnconfiguredDefaultInstanceEnabled, | |
| 18 | 19 | resolveProviderInstanceEnabled, |
| 19 | 20 | type ModelSelection, |
| 20 | 21 | type ProviderDriverKind, |
| ... | ... | @@ -153,11 +154,7 @@ export function deriveProviderInstanceEntries( |
| 153 | 154 | */ |
| 154 | 155 | export function deriveProviderEntriesByEnvironment( |
| 155 | 156 | providersByEnvironment: Iterable< |
| 156 | readonly [ | |
| 157 | string, | |
| 158 | ReadonlyArray<ServerProvider>, | |
| 159 | Pick<ServerSettings, "providerInstances" | "providers">?, | |
| 160 | ] | |
| 157 | readonly [string, ReadonlyArray<ServerProvider>, Pick<ServerSettings, "providerInstances">?] | |
| 161 | 158 | >, |
| 162 | 159 | ): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> { |
| 163 | 160 | const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>(); |
| ... | ... | @@ -178,30 +175,23 @@ export function deriveProviderEntriesByEnvironment( |
| 178 | 175 | * settings write, so picker visibility must follow settings rather than waiting |
| 179 | 176 | * for probe reconciliation. |
| 180 | 177 | * |
| 181 | * Only built-in default instances have a legacy `providers` entry. Every | |
| 182 | * other instance exists through `providerInstances`; if it is absent there, | |
| 183 | * its streamed snapshot is stale (for example immediately after deletion) | |
| 184 | * and is treated as disabled. | |
| 178 | * A built-in default instance without a `providerInstances` entry keeps its | |
| 179 | * streamed state, since the server runs it with default config. Any other | |
| 180 | * instance missing from `providerInstances` is stale (for example | |
| 181 | * immediately after deletion) and is treated as disabled. | |
| 185 | 182 | */ |
| 186 | 183 | export function applyProviderInstanceSettings( |
| 187 | 184 | entries: ReadonlyArray<ProviderInstanceEntry>, |
| 188 | settings: Pick<ServerSettings, "providerInstances" | "providers">, | |
| 185 | settings: Pick<ServerSettings, "providerInstances">, | |
| 189 | 186 | ): ReadonlyArray<ProviderInstanceEntry> { |
| 190 | const legacyProviders = settings.providers as Readonly< | |
| 191 | Record<string, { readonly enabled?: boolean } | undefined> | |
| 192 | >; | |
| 193 | ||
| 194 | 187 | return entries.map((entry) => { |
| 195 | 188 | const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) |
| 196 | 189 | ? settings.providerInstances[entry.instanceId] |
| 197 | 190 | : undefined; |
| 198 | const legacyProvider = Object.hasOwn(legacyProviders, entry.driverKind) | |
| 199 | ? legacyProviders[entry.driverKind] | |
| 200 | : undefined; | |
| 201 | 191 | const enabled = explicitInstance |
| 202 | 192 | ? resolveProviderInstanceEnabled(explicitInstance) |
| 203 | : entry.isDefault && legacyProvider | |
| 204 | ? (legacyProvider.enabled ?? entry.enabled) | |
| 193 | : entry.isDefault | |
| 194 | ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId) | |
| 205 | 195 | : false; |
| 206 | 196 | if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { |
| 207 | 197 | return enabled === entry.enabled ? entry : { ...entry, enabled }; |
docs/internals/adding-a-provider.md+3-2| ... | ... | @@ -103,8 +103,9 @@ produce catches them. |
| 103 | 103 | |
| 104 | 104 | ## Where a driver plugs in |
| 105 | 105 | |
| 106 | - **Contracts:** settings schema and patch, default model, and display name in | |
| 107 | [`packages/contracts`](../../packages/contracts/src). New providers are off by default. | |
| 106 | - **Contracts:** settings schema, default model, and display name in | |
| 107 | [`packages/contracts`](../../packages/contracts/src). Instances store that config in | |
| 108 | `providerInstances`. New providers are off by default. | |
| 108 | 109 | - **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry |
| 109 | 110 | in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in |
| 110 | 111 | the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy |
packages/contracts/src/settings.test.ts+7-100| ... | ... | @@ -208,17 +208,6 @@ describe("custom model settings", () => { |
| 208 | 208 | { slug: "named", name: "Named", capabilities }, |
| 209 | 209 | ]); |
| 210 | 210 | }); |
| 211 | ||
| 212 | it("accepts entries at the settings patch boundary", () => { | |
| 213 | expect( | |
| 214 | decodeServerSettingsPatch({ | |
| 215 | providers: { codex: { customModels: [{ slug: "x", capabilities }] } }, | |
| 216 | }).providers?.codex?.customModels, | |
| 217 | ).toEqual([{ slug: "x", capabilities }]); | |
| 218 | expect(() => | |
| 219 | decodeServerSettingsPatch({ providers: { codex: { customModels: [{ name: "no slug" }] } } }), | |
| 220 | ).toThrow(); | |
| 221 | }); | |
| 222 | 211 | }); |
| 223 | 212 | |
| 224 | 213 | describe("ClaudeSettings auto-compaction", () => { |
| ... | ... | @@ -239,15 +228,6 @@ describe("ClaudeSettings auto-compaction", () => { |
| 239 | 228 | expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow(); |
| 240 | 229 | }, |
| 241 | 230 | ); |
| 242 | ||
| 243 | it("rejects an unsupported threshold at the settings patch boundary", () => { | |
| 244 | expect(() => | |
| 245 | decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300k" } } }), | |
| 246 | ).toThrow(); | |
| 247 | expect( | |
| 248 | decodeServerSettingsPatch({ providers: { claudeAgent: { autoCompactWindow: "300000" } } }), | |
| 249 | ).toBeDefined(); | |
| 250 | }); | |
| 251 | 231 | }); |
| 252 | 232 | |
| 253 | 233 | describe("ClientSettings notifications", () => { |
| ... | ... | @@ -752,9 +732,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 752 | 732 | it("decodes a fully empty config (legacy on-disk shape) without complaint", () => { |
| 753 | 733 | const decoded = decodeServerSettings({}); |
| 754 | 734 | expect(decoded.providerInstances).toEqual({}); |
| 755 | // Legacy `providers` struct is still hydrated with its per-driver defaults | |
| 756 | // so existing call sites keep working through the migration. | |
| 757 | expect(decoded.providers.codex.enabled).toBe(true); | |
| 758 | 735 | }); |
| 759 | 736 | |
| 760 | 737 | it("decodes a multi-instance map mixing first-party and fork drivers", () => { |
| ... | ... | @@ -803,7 +780,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 803 | 780 | describe("provider enabled defaults", () => { |
| 804 | 781 | it("keeps Muse disabled until a configured instance opts in", () => { |
| 805 | 782 | const muse = ProviderDriverKind.make("muse"); |
| 806 | expect(decodeServerSettings({}).providers.muse.enabled).toBe(false); | |
| 807 | 783 | expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false); |
| 808 | 784 | expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true); |
| 809 | 785 | expect( |
| ... | ... | @@ -812,26 +788,13 @@ describe("provider enabled defaults", () => { |
| 812 | 788 | }); |
| 813 | 789 | |
| 814 | 790 | it("enables only the stable bindings by default", () => { |
| 815 | const decoded = decodeServerSettings({}); | |
| 816 | expect(decoded.providers.codex.enabled).toBe(true); | |
| 817 | expect(decoded.providers.claudeAgent.enabled).toBe(true); | |
| 818 | expect(decoded.providers.cursor.enabled).toBe(false); | |
| 819 | expect(decoded.providers.grok.enabled).toBe(false); | |
| 820 | expect(decoded.providers.opencode.enabled).toBe(false); | |
| 821 | }); | |
| 822 | ||
| 823 | it("keeps Cursor enabled when an existing user explicitly opted in", () => { | |
| 824 | const cursor = ProviderDriverKind.make("cursor"); | |
| 825 | const cursorId = ProviderInstanceId.make("cursor"); | |
| 826 | const decoded = decodeServerSettings({ | |
| 827 | providers: { cursor: { enabled: true } }, | |
| 828 | providerInstances: { | |
| 829 | [cursorId]: { driver: cursor, enabled: true, config: {} }, | |
| 830 | }, | |
| 831 | }); | |
| 832 | ||
| 833 | expect(decoded.providers.cursor.enabled).toBe(true); | |
| 834 | expect(resolveProviderInstanceEnabled(decoded.providerInstances[cursorId]!)).toBe(true); | |
| 791 | const enabledByDefault = (driver: string) => | |
| 792 | resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make(driver), config: {} }); | |
| 793 | expect(enabledByDefault("codex")).toBe(true); | |
| 794 | expect(enabledByDefault("claudeAgent")).toBe(true); | |
| 795 | for (const driver of ["cursor", "grok", "muse", "pi", "opencode", "antigravity"]) { | |
| 796 | expect(enabledByDefault(driver)).toBe(false); | |
| 797 | } | |
| 835 | 798 | }); |
| 836 | 799 | |
| 837 | 800 | it("resolves instance enabled state with explicit false winning", () => { |
| ... | ... | @@ -903,42 +866,6 @@ describe("ServerSettings worktree defaults", () => { |
| 903 | 866 | }); |
| 904 | 867 | }); |
| 905 | 868 | |
| 906 | describe("ServerSettings Cursor legacy settings", () => { | |
| 907 | it("preserves V1 Cursor CLI settings when reading and writing shared settings", () => { | |
| 908 | const decoded = decodeServerSettings({ | |
| 909 | providers: { | |
| 910 | cursor: { | |
| 911 | enabled: true, | |
| 912 | binaryPath: "cursor-agent", | |
| 913 | apiEndpoint: "http://127.0.0.1:3774", | |
| 914 | }, | |
| 915 | }, | |
| 916 | }); | |
| 917 | ||
| 918 | expect(decoded.providers.cursor.enabled).toBe(true); | |
| 919 | expect(encodeServerSettings(decoded).providers?.cursor).toMatchObject({ | |
| 920 | binaryPath: "cursor-agent", | |
| 921 | apiEndpoint: "http://127.0.0.1:3774", | |
| 922 | }); | |
| 923 | }); | |
| 924 | ||
| 925 | it("ignores obsolete Cursor CLI settings in patches", () => { | |
| 926 | const patch = decodeServerSettingsPatch({ | |
| 927 | providers: { | |
| 928 | cursor: { | |
| 929 | enabled: true, | |
| 930 | binaryPath: "cursor-agent", | |
| 931 | apiEndpoint: "http://127.0.0.1:3774", | |
| 932 | }, | |
| 933 | }, | |
| 934 | }); | |
| 935 | ||
| 936 | expect(patch.providers?.cursor?.enabled).toBe(true); | |
| 937 | expect(patch.providers?.cursor).not.toHaveProperty("binaryPath"); | |
| 938 | expect(patch.providers?.cursor).not.toHaveProperty("apiEndpoint"); | |
| 939 | }); | |
| 940 | }); | |
| 941 | ||
| 942 | 869 | describe("ServerSettings.sourceControlWritingStyle", () => { |
| 943 | 870 | it("defaults all style settings for legacy configs", () => { |
| 944 | 871 | const settings = decodeServerSettings({}); |
| ... | ... | @@ -1011,13 +938,6 @@ describe("ServerSettingsPatch string normalization", () => { |
| 1011 | 938 | observability: { |
| 1012 | 939 | otlpTracesUrl: " http://localhost:4318/v1/traces ", |
| 1013 | 940 | }, |
| 1014 | providers: { | |
| 1015 | codex: { | |
| 1016 | binaryPath: " /opt/homebrew/bin/codex ", | |
| 1017 | homePath: " ~/.codex ", | |
| 1018 | launchArgs: " --strict-config --enable foo ", | |
| 1019 | }, | |
| 1020 | }, | |
| 1021 | 941 | providerInstances: { |
| 1022 | 942 | codex_personal: { |
| 1023 | 943 | driver: " codex ", |
| ... | ... | @@ -1030,9 +950,6 @@ describe("ServerSettingsPatch string normalization", () => { |
| 1030 | 950 | expect(patch.addProjectBaseDirectory).toBe("~/Development"); |
| 1031 | 951 | expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); |
| 1032 | 952 | expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); |
| 1033 | expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); | |
| 1034 | expect(patch.providers?.codex?.homePath).toBe("~/.codex"); | |
| 1035 | expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); | |
| 1036 | 953 | expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( |
| 1037 | 954 | "codex", |
| 1038 | 955 | ); |
| ... | ... | @@ -1049,19 +966,9 @@ describe("ServerSettingsPatch string normalization", () => { |
| 1049 | 966 | const encoded = encodeServerSettings({ |
| 1050 | 967 | ...defaultSettings, |
| 1051 | 968 | addProjectBaseDirectory: " ~/Development ", |
| 1052 | providers: { | |
| 1053 | ...defaultSettings.providers, | |
| 1054 | codex: { | |
| 1055 | ...defaultSettings.providers.codex, | |
| 1056 | binaryPath: " /opt/homebrew/bin/codex ", | |
| 1057 | launchArgs: " --strict-config ", | |
| 1058 | }, | |
| 1059 | }, | |
| 1060 | 969 | }); |
| 1061 | 970 | |
| 1062 | 971 | expect(encoded.addProjectBaseDirectory).toBe("~/Development"); |
| 1063 | expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); | |
| 1064 | expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); | |
| 1065 | 972 | }); |
| 1066 | 973 | }); |
| 1067 | 974 |
packages/contracts/src/settings.ts+30-106| ... | ... | @@ -40,7 +40,7 @@ import { |
| 40 | 40 | import { |
| 41 | 41 | ProviderInstanceConfig, |
| 42 | 42 | ProviderInstanceId, |
| 43 | type ProviderDriverKind, | |
| 43 | ProviderDriverKind, | |
| 44 | 44 | } from "./providerInstance.ts"; |
| 45 | 45 | import { PullRequestMergeMethod } from "./pullRequest.ts"; |
| 46 | 46 | |
| ... | ... | @@ -1457,22 +1457,6 @@ export const ServerSettings = Schema.Struct({ |
| 1457 | 1457 | Schema.withDecodingDefault(Effect.succeed(null)), |
| 1458 | 1458 | ), |
| 1459 | 1459 | |
| 1460 | // Legacy single-instance-per-driver settings. Continues to be the source | |
| 1461 | // of truth until `providerInstances` (below) lands per-driver migration | |
| 1462 | // shims and the server starts hydrating instances from it. Driver-specific | |
| 1463 | // schemas live here for the duration of the migration; once each driver | |
| 1464 | // owns its config in its own package, this struct shrinks to nothing and | |
| 1465 | // is removed entirely. | |
| 1466 | providers: Schema.Struct({ | |
| 1467 | codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1468 | claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1469 | cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1470 | grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1471 | muse: MuseSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1472 | pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1473 | opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1474 | antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1475 | }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), | |
| 1476 | 1460 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values |
| 1477 | 1461 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob |
| 1478 | 1462 | // is `Schema.Unknown` at this layer so envelopes with unknown drivers |
| ... | ... | @@ -1512,8 +1496,8 @@ export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerS |
| 1512 | 1496 | /** |
| 1513 | 1497 | * Read the legacy `enabled` flag embedded in a provider instance config |
| 1514 | 1498 | * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single |
| 1515 | * flag going forward; this reader exists for legacy `providers.<kind>` | |
| 1516 | * blobs and old settings files that still carry the flag in-config. | |
| 1499 | * flag going forward; this reader exists for old settings files that still | |
| 1500 | * carry the flag in-config. | |
| 1517 | 1501 | */ |
| 1518 | 1502 | export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => { |
| 1519 | 1503 | if (config === null || typeof config !== "object" || Array.isArray(config)) { |
| ... | ... | @@ -1524,18 +1508,34 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un |
| 1524 | 1508 | }; |
| 1525 | 1509 | |
| 1526 | 1510 | /** |
| 1527 | * Default enabled state for a built-in driver when neither the envelope nor | |
| 1528 | * the config blob carries a flag. Derived from the driver's settings schema | |
| 1529 | * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays | |
| 1530 | * the single source of truth. Unknown (fork) drivers default to enabled. | |
| 1511 | * Built-in drivers that stay off until the user turns them on. Matches the | |
| 1512 | * `enabled` decoding default of each driver's settings schema. | |
| 1531 | 1513 | */ |
| 1532 | const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { | |
| 1533 | const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< | |
| 1534 | string, | |
| 1535 | { readonly enabled?: boolean } | undefined | |
| 1536 | >; | |
| 1537 | return legacyDefaults[driver]?.enabled ?? true; | |
| 1538 | }; | |
| 1514 | const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set( | |
| 1515 | ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) => | |
| 1516 | ProviderDriverKind.make(driver), | |
| 1517 | ), | |
| 1518 | ); | |
| 1519 | ||
| 1520 | /** Built-in drivers whose default instance runs before the user configures it. */ | |
| 1521 | const DEFAULT_ENABLED_DEFAULT_INSTANCES: ReadonlySet<ProviderInstanceId> = new Set( | |
| 1522 | ["codex", "claudeAgent"].map((instanceId) => ProviderInstanceId.make(instanceId)), | |
| 1523 | ); | |
| 1524 | ||
| 1525 | /** | |
| 1526 | * Whether the built-in default instance at `instanceId` is enabled while | |
| 1527 | * settings have no `providerInstances` entry for it. Only Codex and Claude | |
| 1528 | * start on; any other id without an entry has no running instance. | |
| 1529 | */ | |
| 1530 | export const isUnconfiguredDefaultInstanceEnabled = (instanceId: ProviderInstanceId): boolean => | |
| 1531 | DEFAULT_ENABLED_DEFAULT_INSTANCES.has(instanceId); | |
| 1532 | ||
| 1533 | /** | |
| 1534 | * Default enabled state for a driver when neither the envelope nor the config | |
| 1535 | * blob carries a flag. Unknown (fork) drivers default to enabled. | |
| 1536 | */ | |
| 1537 | const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => | |
| 1538 | !DEFAULT_DISABLED_PROVIDER_DRIVERS.has(driver); | |
| 1539 | 1539 | |
| 1540 | 1540 | /** |
| 1541 | 1541 | * Resolve whether a configured provider instance is enabled. An explicit |
| ... | ... | @@ -1608,70 +1608,6 @@ const ModelSelectionPatch = Schema.Struct({ |
| 1608 | 1608 | options: Schema.optionalKey(ProviderOptionSelections), |
| 1609 | 1609 | }); |
| 1610 | 1610 | |
| 1611 | const CodexSettingsPatch = Schema.Struct({ | |
| 1612 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1613 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 1614 | homePath: Schema.optionalKey(TrimmedString), | |
| 1615 | shadowHomePath: Schema.optionalKey(TrimmedString), | |
| 1616 | launchArgs: Schema.optionalKey(TrimmedString), | |
| 1617 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1618 | }); | |
| 1619 | ||
| 1620 | const ClaudeSettingsPatch = Schema.Struct({ | |
| 1621 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1622 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 1623 | homePath: Schema.optionalKey(TrimmedString), | |
| 1624 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1625 | launchArgs: Schema.optionalKey(TrimmedString), | |
| 1626 | // Validated at the patch boundary so a typo fails the one update with a | |
| 1627 | // schema error instead of a generic whole-settings failure. | |
| 1628 | autoCompactWindow: Schema.optionalKey( | |
| 1629 | TrimmedString.check(Schema.isPattern(CLAUDE_AUTO_COMPACT_WINDOW_PATTERN)), | |
| 1630 | ), | |
| 1631 | }); | |
| 1632 | ||
| 1633 | const CursorSettingsPatch = Schema.Struct({ | |
| 1634 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1635 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1636 | }); | |
| 1637 | ||
| 1638 | const GrokSettingsPatch = Schema.Struct({ | |
| 1639 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1640 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 1641 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1642 | }); | |
| 1643 | ||
| 1644 | const AntigravitySettingsPatch = Schema.Struct({ | |
| 1645 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1646 | authMethod: Schema.optionalKey(AntigravityAuthMethod), | |
| 1647 | apiKey: Schema.optionalKey(TrimmedString), | |
| 1648 | gcpProject: Schema.optionalKey(TrimmedString), | |
| 1649 | gcpLocation: Schema.optionalKey(TrimmedString), | |
| 1650 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 1651 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1652 | }); | |
| 1653 | ||
| 1654 | const MuseSettingsPatch = Schema.Struct({ | |
| 1655 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1656 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 1657 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1658 | }); | |
| 1659 | ||
| 1660 | const PiSettingsPatch = Schema.Struct({ | |
| 1661 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1662 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 1663 | launchArgs: Schema.optionalKey(TrimmedString), | |
| 1664 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1665 | }); | |
| 1666 | ||
| 1667 | const OpenCodeSettingsPatch = Schema.Struct({ | |
| 1668 | enabled: Schema.optionalKey(Schema.Boolean), | |
| 1669 | binaryPath: Schema.optionalKey(TrimmedString), | |
| 1670 | serverUrl: Schema.optionalKey(TrimmedString), | |
| 1671 | serverPassword: Schema.optionalKey(TrimmedString), | |
| 1672 | customModels: Schema.optionalKey(Schema.Array(CustomModelSetting)), | |
| 1673 | }); | |
| 1674 | ||
| 1675 | 1611 | export const ServerSettingsPatch = Schema.Struct({ |
| 1676 | 1612 | worktreeCleanup: Schema.optionalKey( |
| 1677 | 1613 | Schema.NullOr( |
| ... | ... | @@ -1791,18 +1727,6 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1791 | 1727 | tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)), |
| 1792 | 1728 | }), |
| 1793 | 1729 | ), |
| 1794 | providers: Schema.optionalKey( | |
| 1795 | Schema.Struct({ | |
| 1796 | codex: Schema.optionalKey(CodexSettingsPatch), | |
| 1797 | claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), | |
| 1798 | cursor: Schema.optionalKey(CursorSettingsPatch), | |
| 1799 | grok: Schema.optionalKey(GrokSettingsPatch), | |
| 1800 | muse: Schema.optionalKey(MuseSettingsPatch), | |
| 1801 | pi: Schema.optionalKey(PiSettingsPatch), | |
| 1802 | opencode: Schema.optionalKey(OpenCodeSettingsPatch), | |
| 1803 | antigravity: Schema.optionalKey(AntigravitySettingsPatch), | |
| 1804 | }), | |
| 1805 | ), | |
| 1806 | 1730 | // Whole-map replacement for the new instance config. Patching individual |
| 1807 | 1731 | // entries is intentionally out of scope: the map is small, and partial |
| 1808 | 1732 | // patches risk leaving driver-specific config in a half-merged state. |
packages/provider-core/src/server/driver.ts+8-5| ... | ... | @@ -58,6 +58,12 @@ export interface ProviderDriverMetadata { |
| 58 | 58 | * rejects multi-instance configurations with a clear error. |
| 59 | 59 | */ |
| 60 | 60 | readonly supportsMultipleInstances?: boolean; |
| 61 | /** | |
| 62 | * Whether the server runs an instance at `defaultInstanceIdForDriver(kind)` | |
| 63 | * even when settings have no entry for it. Defaults to `true`; drivers that | |
| 64 | * need user input before they can run (such as the ACP Registry) opt out. | |
| 65 | */ | |
| 66 | readonly hasDefaultInstance?: boolean; | |
| 61 | 67 | } |
| 62 | 68 | |
| 63 | 69 | export type ProviderWorkspaceSnapshot = ServerProvider & |
| ... | ... | @@ -188,11 +194,8 @@ export interface ProviderDriver<Config, R = never> { |
| 188 | 194 | */ |
| 189 | 195 | readonly configSchema: Schema.Codec<Config, unknown>; |
| 190 | 196 | /** |
| 191 | * Default config payload used when the legacy | |
| 192 | * `ServerSettings.providers.<kind>` entry is empty or when the driver | |
| 193 | * is auto-bootstrapped without user configuration. Returning a typed | |
| 194 | * default keeps the migration path simple — no special-casing needed | |
| 195 | * to construct a "blank" instance. | |
| 197 | * Config used when an instance has no config blob, such as the default | |
| 198 | * instance of a driver the user never configured. | |
| 196 | 199 | */ |
| 197 | 200 | readonly defaultConfig: () => Config; |
| 198 | 201 | /** |
packages/shared/src/projectSettings.test.ts+13-2| ... | ... | @@ -2,6 +2,7 @@ import { |
| 2 | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | 3 | PROJECT_SCOPED_SERVER_SETTING_KEYS, |
| 4 | 4 | ProjectId, |
| 5 | ProviderDriverKind, | |
| 5 | 6 | ProviderInstanceId, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | 8 | import { describe, expect, it } from "vite-plus/test"; |
| ... | ... | @@ -78,7 +79,12 @@ describe("resolveProjectSettings", () => { |
| 78 | 79 | it("keeps the environment text generation model when the override's provider is disabled", () => { |
| 79 | 80 | const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); |
| 80 | 81 | const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 81 | providers: { claudeAgent: { enabled: false } }, | |
| 82 | providerInstances: { | |
| 83 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 84 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 85 | enabled: false, | |
| 86 | }, | |
| 87 | }, | |
| 82 | 88 | projectSettingsOverrides: { |
| 83 | 89 | [projectId]: { textGenerationModelSelection: disabledSelection }, |
| 84 | 90 | }, |
| ... | ... | @@ -128,7 +134,12 @@ describe("resolveProjectSettings", () => { |
| 128 | 134 | it("keeps the environment default model when the override's provider is disabled", () => { |
| 129 | 135 | const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); |
| 130 | 136 | const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 131 | providers: { claudeAgent: { enabled: false } }, | |
| 137 | providerInstances: { | |
| 138 | [ProviderInstanceId.make("claudeAgent")]: { | |
| 139 | driver: ProviderDriverKind.make("claudeAgent"), | |
| 140 | enabled: false, | |
| 141 | }, | |
| 142 | }, | |
| 132 | 143 | projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } }, |
| 133 | 144 | }); |
| 134 | 145 | const resolved = resolveProjectSettings(settings, projectId); |
packages/shared/src/serverSettings.ts+2-14| ... | ... | @@ -1,13 +1,12 @@ |
| 1 | 1 | import { |
| 2 | isProviderDriverKind, | |
| 3 | 2 | isProviderAvailable, |
| 3 | isUnconfiguredDefaultInstanceEnabled, | |
| 4 | 4 | resolveProviderInstanceEnabled, |
| 5 | 5 | isProviderTextGenerationCapable, |
| 6 | 6 | type ModelSelection, |
| 7 | 7 | type ProjectId, |
| 8 | 8 | type ProjectScopedServerSettingKey, |
| 9 | 9 | type ProjectSettingsOverrides, |
| 10 | type ProviderDriverKind, | |
| 11 | 10 | type ServerProvider, |
| 12 | 11 | ServerSettings, |
| 13 | 12 | type ServerSettingsPatch, |
| ... | ... | @@ -59,14 +58,6 @@ export function resolveProjectAutoPull( |
| 59 | 58 | ); |
| 60 | 59 | } |
| 61 | 60 | |
| 62 | type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; | |
| 63 | ||
| 64 | const getLegacyProviderSettings = ( | |
| 65 | settings: ServerSettings, | |
| 66 | provider: ProviderDriverKind, | |
| 67 | ): LegacyProviderSettings | undefined => | |
| 68 | (settings.providers as Record<string, LegacyProviderSettings | undefined>)[provider]; | |
| 69 | ||
| 70 | 61 | export function isModelSelectionProviderEnabled( |
| 71 | 62 | settings: ServerSettings, |
| 72 | 63 | selection: ModelSelection, |
| ... | ... | @@ -76,10 +67,7 @@ export function isModelSelectionProviderEnabled( |
| 76 | 67 | return resolveProviderInstanceEnabled(instanceConfig); |
| 77 | 68 | } |
| 78 | 69 | |
| 79 | return ( | |
| 80 | isProviderDriverKind(selection.instanceId) && | |
| 81 | getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true | |
| 82 | ); | |
| 70 | return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); | |
| 83 | 71 | } |
| 84 | 72 | |
| 85 | 73 | export function resolveSourceControlWriterModelSelection( |