authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-04-17 01:31:35+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-16 20:01:35+00:00
log7a08fcf2e832da5969aeb373c46d5249b4e820b7
treec182dd50d087b546014d11037d32a3c7a16967a5
parent19d47408a53181446a02fac967846cc15d77d5b8
signature Signed by PGP key B5690EEEBB952194

fix(server): drop stale text generation options when resetting text-gen model selection (#2076)


5 files changed, 141 insertions(+), 5 deletions(-)

apps/server/src/serverSettings.test.ts+29
......@@ -142,6 +142,35 @@ it.layer(NodeServices.layer)("server settings", (it) => {
142142 }).pipe(Effect.provide(makeServerSettingsLayer())),
143143 );
144144
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
145174 it.effect("trims provider path settings when updates are applied", () =>
146175 Effect.gen(function* () {
147176 const serverSettings = yield* ServerSettingsService;
apps/server/src/serverSettings.ts+18-2
......@@ -42,6 +42,7 @@ import * as Semaphore from "effect/Semaphore";
4242import { ServerConfig } from "./config";
4343import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
4444import { fromLenientJson } from "@t3tools/shared/schemaJson";
45import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
4546
4647export interface ServerSettingsShape {
4748 /** Start the settings runtime and attach file watching. */
......@@ -80,7 +81,20 @@ export class ServerSettingsService extends Context.Service<
8081 getSettings: Ref.get(currentSettingsRef),
8182 updateSettings: (patch) =>
8283 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 ),
8498 Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
8599 ),
86100 streamChanges: Stream.empty,
......@@ -314,7 +328,9 @@ const makeServerSettings = Effect.gen(function* () {
314328 writeSemaphore.withPermits(1)(
315329 Effect.gen(function* () {
316330 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(
318334 Effect.mapError(
319335 (cause) =>
320336 new ServerSettingsError({
apps/web/src/hooks/useSettings.ts+2-2
......@@ -19,7 +19,7 @@ import {
1919} from "@t3tools/contracts/settings";
2020import { ensureLocalApi } from "~/localApi";
2121import { Struct } from "effect";
22import { deepMerge } from "@t3tools/shared/Struct";
22import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
2323import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState";
2424
2525const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]";
......@@ -154,7 +154,7 @@ export function useUpdateSettings() {
154154 if (Object.keys(serverPatch).length > 0) {
155155 const currentServerConfig = getServerConfig();
156156 if (currentServerConfig) {
157 applySettingsUpdated(deepMerge(currentServerConfig.settings, serverPatch));
157 applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch));
158158 }
159159 // Fire-and-forget RPC — push will reconcile on success
160160 void ensureLocalApi().server.updateSettings(serverPatch);
packages/shared/src/serverSettings.test.ts+59
......@@ -1,5 +1,7 @@
1import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
12import { describe, expect, it } from "vitest";
23import {
4 applyServerSettingsPatch,
35 extractPersistedServerObservabilitySettings,
46 normalizePersistedServerSettingString,
57 parsePersistedServerObservabilitySettings,
......@@ -50,4 +52,61 @@ describe("serverSettings helpers", () => {
5052 otlpMetricsUrl: undefined,
5153 });
5254 });
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 });
53112});
packages/shared/src/serverSettings.ts+33-1
......@@ -1,5 +1,6 @@
1import { ServerSettings } from "@t3tools/contracts";
1import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts";
22import { Schema } from "effect";
3import { deepMerge } from "./Struct";
34import { fromLenientJson } from "./schemaJson";
45
56const ServerSettingsJson = fromLenientJson(ServerSettings);
......@@ -38,3 +39,34 @@ export function parsePersistedServerObservabilitySettings(
3839 return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined };
3940 }
4041}
42
43function 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 */
54export 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}