Unified · Split
| @@ -142,6 +142,35 @@ it.layer(NodeServices.layer)("server settings", (it) => { · 141 unmodified lines | ||
| 142 | 142 | }).pipe(Effect.provide(makeServerSettingsLayer())), |
| 143 | 143 | ); |
| 144 | 144 | |
| 145 | + it.effect("drops stale text generation options when resetting model selection", () => | |
| 146 | + Effect.gen(function* () { | |
| 147 | + const serverSettings = yield* ServerSettingsService; | |
| 148 | + | |
| 149 | + yield* serverSettings.updateSettings({ | |
| 150 | + textGenerationModelSelection: { | |
| 151 | + provider: "codex", | |
| 152 | + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 153 | + options: { | |
| 154 | + reasoningEffort: "high", | |
| 155 | + fastMode: true, | |
| 156 | + }, | |
| 157 | + }, | |
| 158 | + }); | |
| 159 | + | |
| 160 | + const next = yield* serverSettings.updateSettings({ | |
| 161 | + textGenerationModelSelection: { | |
| 162 | + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, | |
| 163 | + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 164 | + }, | |
| 165 | + }); | |
| 166 | + | |
| 167 | + assert.deepEqual(next.textGenerationModelSelection, { | |
| 168 | + provider: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.provider, | |
| 169 | + model: DEFAULT_SERVER_SETTINGS.textGenerationModelSelection.model, | |
| 170 | + }); | |
| 171 | + }).pipe(Effect.provide(makeServerSettingsLayer())), | |
| 172 | + ); | |
| 173 | + | |
| 145 | 174 | it.effect("trims provider path settings when updates are applied", () => |
| 146 | 175 | Effect.gen(function* () { |
| 147 | 176 | const serverSettings = yield* ServerSettingsService; |
| @@ -42,6 +42,7 @@ import * as Semaphore from "effect/Semaphore"; · 41 unmodified lines | ||
| 42 | 42 | import { ServerConfig } from "./config"; |
| 43 | 43 | import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; |
| 44 | 44 | import { fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 45 | +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 45 | 46 | |
| 46 | 47 | export interface ServerSettingsShape { |
| 47 | 48 | /** Start the settings runtime and attach file watching. */ |
| @@ -80,7 +81,20 @@ export class ServerSettingsService extends Context.Service< · 32 unmodified lines | ||
| 80 | 81 | getSettings: Ref.get(currentSettingsRef), |
| 81 | 82 | updateSettings: (patch) => |
| 82 | 83 | Ref.get(currentSettingsRef).pipe( |
| 83 | − Effect.map((currentSettings) => deepMerge(currentSettings, patch)), | |
| 84 | + Effect.flatMap((currentSettings) => | |
| 85 | + Schema.decodeEffect(ServerSettings)( | |
| 86 | + applyServerSettingsPatch(currentSettings, patch), | |
| 87 | + ).pipe( | |
| 88 | + Effect.mapError( | |
| 89 | + (cause) => | |
| 90 | + new ServerSettingsError({ | |
| 91 | + settingsPath: "<memory>", | |
| 92 | + detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`, | |
| 93 | + cause, | |
| 94 | + }), | |
| 95 | + ), | |
| 96 | + ), | |
| 97 | + ), | |
| 84 | 98 | Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), |
| 85 | 99 | ), |
| 86 | 100 | streamChanges: Stream.empty, |
| @@ -314,7 +328,9 @@ const makeServerSettings = Effect.gen(function* () { · 227 unmodified lines | ||
| 314 | 328 | writeSemaphore.withPermits(1)( |
| 315 | 329 | Effect.gen(function* () { |
| 316 | 330 | const current = yield* getSettingsFromCache; |
| 317 | − const next = yield* Schema.decodeEffect(ServerSettings)(deepMerge(current, patch)).pipe( | |
| 331 | + const next = yield* Schema.decodeEffect(ServerSettings)( | |
| 332 | + applyServerSettingsPatch(current, patch), | |
| 333 | + ).pipe( | |
| 318 | 334 | Effect.mapError( |
| 319 | 335 | (cause) => |
| 320 | 336 | new ServerSettingsError({ |
| @@ -19,7 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | } from "@t3tools/contracts/settings"; |
| 20 | 20 | import { ensureLocalApi } from "~/localApi"; |
| 21 | 21 | import { Struct } from "effect"; |
| 22 | −import { deepMerge } from "@t3tools/shared/Struct"; | |
| 22 | +import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | |
| 23 | 23 | import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; |
| 24 | 24 | |
| 25 | 25 | const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; |
| @@ -154,7 +154,7 @@ export function useUpdateSettings() { · 128 unmodified lines | ||
| 154 | 154 | if (Object.keys(serverPatch).length > 0) { |
| 155 | 155 | const currentServerConfig = getServerConfig(); |
| 156 | 156 | if (currentServerConfig) { |
| 157 | − applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch)); | |
| 157 | + applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); | |
| 158 | 158 | } |
| 159 | 159 | // Fire-and-forget RPC — push will reconcile on success |
| 160 | 160 | void ensureLocalApi().server.updateSettings(serverPatch); |
| @@ -1,5 +1,7 @@ | ||
| 1 | +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vitest"; |
| 2 | 3 | import { |
| 4 | + applyServerSettingsPatch, | |
| 3 | 5 | extractPersistedServerObservabilitySettings, |
| 4 | 6 | normalizePersistedServerSettingString, |
| 5 | 7 | parsePersistedServerObservabilitySettings, |
| @@ -50,4 +52,61 @@ describe("serverSettings helpers", () => { · 44 unmodified lines | ||
| 50 | 52 | otlpMetricsUrl: undefined, |
| 51 | 53 | }); |
| 52 | 54 | }); |
| 55 | + | |
| 56 | + it("replaces text generation selection when provider/model are provided", () => { | |
| 57 | + const current = { | |
| 58 | + ...DEFAULT_SERVER_SETTINGS, | |
| 59 | + textGenerationModelSelection: { | |
| 60 | + provider: "codex" as const, | |
| 61 | + model: "gpt-5.4-mini", | |
| 62 | + options: { | |
| 63 | + reasoningEffort: "high" as const, | |
| 64 | + fastMode: true, | |
| 65 | + }, | |
| 66 | + }, | |
| 67 | + }; | |
| 68 | + | |
| 69 | + expect( | |
| 70 | + applyServerSettingsPatch(current, { | |
| 71 | + textGenerationModelSelection: { | |
| 72 | + provider: "codex", | |
| 73 | + model: "gpt-5.4-mini", | |
| 74 | + }, | |
| 75 | + }).textGenerationModelSelection, | |
| 76 | + ).toEqual({ | |
| 77 | + provider: "codex", | |
| 78 | + model: "gpt-5.4-mini", | |
| 79 | + }); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it("still deep merges text generation selection when only options are provided", () => { | |
| 83 | + const current = { | |
| 84 | + ...DEFAULT_SERVER_SETTINGS, | |
| 85 | + textGenerationModelSelection: { | |
| 86 | + provider: "codex" as const, | |
| 87 | + model: "gpt-5.4-mini", | |
| 88 | + options: { | |
| 89 | + reasoningEffort: "high" as const, | |
| 90 | + fastMode: true, | |
| 91 | + }, | |
| 92 | + }, | |
| 93 | + }; | |
| 94 | + | |
| 95 | + expect( | |
| 96 | + applyServerSettingsPatch(current, { | |
| 97 | + textGenerationModelSelection: { | |
| 98 | + options: { | |
| 99 | + fastMode: false, | |
| 100 | + }, | |
| 101 | + }, | |
| 102 | + }).textGenerationModelSelection, | |
| 103 | + ).toEqual({ | |
| 104 | + provider: "codex", | |
| 105 | + model: "gpt-5.4-mini", | |
| 106 | + options: { | |
| 107 | + reasoningEffort: "high", | |
| 108 | + fastMode: false, | |
| 109 | + }, | |
| 110 | + }); | |
| 111 | + }); | |
| 53 | 112 | }); |
| @@ -1,5 +1,6 @@ | ||
| 1 | −import { ServerSettings } from "@t3tools/contracts"; | |
| 1 | +import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; | |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | +import { deepMerge } from "./Struct"; | |
| 3 | 4 | import { fromLenientJson } from "./schemaJson"; |
| 4 | 5 | |
| 5 | 6 | const ServerSettingsJson = fromLenientJson(ServerSettings); |
| @@ -38,3 +39,34 @@ export function parsePersistedServerObservabilitySettings( · 32 unmodified lines | ||
| 38 | 39 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; |
| 39 | 40 | } |
| 40 | 41 | } |
| 42 | + | |
| 43 | +function shouldReplaceTextGenerationModelSelection( | |
| 44 | + patch: ServerSettingsPatch["textGenerationModelSelection"] | undefined, | |
| 45 | +): boolean { | |
| 46 | + return Boolean(patch && (patch.provider !== undefined || patch.model !== undefined)); | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * Applies a server settings patch while treating textGenerationModelSelection as | |
| 51 | + * replace-on-provider/model updates. This prevents stale nested options from | |
| 52 | + * surviving a reset patch that intentionally omits options. | |
| 53 | + */ | |
| 54 | +export function applyServerSettingsPatch( | |
| 55 | + current: ServerSettings, | |
| 56 | + patch: ServerSettingsPatch, | |
| 57 | +): ServerSettings { | |
| 58 | + const selectionPatch = patch.textGenerationModelSelection; | |
| 59 | + const next = deepMerge(current, patch); | |
| 60 | + if (!selectionPatch || !shouldReplaceTextGenerationModelSelection(selectionPatch)) { | |
| 61 | + return next; | |
| 62 | + } | |
| 63 | + | |
| 64 | + return { | |
| 65 | + ...next, | |
| 66 | + textGenerationModelSelection: { | |
| 67 | + provider: selectionPatch.provider ?? current.textGenerationModelSelection.provider, | |
| 68 | + model: selectionPatch.model ?? current.textGenerationModelSelection.model, | |
| 69 | + ...(selectionPatch.options ? { options: selectionPatch.options } : {}), | |
| 70 | + }, | |
| 71 | + }; | |
| 72 | +} | |