1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3
4import { type CommandId, ProjectId } from "./baseSchemas.ts";
5import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
6import {
7 ClientSettingsSchema,
8 ClientSettingsPatch,
9 ClaudeSettings,
10 DEFAULT_SERVER_SETTINGS,
11 DEFAULT_CLIENT_SETTINGS,
12 requiredScopesForProjectMutation,
13 resolveProjectProviderInstanceEnabled,
14 resolveProviderInstanceEnabled,
15 ServerSettings,
16 ServerSettingsPatch,
17} from "./settings.ts";
18
19const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
20const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
21const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema);
22const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
23const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
24const encodeServerSettings = Schema.encodeSync(ServerSettings);
25const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
26
27describe("ServerSettings response streaming", () => {
28 it("defaults to paragraph buffering", () => {
29 expect(decodeServerSettings({}).responseStreamingMode).toBe("paragraph");
30 });
31
32 it.each(["turn", "paragraph"])(
33 "round-trips %s as an environment setting and project override",
34 (responseStreamingMode) => {
35 const input = {
36 responseStreamingMode,
37 projectSettingsOverrides: { project: { responseStreamingMode } },
38 };
39 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
40 expect(decodeServerSettingsPatch(input)).toEqual(input);
41 },
42 );
43
44 it.each(["token", "unsupported"])("rejects %s in settings snapshots and writes", (mode) => {
45 for (const input of [
46 { responseStreamingMode: mode },
47 { projectSettingsOverrides: { project: { responseStreamingMode: mode } } },
48 ]) {
49 expect(() => decodeServerSettings(input)).toThrow();
50 expect(() => decodeServerSettingsPatch(input)).toThrow();
51 }
52 });
53});
54
55describe("storage cleanup settings", () => {
56 it("keeps cleanup disabled for existing installations", () => {
57 expect(decodeServerSettings({}).worktreeCleanup).toBeNull();
58 expect(decodeServerSettings({}).storageCleanup).toEqual({
59 worktreeAfterDays: null,
60 worktreeSettledAfterDays: null,
61 worktreeOnMerge: false,
62 worktreeOnDelete: false,
63 worktreeUnchanged: false,
64 worktreeKeepWhen: "uncommitted-changes",
65 browserArtifactsAfterDays: null,
66 logsAfterDays: null,
67 });
68 });
69
70 it("accepts eight-day retention and disabling one rule without resetting others", () => {
71 expect(decodeServerSettingsPatch({ storageCleanup: { worktreeAfterDays: 8 } })).toEqual({
72 storageCleanup: { worktreeAfterDays: 8 },
73 });
74 expect(decodeServerSettingsPatch({ storageCleanup: { worktreeAfterDays: null } })).toEqual({
75 storageCleanup: { worktreeAfterDays: null },
76 });
77 expect(decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: 0 } })).toEqual({
78 storageCleanup: { worktreeSettledAfterDays: 0 },
79 });
80 });
81
82 it("accepts partial custom patches but requires complete stored project rules", () => {
83 expect(
84 decodeServerSettingsPatch({
85 worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } },
86 }),
87 ).toEqual({ worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } } });
88 expect(() =>
89 decodeServerSettings({
90 projectSettingsOverrides: {
91 project: { worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } } },
92 },
93 }),
94 ).toThrow();
95 expect(
96 decodeServerSettings({
97 projectSettingsOverrides: {
98 project: {
99 worktreeCleanup: {
100 mode: "custom",
101 rules: {
102 worktreeAfterDays: null,
103 worktreeOnMerge: false,
104 worktreeOnDelete: false,
105 worktreeUnchanged: false,
106 },
107 },
108 },
109 },
110 }).projectSettingsOverrides[ProjectId.make("project")]?.worktreeCleanup,
111 ).toMatchObject({
112 mode: "custom",
113 rules: { worktreeSettledAfterDays: null, worktreeKeepWhen: "uncommitted-changes" },
114 });
115 });
116
117 it("decodes older custom rules with the default local-file policy", () => {
118 const settings = decodeServerSettings({
119 worktreeCleanup: {
120 mode: "custom",
121 rules: {
122 worktreeAfterDays: 8,
123 worktreeOnMerge: false,
124 worktreeOnDelete: false,
125 worktreeUnchanged: false,
126 },
127 },
128 });
129 expect(settings.worktreeCleanup).toMatchObject({
130 rules: { worktreeKeepWhen: "uncommitted-changes" },
131 });
132 expect(
133 decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }),
134 ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } });
135 expect(() =>
136 decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }),
137 ).toThrow();
138 });
139
140 it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => {
141 expect(() =>
142 decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }),
143 ).toThrow();
144 });
145
146 it.each([-1, 1.5, 3651])("rejects invalid settled retention %s", (days) => {
147 expect(() =>
148 decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: days } }),
149 ).toThrow();
150 });
151});
152
153describe("ClientSettings rich text composer", () => {
154 it("enables rich text for new and existing settings without a saved preference", () => {
155 expect(decodeClientSettings({}).composerRichTextEnabled).toBe(true);
156 expect(decodeClientSettings({ sendShortcut: "mod-enter" }).composerRichTextEnabled).toBe(true);
157 });
158
159 it("preserves an explicit opt-out through patches and persistence", () => {
160 const preference = { composerRichTextEnabled: false };
161 expect(decodeClientSettingsPatch(preference)).toEqual(preference);
162 expect(encodeClientSettings(decodeClientSettings(preference))).toMatchObject(preference);
163 });
164});
165
166describe("ServerSettings default permissions", () => {
167 it("keeps full access for settings saved before a default was configured", () => {
168 expect(decodeServerSettings({}).defaultRuntimeMode).toBe("full-access");
169 expect(DEFAULT_SERVER_SETTINGS.defaultRuntimeMode).toBe("full-access");
170 });
171
172 it.each(["approval-required", "auto-accept-edits", "auto", "full-access"])(
173 "round-trips %s as an environment default and project override",
174 (defaultRuntimeMode) => {
175 const input = {
176 defaultRuntimeMode,
177 projectSettingsOverrides: { project: { defaultRuntimeMode } },
178 };
179 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
180 expect(decodeServerSettingsPatch(input)).toEqual(input);
181 },
182 );
183
184 it("rejects unsupported permission defaults", () => {
185 expect(() => decodeServerSettings({ defaultRuntimeMode: "unsupported" })).toThrow();
186 expect(() => decodeServerSettingsPatch({ defaultRuntimeMode: "unsupported" })).toThrow();
187 expect(() =>
188 decodeServerSettingsPatch({
189 projectSettingsOverrides: { project: { defaultRuntimeMode: "unsupported" } },
190 }),
191 ).toThrow();
192 });
193});
194
195describe("ServerSettings usage price overrides", () => {
196 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
197
198 it("defaults to automatic pricing and round-trips arbitrary model IDs", () => {
199 expect(decodeServerSettings({}).usagePriceOverrides).toEqual({});
200 const settings = decodeServerSettings({
201 usagePriceOverrides: { " vendor/example-model ": prices },
202 });
203 expect(encodeServerSettings(settings).usagePriceOverrides).toEqual({
204 "vendor/example-model": prices,
205 });
206 });
207
208 it("accepts zero rates, optional cache rates, and per-model deletion", () => {
209 const overrides = {
210 "example-model": {
211 inputCostPerMillionTokens: 0,
212 outputCostPerMillionTokens: 0,
213 cacheReadCostPerMillionTokens: 0.5,
214 cacheWriteCostPerMillionTokens: 3,
215 },
216 "removed-model": null,
217 };
218 expect(
219 decodeServerSettingsPatch({ usagePriceOverrides: overrides }).usagePriceOverrides,
220 ).toEqual(overrides);
221 });
222
223 it.each([
224 "inputCostPerMillionTokens",
225 "outputCostPerMillionTokens",
226 "cacheReadCostPerMillionTokens",
227 "cacheWriteCostPerMillionTokens",
228 ])("rejects invalid %s rates at the settings boundary", (field) => {
229 for (const value of [-1, Number.NaN, Number.POSITIVE_INFINITY, "2"]) {
230 const usagePriceOverrides = { "example-model": { ...prices, [field]: value } };
231 expect(() => decodeServerSettings({ usagePriceOverrides })).toThrow();
232 expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow();
233 }
234 });
235
236 it("rejects empty model IDs and incomplete input/output pricing", () => {
237 for (const usagePriceOverrides of [
238 { " ": prices },
239 { "example-model": { inputCostPerMillionTokens: 2 } },
240 { "example-model": { outputCostPerMillionTokens: 8 } },
241 ]) {
242 expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow();
243 }
244 });
245});
246
247describe("custom model settings", () => {
248 const capabilities = {
249 optionDescriptors: [
250 {
251 id: "effort",
252 label: "Reasoning",
253 type: "select",
254 options: [{ id: "high", label: "High", isDefault: true }],
255 },
256 ],
257 };
258
259 it("accepts legacy bare slugs alongside full entries", () => {
260 const decoded = decodeClaudeSettings({
261 customModels: ["bare-slug", { slug: "named", name: "Named", capabilities }],
262 });
263 expect(decoded.customModels).toEqual([
264 "bare-slug",
265 { slug: "named", name: "Named", capabilities },
266 ]);
267 });
268});
269
270describe("ClaudeSettings auto-compaction", () => {
271 it("uses Claude's default threshold when no override is configured", () => {
272 expect(decodeClaudeSettings({}).autoCompactWindow).toBe("");
273 });
274
275 it.each(["100000", "300000", "1000000"])(
276 "accepts a supported auto-compaction threshold: %s",
277 (value) => {
278 expect(decodeClaudeSettings({ autoCompactWindow: value }).autoCompactWindow).toBe(value);
279 },
280 );
281
282 it.each(["99999", "1000001", "300k", "invalid"])(
283 "rejects an unsupported auto-compaction threshold: %s",
284 (value) => {
285 expect(() => decodeClaudeSettings({ autoCompactWindow: value })).toThrow();
286 },
287 );
288});
289
290describe("ClientSettings notifications", () => {
291 it("requires opt-in when existing settings omit notification preferences", () => {
292 expect(decodeClientSettings({}).notificationMode).toBe("off");
293 expect(decodeClientSettings({}).inAppNotificationsEnabled).toBe(false);
294 expect(decodeClientSettingsPatch({})).not.toHaveProperty("inAppNotificationsEnabled");
295 expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");
296 });
297
298 it.each([true, false])(
299 "round-trips in-app notifications set to %s",
300 (inAppNotificationsEnabled) => {
301 const settings = decodeClientSettings({ inAppNotificationsEnabled });
302 expect(encodeClientSettings(settings).inAppNotificationsEnabled).toBe(
303 inAppNotificationsEnabled,
304 );
305 expect(
306 decodeClientSettingsPatch({ inAppNotificationsEnabled }).inAppNotificationsEnabled,
307 ).toBe(inAppNotificationsEnabled);
308 },
309 );
310
311 it.each(["true", 1, null])(
312 "rejects an invalid in-app notification preference %s",
313 (inAppNotificationsEnabled) => {
314 expect(() => decodeClientSettings({ inAppNotificationsEnabled })).toThrow();
315 expect(() => decodeClientSettingsPatch({ inAppNotificationsEnabled })).toThrow();
316 },
317 );
318
319 it.each(["off", "notifications", "sound", "notifications-and-sound"])(
320 "round-trips the %s mode",
321 (notificationMode) => {
322 const settings = decodeClientSettings({ notificationMode });
323 expect(encodeClientSettings(settings).notificationMode).toBe(notificationMode);
324 expect(decodeClientSettingsPatch({ notificationMode }).notificationMode).toBe(
325 notificationMode,
326 );
327 },
328 );
329
330 it.each(["always", true, null])(
331 "rejects unsupported notification mode %s",
332 (notificationMode) => {
333 expect(() => decodeClientSettings({ notificationMode })).toThrow();
334 expect(() => decodeClientSettingsPatch({ notificationMode })).toThrow();
335 },
336 );
337});
338
339describe("ClientSettings default diff file state", () => {
340 it("keeps files collapsed when existing settings omit the preference", () => {
341 expect(decodeClientSettings({}).diffFilesCollapsed).toBe(true);
342 });
343
344 it.each([true, false])("preserves a saved collapsed preference of %s", (diffFilesCollapsed) => {
345 const settings = decodeClientSettings({ diffFilesCollapsed });
346 expect(encodeClientSettings(settings).diffFilesCollapsed).toBe(diffFilesCollapsed);
347 expect(decodeClientSettingsPatch({ diffFilesCollapsed }).diffFilesCollapsed).toBe(
348 diffFilesCollapsed,
349 );
350 });
351});
352
353describe("ClientSettings diff colors", () => {
354 it("keeps red and green for existing settings without a saved palette", () => {
355 expect(decodeClientSettings({}).diffColorScheme).toBe("red-green");
356 });
357
358 it.each(["red-green", "blue-orange"])("round-trips the %s palette", (diffColorScheme) => {
359 const settings = decodeClientSettings({ diffColorScheme });
360 expect(encodeClientSettings(settings).diffColorScheme).toBe(diffColorScheme);
361 expect(decodeClientSettingsPatch({ diffColorScheme }).diffColorScheme).toBe(diffColorScheme);
362 });
363
364 it("rejects unsupported palettes", () => {
365 expect(() => decodeClientSettings({ diffColorScheme: "purple-yellow" })).toThrow();
366 expect(() => decodeClientSettingsPatch({ diffColorScheme: "purple-yellow" })).toThrow();
367 });
368});
369
370describe("ClientSettings chat width", () => {
371 it("keeps the comfortable width for existing settings without a saved width", () => {
372 expect(decodeClientSettings({}).chatWidth).toBe("comfortable");
373 });
374
375 it.each(["comfortable", "wide", "full"])("round-trips the %s width", (chatWidth) => {
376 const settings = decodeClientSettings({ chatWidth });
377 expect(encodeClientSettings(settings).chatWidth).toBe(chatWidth);
378 expect(decodeClientSettingsPatch({ chatWidth }).chatWidth).toBe(chatWidth);
379 });
380
381 it("rejects unsupported widths", () => {
382 expect(() => decodeClientSettings({ chatWidth: "huge" })).toThrow();
383 expect(() => decodeClientSettingsPatch({ chatWidth: "huge" })).toThrow();
384 });
385});
386
387describe("ClientSettings load balancing", () => {
388 it("requires opt-in when settings are new or omit load balancing", () => {
389 expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false);
390 expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false);
391 });
392
393 it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => {
394 const settings = decodeClientSettings({ loadBalancingEnabled });
395 expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled);
396 expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe(
397 loadBalancingEnabled,
398 );
399 });
400});
401
402describe("ClientSettings composer context strip", () => {
403 it("defaults to draft-only and accepts a persistent strip preference", () => {
404 expect(decodeClientSettings({}).persistComposerContextStrip).toBe(false);
405 expect(
406 decodeClientSettingsPatch({ persistComposerContextStrip: true }).persistComposerContextStrip,
407 ).toBe(true);
408 });
409});
410
411describe("ClientSettings word wrap", () => {
412 it("defaults word wrap on", () => {
413 expect(decodeClientSettings({}).wordWrap).toBe(true);
414 });
415
416 it("ignores obsolete wrapping preferences", () => {
417 const decoded = decodeClientSettings({
418 chatWordWrap: false,
419 diffWordWrap: false,
420 });
421
422 expect(decoded.wordWrap).toBe(true);
423 expect(decoded).not.toHaveProperty("chatWordWrap");
424 expect(decoded).not.toHaveProperty("diffWordWrap");
425 });
426});
427
428describe("ClientSettings window capture", () => {
429 it("defaults capture off while keeping its feedback enabled", () => {
430 const settings = decodeClientSettings({});
431
432 expect(settings.snapShotEnabled).toBe(false);
433 expect(settings.snapShotIncludeAccessibility).toBe(true);
434 expect(settings.snapShotShortcut).toEqual({ kind: "both-shift-keys" });
435 expect(settings.snapShotPlaySound).toBe(true);
436 expect(settings.snapShotSound).toBe("soft-pop");
437 expect(settings.snapShotFlash).toBe(true);
438 expect(settings.snapShotAnimations).toBe(true);
439 });
440
441 it("accepts capture preference updates", () => {
442 expect(
443 decodeClientSettingsPatch({
444 snapShotEnabled: true,
445 snapShotIncludeAccessibility: false,
446 snapShotShortcut: {
447 key: "w",
448 metaKey: false,
449 ctrlKey: false,
450 shiftKey: true,
451 altKey: true,
452 modKey: false,
453 },
454 snapShotPlaySound: false,
455 snapShotSound: "camera-shutter",
456 snapShotFlash: false,
457 snapShotAnimations: false,
458 }),
459 ).toEqual({
460 snapShotEnabled: true,
461 snapShotIncludeAccessibility: false,
462 snapShotShortcut: {
463 key: "w",
464 metaKey: false,
465 ctrlKey: false,
466 shiftKey: true,
467 altKey: true,
468 modKey: false,
469 },
470 snapShotPlaySound: false,
471 snapShotSound: "camera-shutter",
472 snapShotFlash: false,
473 snapShotAnimations: false,
474 });
475 });
476
477 it("keeps SnapShots disabled when older settings enable them or a caller tries to persist them", () => {
478 expect(decodeClientSettings({ snapShotEnabled: true }).snapShotEnabled).toBe(false);
479 const saved = encodeClientSettings({ ...DEFAULT_CLIENT_SETTINGS, snapShotEnabled: true });
480 expect(saved.snapShotEnabled).toBe(false);
481 });
482
483 it("rejects unknown capture sounds", () => {
484 expect(() => decodeClientSettingsPatch({ snapShotSound: "doorbell" })).toThrow();
485 });
486
487 it("accepts modifier pair shortcuts", () => {
488 expect(
489 decodeClientSettingsPatch({
490 snapShotShortcut: { kind: "modifier-pair", modifier: "meta" },
491 }),
492 ).toEqual({
493 snapShotShortcut: { kind: "modifier-pair", modifier: "meta" },
494 });
495 expect(() =>
496 decodeClientSettingsPatch({
497 snapShotShortcut: { kind: "modifier-pair", modifier: "hyper" },
498 }),
499 ).toThrow();
500 });
501
502 it("rejects a capture shortcut with no modifier", () => {
503 expect(() =>
504 decodeClientSettingsPatch({
505 snapShotShortcut: {
506 key: "w",
507 metaKey: false,
508 ctrlKey: false,
509 shiftKey: false,
510 altKey: false,
511 modKey: false,
512 },
513 }),
514 ).toThrow();
515 });
516});
517
518describe("ClientSettings proactive panels", () => {
519 it("is opt-in and accepts client-local updates", () => {
520 expect(decodeClientSettings({}).proactivePanelsEnabled).toBe(false);
521 expect(decodeClientSettingsPatch({ proactivePanelsEnabled: true }).proactivePanelsEnabled).toBe(
522 true,
523 );
524 });
525});
526
527describe("ClientSettings quit confirmation", () => {
528 it("defaults to hold", () => {
529 expect(decodeClientSettings({}).confirmQuit).toBe("hold");
530 });
531
532 it.each(["direct", "hold", "double-click"] as const)("accepts the %s mode", (mode) => {
533 expect(decodeClientSettings({ confirmQuit: mode }).confirmQuit).toBe(mode);
534 expect(decodeClientSettingsPatch({ confirmQuit: mode }).confirmQuit).toBe(mode);
535 });
536
537 it.each([
538 [true, "hold"],
539 [false, "direct"],
540 ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => {
541 const settings = decodeClientSettings({ confirmQuit: legacyValue });
542
543 expect(settings.confirmQuit).toBe(mode);
544 expect(encodeClientSettings(settings).confirmQuit).toBe(mode);
545 });
546
547 it("rejects legacy booleans at the patch boundary", () => {
548 expect(() => decodeClientSettingsPatch({ confirmQuit: true })).toThrow();
549 });
550});
551
552describe("ClientSettings browser recording frame rate", () => {
553 it("defaults to 30 fps", () => {
554 expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30);
555 });
556
557 it.each([30, 60])("accepts a supported frame rate: %s", (frameRate) => {
558 expect(
559 decodeClientSettings({ browserRecordingFrameRate: frameRate }).browserRecordingFrameRate,
560 ).toBe(frameRate);
561 expect(
562 decodeClientSettingsPatch({ browserRecordingFrameRate: frameRate }).browserRecordingFrameRate,
563 ).toBe(frameRate);
564 });
565
566 it.each([24, 59, 120])("rejects an unsupported frame rate: %s", (frameRate) => {
567 expect(() => decodeClientSettings({ browserRecordingFrameRate: frameRate })).toThrow();
568 expect(() => decodeClientSettingsPatch({ browserRecordingFrameRate: frameRate })).toThrow();
569 });
570});
571
572describe("ClientSettings recording input overlays", () => {
573 it("defaults both overlays off and accepts independent opt-ins", () => {
574 const settings = decodeClientSettings({});
575 expect(settings.browserRecordingShowKeyPresses).toBe(false);
576 expect(settings.browserRecordingShowMousePresses).toBe(false);
577 expect(
578 decodeClientSettingsPatch({
579 browserRecordingShowKeyPresses: true,
580 browserRecordingShowMousePresses: false,
581 }),
582 ).toMatchObject({
583 browserRecordingShowKeyPresses: true,
584 browserRecordingShowMousePresses: false,
585 });
586 });
587});
588
589describe("ClientSettings glass opacity", () => {
590 it("defaults to a readable translucent surface", () => {
591 expect(decodeClientSettings({}).glassOpacity).toBe(80);
592 });
593
594 it.each([39, 101, 72.5])("rejects an invalid glass opacity: %s", (value) => {
595 expect(() => decodeClientSettings({ glassOpacity: value })).toThrow();
596 expect(() => decodeClientSettingsPatch({ glassOpacity: value })).toThrow();
597 });
598
599 it.each([40, 75, 100])("accepts a glass opacity within the supported range: %s", (value) => {
600 expect(decodeClientSettings({ glassOpacity: value }).glassOpacity).toBe(value);
601 expect(decodeClientSettingsPatch({ glassOpacity: value }).glassOpacity).toBe(value);
602 });
603});
604
605describe("ClientSettings appearance contrast", () => {
606 it("defaults to the theme's original contrast", () => {
607 expect(decodeClientSettings({}).appearanceContrast).toBe(100);
608 });
609
610 it.each([49, 201, 92.5])("rejects an invalid appearance contrast: %s", (value) => {
611 expect(() => decodeClientSettings({ appearanceContrast: value })).toThrow();
612 expect(() => decodeClientSettingsPatch({ appearanceContrast: value })).toThrow();
613 });
614
615 it.each([50, 100, 150, 200])("accepts an appearance contrast in range: %s", (value) => {
616 expect(decodeClientSettings({ appearanceContrast: value }).appearanceContrast).toBe(value);
617 expect(decodeClientSettingsPatch({ appearanceContrast: value }).appearanceContrast).toBe(value);
618 });
619});
620
621describe("ClientSettings panel animations", () => {
622 it("defaults to instant changes", () => {
623 expect(decodeClientSettings({}).panelAnimationDurationMs).toBe(0);
624 });
625
626 it.each([0, 400])("accepts a panel animation duration: %s", (value) => {
627 expect(decodeClientSettingsPatch({ panelAnimationDurationMs: value })).toEqual({
628 panelAnimationDurationMs: value,
629 });
630 });
631
632 it.each([-1, 401, 150.5])("rejects an invalid panel animation duration: %s", (value) => {
633 expect(() => decodeClientSettingsPatch({ panelAnimationDurationMs: value })).toThrow();
634 });
635});
636
637describe("ClientSettings environment identification", () => {
638 it("defaults to artwork and accepts each presentation mode", () => {
639 expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork");
640
641 for (const mode of ["artwork", "pill", "none"] as const) {
642 expect(
643 decodeClientSettingsPatch({ environmentIdentificationMode: mode })
644 .environmentIdentificationMode,
645 ).toBe(mode);
646 }
647 });
648
649 it("rejects unsupported presentation modes", () => {
650 expect(() => decodeClientSettings({ environmentIdentificationMode: "badge" })).toThrow();
651 expect(() => decodeClientSettingsPatch({ environmentIdentificationMode: "badge" })).toThrow();
652 });
653});
654
655describe("ClientSettings sidebar", () => {
656 it("defaults to the current sidebar", () => {
657 expect(decodeClientSettings({}).legacySidebarEnabled).toBe(false);
658 });
659
660 it("drops the retired sidebar v2 beta keys, resetting everyone to the default", () => {
661 const decoded = decodeClientSettings({
662 sidebarV2Enabled: false,
663 sidebarV2ConfiguredByUser: true,
664 });
665 expect(decoded.legacySidebarEnabled).toBe(false);
666 expect(decoded).not.toHaveProperty("sidebarV2Enabled");
667 expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser");
668 });
669
670 it("drops the retired compact sidebar keys for users who opted in", () => {
671 const stored = { compactSidebarEnabled: true, sidebarCompactThreadRows: true };
672 const decoded = decodeClientSettings(stored);
673 expect(decoded).not.toHaveProperty("compactSidebarEnabled");
674 expect(decoded).not.toHaveProperty("sidebarCompactThreadRows");
675 expect(decodeClientSettingsPatch(stored)).toEqual({});
676 });
677
678 it("preserves an explicit legacy sidebar opt-in", () => {
679 expect(decodeClientSettings({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(true);
680 expect(decodeClientSettingsPatch({ legacySidebarEnabled: true }).legacySidebarEnabled).toBe(
681 true,
682 );
683 });
684
685 it("keeps unpin confirmation opt-in and patchable", () => {
686 expect(decodeClientSettings({}).confirmThreadUnpin).toBe(false);
687 expect(decodeClientSettingsPatch({ confirmThreadUnpin: true }).confirmThreadUnpin).toBe(true);
688 expect(() => decodeClientSettingsPatch({ confirmThreadUnpin: "yes" })).toThrow();
689 });
690});
691
692describe("ClientSettings context window meter", () => {
693 it("defaults off and preserves an explicit legacy opt-in", () => {
694 expect(decodeClientSettings({}).contextWindowMeterEnabled).toBe(false);
695 expect(
696 decodeClientSettings({ contextWindowMeterEnabled: true }).contextWindowMeterEnabled,
697 ).toBe(true);
698 expect(
699 decodeClientSettingsPatch({ contextWindowMeterEnabled: true }).contextWindowMeterEnabled,
700 ).toBe(true);
701 });
702});
703
704describe("ClientSettings send shortcut", () => {
705 it("defaults to Enter and validates the supported choices", () => {
706 expect(decodeClientSettings({}).sendShortcut).toBe("enter");
707 for (const sendShortcut of ["enter", "mod-enter-multiline", "mod-enter"]) {
708 expect(decodeClientSettings({ sendShortcut }).sendShortcut).toBe(sendShortcut);
709 expect(decodeClientSettingsPatch({ sendShortcut }).sendShortcut).toBe(sendShortcut);
710 }
711 expect(() => decodeClientSettingsPatch({ sendShortcut: "invalid" })).toThrow();
712 });
713});
714
715describe("ClientSettings follow-up behavior", () => {
716 it("defaults to queue and accepts either behavior", () => {
717 expect(decodeClientSettings({}).followUpBehavior).toBe("queue");
718 for (const followUpBehavior of ["queue", "steer"]) {
719 expect(decodeClientSettings({ followUpBehavior }).followUpBehavior).toBe(followUpBehavior);
720 expect(decodeClientSettingsPatch({ followUpBehavior }).followUpBehavior).toBe(
721 followUpBehavior,
722 );
723 }
724 expect(() => decodeClientSettingsPatch({ followUpBehavior: "invalid" })).toThrow();
725 });
726});
727
728describe("ClientSettings composer collapse", () => {
729 it("collapses on scroll by default and accepts opting out", () => {
730 expect(decodeClientSettings({}).composerCollapseOnScroll).toBe(true);
731 expect(
732 decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll,
733 ).toBe(false);
734 });
735
736 it("drops the retired blur trigger key", () => {
737 const decoded = decodeClientSettings({ composerCollapseOnBlur: false });
738 expect(decoded.composerCollapseOnScroll).toBe(true);
739 expect(decoded).not.toHaveProperty("composerCollapseOnBlur");
740 });
741});
742
743describe("ServerSettings thread settlement", () => {
744 it("defaults merge settlement on and inactivity settlement to three days", () => {
745 const settings = decodeServerSettings({});
746 expect(settings.sidebarAutoSettleAfterDays).toBe(3);
747 expect(settings.sidebarAutoSettleOnMerge).toBe(true);
748 });
749
750 it("allows both automatic rules to be disabled", () => {
751 expect(
752 decodeServerSettings({
753 sidebarAutoSettleAfterDays: null,
754 sidebarAutoSettleOnMerge: false,
755 }),
756 ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false });
757 expect(
758 decodeServerSettingsPatch({
759 sidebarAutoSettleAfterDays: null,
760 sidebarAutoSettleOnMerge: false,
761 }),
762 ).toMatchObject({ sidebarAutoSettleAfterDays: null, sidebarAutoSettleOnMerge: false });
763 });
764
765 it.each([-1, 0, 91])("rejects an auto-settle threshold outside 1..90: %s", (value) => {
766 expect(() => decodeServerSettings({ sidebarAutoSettleAfterDays: value })).toThrow();
767 expect(() => decodeServerSettingsPatch({ sidebarAutoSettleAfterDays: value })).toThrow();
768 });
769});
770
771describe("ClientSettings pull request merge methods", () => {
772 it("defaults to no project overrides and accepts supported methods", () => {
773 expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({});
774 expect(
775 decodeClientSettingsPatch({
776 pullRequestMergeMethodOverrides: { project: "squash" },
777 }).pullRequestMergeMethodOverrides,
778 ).toEqual({ project: "squash" });
779 });
780
781 it("rejects unsupported project merge methods", () => {
782 expect(() =>
783 decodeClientSettingsPatch({
784 pullRequestMergeMethodOverrides: { project: "fast-forward" },
785 }),
786 ).toThrow();
787 });
788});
789
790describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
791 it("defaults to an empty record so legacy configs without the key still decode", () => {
792 expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({});
793 });
794
795 it("decodes a fully empty config (legacy on-disk shape) without complaint", () => {
796 const decoded = decodeServerSettings({});
797 expect(decoded.providerInstances).toEqual({});
798 });
799
800 it("decodes a multi-instance map mixing first-party and fork drivers", () => {
801 const decoded = decodeServerSettings({
802 providerInstances: {
803 codex_personal: {
804 driver: "codex",
805 displayName: "Codex (personal)",
806 config: { homePath: "~/.codex_personal" },
807 },
808 codex_work: {
809 driver: "codex",
810 config: { homePath: "~/.codex_work" },
811 },
812 ollama_local: {
813 driver: "ollama",
814 displayName: "Ollama (local)",
815 config: { endpoint: "http://localhost:11434" },
816 },
817 },
818 });
819 const personalId = ProviderInstanceId.make("codex_personal");
820 const workId = ProviderInstanceId.make("codex_work");
821 const ollamaId = ProviderInstanceId.make("ollama_local");
822
823 expect(decoded.providerInstances[personalId]?.driver).toBe("codex");
824 expect(decoded.providerInstances[workId]?.config).toEqual({ homePath: "~/.codex_work" });
825 // Critical: a config naming a driver this build does not know about
826 // (`ollama` is not in `ProviderDriverKind`) must round-trip without loss.
827 // The runtime handles "driver not installed" — the schema must not.
828 expect(decoded.providerInstances[ollamaId]?.driver).toBe("ollama");
829 expect(decoded.providerInstances[ollamaId]?.config).toEqual({
830 endpoint: "http://localhost:11434",
831 });
832 });
833
834 it("rejects instance keys that violate the slug pattern", () => {
835 expect(() =>
836 decodeServerSettings({
837 providerInstances: { "1bad": { driver: "codex" } },
838 }),
839 ).toThrow();
840 });
841});
842
843describe("provider enabled defaults", () => {
844 it("keeps Muse disabled until a configured instance opts in", () => {
845 const muse = ProviderDriverKind.make("muse");
846 expect(resolveProviderInstanceEnabled({ driver: muse, config: {} })).toBe(false);
847 expect(resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: {} })).toBe(true);
848 expect(
849 resolveProviderInstanceEnabled({ driver: muse, enabled: true, config: { enabled: false } }),
850 ).toBe(false);
851 });
852
853 it("enables only the stable bindings by default", () => {
854 const enabledByDefault = (driver: string) =>
855 resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make(driver), config: {} });
856 expect(enabledByDefault("codex")).toBe(true);
857 expect(enabledByDefault("claudeAgent")).toBe(true);
858 for (const driver of ["cursor", "grok", "muse", "pi", "opencode", "antigravity"]) {
859 expect(enabledByDefault(driver)).toBe(false);
860 }
861 });
862
863 it("resolves instance enabled state with explicit false winning", () => {
864 const grok = ProviderDriverKind.make("grok");
865 const codex = ProviderDriverKind.make("codex");
866 // No flags anywhere: driver default applies.
867 expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false);
868 expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true);
869 // Unknown fork drivers stay enabled.
870 expect(
871 resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }),
872 ).toBe(true);
873 // Envelope flag wins over the driver default.
874 expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true);
875 expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe(
876 false,
877 );
878 // Legacy in-config flag fills in when the envelope is silent.
879 expect(resolveProviderInstanceEnabled({ driver: grok, config: { enabled: true } })).toBe(true);
880 // Conflicting flags: the explicit false wins, whichever side it is on.
881 expect(
882 resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: { enabled: false } }),
883 ).toBe(false);
884 expect(
885 resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: { enabled: true } }),
886 ).toBe(false);
887 });
888});
889
890describe("ServerSettings worktree defaults", () => {
891 it("defaults the thread env mode to inherit and keeps stored values", () => {
892 expect(decodeServerSettings({}).defaultThreadEnvMode).toBeNull();
893 expect(decodeServerSettings({ defaultThreadEnvMode: "worktree" }).defaultThreadEnvMode).toBe(
894 "worktree",
895 );
896 expect(
897 decodeServerSettings({ defaultThreadEnvMode: "remote" }).defaultThreadEnvMode,
898 ).toBeNull();
899 expect(
900 decodeServerSettingsPatch({ defaultThreadEnvMode: null }).defaultThreadEnvMode,
901 ).toBeNull();
902 });
903
904 it("keeps an inherited thread env mode off the wire for older clients", () => {
905 const encode = Schema.encodeSync(ServerSettings);
906 expect("defaultThreadEnvMode" in encode(decodeServerSettings({}))).toBe(false);
907 expect(
908 encode(decodeServerSettings({ defaultThreadEnvMode: "worktree" })).defaultThreadEnvMode,
909 ).toBe("worktree");
910 });
911
912 it("defaults start-from-origin on for legacy configs", () => {
913 expect(decodeServerSettings({}).newWorktreesStartFromOrigin).toBe(true);
914 });
915
916 it("accepts start-from-origin updates", () => {
917 expect(
918 decodeServerSettingsPatch({ newWorktreesStartFromOrigin: false }).newWorktreesStartFromOrigin,
919 ).toBe(false);
920 });
921
922 it("defaults worktree submodules to inherit and tolerates unknown modes", () => {
923 expect(decodeServerSettings({}).worktreeSubmodules).toBeNull();
924 expect(decodeServerSettings({ worktreeSubmodules: "top-level" }).worktreeSubmodules).toBe(
925 "top-level",
926 );
927 expect(decodeServerSettings({ worktreeSubmodules: "shallow" }).worktreeSubmodules).toBeNull();
928 expect(decodeServerSettingsPatch({ worktreeSubmodules: null }).worktreeSubmodules).toBeNull();
929 });
930});
931
932describe("ServerSettings.sourceControlWritingStyle", () => {
933 it("defaults all style settings for legacy configs", () => {
934 const settings = decodeServerSettings({});
935
936 expect(settings.sourceControlWritingStyle).toEqual({
937 mode: "repo_conventions",
938 customInstructions: "",
939 followChangeRequestTemplates: true,
940 });
941 expect(settings.sourceControlWriterModelSelection).toBeNull();
942 });
943
944 it("trims partial style updates", () => {
945 const patch = decodeServerSettingsPatch({
946 sourceControlWritingStyle: {
947 mode: "custom",
948 customInstructions: " Prefer concise wording. ",
949 },
950 });
951
952 expect(patch.sourceControlWritingStyle).toEqual({
953 mode: "custom",
954 customInstructions: "Prefer concise wording.",
955 });
956 });
957});
958
959describe("ServerSettingsPatch.providerInstances", () => {
960 it("treats providerInstances as an optional whole-map replacement", () => {
961 const patch = decodeServerSettingsPatch({});
962 expect(patch.providerInstances).toBeUndefined();
963
964 const replacement = decodeServerSettingsPatch({
965 providerInstances: {
966 codex_personal: { driver: "codex", config: { homePath: "~/.codex" } },
967 },
968 });
969 expect(replacement.providerInstances).toBeDefined();
970 expect(replacement.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
971 "codex",
972 );
973 });
974
975 it("preserves a fork-defined driver entry through patch decoding", () => {
976 const patch = decodeServerSettingsPatch({
977 providerInstances: {
978 ollama_local: {
979 driver: "ollama",
980 config: { endpoint: "http://localhost:11434" },
981 },
982 },
983 });
984 const ollamaId = ProviderInstanceId.make("ollama_local");
985 expect(patch.providerInstances?.[ollamaId]?.driver).toBe("ollama");
986 });
987});
988
989describe("ServerSettingsPatch string normalization", () => {
990 it("trims string settings while decoding patches", () => {
991 const patch = decodeServerSettingsPatch({
992 addProjectBaseDirectory: " ~/Development ",
993 textGenerationModelSelection: { model: " gpt-5.4-mini " },
994 observability: {
995 otlpTracesUrl: " http://localhost:4318/v1/traces ",
996 },
997 providerInstances: {
998 codex_personal: {
999 driver: " codex ",
1000 displayName: " Codex Personal ",
1001 config: { homePath: " ~/.codex-personal " },
1002 },
1003 },
1004 });
1005
1006 expect(patch.addProjectBaseDirectory).toBe("~/Development");
1007 expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini");
1008 expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces");
1009 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe(
1010 "codex",
1011 );
1012 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.displayName).toBe(
1013 "Codex Personal",
1014 );
1015 expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.config).toEqual({
1016 homePath: " ~/.codex-personal ",
1017 });
1018 });
1019
1020 it("trims encoded server settings values before validation", () => {
1021 const defaultSettings = decodeServerSettings({});
1022 const encoded = encodeServerSettings({
1023 ...defaultSettings,
1024 addProjectBaseDirectory: " ~/Development ",
1025 });
1026
1027 expect(encoded.addProjectBaseDirectory).toBe("~/Development");
1028 });
1029});
1030
1031describe("ServerSettings environment icon", () => {
1032 it("defaults to null", () => {
1033 expect(decodeServerSettings({}).environmentIcon).toBeNull();
1034 });
1035
1036 it("keeps a kind this build knows", () => {
1037 expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toBe("mac-mini");
1038 expect(decodeServerSettings({ environmentIcon: "linux" }).environmentIcon).toBe("linux");
1039 });
1040
1041 it("decodes a kind from a newer server as null instead of failing the snapshot", () => {
1042 expect(decodeServerSettings({ environmentIcon: "toaster" }).environmentIcon).toBeNull();
1043 });
1044
1045 it("round-trips through encode", () => {
1046 const settings = decodeServerSettings({ environmentIcon: "laptop" });
1047 expect(encodeServerSettings(settings).environmentIcon).toBe("laptop");
1048
1049 const linuxSettings = decodeServerSettings({ environmentIcon: "linux" });
1050 expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux");
1051 });
1052});
1053
1054const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings);
1055
1056it("validates remote device hosts and rejects ambiguous host ids", () => {
1057 const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 };
1058 expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([host]);
1059 expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow();
1060 expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow();
1061 expect(() =>
1062 decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }),
1063 ).toThrow();
1064 expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow();
1065});
1066
1067describe("branch naming settings", () => {
1068 it("defaults existing settings to the t3 static prefix", () => {
1069 expect(decodeServerSettings({})).toMatchObject({
1070 branchNamingMode: "static",
1071 branchNamePrefix: "t3",
1072 branchNameInstructions: "",
1073 });
1074 });
1075 it.each(["static", "semantic", "custom"])(
1076 "round-trips %s and project overrides",
1077 (branchNamingMode) => {
1078 const naming = {
1079 branchNamingMode,
1080 branchNamePrefix: "team/",
1081 branchNameInstructions: "Include the issue ID.",
1082 };
1083 const input = { ...naming, projectSettingsOverrides: { project: naming } };
1084 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
1085 expect(decodeServerSettingsPatch(input)).toEqual(input);
1086 },
1087 );
1088});
1089
1090describe("ServerSettings.removeAgentCreditsOnMerge", () => {
1091 it("keeps agent credits by default and accepts opt-in patches", () => {
1092 expect(decodeServerSettings({}).removeAgentCreditsOnMerge).toBe(false);
1093 expect(
1094 decodeServerSettingsPatch({ removeAgentCreditsOnMerge: true }).removeAgentCreditsOnMerge,
1095 ).toBe(true);
1096 expect(
1097 decodeServerSettings({
1098 projectSettingsOverrides: { project: { removeAgentCreditsOnMerge: true } },
1099 }).projectSettingsOverrides["project" as ProjectId]?.removeAgentCreditsOnMerge,
1100 ).toBe(true);
1101 });
1102});
1103
1104describe("resolveProjectProviderInstanceEnabled", () => {
1105 const projectId = "project-a" as ProjectId;
1106 const instanceId = ProviderInstanceId.make("codex_work");
1107
1108 it("inherits the machine value when the project has no override", () => {
1109 const settings: ServerSettings = {
1110 ...DEFAULT_SERVER_SETTINGS,
1111 providerInstances: {
1112 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
1113 },
1114 };
1115 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false);
1116 });
1117
1118 it("lets a project enable an instance the machine disabled", () => {
1119 const settings: ServerSettings = {
1120 ...DEFAULT_SERVER_SETTINGS,
1121 providerInstances: {
1122 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false },
1123 },
1124 projectSettingsOverrides: {
1125 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
1126 },
1127 };
1128 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(true);
1129 });
1130
1131 it("lets a project disable an instance the machine enabled", () => {
1132 const settings: ServerSettings = {
1133 ...DEFAULT_SERVER_SETTINGS,
1134 providerInstances: {
1135 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
1136 },
1137 projectSettingsOverrides: {
1138 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
1139 },
1140 };
1141 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false);
1142 });
1143
1144 it("always resolves to the machine value when no project is in scope", () => {
1145 const settings: ServerSettings = {
1146 ...DEFAULT_SERVER_SETTINGS,
1147 providerInstances: {
1148 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
1149 },
1150 projectSettingsOverrides: {
1151 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
1152 },
1153 };
1154 expect(resolveProjectProviderInstanceEnabled(settings, null, instanceId)).toBe(true);
1155 });
1156
1157 it("falls back to the unconfigured default when the instance has no settings entry", () => {
1158 const settings: ServerSettings = { ...DEFAULT_SERVER_SETTINGS, providerInstances: {} };
1159 // `codex_work` has no entry in `providerInstances`, so with no project
1160 // override either, this is the same unconfigured-default fallback a
1161 // machine-wide (no project) lookup would use.
1162 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(
1163 resolveProjectProviderInstanceEnabled(settings, null, instanceId),
1164 );
1165
1166 const overridden = {
1167 ...settings,
1168 projectSettingsOverrides: {
1169 [projectId]: { providerInstances: { [instanceId]: { enabled: true } } },
1170 },
1171 };
1172 expect(resolveProjectProviderInstanceEnabled(overridden, projectId, instanceId)).toBe(true);
1173 });
1174});
1175
1176describe("requiredScopesForProjectMutation", () => {
1177 it("adds the settings scope only when a mutation carries scripts", () => {
1178 const projectId = "project-1" as ProjectId;
1179 const commandId = "command-1" as CommandId;
1180 expect(
1181 requiredScopesForProjectMutation({ type: "project.update", commandId, projectId }),
1182 ).toEqual(["orchestration:operate"]);
1183 expect(
1184 requiredScopesForProjectMutation({
1185 type: "project.update",
1186 commandId,
1187 projectId,
1188 scripts: [],
1189 }),
1190 ).toEqual(["orchestration:operate", "settings:write"]);
1191 });
1192});
1193
1194describe("server model preferences", () => {
1195 it("decodes a visibility update scoped to a provider instance and model", () => {
1196 const patch = { setModelsHidden: [{ provider: "codex_work", model: "sol", hidden: false }] };
1197 expect(decodeServerSettingsPatch(patch)).toEqual(patch);
1198 expect(() =>
1199 decodeServerSettingsPatch({ setModelsHidden: [{ provider: "codex_work", model: "sol" }] }),
1200 ).toThrow();
1201 });
1202 it("distinguishes unmigrated lists from deliberately cleared lists", () => {
1203 expect(decodeServerSettings({})).toMatchObject({
1204 favorites: null,
1205 providerModelPreferences: null,
1206 });
1207 const input = { favorites: [], providerModelPreferences: {} };
1208 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
1209 expect(decodeServerSettingsPatch(input)).toEqual(input);
1210 });
1211
1212 it("round trips favorites and hidden models keyed by provider instance", () => {
1213 const input = {
1214 favorites: [{ provider: "codex_work", model: "sol" }],
1215 providerModelPreferences: { codex_work: { hiddenModels: ["astra"], modelOrder: ["sol"] } },
1216 };
1217 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
1218 expect(decodeServerSettingsPatch(input)).toEqual(input);
1219 expect(decodeServerSettingsPatch({ migrateModelPreferences: input })).toEqual({
1220 migrateModelPreferences: input,
1221 });
1222 });
1223});