| 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,6 +7,8 @@ import { |
| 7 | CommandId, | 7 | CommandId, |
| 8 | MessageId, | 8 | MessageId, |
| 9 | ProjectId, | 9 | ProjectId, |
| 10 | ProviderDriverKind, | ||
| 11 | ProviderInstanceId, | ||
| 10 | ThreadId, | 12 | ThreadId, |
| 11 | type OrchestrationV2ThreadProjection, | 13 | type OrchestrationV2ThreadProjection, |
| 12 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| ... | @@ -60,8 +62,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( | ... | @@ -60,8 +62,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( |
| 60 | const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); | 62 | const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); |
| 61 | 63 | ||
| 62 | const layerServerSettings = ServerSettings.layerTest({ | 64 | const layerServerSettings = ServerSettings.layerTest({ |
| 63 | providers: { | 65 | providerInstances: { |
| 64 | cursor: { enabled: true }, | 66 | [ProviderInstanceId.make("cursor")]: { |
| 67 | driver: ProviderDriverKind.make("cursor"), | ||
| 68 | enabled: true, | ||
| 69 | }, | ||
| 65 | }, | 70 | }, |
| 66 | }); | 71 | }); |
| 67 | const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( | 72 | const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( |
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+7-2| ... | @@ -8,6 +8,8 @@ import { | ... | @@ -8,6 +8,8 @@ import { |
| 8 | MessageId, | 8 | MessageId, |
| 9 | type OrchestrationV2ThreadProjection, | 9 | type OrchestrationV2ThreadProjection, |
| 10 | ProjectId, | 10 | ProjectId, |
| 11 | ProviderDriverKind, | ||
| 12 | ProviderInstanceId, | ||
| 11 | ThreadId, | 13 | ThreadId, |
| 12 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 13 | import * as Console from "effect/Console"; | 15 | import * as Console from "effect/Console"; |
| ... | @@ -59,8 +61,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( | ... | @@ -59,8 +61,11 @@ const layerVcsDriverRegistry = VcsDriverRegistry.layer.pipe( |
| 59 | const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); | 61 | const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); |
| 60 | 62 | ||
| 61 | const layerServerSettings = ServerSettings.layerTest({ | 63 | const layerServerSettings = ServerSettings.layerTest({ |
| 62 | providers: { | 64 | providerInstances: { |
| 63 | grok: { enabled: true }, | 65 | [ProviderInstanceId.make("grok")]: { |
| 66 | driver: ProviderDriverKind.make("grok"), | ||
| 67 | enabled: true, | ||
| 68 | }, | ||
| 64 | }, | 69 | }, |
| 65 | }); | 70 | }); |
| 66 | const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( | 71 | const layerBackgroundPolicy = BackgroundPolicy.layer.pipe( |
apps/server/src/project/AgentSessionScanner.test.ts+13-7| ... | @@ -59,13 +59,19 @@ const layerScannerTest = (input: ScannerTestInput) => | ... | @@ -59,13 +59,19 @@ const layerScannerTest = (input: ScannerTestInput) => |
| 59 | Layer.provide( | 59 | Layer.provide( |
| 60 | Layer.mergeAll( | 60 | Layer.mergeAll( |
| 61 | ServerSettings.layerTest({ | 61 | ServerSettings.layerTest({ |
| 62 | providers: { | 62 | // Default slots point at the fixture homes; explicit test instances |
| 63 | claudeAgent: { homePath: input.claudeHomePath }, | 63 | // replace a slot with the same id. |
| 64 | codex: { homePath: input.codexHomePath }, | 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 | ServerConfig.layerTest( | 76 | ServerConfig.layerTest( |
| 71 | input.claudeHomePath, | 77 | input.claudeHomePath, |
| ... | @@ -533,7 +539,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { | ... | @@ -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 | Effect.gen(function* () { | 543 | Effect.gen(function* () { |
| 538 | const path = yield* Path.Path; | 544 | const path = yield* Path.Path; |
| 539 | const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); | 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,16 +1102,15 @@ export const make = Effect.gen(function* () { |
| 1102 | instanceId: ProviderInstanceId.make(instanceId), | 1102 | instanceId: ProviderInstanceId.make(instanceId), |
| 1103 | config, | 1103 | config, |
| 1104 | })); | 1104 | })); |
| 1105 | // The built-in default instance runs with default config when settings | ||
| 1106 | // have no entry for it. | ||
| 1105 | if (!Object.hasOwn(settings.providerInstances, source)) { | 1107 | if (!Object.hasOwn(settings.providerInstances, source)) { |
| 1106 | const legacyInstance = { | 1108 | const defaultInstance = { |
| 1107 | instanceId: ProviderInstanceId.make(source), | 1109 | instanceId: ProviderInstanceId.make(source), |
| 1108 | config: { | 1110 | config: { driver: ProviderDriverKind.make(source) }, |
| 1109 | driver: ProviderDriverKind.make(source), | ||
| 1110 | config: settings.providers[source], | ||
| 1111 | }, | ||
| 1112 | }; | 1111 | }; |
| 1113 | if (resolveProviderInstanceEnabled(legacyInstance.config)) { | 1112 | if (resolveProviderInstanceEnabled(defaultInstance.config)) { |
| 1114 | instances.push(legacyInstance); | 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,6 +467,7 @@ export const AcpRegistryDriver: ProviderDriver<AcpRegistrySettings, AcpRegistryD |
| 467 | metadata: { | 467 | metadata: { |
| 468 | displayName: "ACP Registry", | 468 | displayName: "ACP Registry", |
| 469 | supportsMultipleInstances: true, | 469 | supportsMultipleInstances: true, |
| 470 | hasDefaultInstance: false, | ||
| 470 | }, | 471 | }, |
| 471 | configSchema: AcpRegistrySettings, | 472 | configSchema: AcpRegistrySettings, |
| 472 | defaultConfig: () => decodeSettings({}), | 473 | defaultConfig: () => decodeSettings({}), |
apps/server/src/provider/ProviderInstanceRegistryHydration.ts+10-54| ... | @@ -2,28 +2,10 @@ | ... | @@ -2,28 +2,10 @@ |
| 2 | * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` | 2 | * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` |
| 3 | * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. | 3 | * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. |
| 4 | * | 4 | * |
| 5 | * The server still reads two shapes: | 5 | * `settings.providerInstances` is the source of truth. Every built-in driver |
| 6 | * | 6 | * with a default instance also runs at `defaultInstanceIdForDriver(kind)` |
| 7 | * 1. `settings.providerInstances` — the new driver-agnostic map the | 7 | * when that slot has no entry, using the driver's default config, so a fresh |
| 8 | * registry expects. Keyed by `ProviderInstanceId`, values are | 8 | * install shows its built-in providers without writing settings first. |
| 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. | ||
| 27 | * | 9 | * |
| 28 | * Hot-reload | 10 | * Hot-reload |
| 29 | * ---------- | 11 | * ---------- |
| ... | @@ -76,17 +58,9 @@ type ProviderInstanceRegistryHydrationEnv = | ... | @@ -76,17 +58,9 @@ type ProviderInstanceRegistryHydrationEnv = |
| 76 | | ServerConfig.ServerConfig; | 58 | | ServerConfig.ServerConfig; |
| 77 | 59 | ||
| 78 | /** | 60 | /** |
| 79 | * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. | 61 | * Explicit `providerInstances` entries plus an implicit default instance for |
| 80 | * | 62 | * each built-in driver whose default slot is empty. Pure so the hydration |
| 81 | * Strategy: | 63 | * rule can be tested without layers. |
| 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. | ||
| 90 | */ | 64 | */ |
| 91 | export const deriveProviderInstanceConfigMap = ( | 65 | export const deriveProviderInstanceConfigMap = ( |
| 92 | settings: ServerSettings, | 66 | settings: ServerSettings, |
| ... | @@ -94,28 +68,10 @@ export const deriveProviderInstanceConfigMap = ( | ... | @@ -94,28 +68,10 @@ export const deriveProviderInstanceConfigMap = ( |
| 94 | const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances }; | 68 | const merged: Record<string, ProviderInstanceConfig> = { ...settings.providerInstances }; |
| 95 | 69 | ||
| 96 | for (const driver of BUILT_IN_DRIVERS) { | 70 | for (const driver of BUILT_IN_DRIVERS) { |
| 71 | if (driver.metadata.hasDefaultInstance === false) continue; | ||
| 97 | const instanceId = defaultInstanceIdForDriver(driver.driverKind); | 72 | const instanceId = defaultInstanceIdForDriver(driver.driverKind); |
| 98 | if (instanceId in merged) { | 73 | if (instanceId in merged) continue; |
| 99 | // Explicit `providerInstances` entry for this slot — user-authored | 74 | merged[instanceId] = { driver: driver.driverKind }; |
| 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 | }; | ||
| 119 | } | 75 | } |
| 120 | 76 | ||
| 121 | return merged as ProviderInstanceConfigMap; | 77 | return merged as ProviderInstanceConfigMap; |
apps/server/src/provider/ProviderRegistry.test.ts+33-35| ... | @@ -68,6 +68,17 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings); | ... | @@ -68,6 +68,17 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings); |
| 68 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | 68 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 69 | const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); | 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 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); | 82 | const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); |
| 72 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); | 83 | const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); |
| 73 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); | 84 | const decodeCodexSettings = Schema.decodeSync(CodexSettings); |
| ... | @@ -2663,24 +2674,18 @@ it.layer( | ... | @@ -2663,24 +2674,18 @@ it.layer( |
| 2663 | const serverSettings = yield* makeMutableServerSettingsService( | 2674 | const serverSettings = yield* makeMutableServerSettingsService( |
| 2664 | decodeServerSettings( | 2675 | decodeServerSettings( |
| 2665 | deepMerge(encodedDefaultServerSettings, { | 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 | // `providerInstances` keys are branded `ProviderInstanceId`; | 2677 | // `providerInstances` keys are branded `ProviderInstanceId`; |
| 2679 | // the branded index signature rejects plain string literals | 2678 | // the branded index signature rejects plain string literals |
| 2680 | // at the TS level even though the runtime schema happily | 2679 | // at the TS level even though the runtime schema happily |
| 2681 | // accepts + decodes them. Cast the patch to `unknown` so | 2680 | // accepts + decodes them. Cast the patch to `unknown` so |
| 2682 | // the `Schema.decodeSync` below does the real validation. | 2681 | // the `Schema.decodeSync` below does the real validation. |
| 2683 | providerInstances: { | 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 | // Matches the shape the user had in `.t3/dev/settings.json` | 2689 | // Matches the shape the user had in `.t3/dev/settings.json` |
| 2685 | // when the bug was reported: a custom enabled Codex instance | 2690 | // when the bug was reported: a custom enabled Codex instance |
| 2686 | // pointing at a binary the server has to actually spawn. | 2691 | // pointing at a binary the server has to actually spawn. |
| ... | @@ -2781,12 +2786,13 @@ it.layer( | ... | @@ -2781,12 +2786,13 @@ it.layer( |
| 2781 | const mutableServerSettings = yield* makeMutableServerSettingsService( | 2786 | const mutableServerSettings = yield* makeMutableServerSettingsService( |
| 2782 | decodeServerSettings( | 2787 | decodeServerSettings( |
| 2783 | deepMerge(encodedDefaultServerSettings, { | 2788 | deepMerge(encodedDefaultServerSettings, { |
| 2784 | providers: { | 2789 | providerInstances: { |
| 2785 | codex: { enabled: true, binaryPath: firstMissing }, | 2790 | ...disabledDefaultSlots("claudeAgent", "cursor", "grok", "opencode"), |
| 2786 | claudeAgent: { enabled: false }, | 2791 | [ProviderInstanceId.make("codex")]: { |
| 2787 | cursor: { enabled: false }, | 2792 | driver: ProviderDriverKind.make("codex"), |
| 2788 | grok: { enabled: false }, | 2793 | enabled: true, |
| 2789 | opencode: { enabled: false }, | 2794 | config: { binaryPath: firstMissing }, |
| 2795 | }, | ||
| 2790 | }, | 2796 | }, |
| 2791 | }), | 2797 | }), |
| 2792 | ), | 2798 | ), |
| ... | @@ -2871,8 +2877,13 @@ it.layer( | ... | @@ -2871,8 +2877,13 @@ it.layer( |
| 2871 | ), | 2877 | ), |
| 2872 | ); | 2878 | ); |
| 2873 | yield* serverSettings.updateSettings({ | 2879 | yield* serverSettings.updateSettings({ |
| 2874 | providers: { | 2880 | providerInstances: { |
| 2875 | codex: { enabled: true, binaryPath: secondMissing }, | 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 | // Start the lazy stream only after publishing. A watcher that did | 2889 | // Start the lazy stream only after publishing. A watcher that did |
| ... | @@ -2900,14 +2911,8 @@ it.layer( | ... | @@ -2900,14 +2911,8 @@ it.layer( |
| 2900 | const serverSettings = yield* makeMutableServerSettingsService( | 2911 | const serverSettings = yield* makeMutableServerSettingsService( |
| 2901 | decodeServerSettings( | 2912 | decodeServerSettings( |
| 2902 | deepMerge(encodedDefaultServerSettings, { | 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 | providerInstances: { | 2914 | providerInstances: { |
| 2915 | ...disabledDefaultSlots("codex", "claudeAgent", "cursor", "grok", "opencode"), | ||
| 2911 | ghost_main: { | 2916 | ghost_main: { |
| 2912 | driver: "ghostDriver", | 2917 | driver: "ghostDriver", |
| 2913 | displayName: "A fork-only driver we don't ship", | 2918 | displayName: "A fork-only driver we don't ship", |
| ... | @@ -2971,14 +2976,7 @@ it.layer( | ... | @@ -2971,14 +2976,7 @@ it.layer( |
| 2971 | const serverSettings = yield* makeMutableServerSettingsService( | 2976 | const serverSettings = yield* makeMutableServerSettingsService( |
| 2972 | decodeServerSettings( | 2977 | decodeServerSettings( |
| 2973 | deepMerge(encodedDefaultServerSettings, { | 2978 | deepMerge(encodedDefaultServerSettings, { |
| 2974 | providers: { | 2979 | providerInstances: disabledDefaultSlots("codex", "grok"), |
| 2975 | codex: { | ||
| 2976 | enabled: false, | ||
| 2977 | }, | ||
| 2978 | grok: { | ||
| 2979 | enabled: false, | ||
| 2980 | }, | ||
| 2981 | }, | ||
| 2982 | }), | 2980 | }), |
| 2983 | ), | 2981 | ), |
| 2984 | ); | 2982 | ); |
apps/server/src/provider/providerInstallation.test.ts+24-3| ... | @@ -147,7 +147,14 @@ describe("provider installation routing", () => { | ... | @@ -147,7 +147,14 @@ describe("provider installation routing", () => { |
| 147 | it.effect("keeps external installs manual without hiding shared install status", () => | 147 | it.effect("keeps external installs manual without hiding shared install status", () => |
| 148 | Effect.gen(function* () { | 148 | Effect.gen(function* () { |
| 149 | const harness = yield* makeHarness({ | 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 | const start = yield* Effect.flip(harness.router.start({ instanceId })); | 159 | const start = yield* Effect.flip(harness.router.start({ instanceId })); |
| 153 | const remove = yield* Effect.flip(harness.router.remove({ instanceId })); | 160 | const remove = yield* Effect.flip(harness.router.remove({ instanceId })); |
| ... | @@ -164,7 +171,14 @@ describe("provider installation routing", () => { | ... | @@ -164,7 +171,14 @@ describe("provider installation routing", () => { |
| 164 | const codexId = ProviderInstanceId.make("codex"); | 171 | const codexId = ProviderInstanceId.make("codex"); |
| 165 | const harness = yield* makeHarness({ | 172 | const harness = yield* makeHarness({ |
| 166 | instance: instance(ProviderDriverKind.make("codex"), codexId), | 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 | assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); | 183 | assert.equal((yield* harness.router.start({ instanceId: codexId })).driver, "codex"); |
| 170 | yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); | 184 | yield* harness.router.cancel({ instanceId: codexId, operationId: "operation" }); |
| ... | @@ -180,7 +194,14 @@ describe("provider installation routing", () => { | ... | @@ -180,7 +194,14 @@ describe("provider installation routing", () => { |
| 180 | const codexId = ProviderInstanceId.make("codex"); | 194 | const codexId = ProviderInstanceId.make("codex"); |
| 181 | const harness = yield* makeHarness({ | 195 | const harness = yield* makeHarness({ |
| 182 | instance: instance(ProviderDriverKind.make("codex"), codexId), | 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 | assert.include( | 206 | assert.include( |
| 186 | (yield* Effect.flip(harness.router.start({ instanceId: codexId }))).detail, | 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,8 +583,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 583 | // The instance registry is the new routing keystone — text generation, | 583 | // The instance registry is the new routing keystone — text generation, |
| 584 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` | 584 | // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` |
| 585 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; | 585 | // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; |
| 586 | // `providerInstances` hydration merges `settings.providers.<kind>` | 586 | // hydration adds their default instances to `providerInstances` on boot. |
| 587 | // with explicit `providerInstances` entries on boot. | ||
| 588 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), | 587 | Layer.provideMerge(ProviderInstanceRegistryHydration.layer), |
| 589 | Layer.provideMerge( | 588 | Layer.provideMerge( |
| 590 | Layer.mergeAll( | 589 | Layer.mergeAll( |
apps/server/src/serverSettings.test.ts+107-306| ... | @@ -327,13 +327,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -327,13 +327,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 327 | 327 | ||
| 328 | it.effect("decodes nested settings patches", () => | 328 | it.effect("decodes nested settings patches", () => |
| 329 | Effect.gen(function* () { | 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 | assert.deepEqual( | 330 | assert.deepEqual( |
| 338 | yield* decodeSettingsPatch({ | 331 | yield* decodeSettingsPatch({ |
| 339 | textGenerationModelSelection: { | 332 | textGenerationModelSelection: { |
| ... | @@ -374,16 +367,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -374,16 +367,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 374 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 367 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 375 | 368 | ||
| 376 | yield* serverSettings.updateSettings({ | 369 | yield* serverSettings.updateSettings({ |
| 377 | providers: { | 370 | observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" }, |
| 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 | }, | ||
| 387 | textGenerationModelSelection: { | 371 | textGenerationModelSelection: { |
| 388 | instanceId: ProviderInstanceId.make("codex"), | 372 | instanceId: ProviderInstanceId.make("codex"), |
| 389 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | 373 | model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, |
| ... | @@ -399,32 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -399,32 +383,14 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 399 | }); | 383 | }); |
| 400 | 384 | ||
| 401 | const next = yield* serverSettings.updateSettings({ | 385 | const next = yield* serverSettings.updateSettings({ |
| 402 | providers: { | 386 | observability: { otlpMetricsUrl: "http://localhost:4318/v1/metrics" }, |
| 403 | codex: { | ||
| 404 | binaryPath: "/opt/homebrew/bin/codex", | ||
| 405 | }, | ||
| 406 | }, | ||
| 407 | textGenerationModelSelection: { | 387 | textGenerationModelSelection: { |
| 408 | options: [{ id: "fastMode", value: false }], | 388 | options: [{ id: "fastMode", value: false }], |
| 409 | }, | 389 | }, |
| 410 | }); | 390 | }); |
| 411 | 391 | ||
| 412 | assert.deepEqual(next.providers.codex, { | 392 | assert.equal(next.observability.otlpTracesUrl, "http://localhost:4318/v1/traces"); |
| 413 | enabled: true, | 393 | assert.equal(next.observability.otlpMetricsUrl, "http://localhost:4318/v1/metrics"); |
| 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 | }); | ||
| 428 | assert.deepEqual( | 394 | assert.deepEqual( |
| 429 | next.textGenerationModelSelection, | 395 | next.textGenerationModelSelection, |
| 430 | createModelSelection( | 396 | createModelSelection( |
| ... | @@ -521,18 +487,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -521,18 +487,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 521 | const changes = yield* serverSettings.subscribeChanges; | 487 | const changes = yield* serverSettings.subscribeChanges; |
| 522 | 488 | ||
| 523 | yield* serverSettings.updateSettings({ | 489 | yield* serverSettings.updateSettings({ |
| 524 | providers: { | 490 | addProjectBaseDirectory: "~/next", |
| 525 | codex: { | ||
| 526 | binaryPath: "/usr/local/bin/codex-next", | ||
| 527 | }, | ||
| 528 | }, | ||
| 529 | }); | 491 | }); |
| 530 | 492 | ||
| 531 | const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); | 493 | const firstChange = yield* changes.pipe(Stream.runHead, Effect.timeout("1 second")); |
| 532 | assert.equal( | 494 | assert.equal(Option.getOrUndefined(firstChange)?.addProjectBaseDirectory, "~/next"); |
| 533 | Option.getOrUndefined(firstChange)?.providers.codex.binaryPath, | ||
| 534 | "/usr/local/bin/codex-next", | ||
| 535 | ); | ||
| 536 | }), | 495 | }), |
| 537 | ).pipe(Effect.provide(layerServerSettings())), | 496 | ).pipe(Effect.provide(layerServerSettings())), |
| 538 | ); | 497 | ); |
| ... | @@ -654,19 +613,18 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -654,19 +613,18 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 654 | ); | 613 | ); |
| 655 | 614 | ||
| 656 | it.effect( | 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 | Effect.gen(function* () { | 618 | Effect.gen(function* () { |
| 660 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 619 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 661 | const instanceId = ProviderInstanceId.make("claude_openrouter"); | 620 | const instanceId = ProviderInstanceId.make("claude_openrouter"); |
| 662 | 621 | ||
| 663 | const next = yield* serverSettings.updateSettings({ | 622 | const next = yield* serverSettings.updateSettings({ |
| 664 | providers: { | 623 | providerInstances: { |
| 665 | claudeAgent: { | 624 | [ProviderInstanceId.make("claudeAgent")]: { |
| 625 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 666 | enabled: false, | 626 | enabled: false, |
| 667 | }, | 627 | }, |
| 668 | }, | ||
| 669 | providerInstances: { | ||
| 670 | [instanceId]: { | 628 | [instanceId]: { |
| 671 | driver: ProviderDriverKind.make("claudeAgent"), | 629 | driver: ProviderDriverKind.make("claudeAgent"), |
| 672 | enabled: true, | 630 | enabled: true, |
| ... | @@ -700,6 +658,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -700,6 +658,10 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 700 | const fallbackInstanceId = ProviderInstanceId.make(fallbackId); | 658 | const fallbackInstanceId = ProviderInstanceId.make(fallbackId); |
| 701 | const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; | 659 | const selection = { instanceId: writerId, model: "claude-sonnet-4-6" }; |
| 702 | const providerInstances = { | 660 | const providerInstances = { |
| 661 | [ProviderInstanceId.make("claudeAgent")]: { | ||
| 662 | driver: ProviderDriverKind.make("claudeAgent"), | ||
| 663 | enabled: false, | ||
| 664 | }, | ||
| 703 | [fallbackInstanceId]: { | 665 | [fallbackInstanceId]: { |
| 704 | driver: ProviderDriverKind.make("codex"), | 666 | driver: ProviderDriverKind.make("codex"), |
| 705 | enabled: true, | 667 | enabled: true, |
| ... | @@ -713,11 +675,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -713,11 +675,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 713 | }; | 675 | }; |
| 714 | 676 | ||
| 715 | yield* serverSettings.updateSettings({ | 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 | providerInstances, | 678 | providerInstances, |
| 722 | textGenerationModelSelection: selection, | 679 | textGenerationModelSelection: selection, |
| 723 | }); | 680 | }); |
| ... | @@ -754,7 +711,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -754,7 +711,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 754 | ).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 715 | Effect.gen(function* () { |
| 759 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 716 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 760 | const next = yield* serverSettings.updateSettings({ | 717 | const next = yield* serverSettings.updateSettings({ |
| ... | @@ -933,242 +890,188 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -933,242 +890,188 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 933 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 894 | Effect.gen(function* () { |
| 938 | const serverConfig = yield* ServerConfig.ServerConfig; | 895 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 939 | const fileSystem = yield* FileSystem.FileSystem; | 896 | const fileSystem = yield* FileSystem.FileSystem; |
| 940 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 897 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 941 | yield* fileSystem.writeFileString( | 898 | yield* fileSystem.writeFileString( |
| 942 | serverConfig.settingsPath, | 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 | const settings = yield* serverSettings.getSettings; | 912 | const settings = yield* serverSettings.getSettings; |
| 948 | 913 | ||
| 949 | assert.isFalse(settings.providers.grok.enabled); | 914 | assert.deepEqual(settings.providerInstances, { |
| 950 | assert.isTrue(settings.providers.opencode.enabled); | 915 | [ProviderInstanceId.make("codex")]: { |
| 951 | assert.isFalse(settings.providers.cursor.enabled); | 916 | driver: ProviderDriverKind.make("codex"), |
| 952 | assert.equal(settings.providers.opencode.serverUrl, "http://127.0.0.1:4096"); | 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 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 937 | Effect.gen(function* () { |
| 958 | const serverConfig = yield* ServerConfig.ServerConfig; | 938 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 959 | const fileSystem = yield* FileSystem.FileSystem; | 939 | const fileSystem = yield* FileSystem.FileSystem; |
| 960 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 940 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 961 | yield* fileSystem.writeFileString( | 941 | yield* fileSystem.writeFileString( |
| 962 | serverConfig.settingsPath, | 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 | const settings = yield* serverSettings.getSettings; | 951 | const settings = yield* serverSettings.getSettings; |
| 970 | 952 | ||
| 971 | assert.isTrue(settings.providers.cursor.enabled); | 953 | assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { |
| 972 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled); | 954 | driver: ProviderDriverKind.make("codex"), |
| 973 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); | 955 | enabled: true, |
| 974 | assert.isTrue(settings.providerInstances[ProviderInstanceId.make("opencode_work")]?.enabled); | 956 | config: { binaryPath: "/explicit/codex" }, |
| 975 | const unused = settings.providerInstances[ProviderInstanceId.make("opencode_unused")]; | 957 | }); |
| 976 | assert.isDefined(unused); | ||
| 977 | assert.isFalse(resolveProviderInstanceEnabled(unused)); | ||
| 978 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 962 | Effect.gen(function* () { |
| 983 | const serverConfig = yield* ServerConfig.ServerConfig; | 963 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 984 | const fileSystem = yield* FileSystem.FileSystem; | 964 | const fileSystem = yield* FileSystem.FileSystem; |
| 985 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 965 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 986 | yield* fileSystem.writeFileString( | 966 | yield* fileSystem.writeFileString( |
| 987 | serverConfig.settingsPath, | 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 | yield* recordProviderUsage("opencode"); | 976 | yield* recordProviderUsage("opencode"); |
| 992 | yield* recordProviderUsage("cursor"); | 977 | yield* recordProviderUsage("grok", null); |
| 978 | yield* recordProviderUsage("cursor", "cursor_work"); | ||
| 993 | 979 | ||
| 994 | const settings = yield* serverSettings.getSettings; | 980 | const settings = yield* serverSettings.getSettings; |
| 995 | 981 | ||
| 996 | assert.isFalse(settings.providers.grok.enabled); | 982 | assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("opencode")], { |
| 997 | assert.isFalse(settings.providers.opencode.enabled); | 983 | driver: ProviderDriverKind.make("opencode"), |
| 998 | assert.isFalse(settings.providers.cursor.enabled); | 984 | enabled: true, |
| 999 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("grok")]?.enabled); | 985 | config: { serverUrl: "http://127.0.0.1:4096" }, |
| 1000 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("opencode")]?.enabled); | 986 | }); |
| 1001 | assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor")]?.enabled); | 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 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 999 | Effect.gen(function* () { |
| 1007 | const serverConfig = yield* ServerConfig.ServerConfig; | 1000 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1008 | const fileSystem = yield* FileSystem.FileSystem; | 1001 | const fileSystem = yield* FileSystem.FileSystem; |
| 1009 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 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 | yield* fileSystem.writeFileString( | 1003 | yield* fileSystem.writeFileString( |
| 1013 | serverConfig.settingsPath, | 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 | const settings = yield* serverSettings.getSettings; | 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 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 1020 | Effect.gen(function* () { |
| 1025 | const serverConfig = yield* ServerConfig.ServerConfig; | 1021 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1026 | const fileSystem = yield* FileSystem.FileSystem; | 1022 | const fileSystem = yield* FileSystem.FileSystem; |
| 1027 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 1023 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1028 | yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}"); | 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 | yield* recordProviderUsage("grok"); | 1025 | yield* recordProviderUsage("grok"); |
| 1042 | 1026 | ||
| 1043 | const settings = yield* serverSettings.getSettings; | 1027 | const settings = yield* serverSettings.getSettings; |
| 1044 | 1028 | ||
| 1045 | assert.isTrue(settings.providers.grok.enabled); | 1029 | assert.deepEqual(settings.providerInstances, {}); |
| 1046 | assert.isFalse(settings.providers.opencode.enabled); | ||
| 1047 | assert.isFalse(settings.providers.cursor.enabled); | ||
| 1048 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 1034 | Effect.gen(function* () { |
| 1053 | const serverConfig = yield* ServerConfig.ServerConfig; | 1035 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1054 | const fileSystem = yield* FileSystem.FileSystem; | 1036 | const fileSystem = yield* FileSystem.FileSystem; |
| 1055 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 1037 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1056 | yield* fileSystem.writeFileString(serverConfig.settingsPath, "{invalid json"); | 1038 | const raw = '{"addProjectBaseDirectory":42,"providers":{"codex":{"binaryPath":"/x"}}}'; |
| 1057 | yield* recordProviderUsage("cursor"); | 1039 | yield* fileSystem.writeFileString(serverConfig.settingsPath, raw); |
| 1058 | 1040 | ||
| 1059 | const settings = yield* serverSettings.getSettings; | 1041 | const settings = yield* serverSettings.getSettings; |
| 1060 | 1042 | ||
| 1061 | assert.isTrue(settings.providers.cursor.enabled); | 1043 | // The readable provider settings still apply, but the file stays for the user to repair. |
| 1062 | assert.isFalse(settings.providers.grok.enabled); | 1044 | assert.deepEqual(settings.providerInstances[ProviderInstanceId.make("codex")], { |
| 1063 | assert.isFalse(settings.providers.opencode.enabled); | 1045 | driver: ProviderDriverKind.make("codex"), |
| 1046 | config: { binaryPath: "/x" }, | ||
| 1047 | }); | ||
| 1048 | assert.equal(yield* fileSystem.readFileString(serverConfig.settingsPath), raw); | ||
| 1064 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 1053 | Effect.gen(function* () { |
| 1069 | const serverConfig = yield* ServerConfig.ServerConfig; | 1054 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1070 | const fileSystem = yield* FileSystem.FileSystem; | 1055 | const fileSystem = yield* FileSystem.FileSystem; |
| 1071 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 1056 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1072 | yield* fileSystem.writeFileString( | 1057 | yield* fileSystem.writeFileString( |
| 1073 | serverConfig.settingsPath, | 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 | const settings = yield* serverSettings.getSettings; | 1062 | const settings = yield* serverSettings.getSettings; |
| 1110 | 1063 | ||
| 1111 | assert.isFalse(settings.providers.grok.enabled); | 1064 | assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); |
| 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); | ||
| 1156 | }).pipe(Effect.provide(layerServerSettings())), | 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 | Effect.gen(function* () { | 1069 | Effect.gen(function* () { |
| 1161 | const serverConfig = yield* ServerConfig.ServerConfig; | 1070 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1162 | const fileSystem = yield* FileSystem.FileSystem; | 1071 | const fileSystem = yield* FileSystem.FileSystem; |
| 1163 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 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 | const next = yield* serverSettings.updateSettings({ | 1074 | const next = yield* serverSettings.updateSettings({ |
| 1171 | addProjectBaseDirectory: "~/Development", | ||
| 1172 | providerInstances: { | 1075 | providerInstances: { |
| 1173 | [ProviderInstanceId.make("grok")]: { | 1076 | [ProviderInstanceId.make("grok")]: { |
| 1174 | driver: ProviderDriverKind.make("grok"), | 1077 | driver: ProviderDriverKind.make("grok"), |
| ... | @@ -1177,18 +1080,11 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -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 | const grok = next.providerInstances[ProviderInstanceId.make("grok")]; | 1083 | const grok = next.providerInstances[ProviderInstanceId.make("grok")]; |
| 1184 | assert.isDefined(grok); | 1084 | assert.isDefined(grok); |
| 1185 | assert.isFalse(resolveProviderInstanceEnabled(grok)); | 1085 | assert.isFalse(resolveProviderInstanceEnabled(grok)); |
| 1186 | 1086 | const persisted = JSON.parse(yield* fileSystem.readFileString(serverConfig.settingsPath)); | |
| 1187 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | 1087 | assert.isUndefined(persisted.providers); |
| 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); | ||
| 1192 | assert.isUndefined(persisted.providerInstances.grok.enabled); | 1088 | assert.isUndefined(persisted.providerInstances.grok.enabled); |
| 1193 | }).pipe(Effect.provide(layerServerSettings())), | 1089 | }).pipe(Effect.provide(layerServerSettings())), |
| 1194 | ); | 1090 | ); |
| ... | @@ -1252,54 +1148,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -1252,54 +1148,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1252 | }).pipe(Effect.provide(layerServerSettings())), | 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 | it.effect("trims observability settings when updates are applied", () => | 1151 | it.effect("trims observability settings when updates are applied", () => |
| 1304 | Effect.gen(function* () { | 1152 | Effect.gen(function* () { |
| 1305 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 1153 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| ... | @@ -1322,51 +1170,20 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -1322,51 +1170,20 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1322 | }).pipe(Effect.provide(layerServerSettings())), | 1170 | }).pipe(Effect.provide(layerServerSettings())), |
| 1323 | ); | 1171 | ); |
| 1324 | 1172 | ||
| 1325 | it.effect("defaults blank binary paths to provider executables", () => | 1173 | it.effect("writes only non-default settings to disk", () => |
| 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", () => | ||
| 1346 | Effect.gen(function* () { | 1174 | Effect.gen(function* () { |
| 1347 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; | 1175 | const serverSettings = yield* ServerSettingsModule.ServerSettingsService; |
| 1348 | const serverConfig = yield* ServerConfig.ServerConfig; | 1176 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 1349 | const fileSystem = yield* FileSystem.FileSystem; | 1177 | const fileSystem = yield* FileSystem.FileSystem; |
| 1350 | const next = yield* serverSettings.updateSettings({ | 1178 | yield* serverSettings.updateSettings({ |
| 1351 | addProjectBaseDirectory: "~/Development", | 1179 | addProjectBaseDirectory: "~/Development", |
| 1352 | observability: { | 1180 | observability: { |
| 1353 | otlpTracesUrl: "http://localhost:4318/v1/traces", | 1181 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 1354 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | 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 | automaticGitFetchInterval: Duration.seconds(10), | 1184 | automaticGitFetchInterval: Duration.seconds(10), |
| 1366 | }); | 1185 | }); |
| 1367 | 1186 | ||
| 1368 | assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex"); | ||
| 1369 | |||
| 1370 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); | 1187 | const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); |
| 1371 | assert.deepEqual(JSON.parse(raw), { | 1188 | assert.deepEqual(JSON.parse(raw), { |
| 1372 | addProjectBaseDirectory: "~/Development", | 1189 | addProjectBaseDirectory: "~/Development", |
| ... | @@ -1374,22 +1191,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { | ... | @@ -1374,22 +1191,6 @@ it.layer(NodeServices.layer)("server settings", (it) => { |
| 1374 | otlpTracesUrl: "http://localhost:4318/v1/traces", | 1191 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 1375 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | 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 | backgroundActivity: { | 1194 | backgroundActivity: { |
| 1394 | schemaVersion: 1, | 1195 | schemaVersion: 1, |
| 1395 | profile: "custom", | 1196 | profile: "custom", |
apps/server/src/serverSettings.ts+102-67| ... | @@ -23,6 +23,8 @@ import { | ... | @@ -23,6 +23,8 @@ import { |
| 23 | type ProviderInstanceEnvironmentVariable, | 23 | type ProviderInstanceEnvironmentVariable, |
| 24 | type UsageLimitSourceConfig, | 24 | type UsageLimitSourceConfig, |
| 25 | type ProviderInstanceMutation, | 25 | type ProviderInstanceMutation, |
| 26 | defaultInstanceIdForDriver, | ||
| 27 | isProviderDriverKind, | ||
| 26 | ProviderDriverKind, | 28 | ProviderDriverKind, |
| 27 | ProviderInstanceId, | 29 | ProviderInstanceId, |
| 28 | resolveProviderInstanceEnabled, | 30 | resolveProviderInstanceEnabled, |
| ... | @@ -384,69 +386,110 @@ const ServerSettingsJson = fromLenientJson( | ... | @@ -384,69 +386,110 @@ const ServerSettingsJson = fromLenientJson( |
| 384 | }), | 386 | }), |
| 385 | ); | 387 | ); |
| 386 | const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); | 388 | const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson); |
| 387 | const PersistedOptionalProviderSettings = Schema.Struct({ | 389 | /** |
| 388 | providers: Schema.optionalKey( | 390 | * The retired `providers.<kind>` map, read without its old schemas. Before |
| 389 | Schema.Struct({ | 391 | * `providerInstances` existed each built-in driver had one blob there; it |
| 390 | cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), | 392 | * now only feeds `migrateLegacyProviderSettings`. |
| 391 | grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), | 393 | */ |
| 392 | opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })), | 394 | const LegacyProviderSettingsJson = fromLenientJson( |
| 393 | }), | 395 | Schema.Struct({ |
| 394 | ), | 396 | providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), |
| 395 | }); | 397 | }), |
| 396 | const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit( | ||
| 397 | fromLenientJson(PersistedOptionalProviderSettings), | ||
| 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 | settings: ServerSettings, | 419 | settings: ServerSettings, |
| 402 | persisted: typeof PersistedOptionalProviderSettings.Type, | 420 | legacyProviders: Readonly<Record<string, unknown>>, |
| 403 | providerHistory: ReadonlyArray<{ | 421 | providerHistory: ReadonlyArray<{ |
| 404 | readonly providerName: string; | 422 | readonly providerName: string; |
| 405 | readonly providerInstanceId: string | null; | 423 | readonly providerInstanceId: string | null; |
| 406 | }>, | 424 | }>, |
| 407 | ): ServerSettings { | 425 | ): ServerSettings { |
| 408 | const usedProviders = new Set(providerHistory.map(({ providerName }) => providerName)); | 426 | // History rows are raw SQL text; compare them as strings. |
| 409 | const usedProviderInstances = new Set( | 427 | const usedProviders = new Set<string>(providerHistory.map(({ providerName }) => providerName)); |
| 428 | const usedProviderInstances = new Set<string>( | ||
| 410 | providerHistory.map( | 429 | providerHistory.map( |
| 411 | ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, | 430 | ({ providerName, providerInstanceId }) => providerInstanceId ?? providerName, |
| 412 | ), | 431 | ), |
| 413 | ); | 432 | ); |
| 414 | const providerInstances = Object.fromEntries( | 433 | |
| 415 | Object.entries(settings.providerInstances).map(([instanceId, instance]) => [ | 434 | const providerInstances: Record<ProviderInstanceId, ProviderInstanceConfig> = {}; |
| 416 | instanceId, | 435 | for (const [instanceId, instance] of Object.entries(settings.providerInstances) as Array< |
| 436 | [ProviderInstanceId, ProviderInstanceConfig] | ||
| 437 | >) { | ||
| 438 | providerInstances[instanceId] = | ||
| 417 | instance.enabled === undefined && | 439 | instance.enabled === undefined && |
| 418 | (instance.driver === "cursor" || | 440 | HISTORY_RESTORED_DRIVERS.has(instance.driver) && |
| 419 | instance.driver === "grok" || | ||
| 420 | instance.driver === "opencode") && | ||
| 421 | usedProviderInstances.has(instanceId) | 441 | usedProviderInstances.has(instanceId) |
| 422 | ? { ...instance, enabled: true } | 442 | ? { ...instance, enabled: true } |
| 423 | : instance, | 443 | : instance; |
| 424 | ]), | 444 | } |
| 425 | ); | 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 | return { | 474 | return { |
| 428 | ...settings, | 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 | providerInstances, | 476 | providerInstances, |
| 445 | }; | 477 | }; |
| 446 | } | 478 | } |
| 447 | 479 | ||
| 448 | const ACP_REGISTRY_DRIVER = ProviderDriverKind.make("acpRegistry"); | 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 | /** ACP Registry instances reject every application text-generation operation. */ | 493 | /** ACP Registry instances reject every application text-generation operation. */ |
| 451 | function selectionSupportsTextGeneration( | 494 | function selectionSupportsTextGeneration( |
| 452 | settings: ServerSettings, | 495 | settings: ServerSettings, |
| ... | @@ -463,14 +506,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings | ... | @@ -463,14 +506,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings |
| 463 | } | 506 | } |
| 464 | 507 | ||
| 465 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { | 508 | function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { |
| 466 | // Same precedence as isModelSelectionProviderEnabled: an explicit provider | 509 | // The built-in default instances in preference order. A slot without an |
| 467 | // instance wins over the legacy providers map, which decodes to defaults | 510 | // explicit instance uses its driver's default enabled state. |
| 468 | // (codex enabled) when the Providers UI has only written providerInstances. | 511 | const fallback = TEXT_GENERATION_FALLBACK_DRIVERS.find((driver) => |
| 469 | const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { | 512 | resolveProviderInstanceEnabled( |
| 470 | const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; | 513 | settings.providerInstances[defaultInstanceIdForDriver(driver)] ?? { driver, config: {} }, |
| 471 | return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); | 514 | ), |
| 472 | }); | 515 | ); |
| 473 | const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; | ||
| 474 | if (!fallback) { | 516 | if (!fallback) { |
| 475 | return settings; | 517 | return settings; |
| 476 | } | 518 | } |
| ... | @@ -497,17 +539,6 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ | ... | @@ -497,17 +539,6 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([ |
| 497 | "pullRequestMergeMethod", | 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 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { | 542 | function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined { |
| 512 | if (Array.isArray(current) || Array.isArray(defaults)) { | 543 | if (Array.isArray(current) || Array.isArray(defaults)) { |
| 513 | return Equal.equals(current, defaults) ? undefined : current; | 544 | return Equal.equals(current, defaults) ? undefined : current; |
| ... | @@ -671,7 +702,7 @@ const make = Effect.gen(function* () { | ... | @@ -671,7 +702,7 @@ const make = Effect.gen(function* () { |
| 671 | const writeSettingsAtomically = Effect.fnUntraced( | 702 | const writeSettingsAtomically = Effect.fnUntraced( |
| 672 | function* (settings: ServerSettings) { | 703 | function* (settings: ServerSettings) { |
| 673 | const sparseSettingsJson = yield* encodeServerSettingsJson( | 704 | const sparseSettingsJson = yield* encodeServerSettingsJson( |
| 674 | stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {}, | 705 | stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}, |
| 675 | ); | 706 | ); |
| 676 | 707 | ||
| 677 | return yield* writeFileStringAtomically({ | 708 | return yield* writeFileStringAtomically({ |
| ... | @@ -740,7 +771,7 @@ const make = Effect.gen(function* () { | ... | @@ -740,7 +771,7 @@ const make = Effect.gen(function* () { |
| 740 | 771 | ||
| 741 | const loadSettingsFromDisk = Effect.gen(function* () { | 772 | const loadSettingsFromDisk = Effect.gen(function* () { |
| 742 | let settings = DEFAULT_SERVER_SETTINGS; | 773 | let settings = DEFAULT_SERVER_SETTINGS; |
| 743 | let persisted: typeof PersistedOptionalProviderSettings.Type = {}; | 774 | let legacyProviders: Readonly<Record<string, unknown>> | undefined; |
| 744 | // A file that failed to decode must stay on disk for the user to repair; | 775 | // A file that failed to decode must stay on disk for the user to repair; |
| 745 | // the fold below only writes when it started from the file's real contents. | 776 | // the fold below only writes when it started from the file's real contents. |
| 746 | let settingsFileTrusted = true; | 777 | let settingsFileTrusted = true; |
| ... | @@ -748,12 +779,12 @@ const make = Effect.gen(function* () { | ... | @@ -748,12 +779,12 @@ const make = Effect.gen(function* () { |
| 748 | if (yield* readConfigExists) { | 779 | if (yield* readConfigExists) { |
| 749 | const raw = yield* readRawConfig; | 780 | const raw = yield* readRawConfig; |
| 750 | const decoded = decodeServerSettingsJsonExit(raw); | 781 | const decoded = decodeServerSettingsJsonExit(raw); |
| 751 | const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw); | 782 | const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); |
| 752 | if (persistedSettings._tag === "Success") { | 783 | if (legacySettings._tag === "Success") { |
| 753 | persisted = persistedSettings.value; | 784 | legacyProviders = legacySettings.value.providers; |
| 754 | } | 785 | } |
| 755 | if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") { | 786 | if (decoded._tag === "Failure" || legacySettings._tag === "Failure") { |
| 756 | const failure = decoded._tag === "Failure" ? decoded : persistedSettings; | 787 | const failure = decoded._tag === "Failure" ? decoded : legacySettings; |
| 757 | settingsFileTrusted = false; | 788 | settingsFileTrusted = false; |
| 758 | if (failure._tag === "Failure") { | 789 | if (failure._tag === "Failure") { |
| 759 | yield* Effect.logWarning("failed to parse settings.json, using defaults", { | 790 | yield* Effect.logWarning("failed to parse settings.json, using defaults", { |
| ... | @@ -817,14 +848,18 @@ const make = Effect.gen(function* () { | ... | @@ -817,14 +848,18 @@ const make = Effect.gen(function* () { |
| 817 | ); | 848 | ); |
| 818 | 849 | ||
| 819 | const loaded = foldProviderInstanceEnabledFlags( | 850 | const loaded = foldProviderInstanceEnabledFlags( |
| 820 | restoreUsedProviders(settings, persisted, providerHistory), | 851 | legacyProviders === undefined |
| 852 | ? settings | ||
| 853 | : migrateLegacyProviderSettings(settings, legacyProviders, providerHistory), | ||
| 821 | ); | 854 | ); |
| 822 | const folded = settingsFileTrusted | 855 | const folded = settingsFileTrusted |
| 823 | ? foldLegacyProjectSettings(loaded, legacyProjectRows) | 856 | ? foldLegacyProjectSettings(loaded, legacyProjectRows) |
| 824 | : loaded; | 857 | : loaded; |
| 825 | // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. | 858 | // Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair. |
| 826 | const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded; | 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 | yield* writeSettingsAtomically(migrated); | 863 | yield* writeSettingsAtomically(migrated); |
| 829 | } | 864 | } |
| 830 | return migrated; | 865 | return migrated; |
apps/server/src/terminal/Manager.test.ts+21-23| ... | @@ -2272,7 +2272,7 @@ it.layer( | ... | @@ -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 | Effect.gen(function* () { | 2276 | Effect.gen(function* () { |
| 2277 | const path = yield* Path.Path; | 2277 | const path = yield* Path.Path; |
| 2278 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 2278 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | @@ -2283,18 +2283,22 @@ it.layer( | ... | @@ -2283,18 +2283,22 @@ it.layer( |
| 2283 | env: undefined, | 2283 | env: undefined, |
| 2284 | }); | 2284 | }); |
| 2285 | 2285 | ||
| 2286 | expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); | 2286 | expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-default$/); |
| 2287 | }).pipe( | 2287 | }).pipe( |
| 2288 | Effect.provide( | 2288 | Effect.provide( |
| 2289 | ServerSettings.ServerSettingsService.layerTest({ | 2289 | ServerSettings.ServerSettingsService.layerTest({ |
| 2290 | providerInstances: {}, | 2290 | providerInstances: { |
| 2291 | providers: { codex: { homePath: "~/.codex-legacy" } }, | 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 | Effect.gen(function* () { | 2302 | Effect.gen(function* () { |
| 2299 | const path = yield* Path.Path; | 2303 | const path = yield* Path.Path; |
| 2300 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 2304 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | @@ -2305,18 +2309,22 @@ it.layer( | ... | @@ -2305,18 +2309,22 @@ it.layer( |
| 2305 | env: undefined, | 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 | }).pipe( | 2313 | }).pipe( |
| 2310 | Effect.provide( | 2314 | Effect.provide( |
| 2311 | ServerSettings.ServerSettingsService.layerTest({ | 2315 | ServerSettings.ServerSettingsService.layerTest({ |
| 2312 | providerInstances: {}, | 2316 | providerInstances: { |
| 2313 | providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, | 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 | Effect.gen(function* () { | 2328 | Effect.gen(function* () { |
| 2321 | const path = yield* Path.Path; | 2329 | const path = yield* Path.Path; |
| 2322 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 2330 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| ... | @@ -2324,26 +2332,16 @@ it.layer( | ... | @@ -2324,26 +2332,16 @@ it.layer( |
| 2324 | serverSettings, | 2332 | serverSettings, |
| 2325 | path, | 2333 | path, |
| 2326 | rawProviderInstanceId: "codex", | 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 | }).pipe( | 2339 | }).pipe( |
| 2332 | Effect.provide( | 2340 | Effect.provide(ServerSettings.ServerSettingsService.layerTest({ providerInstances: {} })), |
| 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 | ), | ||
| 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 | Effect.gen(function* () { | 2345 | Effect.gen(function* () { |
| 2348 | const path = yield* Path.Path; | 2346 | const path = yield* Path.Path; |
| 2349 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 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,9 +73,15 @@ const setup = Effect.gen(function* () { |
| 73 | home, | 73 | home, |
| 74 | transcript: NodePath.join(transcriptDir, "session.jsonl"), | 74 | transcript: NodePath.join(transcriptDir, "session.jsonl"), |
| 75 | settings: { | 75 | settings: { |
| 76 | providers: { | 76 | providerInstances: { |
| 77 | claudeAgent: { homePath: NodePath.join(home, "claude") }, | 77 | [ProviderInstanceId.make("claudeAgent")]: { |
| 78 | codex: { homePath: NodePath.join(home, "codex") }, | 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,98 +233,86 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { |
| 227 | } | 233 | } |
| 228 | 234 | ||
| 229 | describe("UsageService", () => { | 235 | describe("UsageService", () => { |
| 230 | it.live.each([ | 236 | it.live("reads shared managed default and disabled extra account history once", () => |
| 231 | { explicitDefault: true, label: "explicit" }, | 237 | Effect.gen(function* () { |
| 232 | { explicitDefault: false, label: "legacy" }, | 238 | const { home, settings } = yield* setup; |
| 233 | ])( | 239 | const summary = yield* Effect.gen(function* () { |
| 234 | "reads shared managed $label default and disabled extra account history once", | 240 | for (const [id, output] of [ |
| 235 | ({ explicitDefault }) => | 241 | ["codex", 17], |
| 236 | Effect.gen(function* () { | 242 | ["codex-personal", 23], |
| 237 | const { home, settings } = yield* setup; | 243 | ] as const) { |
| 238 | const summary = yield* Effect.gen(function* () { | 244 | const sessions = NodePath.join(home, "shared-codex", "sessions"); |
| 239 | for (const [id, output] of [ | 245 | yield* Effect.promise(async () => { |
| 240 | ["codex", 17], | 246 | await NodeFSP.mkdir(sessions, { recursive: true }); |
| 241 | ["codex-personal", 23], | 247 | await NodeFSP.writeFile( |
| 242 | ] as const) { | 248 | NodePath.join(sessions, `${id}-rollout.jsonl`), |
| 243 | const sessions = NodePath.join(home, "shared-codex", "sessions"); | 249 | [ |
| 244 | yield* Effect.promise(async () => { | 250 | { type: "session_meta", payload: { id } }, |
| 245 | await NodeFSP.mkdir(sessions, { recursive: true }); | 251 | { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, |
| 246 | await NodeFSP.writeFile( | 252 | { |
| 247 | NodePath.join(sessions, `${id}-rollout.jsonl`), | 253 | type: "event_msg", |
| 248 | [ | 254 | timestamp: "2026-08-01T10:00:00Z", |
| 249 | { type: "session_meta", payload: { id } }, | 255 | payload: { |
| 250 | { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, | 256 | type: "token_count", |
| 251 | { | 257 | info: { last_token_usage: { input_tokens: 10, output_tokens: output } }, |
| 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 | }, | ||
| 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: { | 260 | ] |
| 282 | ...(explicitDefault | 261 | .map((line) => encodeUnknownJsonString(line)) |
| 283 | ? { | 262 | .join("\n") + "\n", |
| 284 | [ProviderInstanceId.make("codex")]: { | 263 | ); |
| 285 | driver: ProviderDriverKind.make("codex"), | 264 | }); |
| 286 | config: { | 265 | } |
| 287 | setupMode: "managed", | 266 | const service = yield* UsageService.make; |
| 288 | homePath: NodePath.join(home, "shared-codex"), | 267 | return yield* service.readSummary(WINDOW); |
| 289 | }, | 268 | }).pipe( |
| 290 | }, | 269 | // Scoped inside the state directory, so pending cache writes land |
| 291 | } | 270 | // before it is removed. |
| 292 | : {}), | 271 | Effect.scoped, |
| 293 | [ProviderInstanceId.make("codex-personal")]: { | 272 | Effect.provide( |
| 294 | driver: ProviderDriverKind.make("codex"), | 273 | layerService({ |
| 295 | enabled: false, | 274 | prefix: "usage-managed-accounts", |
| 296 | config: { | 275 | home, |
| 297 | setupMode: "managed", | 276 | settings: { |
| 298 | homePath: NodePath.join(home, "shared-codex"), | 277 | ...settings, |
| 299 | shadowHomePath: NodePath.join(home, "personal-shadow"), | 278 | providerInstances: { |
| 300 | }, | 279 | ...settings.providerInstances, |
| 301 | environment: [ | 280 | [ProviderInstanceId.make("codex")]: { |
| 302 | { | 281 | driver: ProviderDriverKind.make("codex"), |
| 303 | name: "CODEX_HOME", | 282 | config: { |
| 304 | value: NodePath.join(home, "ignored-environment"), | 283 | setupMode: "managed", |
| 305 | sensitive: false, | 284 | homePath: NodePath.join(home, "shared-codex"), |
| 306 | }, | 285 | }, |
| 307 | ], | 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 | }), | 304 | }, |
| 312 | ), | 305 | }), |
| 313 | ); | 306 | ), |
| 314 | assert.strictEqual(totalOutputTokens(summary), 40); | 307 | ); |
| 315 | assert.strictEqual( | 308 | assert.strictEqual(totalOutputTokens(summary), 40); |
| 316 | summary.sources.filter( | 309 | assert.strictEqual( |
| 317 | (source) => source.fingerprint.provider === "codex" && source.status === "ok", | 310 | summary.sources.filter( |
| 318 | ).length, | 311 | (source) => source.fingerprint.provider === "codex" && source.status === "ok", |
| 319 | 1, | 312 | ).length, |
| 320 | ); | 313 | 1, |
| 321 | }).pipe(Effect.scoped), | 314 | ); |
| 315 | }).pipe(Effect.scoped), | ||
| 322 | ); | 316 | ); |
| 323 | it.live("omits Cursor account usage when no file login is saved", () => | 317 | it.live("omits Cursor account usage when no file login is saved", () => |
| 324 | Effect.gen(function* () { | 318 | Effect.gen(function* () { |
| ... | @@ -788,6 +782,7 @@ describe("UsageService", () => { | ... | @@ -788,6 +782,7 @@ describe("UsageService", () => { |
| 788 | settings: { | 782 | settings: { |
| 789 | ...settings, | 783 | ...settings, |
| 790 | providerInstances: { | 784 | providerInstances: { |
| 785 | ...settings.providerInstances, | ||
| 791 | [ProviderInstanceId.make("claude-work")]: { | 786 | [ProviderInstanceId.make("claude-work")]: { |
| 792 | driver: ProviderDriverKind.make("claudeAgent"), | 787 | driver: ProviderDriverKind.make("claudeAgent"), |
| 793 | enabled: false, | 788 | enabled: false, |
| ... | @@ -847,7 +842,7 @@ describe("UsageService", () => { | ... | @@ -847,7 +842,7 @@ describe("UsageService", () => { |
| 847 | ); | 842 | ); |
| 848 | 843 | ||
| 849 | it.live( | 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 | Effect.gen(function* () { | 847 | Effect.gen(function* () { |
| 853 | const { transcript, settings, home } = yield* setup; | 848 | const { transcript, settings, home } = yield* setup; |
| ... | @@ -884,6 +879,7 @@ describe("UsageService", () => { | ... | @@ -884,6 +879,7 @@ describe("UsageService", () => { |
| 884 | ); | 879 | ); |
| 885 | yield* settingsService.updateSettings({ | 880 | yield* settingsService.updateSettings({ |
| 886 | providerInstances: { | 881 | providerInstances: { |
| 882 | ...settings.providerInstances, | ||
| 887 | [ProviderInstanceId.make("claudeAgent")]: { | 883 | [ProviderInstanceId.make("claudeAgent")]: { |
| 888 | driver: ProviderDriverKind.make("claudeAgent"), | 884 | driver: ProviderDriverKind.make("claudeAgent"), |
| 889 | config: { homePath: "" }, | 885 | config: { homePath: "" }, |
| ... | @@ -912,6 +908,7 @@ describe("UsageService", () => { | ... | @@ -912,6 +908,7 @@ describe("UsageService", () => { |
| 912 | settings: { | 908 | settings: { |
| 913 | ...settings, | 909 | ...settings, |
| 914 | providerInstances: { | 910 | providerInstances: { |
| 911 | ...settings.providerInstances, | ||
| 915 | [ProviderInstanceId.make("claudeAgent")]: { | 912 | [ProviderInstanceId.make("claudeAgent")]: { |
| 916 | driver: ProviderDriverKind.make("claudeAgent"), | 913 | driver: ProviderDriverKind.make("claudeAgent"), |
| 917 | config: { homePath: configured }, | 914 | config: { homePath: configured }, |
| ... | @@ -1271,7 +1268,15 @@ describe("UsageService", () => { | ... | @@ -1271,7 +1268,15 @@ describe("UsageService", () => { |
| 1271 | layerService({ | 1268 | layerService({ |
| 1272 | prefix: "usage-service-cleanup-test", | 1269 | prefix: "usage-service-cleanup-test", |
| 1273 | home, | 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 | ratesDocument: { | 1280 | ratesDocument: { |
| 1276 | "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, | 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,18 +351,15 @@ export const make = Effect.gen(function* () { |
| 351 | }> = []; | 351 | }> = []; |
| 352 | const seen = new Set<string>(); | 352 | const seen = new Set<string>(); |
| 353 | for (const driver of ["claudeAgent", "codex", "grok"] as const) { | 353 | for (const driver of ["claudeAgent", "codex", "grok"] as const) { |
| 354 | // Disabled accounts still have history. Explicit default slots replace | 354 | // Disabled accounts still have history. An unconfigured default slot |
| 355 | // the legacy settings, just as they do in the provider registry. | 355 | // runs with default config, just as it does in the provider registry. |
| 356 | const instances: Array< | 356 | const instances: Array< |
| 357 | Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId } | 357 | Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId } |
| 358 | > = Object.entries(settings.providerInstances) | 358 | > = Object.entries(settings.providerInstances) |
| 359 | .filter(([, instance]) => instance.driver === driver) | 359 | .filter(([, instance]) => instance.driver === driver) |
| 360 | .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); | 360 | .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); |
| 361 | if (!Object.hasOwn(settings.providerInstances, driver)) { | 361 | if (!Object.hasOwn(settings.providerInstances, driver)) { |
| 362 | instances.push({ | 362 | instances.push({ instanceId: ProviderInstanceId.make(driver) }); |
| 363 | config: settings.providers[driver], | ||
| 364 | instanceId: ProviderInstanceId.make(driver), | ||
| 365 | }); | ||
| 366 | } | 363 | } |
| 367 | for (const instance of instances) { | 364 | for (const instance of instances) { |
| 368 | const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); | 365 | const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); |
apps/web/src/components/onboarding/WelcomeWizard.tsx+1-4| ... | @@ -898,8 +898,7 @@ function OnboardingCodexSetup({ | ... | @@ -898,8 +898,7 @@ function OnboardingCodexSetup({ |
| 898 | const settings = serverConfig.settings; | 898 | const settings = serverConfig.settings; |
| 899 | const instance = settings.providerInstances[instanceId] ?? { | 899 | const instance = settings.providerInstances[instanceId] ?? { |
| 900 | driver: ProviderDriverKind.make("codex"), | 900 | driver: ProviderDriverKind.make("codex"), |
| 901 | enabled: settings.providers.codex.enabled, | 901 | config: createdAccount ? { enabled: true, setupMode: "managed" } : {}, |
| 902 | config: createdAccount ? { enabled: true, setupMode: "managed" } : settings.providers.codex, | ||
| 903 | }; | 902 | }; |
| 904 | const mode = readCodexSetupMode(instance.config); | 903 | const mode = readCodexSetupMode(instance.config); |
| 905 | const existingChosen = | 904 | const existingChosen = |
| ... | @@ -915,8 +914,6 @@ function OnboardingCodexSetup({ | ... | @@ -915,8 +914,6 @@ function OnboardingCodexSetup({ |
| 915 | patch: buildProviderInstanceUpdatePatch({ | 914 | patch: buildProviderInstanceUpdatePatch({ |
| 916 | settings, | 915 | settings, |
| 917 | instanceId, | 916 | instanceId, |
| 918 | driver: ProviderDriverKind.make("codex"), | ||
| 919 | isDefault: instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("codex")), | ||
| 920 | instance: { | 917 | instance: { |
| 921 | ...instance, | 918 | ...instance, |
| 922 | enabled: true, | 919 | enabled: true, |
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+6-11| ... | @@ -7,7 +7,6 @@ import { CheckIcon } from "lucide-react"; | ... | @@ -7,7 +7,6 @@ import { CheckIcon } from "lucide-react"; |
| 7 | import { useMemo, useState } from "react"; | 7 | import { useMemo, useState } from "react"; |
| 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 9 | import { | 9 | import { |
| 10 | DEFAULT_UNIFIED_SETTINGS, | ||
| 11 | type AcpRegistrySearchAgent, | 10 | type AcpRegistrySearchAgent, |
| 12 | AuthProvidersManageScope, | 11 | AuthProvidersManageScope, |
| 13 | ProviderInstanceId, | 12 | ProviderInstanceId, |
| ... | @@ -21,7 +20,6 @@ import { | ... | @@ -21,7 +20,6 @@ import { |
| 21 | useEnvironmentSettings, | 20 | useEnvironmentSettings, |
| 22 | usePersistEnvironmentProviderInstanceMutation, | 21 | usePersistEnvironmentProviderInstanceMutation, |
| 23 | } from "../../hooks/useSettings"; | 22 | } from "../../hooks/useSettings"; |
| 24 | import * as Equal from "effect/Equal"; | ||
| 25 | 23 | ||
| 26 | import { cn } from "../../lib/utils"; | 24 | import { cn } from "../../lib/utils"; |
| 27 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 25 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| ... | @@ -139,15 +137,12 @@ export function AddProviderInstanceDialog({ | ... | @@ -139,15 +137,12 @@ export function AddProviderInstanceDialog({ |
| 139 | const [isSaving, setIsSaving] = useState(false); | 137 | const [isSaving, setIsSaving] = useState(false); |
| 140 | const [createdInstanceId, setCreatedInstanceId] = useState<ProviderInstanceId | null>(null); | 138 | const [createdInstanceId, setCreatedInstanceId] = useState<ProviderInstanceId | null>(null); |
| 141 | 139 | ||
| 142 | const existingIds = useMemo(() => { | 140 | // Codex and Claude run at their default slots before they are configured, so |
| 143 | const ids = new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]); | 141 | // those ids stay reserved; other unconfigured default slots are free to take. |
| 144 | const defaults = DEFAULT_UNIFIED_SETTINGS.providers as Record<string, unknown>; | 142 | const existingIds = useMemo( |
| 145 | // Reserve configured legacy slots too, so adding an account cannot replace them. | 143 | () => new Set(["codex", "claudeAgent", ...Object.keys(settings.providerInstances ?? {})]), |
| 146 | for (const [kind, config] of Object.entries(settings.providers ?? {})) { | 144 | [settings.providerInstances], |
| 147 | if (!Equal.equals(config, defaults[kind])) ids.add(kind); | 145 | ); |
| 148 | } | ||
| 149 | return ids; | ||
| 150 | }, [settings.providerInstances, settings.providers]); | ||
| 151 | 146 | ||
| 152 | const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION; | 147 | const driverOption = providerClients.get(driver) ?? DEFAULT_DRIVER_OPTION; |
| 153 | const isAcpRegistry = driver === ACP_REGISTRY_DRIVER_KIND; | 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,12 +276,13 @@ describe("EnvironmentProviderSettings routing", () => { |
| 276 | it("keeps legacy provider configuration visible when disabled", () => { | 276 | it("keeps legacy provider configuration visible when disabled", () => { |
| 277 | settingsState.value = { | 277 | settingsState.value = { |
| 278 | ...DEFAULT_UNIFIED_SETTINGS, | 278 | ...DEFAULT_UNIFIED_SETTINGS, |
| 279 | providers: { | 279 | providerInstances: { |
| 280 | ...DEFAULT_UNIFIED_SETTINGS.providers, | 280 | [ProviderInstanceId.make("grok")]: { |
| 281 | grok: { | 281 | driver: ProviderDriverKind.make("grok"), |
| 282 | ...DEFAULT_UNIFIED_SETTINGS.providers.grok, | ||
| 283 | enabled: false, | 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,9 +517,8 @@ describe("EnvironmentProviderSettings routing", () => { |
| 516 | 517 | ||
| 517 | const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? []; | 518 | const [resetMutation, resetPatch] = settingsState.mutateProviderInstance.mock.lastCall ?? []; |
| 518 | expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId }); | 519 | expect(resetMutation).toEqual({ operation: "remove", instanceId: codexId }); |
| 519 | expect(Object.keys(resetPatch ?? {}).sort()).toEqual(["providers"]); | 520 | // Removing the instance is the whole reset; shared preferences stay untouched. |
| 520 | expect(resetPatch).not.toHaveProperty("favorites"); | 521 | expect(resetPatch ?? {}).toEqual({}); |
| 521 | expect(resetPatch).not.toHaveProperty("providerModelPreferences"); | ||
| 522 | }); | 522 | }); |
| 523 | 523 | ||
| 524 | it("updates one provider instance without sending a stale whole map", async () => { | 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,6 +147,7 @@ function providerConfigString(config: unknown, key: string): string | null { |
| 147 | 147 | ||
| 148 | const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ | 148 | const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ |
| 149 | provider: definition.driverKind, | 149 | provider: definition.driverKind, |
| 150 | hasDefaultInstance: definition.hasDefaultInstance !== false, | ||
| 150 | })); | 151 | })); |
| 151 | 152 | ||
| 152 | function configuredBinaryPath(config: unknown): string { | 153 | function configuredBinaryPath(config: unknown): string { |
| ... | @@ -757,57 +758,29 @@ export function EnvironmentProviderSettings({ | ... | @@ -757,57 +758,29 @@ export function EnvironmentProviderSettings({ |
| 757 | ); | 758 | ); |
| 758 | 759 | ||
| 759 | for (const providerSettings of visibleProviderSettings) { | 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 | const driver = providerSettings.provider; | 761 | const driver = providerSettings.provider; |
| 767 | const defaultInstanceId = defaultInstanceIdForDriver(driver); | 762 | const defaultInstanceId = defaultInstanceIdForDriver(driver); |
| 768 | const explicitInstance = settings.providerInstances?.[defaultInstanceId]; | 763 | const explicitInstance = settings.providerInstances?.[defaultInstanceId]; |
| 769 | // A remote device may run a server version whose settings predate this | 764 | // An unconfigured default slot runs with the driver's default config. |
| 770 | // driver, so the legacy mirror can be absent. Without either an explicit | 765 | const effectiveInstance: ProviderInstanceConfig = explicitInstance ?? { driver }; |
| 771 | // instance or a legacy blob there is nothing to render for the slot. | 766 | const isDirty = explicitInstance !== undefined; |
| 772 | const legacyConfig = legacyProviders[providerSettings.provider]; | 767 | // Drivers without a default instance list only their configured instances. |
| 773 | const defaultLegacyConfig = defaultLegacyProviders[providerSettings.provider]; | 768 | const hasDefaultSlot = providerSettings.hasDefaultInstance || explicitInstance !== undefined; |
| 774 | // The envelope is the single enabled flag: keep the legacy in-config | 769 | if ( |
| 775 | // flag out of the synthesized blob, or an explicit `enabled: false` | 770 | hasDefaultSlot && |
| 776 | // would keep winning over the envelope and the Switch could never | 771 | (driver === "codex" || |
| 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" || | ||
| 798 | driver === "claudeAgent" || | 772 | driver === "claudeAgent" || |
| 799 | isDirty || | 773 | isDirty || |
| 800 | resolveProviderInstanceEnabled(effectiveInstance) || | 774 | resolveProviderInstanceEnabled(effectiveInstance) || |
| 801 | defaultInstanceId === targetInstanceId | 775 | defaultInstanceId === targetInstanceId) |
| 802 | ) { | 776 | ) { |
| 803 | rows.push({ | 777 | rows.push({ |
| 804 | instanceId: defaultInstanceId, | 778 | instanceId: defaultInstanceId, |
| 805 | instance: effectiveInstance, | 779 | instance: effectiveInstance, |
| 806 | driver, | 780 | driver, |
| 807 | isDefault: true, | 781 | isDefault: true, |
| 808 | isDirty, | 782 | isDirty, |
| 809 | }); | 783 | }); |
| 810 | } | ||
| 811 | } | 784 | } |
| 812 | for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { | 785 | for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) { |
| 813 | if (id === defaultInstanceId) continue; | 786 | if (id === defaultInstanceId) continue; |
| ... | @@ -847,8 +820,6 @@ export function EnvironmentProviderSettings({ | ... | @@ -847,8 +820,6 @@ export function EnvironmentProviderSettings({ |
| 847 | settings, | 820 | settings, |
| 848 | instanceId: row.instanceId, | 821 | instanceId: row.instanceId, |
| 849 | instance: next, | 822 | instance: next, |
| 850 | driver: row.driver, | ||
| 851 | isDefault: row.isDefault, | ||
| 852 | textGenerationModelSelection: options?.textGenerationModelSelection, | 823 | textGenerationModelSelection: options?.textGenerationModelSelection, |
| 853 | }); | 824 | }); |
| 854 | const result = await persistProviderInstance( | 825 | const result = await persistProviderInstance( |
| ... | @@ -947,23 +918,10 @@ export function EnvironmentProviderSettings({ | ... | @@ -947,23 +918,10 @@ export function EnvironmentProviderSettings({ |
| 947 | }; | 918 | }; |
| 948 | 919 | ||
| 949 | const resetDefaultInstance = async (driverKind: ProviderDriverKind) => { | 920 | const resetDefaultInstance = async (driverKind: ProviderDriverKind) => { |
| 950 | type LegacyProviderSettings = (typeof settings.providers)[keyof typeof settings.providers]; | 921 | const result = await persistProviderInstance({ |
| 951 | const defaultLegacyProviders = DEFAULT_UNIFIED_SETTINGS.providers as Record< | 922 | operation: "remove", |
| 952 | string, | 923 | instanceId: defaultInstanceIdForDriver(driverKind), |
| 953 | LegacyProviderSettings | undefined | 924 | }); |
| 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 | ); | ||
| 967 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 925 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 968 | const error = squashAtomCommandFailure(result); | 926 | const error = squashAtomCommandFailure(result); |
| 969 | toastManager.add({ | 927 | toastManager.add({ |
apps/web/src/components/settings/SettingsPanels.logic.test.ts+8-38| ... | @@ -190,57 +190,27 @@ describe("formatDiagnosticsDescription", () => { | ... | @@ -190,57 +190,27 @@ describe("formatDiagnosticsDescription", () => { |
| 190 | }); | 190 | }); |
| 191 | 191 | ||
| 192 | describe("buildProviderInstanceUpdatePatch", () => { | 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 | const instanceId = ProviderInstanceId.make("codex"); | 194 | const instanceId = ProviderInstanceId.make("codex"); |
| 195 | const nextInstance = { | 195 | const otherId = ProviderInstanceId.make("codex_personal"); |
| 196 | const other = { | ||
| 196 | driver: ProviderDriverKind.make("codex"), | 197 | driver: ProviderDriverKind.make("codex"), |
| 197 | enabled: true, | 198 | config: { homePath: "/Users/example/.codex-personal" }, |
| 198 | config: { | ||
| 199 | binaryPath: "/opt/t3/codex", | ||
| 200 | }, | ||
| 201 | } satisfies ProviderInstanceConfig; | 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 | const nextInstance = { | 200 | const nextInstance = { |
| 227 | driver: ProviderDriverKind.make("codex"), | 201 | driver: ProviderDriverKind.make("codex"), |
| 228 | enabled: true, | 202 | enabled: true, |
| 229 | config: { | 203 | config: { binaryPath: "/opt/t3/codex" }, |
| 230 | homePath: "/Users/example/.codex-personal", | ||
| 231 | }, | ||
| 232 | } satisfies ProviderInstanceConfig; | 204 | } satisfies ProviderInstanceConfig; |
| 233 | 205 | ||
| 234 | const patch = buildProviderInstanceUpdatePatch({ | 206 | const patch = buildProviderInstanceUpdatePatch({ |
| 235 | settings: DEFAULT_SERVER_SETTINGS, | 207 | settings: { providerInstances: { [otherId]: other } }, |
| 236 | instanceId, | 208 | instanceId, |
| 237 | instance: nextInstance, | 209 | instance: nextInstance, |
| 238 | driver: ProviderDriverKind.make("codex"), | ||
| 239 | isDefault: false, | ||
| 240 | }); | 210 | }); |
| 241 | 211 | ||
| 242 | expect(patch.providerInstances?.[instanceId]).toEqual(nextInstance); | 212 | expect(patch.providerInstances).toEqual({ [otherId]: other, [instanceId]: nextInstance }); |
| 243 | expect(patch.providers).toBeUndefined(); | 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,7 +1,6 @@ |
| 1 | import type { | 1 | import type { |
| 2 | BackgroundActivityProfile, | 2 | BackgroundActivityProfile, |
| 3 | BackgroundActivitySettings, | 3 | BackgroundActivitySettings, |
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceConfig, | 4 | ProviderInstanceConfig, |
| 6 | PreviewViewportSetting, | 5 | PreviewViewportSetting, |
| 7 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| ... | @@ -255,30 +254,14 @@ export function formatDiagnosticsDescription(input: { | ... | @@ -255,30 +254,14 @@ export function formatDiagnosticsDescription(input: { |
| 255 | } | 254 | } |
| 256 | 255 | ||
| 257 | export function buildProviderInstanceUpdatePatch(input: { | 256 | export function buildProviderInstanceUpdatePatch(input: { |
| 258 | readonly settings: Pick<ServerSettings, "providers" | "providerInstances">; | 257 | readonly settings: Pick<ServerSettings, "providerInstances">; |
| 259 | readonly instanceId: ProviderInstanceId; | 258 | readonly instanceId: ProviderInstanceId; |
| 260 | readonly instance: ProviderInstanceConfig; | 259 | readonly instance: ProviderInstanceConfig; |
| 261 | readonly driver: ProviderDriverKind; | ||
| 262 | readonly isDefault: boolean; | ||
| 263 | readonly textGenerationModelSelection?: | 260 | readonly textGenerationModelSelection?: |
| 264 | | ServerSettings["textGenerationModelSelection"] | 261 | | ServerSettings["textGenerationModelSelection"] |
| 265 | | undefined; | 262 | | undefined; |
| 266 | }): Partial<UnifiedSettings> { | 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 | return { | 264 | return { |
| 274 | ...(legacyProviderDefault !== undefined | ||
| 275 | ? { | ||
| 276 | providers: { | ||
| 277 | ...input.settings.providers, | ||
| 278 | [input.driver]: legacyProviderDefault, | ||
| 279 | } as ServerSettings["providers"], | ||
| 280 | } | ||
| 281 | : {}), | ||
| 282 | providerInstances: { | 265 | providerInstances: { |
| 283 | ...input.settings.providerInstances, | 266 | ...input.settings.providerInstances, |
| 284 | [input.instanceId]: input.instance, | 267 | [input.instanceId]: input.instance, |
apps/web/src/modelSelection.test.ts+4-7| ... | @@ -636,14 +636,11 @@ describe("instance-scoped model selection", () => { | ... | @@ -636,14 +636,11 @@ describe("instance-scoped model selection", () => { |
| 636 | const nativeModel = "gemini-3.1-pro"; | 636 | const nativeModel = "gemini-3.1-pro"; |
| 637 | const settings: UnifiedSettings = { | 637 | const settings: UnifiedSettings = { |
| 638 | ...DEFAULT_UNIFIED_SETTINGS, | 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 | providerInstances: { | 639 | providerInstances: { |
| 640 | [ProviderInstanceId.make("antigravity")]: { | ||
| 641 | driver, | ||
| 642 | config: { customModels: ["api-only-model"] }, | ||
| 643 | }, | ||
| 647 | [customId]: { driver, config: { customModels: ["unknown-model"] } }, | 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,15 +66,7 @@ function readInstanceCustomModels( |
| 66 | return readCustomModelEntries(value); | 66 | return readCustomModelEntries(value); |
| 67 | } | 67 | } |
| 68 | } | 68 | } |
| 69 | const defaultInstanceId = defaultInstanceIdForDriver(driverKind); | 69 | return []; |
| 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 ?? []); | ||
| 78 | } | 70 | } |
| 79 | 71 | ||
| 80 | export interface AppModelOption { | 72 | export interface AppModelOption { |
| ... | @@ -228,11 +220,8 @@ function getAppModelOptions( | ... | @@ -228,11 +220,8 @@ function getAppModelOptions( |
| 228 | * come from the instance's own `entry.models` snapshot (rather than the | 220 | * come from the instance's own `entry.models` snapshot (rather than the |
| 229 | * first-matching-kind fallback in `getProviderModels`), so each custom | 221 | * first-matching-kind fallback in `getProviderModels`), so each custom |
| 230 | * instance gets the precise model list its driver reported. Custom model | 222 | * instance gets the precise model list its driver reported. Custom model |
| 231 | * slugs come from the instance's own `providerInstances[id].config.customModels` | 223 | * slugs come from the instance's own `providerInstances[id].config.customModels`, |
| 232 | * when present, falling back to the legacy per-kind | 224 | * so two instances of the same kind never leak custom slugs into each other. Custom rows reported by the server are |
| 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 | ||
| 236 | * ignored so a slug removed in Settings disappears without waiting for the | 225 | * ignored so a slug removed in Settings disappears without waiting for the |
| 237 | * next provider probe. | 226 | * next provider probe. |
| 238 | */ | 227 | */ |
apps/web/src/onboarding/providerReadiness.logic.test.ts+36-30| ... | @@ -196,11 +196,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { | ... | @@ -196,11 +196,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 196 | readyCodex, | 196 | readyCodex, |
| 197 | { | 197 | { |
| 198 | ...DEFAULT_SERVER_SETTINGS, | 198 | ...DEFAULT_SERVER_SETTINGS, |
| 199 | providers: { | 199 | providerInstances: { |
| 200 | ...DEFAULT_SERVER_SETTINGS.providers, | 200 | [ProviderInstanceId.make("codex")]: { |
| 201 | codex: { | 201 | driver: ProviderDriverKind.make("codex"), |
| 202 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | 202 | config: { |
| 203 | binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", | 203 | binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", |
| 204 | }, | ||
| 204 | }, | 205 | }, |
| 205 | }, | 206 | }, |
| 206 | }, | 207 | }, |
| ... | @@ -221,11 +222,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { | ... | @@ -221,11 +222,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 221 | provider, | 222 | provider, |
| 222 | { | 223 | { |
| 223 | ...DEFAULT_SERVER_SETTINGS, | 224 | ...DEFAULT_SERVER_SETTINGS, |
| 224 | providers: { | 225 | providerInstances: { |
| 225 | ...DEFAULT_SERVER_SETTINGS.providers, | 226 | [ProviderInstanceId.make("claude")]: { |
| 226 | claudeAgent: { | 227 | driver: ProviderDriverKind.make("claudeAgent"), |
| 227 | ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | 228 | config: { |
| 228 | binaryPath: "/opt/Claude Tools/$current/claude", | 229 | binaryPath: "/opt/Claude Tools/$current/claude", |
| 230 | }, | ||
| 229 | }, | 231 | }, |
| 230 | }, | 232 | }, |
| 231 | }, | 233 | }, |
| ... | @@ -246,11 +248,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { | ... | @@ -246,11 +248,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 246 | readyCodex, | 248 | readyCodex, |
| 247 | { | 249 | { |
| 248 | ...DEFAULT_SERVER_SETTINGS, | 250 | ...DEFAULT_SERVER_SETTINGS, |
| 249 | providers: { | 251 | providerInstances: { |
| 250 | ...DEFAULT_SERVER_SETTINGS.providers, | 252 | [ProviderInstanceId.make("codex")]: { |
| 251 | codex: { | 253 | driver: ProviderDriverKind.make("codex"), |
| 252 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | 254 | config: { |
| 253 | binaryPath, | 255 | binaryPath, |
| 256 | }, | ||
| 254 | }, | 257 | }, |
| 255 | }, | 258 | }, |
| 256 | }, | 259 | }, |
| ... | @@ -265,11 +268,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { | ... | @@ -265,11 +268,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 265 | readyCodex, | 268 | readyCodex, |
| 266 | { | 269 | { |
| 267 | ...DEFAULT_SERVER_SETTINGS, | 270 | ...DEFAULT_SERVER_SETTINGS, |
| 268 | providers: { | 271 | providerInstances: { |
| 269 | ...DEFAULT_SERVER_SETTINGS.providers, | 272 | [ProviderInstanceId.make("codex")]: { |
| 270 | codex: { | 273 | driver: ProviderDriverKind.make("codex"), |
| 271 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | 274 | config: { |
| 272 | binaryPath: "/opt/codex\\work/codex", | 275 | binaryPath: "/opt/codex\\work/codex", |
| 276 | }, | ||
| 273 | }, | 277 | }, |
| 274 | }, | 278 | }, |
| 275 | }, | 279 | }, |
| ... | @@ -284,11 +288,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { | ... | @@ -284,11 +288,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 284 | readyCodex, | 288 | readyCodex, |
| 285 | { | 289 | { |
| 286 | ...DEFAULT_SERVER_SETTINGS, | 290 | ...DEFAULT_SERVER_SETTINGS, |
| 287 | providers: { | 291 | providerInstances: { |
| 288 | ...DEFAULT_SERVER_SETTINGS.providers, | 292 | [ProviderInstanceId.make("codex")]: { |
| 289 | codex: { | 293 | driver: ProviderDriverKind.make("codex"), |
| 290 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | 294 | config: { |
| 291 | binaryPath: "C:\\Tools\\codex.exe", | 295 | binaryPath: "C:\\Tools\\codex.exe", |
| 296 | }, | ||
| 292 | }, | 297 | }, |
| 293 | }, | 298 | }, |
| 294 | }, | 299 | }, |
| ... | @@ -303,11 +308,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { | ... | @@ -303,11 +308,12 @@ describe("resolveOnboardingProviderLoginCommand", () => { |
| 303 | readyCodex, | 308 | readyCodex, |
| 304 | { | 309 | { |
| 305 | ...DEFAULT_SERVER_SETTINGS, | 310 | ...DEFAULT_SERVER_SETTINGS, |
| 306 | providers: { | 311 | providerInstances: { |
| 307 | ...DEFAULT_SERVER_SETTINGS.providers, | 312 | [ProviderInstanceId.make("codex")]: { |
| 308 | codex: { | 313 | driver: ProviderDriverKind.make("codex"), |
| 309 | ...DEFAULT_SERVER_SETTINGS.providers.codex, | 314 | config: { |
| 310 | binaryPath: "/opt/Codex Tools/codex", | 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,17 +112,13 @@ export function resolveOnboardingProviderLoginCommand( |
| 112 | const instance = settings.providerInstances[provider.instanceId]; | 112 | const instance = settings.providerInstances[provider.instanceId]; |
| 113 | 113 | ||
| 114 | if (provider.driver === "claudeAgent") { | 114 | if (provider.driver === "claudeAgent") { |
| 115 | const config = decodeClaudeSettings( | 115 | const config = decodeClaudeSettings(instance?.config ?? {}); |
| 116 | instance ? (instance.config ?? {}) : settings.providers.claudeAgent, | ||
| 117 | ); | ||
| 118 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; | 116 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; |
| 119 | return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; | 117 | return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; |
| 120 | } | 118 | } |
| 121 | 119 | ||
| 122 | if (provider.driver === "codex") { | 120 | if (provider.driver === "codex") { |
| 123 | const config = decodeCodexSettings( | 121 | const config = decodeCodexSettings(instance?.config ?? {}); |
| 124 | instance ? (instance.config ?? {}) : settings.providers.codex, | ||
| 125 | ); | ||
| 126 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; | 122 | const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; |
| 127 | return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; | 123 | return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; |
| 128 | } | 124 | } |
apps/web/src/providerInstances.test.ts+7-19| ... | @@ -143,7 +143,6 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -143,7 +143,6 @@ describe("applyProviderInstanceSettings", () => { |
| 143 | enabled: false, | 143 | enabled: false, |
| 144 | }, | 144 | }, |
| 145 | }, | 145 | }, |
| 146 | providers: {} as never, | ||
| 147 | }); | 146 | }); |
| 148 | 147 | ||
| 149 | expect(entry?.enabled).toBe(false); | 148 | expect(entry?.enabled).toBe(false); |
| ... | @@ -158,7 +157,6 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -158,7 +157,6 @@ describe("applyProviderInstanceSettings", () => { |
| 158 | ]); | 157 | ]); |
| 159 | const [entry] = applyProviderInstanceSettings(entries, { | 158 | const [entry] = applyProviderInstanceSettings(entries, { |
| 160 | providerInstances: {}, | 159 | providerInstances: {}, |
| 161 | providers: {} as never, | ||
| 162 | }); | 160 | }); |
| 163 | 161 | ||
| 164 | expect(entry?.enabled).toBe(false); | 162 | expect(entry?.enabled).toBe(false); |
| ... | @@ -175,7 +173,6 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -175,7 +173,6 @@ describe("applyProviderInstanceSettings", () => { |
| 175 | ]); | 173 | ]); |
| 176 | const [entry] = applyProviderInstanceSettings(entries, { | 174 | const [entry] = applyProviderInstanceSettings(entries, { |
| 177 | providerInstances: {}, | 175 | providerInstances: {}, |
| 178 | providers: {} as never, | ||
| 179 | }); | 176 | }); |
| 180 | 177 | ||
| 181 | expect(entry?.enabled).toBe(false); | 178 | expect(entry?.enabled).toBe(false); |
| ... | @@ -197,7 +194,6 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -197,7 +194,6 @@ describe("applyProviderInstanceSettings", () => { |
| 197 | enabled: false, | 194 | enabled: false, |
| 198 | }, | 195 | }, |
| 199 | }, | 196 | }, |
| 200 | providers: {} as never, | ||
| 201 | }); | 197 | }); |
| 202 | 198 | ||
| 203 | expect(entry?.enabled).toBe(false); | 199 | expect(entry?.enabled).toBe(false); |
| ... | @@ -213,26 +209,22 @@ describe("applyProviderInstanceSettings", () => { | ... | @@ -213,26 +209,22 @@ describe("applyProviderInstanceSettings", () => { |
| 213 | ]); | 209 | ]); |
| 214 | const [entry] = applyProviderInstanceSettings(entries, { | 210 | const [entry] = applyProviderInstanceSettings(entries, { |
| 215 | providerInstances: {}, | 211 | providerInstances: {}, |
| 216 | providers: {} as never, | ||
| 217 | }); | 212 | }); |
| 218 | 213 | ||
| 219 | expect(entry?.isDefault).toBe(true); | 214 | expect(entry?.isDefault).toBe(true); |
| 220 | expect(entry?.enabled).toBe(false); | 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 | const entries = deriveProviderInstanceEntries([ | 219 | const entries = deriveProviderInstanceEntries([ |
| 225 | provider({ | 220 | provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex", enabled: false }), |
| 226 | provider: ProviderDriverKind.make("codex"), | 221 | provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok", enabled: true }), |
| 227 | instanceId: "codex", | ||
| 228 | }), | ||
| 229 | ]); | 222 | ]); |
| 230 | const [entry] = applyProviderInstanceSettings(entries, { | 223 | const [codex, grok] = applyProviderInstanceSettings(entries, { providerInstances: {} }); |
| 231 | providerInstances: {}, | ||
| 232 | providers: { codex: { enabled: false } } as never, | ||
| 233 | }); | ||
| 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,7 +253,6 @@ describe("deriveProviderEntriesByEnvironment", () => { |
| 261 | agentId, | 253 | agentId, |
| 262 | [snapshot], | 254 | [snapshot], |
| 263 | { | 255 | { |
| 264 | providers: {} as never, | ||
| 265 | providerInstances: { | 256 | providerInstances: { |
| 266 | [instanceId]: { | 257 | [instanceId]: { |
| 267 | driver: ProviderDriverKind.make("acpRegistry"), | 258 | driver: ProviderDriverKind.make("acpRegistry"), |
| ... | @@ -629,7 +620,6 @@ describe("provider icon metadata", () => { | ... | @@ -629,7 +620,6 @@ describe("provider icon metadata", () => { |
| 629 | const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; | 620 | const registryConfig = { agentId: "swe-agent", registryIconUrl: iconUrl }; |
| 630 | const brandedEntries = applyProviderInstanceSettings(snapshots, { | 621 | const brandedEntries = applyProviderInstanceSettings(snapshots, { |
| 631 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | 622 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, |
| 632 | providers: {} as never, | ||
| 633 | }); | 623 | }); |
| 634 | expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); | 624 | expect(brandedEntries[0]?.acpRegistryAgentId).toBe("swe-agent"); |
| 635 | expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); | 625 | expect(brandedEntries[0]?.acpRegistryIconUrl).toBe(iconUrl); |
| ... | @@ -642,7 +632,6 @@ describe("provider icon metadata", () => { | ... | @@ -642,7 +632,6 @@ describe("provider icon metadata", () => { |
| 642 | config: { ...registryConfig, source: "local", commandPath: "dsh" }, | 632 | config: { ...registryConfig, source: "local", commandPath: "dsh" }, |
| 643 | }, | 633 | }, |
| 644 | }, | 634 | }, |
| 645 | providers: {} as never, | ||
| 646 | }); | 635 | }); |
| 647 | expect(localEntry?.acpRegistryAgentId).toBeUndefined(); | 636 | expect(localEntry?.acpRegistryAgentId).toBeUndefined(); |
| 648 | expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); | 637 | expect(localEntry?.acpRegistryIconUrl).toBeUndefined(); |
| ... | @@ -651,7 +640,6 @@ describe("provider icon metadata", () => { | ... | @@ -651,7 +640,6 @@ describe("provider icon metadata", () => { |
| 651 | 640 | ||
| 652 | const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { | 641 | const [restoredEntry] = applyProviderInstanceSettings(localEntry ? [localEntry] : [], { |
| 653 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, | 642 | providerInstances: { [instanceId]: { driver, enabled: true, config: registryConfig } }, |
| 654 | providers: {} as never, | ||
| 655 | }); | 643 | }); |
| 656 | expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); | 644 | expect(restoredEntry?.acpRegistryAgentId).toBe("swe-agent"); |
| 657 | expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); | 645 | expect(restoredEntry?.acpRegistryIconUrl).toBe(iconUrl); |
apps/web/src/providerInstances.ts+9-19| ... | @@ -15,6 +15,7 @@ | ... | @@ -15,6 +15,7 @@ |
| 15 | import { | 15 | import { |
| 16 | DEFAULT_MODEL_BY_PROVIDER, | 16 | DEFAULT_MODEL_BY_PROVIDER, |
| 17 | defaultInstanceIdForDriver, | 17 | defaultInstanceIdForDriver, |
| 18 | isUnconfiguredDefaultInstanceEnabled, | ||
| 18 | resolveProviderInstanceEnabled, | 19 | resolveProviderInstanceEnabled, |
| 19 | type ModelSelection, | 20 | type ModelSelection, |
| 20 | type ProviderDriverKind, | 21 | type ProviderDriverKind, |
| ... | @@ -153,11 +154,7 @@ export function deriveProviderInstanceEntries( | ... | @@ -153,11 +154,7 @@ export function deriveProviderInstanceEntries( |
| 153 | */ | 154 | */ |
| 154 | export function deriveProviderEntriesByEnvironment( | 155 | export function deriveProviderEntriesByEnvironment( |
| 155 | providersByEnvironment: Iterable< | 156 | providersByEnvironment: Iterable< |
| 156 | readonly [ | 157 | readonly [string, ReadonlyArray<ServerProvider>, Pick<ServerSettings, "providerInstances">?] |
| 157 | string, | ||
| 158 | ReadonlyArray<ServerProvider>, | ||
| 159 | Pick<ServerSettings, "providerInstances" | "providers">?, | ||
| 160 | ] | ||
| 161 | >, | 158 | >, |
| 162 | ): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> { | 159 | ): ReadonlyMap<string, ReadonlyMap<string, ProviderInstanceEntry>> { |
| 163 | const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>(); | 160 | const byEnvironment = new Map<string, ReadonlyMap<string, ProviderInstanceEntry>>(); |
| ... | @@ -178,30 +175,23 @@ export function deriveProviderEntriesByEnvironment( | ... | @@ -178,30 +175,23 @@ export function deriveProviderEntriesByEnvironment( |
| 178 | * settings write, so picker visibility must follow settings rather than waiting | 175 | * settings write, so picker visibility must follow settings rather than waiting |
| 179 | * for probe reconciliation. | 176 | * for probe reconciliation. |
| 180 | * | 177 | * |
| 181 | * Only built-in default instances have a legacy `providers` entry. Every | 178 | * A built-in default instance without a `providerInstances` entry keeps its |
| 182 | * other instance exists through `providerInstances`; if it is absent there, | 179 | * streamed state, since the server runs it with default config. Any other |
| 183 | * its streamed snapshot is stale (for example immediately after deletion) | 180 | * instance missing from `providerInstances` is stale (for example |
| 184 | * and is treated as disabled. | 181 | * immediately after deletion) and is treated as disabled. |
| 185 | */ | 182 | */ |
| 186 | export function applyProviderInstanceSettings( | 183 | export function applyProviderInstanceSettings( |
| 187 | entries: ReadonlyArray<ProviderInstanceEntry>, | 184 | entries: ReadonlyArray<ProviderInstanceEntry>, |
| 188 | settings: Pick<ServerSettings, "providerInstances" | "providers">, | 185 | settings: Pick<ServerSettings, "providerInstances">, |
| 189 | ): ReadonlyArray<ProviderInstanceEntry> { | 186 | ): ReadonlyArray<ProviderInstanceEntry> { |
| 190 | const legacyProviders = settings.providers as Readonly< | ||
| 191 | Record<string, { readonly enabled?: boolean } | undefined> | ||
| 192 | >; | ||
| 193 | |||
| 194 | return entries.map((entry) => { | 187 | return entries.map((entry) => { |
| 195 | const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) | 188 | const explicitInstance = Object.hasOwn(settings.providerInstances, entry.instanceId) |
| 196 | ? settings.providerInstances[entry.instanceId] | 189 | ? settings.providerInstances[entry.instanceId] |
| 197 | : undefined; | 190 | : undefined; |
| 198 | const legacyProvider = Object.hasOwn(legacyProviders, entry.driverKind) | ||
| 199 | ? legacyProviders[entry.driverKind] | ||
| 200 | : undefined; | ||
| 201 | const enabled = explicitInstance | 191 | const enabled = explicitInstance |
| 202 | ? resolveProviderInstanceEnabled(explicitInstance) | 192 | ? resolveProviderInstanceEnabled(explicitInstance) |
| 203 | : entry.isDefault && legacyProvider | 193 | : entry.isDefault |
| 204 | ? (legacyProvider.enabled ?? entry.enabled) | 194 | ? isUnconfiguredDefaultInstanceEnabled(entry.instanceId) |
| 205 | : false; | 195 | : false; |
| 206 | if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { | 196 | if (entry.driverKind !== "acpRegistry" || explicitInstance === undefined) { |
| 207 | return enabled === entry.enabled ? entry : { ...entry, enabled }; | 197 | return enabled === entry.enabled ? entry : { ...entry, enabled }; |
docs/internals/adding-a-provider.md+3-2| ... | @@ -103,8 +103,9 @@ produce catches them. | ... | @@ -103,8 +103,9 @@ produce catches them. |
| 103 | 103 | ||
| 104 | ## Where a driver plugs in | 104 | ## Where a driver plugs in |
| 105 | 105 | ||
| 106 | - **Contracts:** settings schema and patch, default model, and display name in | 106 | - **Contracts:** settings schema, default model, and display name in |
| 107 | [`packages/contracts`](../../packages/contracts/src). New providers are off by default. | 107 | [`packages/contracts`](../../packages/contracts/src). Instances store that config in |
| 108 | `providerInstances`. New providers are off by default. | ||
| 108 | - **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry | 109 | - **Server:** the driver in [`provider/Drivers`](../../apps/server/src/provider/Drivers) and its entry |
| 109 | in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in | 110 | in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts). Also its position in |
| 110 | the [status order](../../apps/server/src/provider/providerStatusCache.ts), a compatibility policy | 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,17 +208,6 @@ describe("custom model settings", () => { |
| 208 | { slug: "named", name: "Named", capabilities }, | 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 | describe("ClaudeSettings auto-compaction", () => { | 213 | describe("ClaudeSettings auto-compaction", () => { |
| ... | @@ -239,15 +228,6 @@ describe("ClaudeSettings auto-compaction", () => { | ... | @@ -239,15 +228,6 @@ describe("ClaudeSettings auto-compaction", () => { |
| 239 | expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow(); | 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 | describe("ClientSettings notifications", () => { | 233 | describe("ClientSettings notifications", () => { |
| ... | @@ -752,9 +732,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { | ... | @@ -752,9 +732,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 752 | it("decodes a fully empty config (legacy on-disk shape) without complaint", () => { | 732 | it("decodes a fully empty config (legacy on-disk shape) without complaint", () => { |
| 753 | const decoded = decodeServerSettings({}); | 733 | const decoded = decodeServerSettings({}); |
| 754 | expect(decoded.providerInstances).toEqual({}); | 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 | it("decodes a multi-instance map mixing first-party and fork drivers", () => { | 737 | it("decodes a multi-instance map mixing first-party and fork drivers", () => { |
| ... | @@ -803,7 +780,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { | ... | @@ -803,7 +780,6 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { |
| 803 | describe("provider enabled defaults", () => { | 780 | describe("provider enabled defaults", () => { |
| 804 | it("keeps Muse disabled until a configured instance opts in", () => { | 781 | it("keeps Muse disabled until a configured instance opts in", () => { |
| 805 | const muse = ProviderDriverKind.make("muse"); | 782 | const muse = ProviderDriverKind.make("muse"); |
| 806 | expect(decodeServerSettings({}).providers.muse.enabled).toBe(false); | ||
| 807 | expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false); | 783 | expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false); |
| 808 | expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true); | 784 | expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true); |
| 809 | expect( | 785 | expect( |
| ... | @@ -812,26 +788,13 @@ describe("provider enabled defaults", () => { | ... | @@ -812,26 +788,13 @@ describe("provider enabled defaults", () => { |
| 812 | }); | 788 | }); |
| 813 | 789 | ||
| 814 | it("enables only the stable bindings by default", () => { | 790 | it("enables only the stable bindings by default", () => { |
| 815 | const decoded = decodeServerSettings({}); | 791 | const enabledByDefault = (driver: string) => |
| 816 | expect(decoded.providers.codex.enabled).toBe(true); | 792 | resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make(driver), config: {} }); |
| 817 | expect(decoded.providers.claudeAgent.enabled).toBe(true); | 793 | expect(enabledByDefault("codex")).toBe(true); |
| 818 | expect(decoded.providers.cursor.enabled).toBe(false); | 794 | expect(enabledByDefault("claudeAgent")).toBe(true); |
| 819 | expect(decoded.providers.grok.enabled).toBe(false); | 795 | for (const driver of ["cursor", "grok", "muse", "pi", "opencode", "antigravity"]) { |
| 820 | expect(decoded.providers.opencode.enabled).toBe(false); | 796 | expect(enabledByDefault(driver)).toBe(false); |
| 821 | }); | 797 | } |
| 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); | ||
| 835 | }); | 798 | }); |
| 836 | 799 | ||
| 837 | it("resolves instance enabled state with explicit false winning", () => { | 800 | it("resolves instance enabled state with explicit false winning", () => { |
| ... | @@ -903,42 +866,6 @@ describe("ServerSettings worktree defaults", () => { | ... | @@ -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 | describe("ServerSettings.sourceControlWritingStyle", () => { | 869 | describe("ServerSettings.sourceControlWritingStyle", () => { |
| 943 | it("defaults all style settings for legacy configs", () => { | 870 | it("defaults all style settings for legacy configs", () => { |
| 944 | const settings = decodeServerSettings({}); | 871 | const settings = decodeServerSettings({}); |
| ... | @@ -1011,13 +938,6 @@ describe("ServerSettingsPatch string normalization", () => { | ... | @@ -1011,13 +938,6 @@ describe("ServerSettingsPatch string normalization", () => { |
| 1011 | observability: { | 938 | observability: { |
| 1012 | otlpTracesUrl: " http://localhost:4318/v1/traces ", | 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 | providerInstances: { | 941 | providerInstances: { |
| 1022 | codex_personal: { | 942 | codex_personal: { |
| 1023 | driver: " codex ", | 943 | driver: " codex ", |
| ... | @@ -1030,9 +950,6 @@ describe("ServerSettingsPatch string normalization", () => { | ... | @@ -1030,9 +950,6 @@ describe("ServerSettingsPatch string normalization", () => { |
| 1030 | expect(patch.addProjectBaseDirectory).toBe("~/Development"); | 950 | expect(patch.addProjectBaseDirectory).toBe("~/Development"); |
| 1031 | expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); | 951 | expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); |
| 1032 | expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); | 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 | expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( | 953 | expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( |
| 1037 | "codex", | 954 | "codex", |
| 1038 | ); | 955 | ); |
| ... | @@ -1049,19 +966,9 @@ describe("ServerSettingsPatch string normalization", () => { | ... | @@ -1049,19 +966,9 @@ describe("ServerSettingsPatch string normalization", () => { |
| 1049 | const encoded = encodeServerSettings({ | 966 | const encoded = encodeServerSettings({ |
| 1050 | ...defaultSettings, | 967 | ...defaultSettings, |
| 1051 | addProjectBaseDirectory: " ~/Development ", | 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 | expect(encoded.addProjectBaseDirectory).toBe("~/Development"); | 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,7 +40,7 @@ import { |
| 40 | import { | 40 | import { |
| 41 | ProviderInstanceConfig, | 41 | ProviderInstanceConfig, |
| 42 | ProviderInstanceId, | 42 | ProviderInstanceId, |
| 43 | type ProviderDriverKind, | 43 | ProviderDriverKind, |
| 44 | } from "./providerInstance.ts"; | 44 | } from "./providerInstance.ts"; |
| 45 | import { PullRequestMergeMethod } from "./pullRequest.ts"; | 45 | import { PullRequestMergeMethod } from "./pullRequest.ts"; |
| 46 | 46 | ||
| ... | @@ -1457,22 +1457,6 @@ export const ServerSettings = Schema.Struct({ | ... | @@ -1457,22 +1457,6 @@ export const ServerSettings = Schema.Struct({ |
| 1457 | Schema.withDecodingDefault(Effect.succeed(null)), | 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 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values | 1460 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values |
| 1477 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob | 1461 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob |
| 1478 | // is `Schema.Unknown` at this layer so envelopes with unknown drivers | 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,8 +1496,8 @@ export const DEFAULT_SERVER_SETTINGS: ServerSettings = Schema.decodeSync(ServerS |
| 1512 | /** | 1496 | /** |
| 1513 | * Read the legacy `enabled` flag embedded in a provider instance config | 1497 | * Read the legacy `enabled` flag embedded in a provider instance config |
| 1514 | * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single | 1498 | * blob. The envelope-level `ProviderInstanceConfig.enabled` is the single |
| 1515 | * flag going forward; this reader exists for legacy `providers.<kind>` | 1499 | * flag going forward; this reader exists for old settings files that still |
| 1516 | * blobs and old settings files that still carry the flag in-config. | 1500 | * carry the flag in-config. |
| 1517 | */ | 1501 | */ |
| 1518 | export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => { | 1502 | export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | undefined => { |
| 1519 | if (config === null || typeof config !== "object" || Array.isArray(config)) { | 1503 | if (config === null || typeof config !== "object" || Array.isArray(config)) { |
| ... | @@ -1524,18 +1508,34 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un | ... | @@ -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 | 1511 | * Built-in drivers that stay off until the user turns them on. Matches the |
| 1528 | * the config blob carries a flag. Derived from the driver's settings schema | 1512 | * `enabled` decoding default of each 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. | ||
| 1531 | */ | 1513 | */ |
| 1532 | const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { | 1514 | const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet<ProviderDriverKind> = new Set( |
| 1533 | const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< | 1515 | ["cursor", "grok", "muse", "pi", "opencode", "antigravity"].map((driver) => |
| 1534 | string, | 1516 | ProviderDriverKind.make(driver), |
| 1535 | { readonly enabled?: boolean } | undefined | 1517 | ), |
| 1536 | >; | 1518 | ); |
| 1537 | return legacyDefaults[driver]?.enabled ?? true; | 1519 | |
| 1538 | }; | 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 | * Resolve whether a configured provider instance is enabled. An explicit | 1541 | * Resolve whether a configured provider instance is enabled. An explicit |
| ... | @@ -1608,70 +1608,6 @@ const ModelSelectionPatch = Schema.Struct({ | ... | @@ -1608,70 +1608,6 @@ const ModelSelectionPatch = Schema.Struct({ |
| 1608 | options: Schema.optionalKey(ProviderOptionSelections), | 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 | export const ServerSettingsPatch = Schema.Struct({ | 1611 | export const ServerSettingsPatch = Schema.Struct({ |
| 1676 | worktreeCleanup: Schema.optionalKey( | 1612 | worktreeCleanup: Schema.optionalKey( |
| 1677 | Schema.NullOr( | 1613 | Schema.NullOr( |
| ... | @@ -1791,18 +1727,6 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1791,18 +1727,6 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1791 | tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)), | 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 | // Whole-map replacement for the new instance config. Patching individual | 1730 | // Whole-map replacement for the new instance config. Patching individual |
| 1807 | // entries is intentionally out of scope: the map is small, and partial | 1731 | // entries is intentionally out of scope: the map is small, and partial |
| 1808 | // patches risk leaving driver-specific config in a half-merged state. | 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,6 +58,12 @@ export interface ProviderDriverMetadata { |
| 58 | * rejects multi-instance configurations with a clear error. | 58 | * rejects multi-instance configurations with a clear error. |
| 59 | */ | 59 | */ |
| 60 | readonly supportsMultipleInstances?: boolean; | 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 | export type ProviderWorkspaceSnapshot = ServerProvider & | 69 | export type ProviderWorkspaceSnapshot = ServerProvider & |
| ... | @@ -188,11 +194,8 @@ export interface ProviderDriver<Config, R = never> { | ... | @@ -188,11 +194,8 @@ export interface ProviderDriver<Config, R = never> { |
| 188 | */ | 194 | */ |
| 189 | readonly configSchema: Schema.Codec<Config, unknown>; | 195 | readonly configSchema: Schema.Codec<Config, unknown>; |
| 190 | /** | 196 | /** |
| 191 | * Default config payload used when the legacy | 197 | * Config used when an instance has no config blob, such as the default |
| 192 | * `ServerSettings.providers.<kind>` entry is empty or when the driver | 198 | * instance of a driver the user never configured. |
| 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. | ||
| 196 | */ | 199 | */ |
| 197 | readonly defaultConfig: () => Config; | 200 | readonly defaultConfig: () => Config; |
| 198 | /** | 201 | /** |
packages/shared/src/projectSettings.test.ts+13-2| ... | @@ -2,6 +2,7 @@ import { | ... | @@ -2,6 +2,7 @@ import { |
| 2 | DEFAULT_SERVER_SETTINGS, | 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | PROJECT_SCOPED_SERVER_SETTING_KEYS, | 3 | PROJECT_SCOPED_SERVER_SETTING_KEYS, |
| 4 | ProjectId, | 4 | ProjectId, |
| 5 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| ... | @@ -78,7 +79,12 @@ describe("resolveProjectSettings", () => { | ... | @@ -78,7 +79,12 @@ describe("resolveProjectSettings", () => { |
| 78 | it("keeps the environment text generation model when the override's provider is disabled", () => { | 79 | it("keeps the environment text generation model when the override's provider is disabled", () => { |
| 79 | const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); | 80 | const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); |
| 80 | const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | 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 | projectSettingsOverrides: { | 88 | projectSettingsOverrides: { |
| 83 | [projectId]: { textGenerationModelSelection: disabledSelection }, | 89 | [projectId]: { textGenerationModelSelection: disabledSelection }, |
| 84 | }, | 90 | }, |
| ... | @@ -128,7 +134,12 @@ describe("resolveProjectSettings", () => { | ... | @@ -128,7 +134,12 @@ describe("resolveProjectSettings", () => { |
| 128 | it("keeps the environment default model when the override's provider is disabled", () => { | 134 | it("keeps the environment default model when the override's provider is disabled", () => { |
| 129 | const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); | 135 | const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus"); |
| 130 | const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { | 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 | projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } }, | 143 | projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } }, |
| 133 | }); | 144 | }); |
| 134 | const resolved = resolveProjectSettings(settings, projectId); | 145 | const resolved = resolveProjectSettings(settings, projectId); |
packages/shared/src/serverSettings.ts+2-14| ... | @@ -1,13 +1,12 @@ | ... | @@ -1,13 +1,12 @@ |
| 1 | import { | 1 | import { |
| 2 | isProviderDriverKind, | ||
| 3 | isProviderAvailable, | 2 | isProviderAvailable, |
| 3 | isUnconfiguredDefaultInstanceEnabled, | ||
| 4 | resolveProviderInstanceEnabled, | 4 | resolveProviderInstanceEnabled, |
| 5 | isProviderTextGenerationCapable, | 5 | isProviderTextGenerationCapable, |
| 6 | type ModelSelection, | 6 | type ModelSelection, |
| 7 | type ProjectId, | 7 | type ProjectId, |
| 8 | type ProjectScopedServerSettingKey, | 8 | type ProjectScopedServerSettingKey, |
| 9 | type ProjectSettingsOverrides, | 9 | type ProjectSettingsOverrides, |
| 10 | type ProviderDriverKind, | ||
| 11 | type ServerProvider, | 10 | type ServerProvider, |
| 12 | ServerSettings, | 11 | ServerSettings, |
| 13 | type ServerSettingsPatch, | 12 | type ServerSettingsPatch, |
| ... | @@ -59,14 +58,6 @@ export function resolveProjectAutoPull( | ... | @@ -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 | export function isModelSelectionProviderEnabled( | 61 | export function isModelSelectionProviderEnabled( |
| 71 | settings: ServerSettings, | 62 | settings: ServerSettings, |
| 72 | selection: ModelSelection, | 63 | selection: ModelSelection, |
| ... | @@ -76,10 +67,7 @@ export function isModelSelectionProviderEnabled( | ... | @@ -76,10 +67,7 @@ export function isModelSelectionProviderEnabled( |
| 76 | return resolveProviderInstanceEnabled(instanceConfig); | 67 | return resolveProviderInstanceEnabled(instanceConfig); |
| 77 | } | 68 | } |
| 78 | 69 | ||
| 79 | return ( | 70 | return isUnconfiguredDefaultInstanceEnabled(selection.instanceId); |
| 80 | isProviderDriverKind(selection.instanceId) && | ||
| 81 | getLegacyProviderSettings(settings, selection.instanceId)?.enabled === true | ||
| 82 | ); | ||
| 83 | } | 71 | } |
| 84 | 72 | ||
| 85 | export function resolveSourceControlWriterModelSelection( | 73 | export function resolveSourceControlWriterModelSelection( |