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