Skip to content
Source
Sign inSign up

serverSettings.test.ts

1,097 lines40.9 KB

1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProjectId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 SecretProviderId,
7 SecretProviderKind,
8 ServerSettings,
9 ServerSettingsPatch,
10 UsageLimitSourceId,
11 type ServerProvider,
12} from "@t3tools/contracts";
13import * as Duration from "effect/Duration";
14import * as Schema from "effect/Schema";
15import { describe, expect, it } from "vite-plus/test";
16import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts";
17import { createModelSelection } from "./model.ts";
18import { resolveProjectScripts } from "./projectScripts.ts";
19import {
20 applyServerSettingsPatch,
21 fallbackTextGenerationModelSelection,
22 modelPreferencesMigrationPatch,
23 resolveModelPreferences,
24 isModelSelectionProviderEnabled,
25 parsePersistedServerObservabilitySettings,
26 resolveReviewGuideModelSelection,
27 resolveSourceControlWriterModelSelection,
28 resolveSpeechPostProcessingModelSelection,
29} from "./serverSettings.ts";
30​
31const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
32const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
33​
34const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };
35​
36describe("fallbackTextGenerationModelSelection", () => {
37 it("uses Big Pickle when only OpenCode is enabled", () => {
38 expect(
39 fallbackTextGenerationModelSelection({}, (instanceId) => instanceId === "opencode"),
40 ).toEqual({ instanceId: "opencode", model: "opencode/big-pickle" });
41 const threadDefault = {
42 instanceId: ProviderInstanceId.make("opencode"),
43 model: "opencode-go/glm-5.3",
44 };
45 const providerInstances = {
46 [threadDefault.instanceId]: {
47 driver: ProviderDriverKind.make("opencode"),
48 config: { customModels: [threadDefault.model] },
49 },
50 };
51 // An exhausted Go quota leaves the custom model unusable but Big Pickle still works.
52 expect(
53 fallbackTextGenerationModelSelection(
54 providerInstances,
55 (instanceId, _driver, model) =>
56 instanceId === "opencode" && !model.startsWith("opencode-go/"),
57 { defaultModelSelection: threadDefault },
58 ),
59 ).toEqual({ instanceId: "opencode", model: "opencode/big-pickle" });
60 });
61​
62 it("replaces and clears the fallback without carrying model options", () => {
63 const fallbackModelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "new" };
64 const settings = applyServerSettingsPatch(
65 {
66 ...DEFAULT_SERVER_SETTINGS,
67 fallbackModelSelection: {
68 ...fallbackModelSelection,
69 model: "old",
70 options: [{ id: "effort", value: "high" }],
71 },
72 },
73 { fallbackModelSelection },
74 );
75 expect(settings.fallbackModelSelection).toEqual(fallbackModelSelection);
76 expect(
77 applyServerSettingsPatch(settings, { fallbackModelSelection: null }).fallbackModelSelection,
78 ).toBeNull();
79 });
80 it("falls back to a configured non-default instance", () => {
81 const configured = ProviderInstanceId.make("codex_work");
82 const providerInstances = {
83 [configured]: { driver: ProviderDriverKind.make("codex"), config: {} },
84 };
85 const fallback = fallbackTextGenerationModelSelection(
86 providerInstances,
87 (instanceId) => instanceId === configured,
88 );
89 expect(fallback?.instanceId).toBe(configured);
90 });
91});
92​
93describe("serverSettings helpers", () => {
94 it("translates a shipped client's idle compaction switch into a mode", () => {
95 const off = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { idleCompaction: "off" });
96 expect([off.idleCompaction, off.compactIdleThreads]).toEqual(["off", false]);
97 // Turning the old switch off keeps "Never" instead of reverting to the default.
98 const stillOff = applyServerSettingsPatch(off, { compactIdleThreads: false });
99 expect(stillOff.idleCompaction).toBe("off");
100 const on = applyServerSettingsPatch(off, { compactIdleThreads: true });
101 expect([on.idleCompaction, on.compactIdleThreads]).toEqual(["beforeCacheExpires", true]);
102 });
103​
104 it("changes a cleanup rule without replacing the machine's other rules", () => {
105 const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
106 storageCleanup: { worktreeAfterDays: 8, worktreeOnMerge: true, logsAfterDays: 30 },
107 });
108 expect(
109 applyServerSettingsPatch(enabled, {
110 storageCleanup: { worktreeAfterDays: null },
111 }).storageCleanup,
112 ).toEqual({
113 worktreeAfterDays: null,
114 worktreeSettledAfterDays: null,
115 worktreeOnMerge: true,
116 worktreeOnDelete: false,
117 worktreeUnchanged: false,
118 worktreeKeepWhen: "uncommitted-changes",
119 browserArtifactsAfterDays: null,
120 logsAfterDays: 30,
121 });
122 });
123 it("replaces a source control host's patched field so a cleared account pin does not survive", () => {
124 const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
125 sourceControlHosts: {
126 github: { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} },
127 },
128 });
129 const unpinned = applyServerSettingsPatch(pinned, {
130 sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } },
131 });
132 expect(unpinned.sourceControlHosts.github).toEqual({
133 hosts: { "github.com": { enabled: false } },
134 tokens: {},
135 });
136 });
137​
138 it("keeps a source control host's saved tokens when a patch sets another host's token", () => {
139 const reference = { provider: "vault", reference: "github/token" };
140 const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
141 sourceControlHosts: { github: { tokens: { "github.com": reference } } },
142 });
143 const patched = applyServerSettingsPatch(saved, {
144 sourceControlHosts: { github: { tokens: { "ghe.example": "x" } } },
145 });
146 expect(patched.sourceControlHosts.github?.tokens).toEqual({
147 "github.com": reference,
148 "ghe.example": "x",
149 });
150 const recased = applyServerSettingsPatch(saved, {
151 sourceControlHosts: { github: { tokens: { " GitHub.com ": "ghp_new" } } },
152 });
153 expect(recased.sourceControlHosts.github?.tokens).toEqual({ "github.com": "ghp_new" });
154 });
155​
156 it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => {
157 const project = { id: ProjectId.make("project-actions"), scripts: [] };
158 const action = {
159 id: "check",
160 name: "Check",
161 command: "npm test",
162 icon: "play" as const,
163 runOnWorktreeCreate: false,
164 };
165 const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] };
166 const unfolded = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
167 defaultProjectScripts: [action],
168 });
169 expect(resolveProjectScripts(unfolded, existing)).toEqual(existing.scripts);
170 const defaults = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, {
171 defaultProjectScripts: [action],
172 });
173 expect(resolveProjectScripts(defaults, project)).toEqual([action]);
174 expect(resolveProjectScripts(defaults, existing)).toEqual([action]);
175 const disabled = applyServerSettingsPatch(defaults, {
176 projectScriptOverrides: { [project.id]: [] },
177 });
178 expect(resolveProjectScripts(disabled, project)).toEqual([]);
179 const changedDefault = applyServerSettingsPatch(disabled, {
180 defaultProjectScripts: [{ ...action, command: "npm run build" }],
181 });
182 expect(resolveProjectScripts(changedDefault, project)).toEqual([]);
183 const reset = applyServerSettingsPatch(changedDefault, {
184 projectScriptOverrides: { [project.id]: null },
185 });
186 expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts);
187 expect(
188 resolveProjectScripts(
189 applyServerSettingsPatch(reset, { defaultProjectScripts: [] }),
190 existing,
191 ),
192 ).toEqual([]);
193 });
194​
195 it("preserves other projects' actions when overriding, clearing, or resetting one project", () => {
196 const firstProject = { id: ProjectId.make("first-project"), scripts: [] };
197 const secondProject = { id: ProjectId.make("second-project"), scripts: [] };
198 const defaultAction = {
199 id: "check",
200 name: "Check",
201 command: "npm test",
202 icon: "play" as const,
203 runOnWorktreeCreate: false,
204 };
205 const firstAction = { ...defaultAction, command: "npm run lint" };
206 const secondAction = { ...defaultAction, command: "npm run build" };
207 const firstUpdate = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, {
208 defaultProjectScripts: [defaultAction],
209 projectScriptOverrides: { [firstProject.id]: [firstAction] },
210 });
211 const secondUpdate = applyServerSettingsPatch(firstUpdate, {
212 projectScriptOverrides: { [secondProject.id]: [secondAction] },
213 });
214 expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
215 expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]);
216​
217 const cleared = applyServerSettingsPatch(secondUpdate, {
218 projectScriptOverrides: { [firstProject.id]: [] },
219 });
220 expect(resolveProjectScripts(cleared, firstProject)).toEqual([]);
221 expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]);
222​
223 const reset = applyServerSettingsPatch(cleared, {
224 projectScriptOverrides: { [firstProject.id]: null },
225 });
226 expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([
227 defaultAction,
228 ]);
229 expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]);
230 expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]);
231 });
232​
233 it("preserves other projects' boolean overrides across separate updates and resets", () => {
234 const firstProjectId = ProjectId.make("first-project");
235 const secondProjectId = ProjectId.make("second-project");
236 const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
237 defaultAutoPull: true,
238 projectAutoPullOverrides: { [firstProjectId]: false },
239 projectAgentBrowserAccessOverrides: { [firstProjectId]: false },
240 });
241 const secondUpdate = applyServerSettingsPatch(firstUpdate, {
242 projectAutoPullOverrides: { [secondProjectId]: false },
243 projectAgentBrowserAccessOverrides: { [secondProjectId]: false },
244 });
245 expect(secondUpdate.projectAutoPullOverrides).toEqual({
246 [firstProjectId]: false,
247 [secondProjectId]: false,
248 });
249 expect(secondUpdate.projectAgentBrowserAccessOverrides).toEqual({
250 [firstProjectId]: false,
251 [secondProjectId]: false,
252 });
253​
254 const reset = applyServerSettingsPatch(secondUpdate, {
255 projectAutoPullOverrides: { [firstProjectId]: null },
256 projectAgentBrowserAccessOverrides: { [firstProjectId]: null },
257 });
258 expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined();
259 expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined();
260 expect(reset.projectAutoPullOverrides[secondProjectId]).toBe(false);
261 expect(reset.projectAgentBrowserAccessOverrides[secondProjectId]).toBe(false);
262 expect(secondUpdate.projectAutoPullOverrides[firstProjectId]).toBe(false);
263 expect(secondUpdate.projectAgentBrowserAccessOverrides[firstProjectId]).toBe(false);
264 });
265​
266 it("parses lenient persisted settings JSON and trims observability URLs", () => {
267 expect(
268 parsePersistedServerObservabilitySettings(
269 JSON.stringify({
270 observability: {
271 otlpTracesUrl: " http://localhost:4318/v1/traces ",
272 otlpMetricsUrl: " http://localhost:4318/v1/metrics ",
273 otlpLogsUrl: " http://localhost:4318/v1/logs ",
274 },
275 }),
276 ),
277 ).toEqual({
278 otlpTracesUrl: "http://localhost:4318/v1/traces",
279 otlpMetricsUrl: "http://localhost:4318/v1/metrics",
280 otlpLogsUrl: "http://localhost:4318/v1/logs",
281 });
282 // The server still loads a file holding the retired token streaming mode.
283 expect(
284 parsePersistedServerObservabilitySettings(
285 JSON.stringify({
286 responseStreamingMode: "token",
287 observability: { otlpTracesUrl: "http://localhost:4318/v1/traces" },
288 }),
289 ).otlpTracesUrl,
290 ).toBe("http://localhost:4318/v1/traces");
291 });
292​
293 it("falls back cleanly when persisted settings are invalid", () => {
294 expect(parsePersistedServerObservabilitySettings("{")).toEqual({
295 otlpTracesUrl: undefined,
296 otlpMetricsUrl: undefined,
297 otlpLogsUrl: undefined,
298 });
299 });
300​
301 it("still deep merges text generation selection when only options are provided", () => {
302 const current = {
303 ...DEFAULT_SERVER_SETTINGS,
304 textGenerationModelSelection: createModelSelection(
305 ProviderInstanceId.make("codex"),
306 "gpt-5.4-mini",
307 [
308 { id: "reasoningEffort", value: "high" },
309 { id: "fastMode", value: true },
310 ],
311 ),
312 };
313​
314 expect(
315 applyServerSettingsPatch(current, {
316 textGenerationModelSelection: {
317 options: [{ id: "fastMode", value: false }],
318 },
319 }).textGenerationModelSelection,
320 ).toEqual({
321 instanceId: "codex",
322 model: "gpt-5.4-mini",
323 options: [
324 { id: "reasoningEffort", value: "high" },
325 { id: "fastMode", value: false },
326 ],
327 });
328 });
329​
330 it("replaces text generation selection across providers without leaking stale options", () => {
331 const current = {
332 ...DEFAULT_SERVER_SETTINGS,
333 textGenerationModelSelection: createModelSelection(
334 ProviderInstanceId.make("codex"),
335 "gpt-5.4-mini",
336 [
337 { id: "reasoningEffort", value: "high" },
338 { id: "fastMode", value: true },
339 ],
340 ),
341 };
342​
343 expect(
344 applyServerSettingsPatch(current, {
345 textGenerationModelSelection: {
346 instanceId: ProviderInstanceId.make("opencode"),
347 model: "openai/gpt-5",
348 },
349 }).textGenerationModelSelection,
350 ).toEqual({
351 instanceId: "opencode",
352 model: "openai/gpt-5",
353 });
354 });
355​
356 it("falls back from a disabled source control writer provider without clearing its selection", () => {
357 const instanceId = ProviderInstanceId.make("codex_writer");
358 const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini");
359 const settings = {
360 ...DEFAULT_SERVER_SETTINGS,
361 providerInstances: {
362 [instanceId]: {
363 driver: ProviderDriverKind.make("codex"),
364 enabled: false,
365 config: {},
366 },
367 },
368 sourceControlWriterModelSelection,
369 };
370​
371 expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe(
372 false,
373 );
374 expect(resolveSourceControlWriterModelSelection(settings, undefined, null)).toBe(
375 settings.textGenerationModelSelection,
376 );
377 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
378 });
379​
380 it("falls back from a removed instance whose id matches an inherited object key", () => {
381 for (const id of ["constructor", "toString", "valueOf"]) {
382 const speechPostProcessingModelSelection = createModelSelection(
383 ProviderInstanceId.make(id),
384 "gpt-5.4-mini",
385 );
386 const settings = { ...DEFAULT_SERVER_SETTINGS, speechPostProcessingModelSelection };
387​
388 expect(isModelSelectionProviderEnabled(settings, speechPostProcessingModelSelection)).toBe(
389 false,
390 );
391 expect(resolveSpeechPostProcessingModelSelection(settings)).toBe(
392 settings.textGenerationModelSelection,
393 );
394 }
395 });
396​
397 it("falls back from a writer provider that cannot generate application text", () => {
398 const instanceId = ProviderInstanceId.make("acp_writer");
399 const sourceControlWriterModelSelection = createModelSelection(instanceId, "default");
400 const settings = {
401 ...DEFAULT_SERVER_SETTINGS,
402 providerInstances: {
403 [instanceId]: {
404 driver: ProviderDriverKind.make("acpRegistry"),
405 enabled: true,
406 config: {},
407 },
408 },
409 sourceControlWriterModelSelection,
410 };
411 const incapableProvider = {
412 instanceId,
413 driver: ProviderDriverKind.make("acpRegistry"),
414 supportsTextGeneration: false,
415 enabled: true,
416 installed: true,
417 version: null,
418 status: "ready",
419 auth: { status: "authenticated" },
420 checkedAt: "2026-07-27T00:00:00.000Z",
421 models: [],
422 slashCommands: [],
423 skills: [],
424 } satisfies ServerProvider;
425​
426 expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider], null)).toBe(
427 settings.textGenerationModelSelection,
428 );
429 expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
430 });
431​
432 it("routes every text task but review guides to a text generator, until it is turned off", () => {
433 const generatorId = ProviderInstanceId.make("openrouter");
434 const selection = createModelSelection(generatorId, "openai/gpt-5-mini");
435 const settings = (enabled: boolean) => ({
436 ...DEFAULT_SERVER_SETTINGS,
437 textGenerators: {
438 [generatorId]: {
439 kind: "openaiCompatible" as const,
440 baseUrl: "https://openrouter.ai/api/v1",
441 apiKey: "",
442 enabled,
443 },
444 },
445 sourceControlWriterModelSelection: selection,
446 reviewGuideModelSelection: selection,
447 speechPostProcessingModelSelection: selection,
448 });
449 // A text generator has no provider snapshot; an empty list must not read as unusable.
450 const on = settings(true);
451 expect(resolveSourceControlWriterModelSelection(on, [], null)).toBe(selection);
452 expect(resolveSpeechPostProcessingModelSelection(on, [])).toBe(selection);
453 expect(resolveReviewGuideModelSelection(on, [], null)).toBe(selection);
454 expect(
455 resolveReviewGuideModelSelection(
456 { ...on, sourceControlWriterModelSelection: null },
457 [],
458 null,
459 ),
460 ).toBe(on.textGenerationModelSelection);
461​
462 const off = settings(false);
463 expect(resolveSourceControlWriterModelSelection(off, [], null)).toBe(
464 off.textGenerationModelSelection,
465 );
466 expect(resolveSpeechPostProcessingModelSelection(off, [])).toBe(
467 off.textGenerationModelSelection,
468 );
469 });
470​
471 it("falls back from a writer provider a project turned off, keeping it for other projects", () => {
472 const instanceId = ProviderInstanceId.make("codex_writer");
473 const projectId = ProjectId.make("project-a");
474 const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini");
475 const settings = {
476 ...DEFAULT_SERVER_SETTINGS,
477 providerInstances: {
478 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} },
479 },
480 projectSettingsOverrides: {
481 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
482 },
483 sourceControlWriterModelSelection,
484 };
485 const provider = {
486 instanceId,
487 driver: ProviderDriverKind.make("codex"),
488 enabled: true,
489 installed: true,
490 version: null,
491 status: "ready",
492 auth: { status: "authenticated" },
493 checkedAt: "2026-07-27T00:00:00.000Z",
494 availability: "available",
495 models: [],
496 slashCommands: [],
497 skills: [],
498 } satisfies ServerProvider;
499​
500 expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe(
501 settings.textGenerationModelSelection,
502 );
503 expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe(
504 sourceControlWriterModelSelection,
505 );
506 });
507​
508 it("keeps a writer on an instance the project turned on while the machine has it off", () => {
509 const instanceId = ProviderInstanceId.make("codex_writer");
510 const projectId = ProjectId.make("project-a");
511 const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini");
512 const settings = {
513 ...DEFAULT_SERVER_SETTINGS,
514 providerInstances: {
515 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} },
516 },
517 projectSettingsOverrides: {
518 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
519 },
520 sourceControlWriterModelSelection,
521 };
522 const provider = {
523 instanceId,
524 driver: ProviderDriverKind.make("codex"),
525 enabled: true,
526 installed: true,
527 version: null,
528 status: "ready",
529 auth: { status: "authenticated" },
530 checkedAt: "2026-07-27T00:00:00.000Z",
531 availability: "available",
532 models: [],
533 slashCommands: [],
534 skills: [],
535 } satisfies ServerProvider;
536​
537 expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe(
538 sourceControlWriterModelSelection,
539 );
540 expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe(
541 settings.textGenerationModelSelection,
542 );
543 });
544​
545 it("replaces providerInstances maps so omitted instance fields are cleared", () => {
546 const codexId = ProviderInstanceId.make("codex");
547 const current = {
548 ...DEFAULT_SERVER_SETTINGS,
549 providerInstances: {
550 [codexId]: {
551 driver: ProviderDriverKind.make("codex"),
552 displayName: "Codex Work",
553 accentColor: "#7c3aed",
554 enabled: true,
555 config: { homePath: "~/.codex" },
556 },
557 },
558 };
559​
560 expect(
561 applyServerSettingsPatch(current, {
562 providerInstances: {
563 [codexId]: {
564 driver: ProviderDriverKind.make("codex"),
565 displayName: "Codex Work",
566 enabled: true,
567 config: { homePath: "~/.codex" },
568 },
569 },
570 }).providerInstances[codexId],
571 ).toEqual({
572 driver: ProviderDriverKind.make("codex"),
573 displayName: "Codex Work",
574 enabled: true,
575 config: { homePath: "~/.codex" },
576 });
577 });
578​
579 it("keeps a project's providerInstances override when a patch entry omits the key", () => {
580 const instanceId = ProviderInstanceId.make("codex_work");
581 const projectId = ProjectId.make("project-a");
582 const current = {
583 ...DEFAULT_SERVER_SETTINGS,
584 projectSettingsOverrides: {
585 [projectId]: {
586 newWorktreesStartFromOrigin: true,
587 providerInstances: { [instanceId]: { enabled: false } },
588 },
589 },
590 };
591​
592 const patched = applyServerSettingsPatch(current, {
593 projectSettingsOverrides: {
594 [projectId]: { newWorktreesStartFromOrigin: false },
595 },
596 });
597​
598 expect(patched.projectSettingsOverrides[projectId]).toEqual({
599 newWorktreesStartFromOrigin: false,
600 providerInstances: { [instanceId]: { enabled: false } },
601 });
602 });
603​
604 // regression: #18334 a newer client must still see this server drop a key it does not know
605 it("drops an override key this server does not know from a patch and keeps one it stored", () => {
606 const projectId = ProjectId.make("project-a");
607 const current = decodeServerSettings({
608 projectSettingsFolded: true,
609 projectSettingsOverrides: {
610 [projectId]: { defaultAutoPull: true, addedByNewerServer: 3 },
611 },
612 });
613 const patch = decodeServerSettingsPatch({
614 projectSettingsOverrides: {
615 [projectId]: { defaultAutoPull: false, addedByNewerServer: 4, addedByNewerClient: "x" },
616 },
617 });
618​
619 const updated = applyServerSettingsPatch(current, patch);
620 expect(updated.projectSettingsOverrides[projectId]).toEqual({
621 defaultAutoPull: false,
622 addedByNewerServer: 3,
623 });
624 const reset = applyServerSettingsPatch(updated, {
625 projectSettingsOverrides: { [projectId]: {} },
626 });
627 expect(reset.projectSettingsOverrides[projectId]).toEqual({ addedByNewerServer: 3 });
628 expect(
629 applyServerSettingsPatch(reset, {
630 projectSettingsOverrides: { [projectId]: null },
631 }).projectSettingsOverrides[projectId],
632 ).toBeUndefined();
633 });
634​
635 it("clears a project's providerInstances override when a patch entry sends an empty map", () => {
636 const instanceId = ProviderInstanceId.make("codex_work");
637 const projectId = ProjectId.make("project-a");
638 const current = {
639 ...DEFAULT_SERVER_SETTINGS,
640 projectSettingsOverrides: {
641 [projectId]: {
642 newWorktreesStartFromOrigin: true,
643 providerInstances: { [instanceId]: { enabled: false } },
644 },
645 },
646 };
647​
648 const patched = applyServerSettingsPatch(current, {
649 projectSettingsOverrides: {
650 [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} },
651 },
652 });
653​
654 expect(patched.projectSettingsOverrides[projectId]).toEqual({
655 newWorktreesStartFromOrigin: true,
656 providerInstances: {},
657 });
658 });
659​
660 it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => {
661 const hubA = UsageLimitSourceId.make("cliproxy-a");
662 const hubB = UsageLimitSourceId.make("cliproxy-b");
663 const source = (url: string) => ({
664 kind: "cliproxy" as const,
665 url,
666 managementKey: "secret",
667 enabled: true,
668 });
669 const current = {
670 ...DEFAULT_SERVER_SETTINGS,
671 usageLimitSources: { [hubA]: source("http://a:8318") },
672 };
673​
674 const added = applyServerSettingsPatch(current, {
675 usageLimitSources: { [hubB]: source("http://b:8318") },
676 });
677 expect(Object.keys(added.usageLimitSources)).toEqual([hubA, hubB]);
678​
679 const removed = applyServerSettingsPatch(added, { usageLimitSources: { [hubA]: null } });
680 expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]);
681 });
682​
683 it("upserts and removes secretProviders per entry so concurrent edits cannot clobber", () => {
684 const personal = SecretProviderId.make("personal");
685 const work = SecretProviderId.make("work");
686 const onePassword = (label: string, account: string) => ({
687 kind: SecretProviderKind.make("one_password"),
688 label,
689 config: { account },
690 });
691 const current = {
692 ...DEFAULT_SERVER_SETTINGS,
693 secretProviders: { [personal]: onePassword("Personal", "my.1password.com") },
694 };
695​
696 const added = applyServerSettingsPatch(current, {
697 secretProviders: { [work]: onePassword("Work", "team.1password.com") },
698 });
699 expect(Object.keys(added.secretProviders)).toEqual([personal, work]);
700​
701 const removed = applyServerSettingsPatch(added, { secretProviders: { [personal]: null } });
702 expect(Object.keys(removed.secretProviders)).toEqual([work]);
703 });
704​
705 it("replaces and removes individual usage prices without clobbering other models", () => {
706 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
707 const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
708 usagePriceOverrides: { "example-model": { ...prices, cacheReadCostPerMillionTokens: 0.5 } },
709 });
710 const added = applyServerSettingsPatch(current, {
711 usagePriceOverrides: { "other-model": prices },
712 });
713 const replaced = applyServerSettingsPatch(added, {
714 usagePriceOverrides: { "example-model": prices },
715 });
716 expect(replaced.usagePriceOverrides).toEqual({
717 "example-model": prices,
718 "other-model": prices,
719 });
720 const removed = applyServerSettingsPatch(replaced, {
721 usagePriceOverrides: { "example-model": null },
722 });
723 expect(removed.usagePriceOverrides).toEqual({ "other-model": prices });
724 expect(current.usagePriceOverrides["example-model"]?.cacheReadCostPerMillionTokens).toBe(0.5);
725 });
726​
727 it("stores background activity profiles as a versioned object and syncs legacy aliases", () => {
728 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
729 backgroundActivity: {
730 schemaVersion: 1,
731 profile: "battery-saver",
732 overrides: {},
733 },
734 });
735​
736 expect(next.backgroundActivity).toEqual({
737 schemaVersion: 1,
738 profile: "battery-saver",
739 overrides: {},
740 });
741 expect(next.backgroundActivityProfile).toBe("battery-saver");
742 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0);
743 expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe(
744 Duration.toMillis(Duration.minutes(15)),
745 );
746 });
747​
748 it("turns legacy interval patches into custom background activity overrides", () => {
749 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
750 automaticGitFetchInterval: Duration.seconds(15),
751 });
752​
753 expect(next.backgroundActivity).toEqual({
754 schemaVersion: 1,
755 profile: "custom",
756 baseProfile: "balanced",
757 overrides: {
758 automaticGitFetchInterval: Duration.seconds(15),
759 },
760 });
761 expect(resolveServerBackgroundActivitySettings(next).profile).toBe("balanced");
762 expect(
763 Duration.toMillis(resolveServerBackgroundActivitySettings(next).automaticGitFetchInterval),
764 ).toBe(15_000);
765 });
766​
767 it("preserves legacy background activity settings when applying an unrelated patch", () => {
768 const current = {
769 ...DEFAULT_SERVER_SETTINGS,
770 backgroundActivityProfile: "performance" as const,
771 automaticGitFetchInterval: Duration.seconds(7),
772 providerHealthRefreshInterval: Duration.minutes(4),
773 };
774​
775 const next = applyServerSettingsPatch(current, {
776 sourceControlWriterModelSelection: createModelSelection(
777 ProviderInstanceId.make("codex"),
778 "gpt-5.4-mini",
779 ),
780 });
781​
782 expect(next.backgroundActivity).toEqual({
783 schemaVersion: 1,
784 profile: "custom",
785 baseProfile: "performance",
786 overrides: {
787 automaticGitFetchInterval: Duration.seconds(7),
788 providerHealthRefreshInterval: Duration.minutes(4),
789 },
790 });
791 expect(next.backgroundActivityProfile).toBe("performance");
792 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(7_000);
793 expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe(240_000);
794 });
795​
796 it("does not reactivate dormant overrides from a concrete profile", () => {
797 const current = {
798 ...DEFAULT_SERVER_SETTINGS,
799 backgroundActivity: {
800 schemaVersion: 1 as const,
801 profile: "battery-saver" as const,
802 overrides: {
803 providerHealthRefreshInterval: Duration.seconds(5),
804 },
805 },
806 };
807​
808 const next = applyServerSettingsPatch(current, {
809 automaticGitFetchInterval: Duration.seconds(15),
810 });
811​
812 expect(next.backgroundActivity).toEqual({
813 schemaVersion: 1,
814 profile: "custom",
815 baseProfile: "battery-saver",
816 overrides: {
817 automaticGitFetchInterval: Duration.seconds(15),
818 },
819 });
820 });
821​
822 it("prefers structured background activity settings over legacy aliases", () => {
823 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
824 backgroundActivity: {
825 schemaVersion: 1,
826 profile: "battery-saver",
827 overrides: {},
828 },
829 automaticGitFetchInterval: Duration.seconds(5),
830 backgroundActivityProfile: "performance",
831 });
832​
833 expect(next.backgroundActivity).toEqual({
834 schemaVersion: 1,
835 profile: "battery-saver",
836 overrides: {},
837 });
838 expect(next.backgroundActivityProfile).toBe("battery-saver");
839 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0);
840 });
841​
842 it("reconciles custom background activity back to a preset when overrides match the preset", () => {
843 const custom = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
844 automaticGitFetchInterval: Duration.seconds(15),
845 });
846 const next = applyServerSettingsPatch(custom, {
847 automaticGitFetchInterval: Duration.seconds(30),
848 });
849​
850 expect(next.backgroundActivity).toEqual({
851 schemaVersion: 1,
852 profile: "balanced",
853 overrides: {},
854 });
855 expect(next.backgroundActivityProfile).toBe("balanced");
856 expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(30_000);
857 });
858​
859 it("replaces the complete background override record", () => {
860 const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
861 backgroundActivity: {
862 schemaVersion: 1,
863 profile: "custom",
864 baseProfile: "balanced",
865 overrides: {
866 automaticGitFetchInterval: Duration.seconds(15),
867 providerHealthRefreshInterval: Duration.minutes(3),
868 },
869 },
870 });
871​
872 const next = applyServerSettingsPatch(current, {
873 backgroundActivity: {
874 overrides: {
875 automaticGitFetchInterval: Duration.seconds(10),
876 },
877 },
878 });
879​
880 expect(next.backgroundActivity).toEqual({
881 schemaVersion: 1,
882 profile: "custom",
883 baseProfile: "balanced",
884 overrides: {
885 automaticGitFetchInterval: Duration.seconds(10),
886 },
887 });
888 });
889​
890 it("keeps interval overrides supplied with a profile patch", () => {
891 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
892 backgroundActivityProfile: "performance",
893 automaticGitFetchInterval: Duration.seconds(0),
894 providerHealthRefreshInterval: Duration.minutes(4),
895 });
896​
897 expect(next.backgroundActivity).toEqual({
898 schemaVersion: 1,
899 profile: "custom",
900 baseProfile: "performance",
901 overrides: {
902 automaticGitFetchInterval: Duration.seconds(0),
903 providerHealthRefreshInterval: Duration.minutes(4),
904 },
905 });
906 });
907});
908​
909describe("worktreesDirectory", () => {
910 it("remembers previous custom locations so their worktrees stay managed", () => {
911 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { worktreesDirectory: "/a" });
912 expect(first.previousWorktreesDirectories).toEqual([]);
913 const second = applyServerSettingsPatch(first, { worktreesDirectory: "/b" });
914 expect(second.previousWorktreesDirectories).toEqual(["/a"]);
915 const reset = applyServerSettingsPatch(second, { worktreesDirectory: "" });
916 expect(reset.previousWorktreesDirectories).toEqual(["/a", "/b"]);
917 const back = applyServerSettingsPatch(reset, { worktreesDirectory: "/a" });
918 expect(back.previousWorktreesDirectories).toEqual(["/b"]);
919 });
920});
921​
922describe("synchronized model preferences", () => {
923 it("supports provider identifiers that match inherited object keys", () => {
924 const provider = ProviderInstanceId.make("toString");
925 const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
926 setModelsHidden: [{ provider, model: "example", hidden: true }],
927 });
928 expect(next.providerModelPreferences?.[provider]?.hiddenModels).toEqual(["example"]);
929 });
930 const provider = ProviderInstanceId.make("codex_work");
931 const legacy = {
932 favorites: [{ provider, model: "sol" }],
933 providerModelPreferences: { [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] } },
934 };
935​
936 it("keeps concurrent adds and removes of the same favorite idempotent", () => {
937 const add = { setModelFavorites: [{ provider, model: "sol", favorite: true }] };
938 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, add);
939 expect(applyServerSettingsPatch(first, add).favorites).toEqual([{ provider, model: "sol" }]);
940 const remove = { setModelFavorites: [{ provider, model: "sol", favorite: false }] };
941 const removed = applyServerSettingsPatch(first, remove);
942 expect(applyServerSettingsPatch(removed, remove).favorites).toEqual([]);
943 expect(first.favorites).toEqual([{ provider, model: "sol" }]);
944 });
945​
946 it("preserves unrelated models and providers during bulk visibility and order edits", () => {
947 const otherProvider = ProviderInstanceId.make("claude_work");
948 const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
949 providerModelPreferences: {
950 ...legacy.providerModelPreferences,
951 [otherProvider]: { hiddenModels: ["opus"], modelOrder: ["sonnet", "opus"] },
952 },
953 });
954 const mobile = applyServerSettingsPatch(initial, {
955 setModelsHidden: [{ provider, model: "luna", hidden: true }],
956 });
957 const web = applyServerSettingsPatch(mobile, {
958 setModelsHidden: [
959 { provider, model: "astra", hidden: false },
960 { provider, model: "sol", hidden: true },
961 ],
962 setProviderModelOrder: { provider, modelOrder: ["sol", "luna", "astra"] },
963 });
964 expect(initial.providerModelPreferences?.[provider]).toEqual(
965 legacy.providerModelPreferences[provider],
966 );
967 expect(web.providerModelPreferences?.[provider]).toEqual({
968 hiddenModels: ["luna", "sol"],
969 modelOrder: ["sol", "luna", "astra"],
970 });
971 expect(web.providerModelPreferences?.[otherProvider]).toEqual(
972 initial.providerModelPreferences?.[otherProvider],
973 );
974 expect(
975 applyServerSettingsPatch(web, {
976 setProviderModelOrder: { provider, modelOrder: [] },
977 }).providerModelPreferences?.[provider],
978 ).toEqual({ hiddenModels: ["luna", "sol"], modelOrder: [] });
979 });
980​
981 it("migrates saved lists independently without claiming empty legacy lists", () => {
982 expect(modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, {})).toBeNull();
983 const patch = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!;
984 const migrated = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, patch);
985 expect(resolveModelPreferences(migrated)).toEqual(legacy);
986 expect(modelPreferencesMigrationPatch(migrated, legacy)).toBeNull();
987 const favoritesOnly = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { favorites: [] });
988 expect(modelPreferencesMigrationPatch(favoritesOnly, legacy)).toEqual({
989 migrateModelPreferences: { providerModelPreferences: legacy.providerModelPreferences },
990 });
991 });
992​
993 it("keeps explicit clears when a delayed migration arrives from another device", () => {
994 const staleMigration = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!;
995 const cleared = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
996 favorites: [],
997 providerModelPreferences: {},
998 });
999 expect(
1000 resolveModelPreferences(applyServerSettingsPatch(cleared, staleMigration), legacy),
1001 ).toEqual({
1002 favorites: [],
1003 providerModelPreferences: {},
1004 });
1005 });
1006​
1007 it("replaces visibility records so showing every model clears removed entries", () => {
1008 const migrated = applyServerSettingsPatch(
1009 DEFAULT_SERVER_SETTINGS,
1010 modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!,
1011 );
1012 const cleared = applyServerSettingsPatch(migrated, { providerModelPreferences: {} });
1013 expect(cleared.providerModelPreferences).toEqual({});
1014 expect(cleared.favorites).toEqual(legacy.favorites);
1015 });
1016​
1017 it("sets favorites without losing changes from other clients", () => {
1018 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
1019 setModelFavorites: [{ provider, model: "sol", favorite: true }],
1020 });
1021 const second = applyServerSettingsPatch(first, {
1022 setModelFavorites: [{ provider, model: "astra", favorite: true }],
1023 migrateModelPreferences: { favorites: [] },
1024 });
1025 expect(second.favorites).toEqual([
1026 { provider, model: "sol" },
1027 { provider, model: "astra" },
1028 ]);
1029 const removed = applyServerSettingsPatch(second, {
1030 setModelFavorites: [{ provider, model: "sol", favorite: false }],
1031 });
1032 expect(removed.favorites).toEqual([{ provider, model: "astra" }]);
1033 expect(
1034 applyServerSettingsPatch(removed, {
1035 setModelFavorites: [{ provider, model: "astra", favorite: false }],
1036 }).favorites,
1037 ).toEqual([]);
1038 });
1039​
1040 it("keeps the same model on different provider instances as separate favorites", () => {
1041 const otherProvider = ProviderInstanceId.make("codex_personal");
1042 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
1043 setModelFavorites: [{ provider, model: "sol", favorite: true }],
1044 });
1045 const second = applyServerSettingsPatch(first, {
1046 setModelFavorites: [{ provider: otherProvider, model: "sol", favorite: true }],
1047 });
1048 expect(second.favorites).toEqual([
1049 { provider, model: "sol" },
1050 { provider: otherProvider, model: "sol" },
1051 ]);
1052 expect(
1053 applyServerSettingsPatch(second, {
1054 setModelFavorites: [{ provider, model: "sol", favorite: false }],
1055 }).favorites,
1056 ).toEqual([{ provider: otherProvider, model: "sol" }]);
1057 });
1058});
1059​
1060describe("resolveSpeechPostProcessingModelSelection", () => {
1061 it("follows the text generation model until a usable cleanup model is set", () => {
1062 const textGeneration = createModelSelection(ProviderInstanceId.make("codex"), "gpt-6-luna");
1063 const cleanup = createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku", [
1064 { id: "effort", value: "low" },
1065 ]);
1066 const base = { ...DEFAULT_SERVER_SETTINGS, textGenerationModelSelection: textGeneration };
1067 expect(resolveSpeechPostProcessingModelSelection(base)).toBe(textGeneration);
1068 const pinned = applyServerSettingsPatch(base, { speechPostProcessingModelSelection: cleanup });
1069 expect(resolveSpeechPostProcessingModelSelection(pinned)).toEqual(cleanup);
1070 expect(
1071 resolveSpeechPostProcessingModelSelection({
1072 ...pinned,
1073 providerInstances: {
1074 [ProviderInstanceId.make("claudeAgent")]: {
1075 driver: ProviderDriverKind.make("claudeAgent"),
1076 enabled: false,
1077 config: {},
1078 },
1079 },
1080 }),
1081 ).toBe(textGeneration);
1082 expect(
1083 resolveSpeechPostProcessingModelSelection(pinned, [
1085 instanceId: "claudeAgent",
1086 enabled: true,
1087 installed: true,
1088 supportsTextGeneration: false,
1089 },
1090 ] as never),
1091 ).toBe(textGeneration);
1092 expect(
1093 applyServerSettingsPatch(pinned, { speechPostProcessingModelSelection: null })
1094 .speechPostProcessingModelSelection,
1095 ).toBeNull();
1096 });
1097});