Open History to see the last commit
1import {
2 DEFAULT_SERVER_SETTINGS,
3 EnvironmentAuthorizationError,
4 EnvironmentId,
5 ProjectId,
6 ProviderInstanceId,
7 type ServerSettings,
8 type ServerSettingsPatch,
9} from "@t3tools/contracts";
10import { describe, expect, it, vi } from "vite-plus/test";
11import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
12import * as Cause from "effect/Cause";
13import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
14
15import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
16import {
17 applyPendingScopedWrites,
18 createScopedSettingsWriter,
19 persistScopedSettingsPatch,
20 planProjectOverridesClear,
21 planScopedSettingsClear,
22 planScopedSettingsPatch,
23 scopedIdleCompaction,
24 resolveScopedSettingsTargets,
25 scopedSettingsAreMixed,
26 scopedSettingsSource,
27 selectScopedSettingsEnvironments,
28 settlePendingScopedWrites,
29 type PendingScopedWrite,
30} from "./scopedSettings";
31import { resolveSettingsScope } from "./settingsScope";
32
33function environment(
34 id: string,
35 options: {
36 connected?: boolean;
37 loaded?: boolean;
38 settings?: Partial<ServerSettings>;
39 } = {},
40) {
41 return {
42 environmentId: EnvironmentId.make(id),
43 label: id,
44 connection: {
45 phase: options.connected === false ? ("offline" as const) : ("connected" as const),
46 },
47 serverConfig:
48 options.loaded === false
49 ? null
50 : {
51 settings: { ...DEFAULT_SERVER_SETTINGS, ...options.settings },
52 },
53 };
54}
55
56const laptop = environment("Laptop");
57const server = environment("Server");
58const offline = environment("Offline", { connected: false });
59const loading = environment("Loading", { loaded: false });
60const environments = [laptop, server, offline, loading];
61const all = resolveSettingsScope({}, [], environments);
62const named = resolveSettingsScope({ machine: server.environmentId }, [], environments);
63
64const projectId = ProjectId.make("project");
65const laptopProjectId = ProjectId.make("laptop-project");
66const member = {
67 id: projectId,
68 environmentId: server.environmentId,
69 title: "Project",
70 workspaceRoot: "/repo",
71 physicalProjectKey: `${server.environmentId}:/repo`,
72 environmentLabel: server.label,
73 defaultModelSelection: null,
74 scripts: [],
75 createdAt: "2026-09-07T00:00:00.000Z",
76 updatedAt: "2026-09-07T00:00:00.000Z",
77};
78const laptopMember = {
79 ...member,
80 id: laptopProjectId,
81 environmentId: laptop.environmentId,
82 physicalProjectKey: `${laptop.environmentId}:/repo`,
83 environmentLabel: laptop.label,
84};
85const group: SidebarProjectSnapshot = {
86 ...member,
87 projectKey: "project-group",
88 displayName: "Project",
89 memberProjects: [member, laptopMember],
90 memberProjectRefs: [
91 { environmentId: server.environmentId, projectId },
92 { environmentId: laptop.environmentId, projectId: laptopProjectId },
93 ],
94 groupedProjectCount: 2,
95 environmentPresence: "remote-only",
96 allRemoteMembersAreDesktopLocal: false,
97 allRemoteMembersAreWsl: false,
98 remoteEnvironmentLabels: [server.label, laptop.label],
99 pinned: false,
100 archived: false,
101};
102const project = resolveSettingsScope({ project: group.projectKey }, [group], environments);
103const checkout = resolveSettingsScope(
104 { project: group.projectKey, machine: server.environmentId, checkout: member.physicalProjectKey },
105 [group],
106 environments,
107);
108
109describe("scoped settings targets", () => {
110 it("uses the named environment even when a different primary is available", () => {
111 const selected = selectScopedSettingsEnvironments(named, environments, laptop.environmentId);
112 expect(selected.environments).toEqual([server]);
113 expect(selected.environment).toBe(server);
114 });
115
116 it("keeps an offline named environment selected without falling back to primary", () => {
117 const scope = resolveSettingsScope({ machine: offline.environmentId }, [], environments);
118 const selected = selectScopedSettingsEnvironments(scope, environments, laptop.environmentId);
119 expect(selected.environments).toEqual([offline]);
120 expect(selected.connectedEnvironments).toEqual([]);
121 expect(selected.environment).toBeNull();
122 expect(
123 planScopedSettingsPatch(scope, environments, { enableProviderUpdateChecks: false }),
124 ).toMatchObject({
125 serverWrites: [],
126 unavailableReason: "Connect Offline to save this setting.",
127 });
128 });
129
130 it("resolves each member's effective settings and source at project scope", () => {
131 const overridden = environment("Server", {
132 settings: {
133 defaultAutoPull: false,
134 projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
135 },
136 });
137 const targets = resolveScopedSettingsTargets(project, [laptop, overridden]);
138 expect(targets.map((target) => [target.projectId, target.settings.defaultAutoPull])).toEqual([
139 [projectId, true],
140 [laptopProjectId, false],
141 ]);
142 expect(scopedSettingsSource(targets, ["defaultAutoPull"])).toBe("mixed");
143 expect(scopedSettingsSource([targets[0]!], ["defaultAutoPull"])).toBe("project");
144 expect(scopedSettingsSource(targets, ["enableProviderUpdateChecks"])).toBe("environment");
145 expect(scopedSettingsAreMixed(targets, ["defaultAutoPull"])).toBe(true);
146 });
147});
148
149describe("scoped settings writes", () => {
150 it("edits the effective machine policy without changing other machines' rules", () => {
151 const custom = environment("Laptop", {
152 settings: {
153 worktreeCleanup: {
154 mode: "custom",
155 rules: {
156 worktreeAfterDays: 12,
157 worktreeSettledAfterDays: null,
158 worktreeOnDelete: true,
159 worktreeOnMerge: true,
160 worktreeUnchanged: false,
161 worktreeKeepWhen: "uncommitted-changes",
162 },
163 },
164 },
165 });
166 const disabled = environment("Server", {
167 settings: { worktreeCleanup: { mode: "off" } },
168 });
169 const targets = [custom, disabled];
170 const plan = planScopedSettingsPatch(all, targets, {
171 storageCleanup: { worktreeOnDelete: false },
172 });
173 const policies = plan.serverWrites.map((write, index) =>
174 resolveWorktreeCleanup(
175 applyServerSettingsPatch(targets[index]!.serverConfig!.settings, write.patch),
176 null,
177 ),
178 );
179 expect(policies).toEqual([
180 {
181 worktreeAfterDays: 12,
182 worktreeSettledAfterDays: null,
183 worktreeOnDelete: false,
184 worktreeOnMerge: true,
185 worktreeUnchanged: false,
186 worktreeKeepWhen: "uncommitted-changes",
187 },
188 {
189 worktreeAfterDays: null,
190 worktreeSettledAfterDays: null,
191 worktreeOnDelete: false,
192 worktreeOnMerge: false,
193 worktreeUnchanged: false,
194 worktreeKeepWhen: "uncommitted-changes",
195 },
196 ]);
197 });
198
199 it("compares idle compaction by mode, and offers Never only where every server stores it", () => {
200 const fixed = {
201 settings: {
202 ...DEFAULT_SERVER_SETTINGS,
203 compactIdleThreads: false,
204 idleCompaction: "onReturn" as const,
205 },
206 };
207 const modes = {
208 settings: { ...DEFAULT_SERVER_SETTINGS, compactIdleThreads: "onReturn" as const },
209 };
210 const legacy = { settings: { ...DEFAULT_SERVER_SETTINGS, compactIdleThreads: false } };
211 expect(scopedIdleCompaction([fixed, modes])).toEqual({ mixed: false, neverAvailable: true });
212 expect(scopedIdleCompaction([fixed, legacy])).toEqual({ mixed: false, neverAvailable: false });
213 const legacyEnvironment = environment("Legacy", { settings: legacy.settings });
214 const projected = applyPendingScopedWrites(
215 [legacyEnvironment],
216 [
217 {
218 save: {},
219 environmentId: legacyEnvironment.environmentId,
220 patch: { defaultRuntimeMode: "auto" },
221 },
222 ],
223 );
224 expect(scopedIdleCompaction(resolveScopedSettingsTargets(all, projected)).neverAvailable).toBe(
225 false,
226 );
227 expect(
228 scopedIdleCompaction([fixed, { settings: { ...fixed.settings, idleCompaction: "off" } }])
229 .mixed,
230 ).toBe(true);
231 });
232
233 it("sends an idle compaction mode in the form each environment's server reads", () => {
234 const targets = [
235 environment("Fixed", { settings: { compactIdleThreads: false, idleCompaction: "onReturn" } }),
236 environment("Modes", { settings: { compactIdleThreads: "onReturn" } }),
237 environment("Switch", { settings: { compactIdleThreads: false } }),
238 ];
239 const plan = planScopedSettingsPatch(resolveSettingsScope({}, [], targets), targets, {
240 idleCompaction: "beforeCacheExpires",
241 });
242 expect(plan.serverWrites.map((write) => write.patch)).toEqual([
243 { idleCompaction: "beforeCacheExpires" },
244 { compactIdleThreads: "beforeCacheExpires" },
245 { compactIdleThreads: true },
246 ]);
247 });
248
249 it("writes an aggregate preference only to connected environments with loaded configuration", async () => {
250 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
251 const persistClient = vi.fn();
252 await persistScopedSettingsPatch(
253 planScopedSettingsPatch(all, environments, { enableProviderUpdateChecks: false }),
254 persistServer,
255 persistClient,
256 );
257 expect(persistServer.mock.calls.map(([input]) => input.environmentId)).toEqual([
258 laptop.environmentId,
259 server.environmentId,
260 ]);
261 expect(persistClient).not.toHaveBeenCalled();
262 });
263
264 describe("an environment that saves but drops keys it does not know", () => {
265 const upgradedLaptop = (saved: Partial<ServerSettings>) =>
266 vi.fn(async ({ environmentId }: { environmentId: EnvironmentId }) => ({
267 _tag: "Success" as const,
268 value: {
269 ...DEFAULT_SERVER_SETTINGS,
270 ...(environmentId === laptop.environmentId ? saved : {}),
271 },
272 }));
273 const outcome = async (
274 plan: ReturnType<typeof planScopedSettingsPatch>,
275 persistServer: ReturnType<typeof upgradedLaptop>,
276 ) => {
277 const result = await persistScopedSettingsPatch(plan, persistServer, vi.fn());
278 return {
279 saved: result.savedEnvironments.map(({ label }) => label),
280 failed: result.failedEnvironments.map(({ label, message }) => ({ label, message })),
281 };
282 };
283 const unsupported = [
284 {
285 label: server.label,
286 message: "This environment does not support this setting. Update T3 Code on it.",
287 },
288 ];
289
290 it("reports a dropped toggle", async () => {
291 expect(
292 await outcome(
293 planScopedSettingsPatch(all, environments, { reviewGuidesEnabled: true }),
294 upgradedLaptop({ reviewGuidesEnabled: true }),
295 ),
296 ).toEqual({ saved: [laptop.label], failed: unsupported });
297 });
298
299 it("reports dropped text but accepts the server trimming it", async () => {
300 expect(
301 await outcome(
302 planScopedSettingsPatch(all, environments, { reviewGuideInstructions: " Read tests " }),
303 upgradedLaptop({ reviewGuideInstructions: "Read tests" }),
304 ),
305 ).toEqual({ saved: [laptop.label], failed: unsupported });
306 });
307
308 // regression: #18334 an environment silently dropped a setting with no warning
309 it("reports a dropped key inside a project override", async () => {
310 expect(
311 await outcome(
312 planScopedSettingsPatch(project, environments, { defaultAutoPull: true }),
313 upgradedLaptop({
314 projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } },
315 }),
316 ),
317 ).toEqual({ saved: [laptop.label], failed: unsupported });
318 });
319
320 it("accepts a write that matches what the environment already had", async () => {
321 expect(
322 await outcome(
323 planScopedSettingsPatch(all, environments, { reviewGuidesEnabled: false }),
324 upgradedLaptop({}),
325 ),
326 ).toEqual({ saved: [laptop.label, server.label], failed: [] });
327 });
328 });
329
330 it("persists client keys locally at any scope alongside server keys", async () => {
331 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
332 const persistClient = vi.fn();
333 await persistScopedSettingsPatch(
334 planScopedSettingsPatch(named, environments, {
335 diffIgnoreWhitespace: false,
336 enableProviderUpdateChecks: false,
337 }),
338 persistServer,
339 persistClient,
340 );
341 expect(persistClient).toHaveBeenCalledExactlyOnceWith({ diffIgnoreWhitespace: false });
342 expect(persistServer).toHaveBeenCalledExactlyOnceWith({
343 environmentId: server.environmentId,
344 input: { patch: { enableProviderUpdateChecks: false } },
345 });
346 });
347
348 it("writes project overrides into each member's entry on its environment", () => {
349 const withExisting = environment("Server", {
350 settings: {
351 projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } },
352 },
353 });
354 const plan = planScopedSettingsPatch(project, [laptop, withExisting], {
355 defaultAutoPull: true,
356 });
357 expect(plan.unavailableReason).toBeNull();
358 expect(plan.serverWrites).toEqual([
359 {
360 environmentId: server.environmentId,
361 label: server.label,
362 previous: expect.anything(),
363 patch: {
364 projectSettingsOverrides: {
365 [projectId]: { enableAgentBrowserAccess: false, defaultAutoPull: true },
366 },
367 },
368 },
369 {
370 environmentId: laptop.environmentId,
371 label: laptop.label,
372 previous: expect.anything(),
373 patch: { projectSettingsOverrides: { [laptopProjectId]: { defaultAutoPull: true } } },
374 },
375 ]);
376 expect(
377 planScopedSettingsPatch(checkout, [laptop, server], { defaultAutoPull: true }),
378 ).toMatchObject({
379 serverWrites: [{ environmentId: server.environmentId }],
380 });
381 });
382
383 it("keeps each project's other cleanup rules when changing one rule across machines", () => {
384 const machine = environment("Laptop", {
385 settings: {
386 storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 30 },
387 },
388 });
389 const customized = environment("Server", {
390 settings: {
391 projectSettingsOverrides: {
392 [projectId]: {
393 defaultAutoPull: true,
394 worktreeCleanup: {
395 mode: "custom",
396 rules: {
397 worktreeAfterDays: 8,
398 worktreeSettledAfterDays: null,
399 worktreeOnDelete: false,
400 worktreeOnMerge: true,
401 worktreeUnchanged: false,
402 worktreeKeepWhen: "uncommitted-changes",
403 },
404 },
405 },
406 },
407 },
408 });
409 const plan = planScopedSettingsPatch(project, [machine, customized], {
410 worktreeCleanup: { mode: "custom", rules: { worktreeOnDelete: true } },
411 });
412 const settledPlan = planScopedSettingsPatch(project, [machine, customized], {
413 worktreeCleanup: { mode: "custom", rules: { worktreeSettledAfterDays: 0 } },
414 });
415 expect(
416 settledPlan.serverWrites.map((write) => write.patch.projectSettingsOverrides),
417 ).toMatchObject([
418 { [projectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } },
419 { [laptopProjectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } },
420 ]);
421 expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([
422 {
423 [projectId]: {
424 defaultAutoPull: true,
425 worktreeCleanup: {
426 mode: "custom",
427 rules: {
428 worktreeAfterDays: 8,
429 worktreeSettledAfterDays: null,
430 worktreeOnDelete: true,
431 worktreeOnMerge: true,
432 worktreeUnchanged: false,
433 worktreeKeepWhen: "uncommitted-changes",
434 },
435 },
436 },
437 },
438 {
439 [laptopProjectId]: {
440 worktreeCleanup: {
441 mode: "custom",
442 rules: {
443 worktreeAfterDays: 30,
444 worktreeSettledAfterDays: null,
445 worktreeOnDelete: true,
446 worktreeOnMerge: false,
447 worktreeUnchanged: false,
448 worktreeKeepWhen: "uncommitted-changes",
449 },
450 },
451 },
452 },
453 ]);
454 expect(
455 planScopedSettingsClear(checkout, [customized], ["worktreeCleanup"]).serverWrites[0]?.patch,
456 ).toEqual({
457 projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
458 });
459 expect(
460 planScopedSettingsPatch(project, [machine, customized], {
461 storageCleanup: { browserArtifactsAfterDays: 8 },
462 }).serverWrites,
463 ).toEqual([]);
464 });
465
466 // regression: #11541 device settings were written to the wrong scope
467 it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => {
468 const plan = planScopedSettingsPatch(project, [laptop, server], {
469 enableAgentDeviceAccess: true,
470 });
471 expect(plan.serverWrites.map((write) => write.patch)).toEqual([
472 { projectSettingsOverrides: { [projectId]: { enableAgentDeviceAccess: true } } },
473 { projectSettingsOverrides: { [laptopProjectId]: { enableAgentDeviceAccess: true } } },
474 ]);
475 for (const patch of [{ enableDeviceSupport: true }, { deviceHosts: [] }]) {
476 expect(planScopedSettingsPatch(project, environments, patch).serverWrites).toEqual([]);
477 }
478 });
479
480 it("refuses environment-wide keys at project scope", () => {
481 expect(
482 planScopedSettingsPatch(project, environments, { enableProviderUpdateChecks: false }),
483 ).toMatchObject({
484 serverWrites: [],
485 unavailableReason: "This setting is environment-wide and cannot be overridden by a project.",
486 });
487 });
488
489 // regression: #35093 client settings saved in project scope overwrote the global default
490 it("refuses client-only keys at project scope", () => {
491 for (const scope of [project, checkout]) {
492 expect(
493 planScopedSettingsPatch(scope, environments, {
494 browserDefaultViewport: { _tag: "freeform", width: 1440, height: 900 },
495 }),
496 ).toMatchObject({
497 clientPatch: {},
498 hasClientWrite: false,
499 serverWrites: [],
500 unavailableReason:
501 "This setting is environment-wide and cannot be overridden by a project.",
502 });
503 expect(
504 planScopedSettingsPatch(
505 scope,
506 environments,
507 { fontSizeInterface: 15 },
508 { deviceOnly: true },
509 ),
510 ).toMatchObject({ clientPatch: { fontSizeInterface: 15 }, unavailableReason: null });
511 }
512 });
513
514 it("clears overrides per member and removes an emptied entry", () => {
515 const withOverrides = environment("Server", {
516 settings: {
517 projectSettingsOverrides: {
518 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
519 },
520 },
521 });
522 const plan = planScopedSettingsClear(checkout, [laptop, withOverrides], ["defaultAutoPull"]);
523 expect(plan.serverWrites).toEqual([
524 {
525 environmentId: server.environmentId,
526 label: server.label,
527 previous: expect.anything(),
528 patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } },
529 },
530 ]);
531 expect(
532 planScopedSettingsClear(
533 checkout,
534 [laptop, withOverrides],
535 ["defaultAutoPull", "enableAgentBrowserAccess"],
536 ).serverWrites[0]?.patch,
537 ).toEqual({ projectSettingsOverrides: { [projectId]: null } });
538 });
539
540 it("never substitutes an environment-default write for an invalid scope", () => {
541 const scope = resolveSettingsScope({ machine: "removed" }, [group], environments);
542 const plan = planScopedSettingsPatch(scope, environments, { enableAgentBrowserAccess: false });
543 expect(plan.serverWrites).toEqual([]);
544 expect(plan.hasClientWrite).toBe(false);
545 expect(plan.unavailableReason).not.toBeNull();
546 });
547
548 it("waits for every target and identifies both RPC failures and rejected writes", async () => {
549 const third = environment("Third");
550 const fourth = environment("Fourth");
551 const selected = [...environments, third, fourth];
552 const scope = resolveSettingsScope({}, [], selected);
553 const persistServer = vi
554 .fn()
555 .mockResolvedValueOnce({ _tag: "Success" })
556 .mockResolvedValueOnce({
557 _tag: "Failure",
558 cause: Cause.fail(
559 new EnvironmentAuthorizationError({
560 requiredScope: "settings:write",
561 message: "This connection lacks permission to change settings.",
562 }),
563 ),
564 })
565 .mockRejectedValueOnce(new Error("Disconnected during save"))
566 .mockResolvedValueOnce({ _tag: "Success" });
567 const result = await persistScopedSettingsPatch(
568 planScopedSettingsPatch(scope, selected, { enableAgentBrowserAccess: false }),
569 persistServer,
570 vi.fn(),
571 );
572 expect(result.savedEnvironmentCount).toBe(2);
573 expect(result.savedEnvironments.map(({ label }) => label)).toEqual([
574 laptop.label,
575 fourth.label,
576 ]);
577 expect(result.failedEnvironments.map(({ message }) => message)).toEqual([
578 "This connection lacks permission to change settings.",
579 "Disconnected during save",
580 ]);
581 expect(result.failedEnvironments.map(({ label }) => label)).toEqual([
582 server.label,
583 third.label,
584 ]);
585 expect(persistServer).toHaveBeenCalledTimes(4);
586 });
587
588 describe("rapid project saves on one environment", () => {
589 const saving = (initial: ServerSettings) => {
590 let stored = initial;
591 const persistServer = vi.fn(async ({ input }: { input: { patch: ServerSettingsPatch } }) => {
592 stored = applyServerSettingsPatch(stored, input.patch);
593 return { _tag: "Success" as const, value: stored };
594 });
595 return { persistServer, stored: () => stored };
596 };
597
598 // regression: #18426 rapid project saves rebuilt overrides from a copy that trailed them
599 it("keeps every edit and reset while the client's copy trails its saves", async () => {
600 let copy: ServerSettings = {
601 ...DEFAULT_SERVER_SETTINGS,
602 projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } },
603 };
604 const host = saving(copy);
605 // No config push arrives, so the client's copy stays as it was before the first save.
606 const write = createScopedSettingsWriter(() => copy);
607 const save = (plan: Parameters<typeof write>[1]) =>
608 write([environment("Server", { settings: copy })], plan, host.persistServer, vi.fn());
609 await Promise.all([
610 save((current) => planScopedSettingsPatch(checkout, current, { defaultAutoPull: true })),
611 save((current) => planScopedSettingsClear(checkout, current, ["enableAgentBrowserAccess"])),
612 save((current) =>
613 planScopedSettingsPatch(checkout, current, { removeAgentCreditsOnMerge: true }),
614 ),
615 ]);
616 expect(host.stored().projectSettingsOverrides[projectId]).toEqual({
617 defaultAutoPull: true,
618 removeAgentCreditsOnMerge: true,
619 });
620 // The first save's push arrives late, after the newer saves have finished.
621 copy = (await host.persistServer.mock.results[0]!.value).value;
622 await save((current) =>
623 planScopedSettingsPatch(checkout, current, { defaultThreadEnvMode: "worktree" }),
624 );
625 expect(host.stored().projectSettingsOverrides[projectId]).toEqual({
626 defaultAutoPull: true,
627 removeAgentCreditsOnMerge: true,
628 defaultThreadEnvMode: "worktree",
629 });
630 });
631
632 it.each(["remote", "previous save"] as const)(
633 "keeps the current base when a %s push arrives during a save",
634 async (push) => {
635 let copy: ServerSettings = DEFAULT_SERVER_SETTINGS;
636 const host = saving(copy);
637 const write = createScopedSettingsWriter(() => copy);
638 const edit = (patch: ServerSettingsPatch) =>
639 write(
640 [environment("Server", { settings: copy })],
641 (current) => planScopedSettingsPatch(checkout, current, patch),
642 host.persistServer,
643 vi.fn(),
644 );
645 await edit({ defaultAutoPull: true });
646 const previous = host.stored();
647 const persist = host.persistServer.getMockImplementation()!;
648 host.persistServer.mockImplementationOnce(async (request) => {
649 const response = await persist(request);
650 if (push === "remote") {
651 await persist({
652 input: {
653 patch: {
654 projectSettingsOverrides: {
655 [projectId]: { enableAgentBrowserAccess: false },
656 },
657 },
658 },
659 });
660 copy = host.stored();
661 } else {
662 copy = { ...previous };
663 }
664 return response;
665 });
666 await edit({ removeAgentCreditsOnMerge: true });
667 await edit({ defaultThreadEnvMode: "worktree" });
668 expect(host.stored().projectSettingsOverrides[projectId]).toEqual(
669 push === "remote"
670 ? { enableAgentBrowserAccess: false, defaultThreadEnvMode: "worktree" }
671 : {
672 defaultAutoPull: true,
673 removeAgentCreditsOnMerge: true,
674 defaultThreadEnvMode: "worktree",
675 },
676 );
677 },
678 );
679
680 it("plans from a copy that changed after the last save, and saves again after a failure", async () => {
681 let copy: ServerSettings = DEFAULT_SERVER_SETTINGS;
682 const host = saving(copy);
683 const write = createScopedSettingsWriter(() => copy);
684 const edit = (patch: ServerSettingsPatch) =>
685 write(
686 [environment("Server", { settings: copy })],
687 (current) => planScopedSettingsPatch(checkout, current, patch),
688 host.persistServer,
689 vi.fn(),
690 );
691 host.persistServer.mockRejectedValueOnce(new Error("Disconnected"));
692 expect((await edit({ defaultAutoPull: true })).failedEnvironments).toMatchObject([
693 { message: "Disconnected" },
694 ]);
695 expect((await edit({ defaultAutoPull: true })).savedEnvironmentCount).toBe(1);
696 // Another client replaces the override, and its push reaches this client.
697 await host.persistServer({
698 input: {
699 patch: { projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } } },
700 },
701 });
702 copy = host.stored();
703 await edit({ removeAgentCreditsOnMerge: true });
704 expect(host.stored().projectSettingsOverrides[projectId]).toEqual({
705 enableAgentBrowserAccess: false,
706 removeAgentCreditsOnMerge: true,
707 });
708 });
709 });
710});
711
712describe("scoped settings mixed values", () => {
713 it("treats independently decoded equal nested settings as the same value", () => {
714 const style = {
715 ...DEFAULT_SERVER_SETTINGS.sourceControlWritingStyle,
716 mode: "custom" as const,
717 customInstructions: "Use plain language",
718 };
719 const first = environment("First", { settings: { sourceControlWritingStyle: { ...style } } });
720 const second = environment("Second", { settings: { sourceControlWritingStyle: { ...style } } });
721 const targets = resolveScopedSettingsTargets(all, [first, second]);
722 expect(scopedSettingsAreMixed(targets, ["sourceControlWritingStyle"])).toBe(false);
723 });
724
725 it("agrees while a save is in flight and follows the environment once it reports back", () => {
726 const patch = { defaultRuntimeMode: "auto" as const };
727 const saved = environment("Laptop", { settings: patch });
728 let waiting = environment("Server");
729 const save = {};
730 let writes: readonly PendingScopedWrite[] = [saved, waiting].map(({ environmentId }) => ({
731 save,
732 environmentId,
733 patch,
734 }));
735 const mixed = () =>
736 scopedSettingsAreMixed(
737 resolveScopedSettingsTargets(all, applyPendingScopedWrites([saved, waiting], writes)),
738 ["defaultRuntimeMode"],
739 );
740 const settle = (environmentId: EnvironmentId, result?: ServerSettings) => {
741 writes = settlePendingScopedWrites(
742 writes,
743 { save, environmentId },
744 (id) => [saved, waiting].find((env) => env.environmentId === id)?.serverConfig?.settings,
745 result,
746 );
747 };
748 expect(mixed()).toBe(false);
749
750 // Laptop already pushed its result; Server answers before its push arrives.
751 settle(saved.environmentId, saved.serverConfig!.settings);
752 settle(waiting.environmentId, applyServerSettingsPatch(waiting.serverConfig!.settings, patch));
753 expect(writes.map(({ environmentId }) => environmentId)).toEqual([waiting.environmentId]);
754 expect(mixed()).toBe(false);
755
756 waiting = environment("Server", { settings: { defaultRuntimeMode: "approval-required" } });
757 expect(mixed()).toBe(true);
758
759 writes = [{ save, environmentId: waiting.environmentId, patch }];
760 settle(waiting.environmentId);
761 expect(writes).toEqual([]);
762 });
763});
764
765describe("project overrides at environment scope", () => {
766 const laptop = EnvironmentId.make("laptop");
767 const fleet = ProjectId.make("fleet");
768 const t3 = ProjectId.make("t3");
769 const environment = (
770 environmentId: EnvironmentId,
771 overrides: ServerSettings["projectSettingsOverrides"],
772 ) => ({
773 environmentId,
774 label: environmentId,
775 connection: { phase: "connected" as const },
776 serverConfig: {
777 settings: { ...DEFAULT_SERVER_SETTINGS, projectSettingsOverrides: overrides },
778 environment: { capabilities: { projectSettingsOverrides: true } },
779 },
780 });
781
782 it("clears only those keys and drops entries that become empty", () => {
783 const plan = planProjectOverridesClear(
784 [
785 environment(laptop, {
786 [fleet]: { defaultAutoPull: true, defaultThreadEnvMode: "local" },
787 [t3]: { defaultAutoPull: true },
788 }),
789 ],
790 [
791 { environmentId: laptop, projectId: fleet },
792 { environmentId: laptop, projectId: t3 },
793 ],
794 ["defaultAutoPull"],
795 );
796 expect(plan.serverWrites).toEqual([
797 {
798 environmentId: laptop,
799 label: laptop,
800 previous: expect.anything(),
801 patch: {
802 projectSettingsOverrides: { [fleet]: { defaultThreadEnvMode: "local" }, [t3]: null },
803 },
804 },
805 ]);
806 });
807});
808
809describe("null patches at project scope", () => {
810 it("removes the override for keys that cannot store null and keeps it for keys that can", () => {
811 const environmentId = EnvironmentId.make("laptop");
812 const projectId = ProjectId.make("fleet");
813 const scope = {
814 kind: "project" as const,
815 group: {} as never,
816 environmentId: null,
817 label: "fleet",
818 members: [{ id: projectId, environmentId } as never],
819 environmentIds: [environmentId],
820 };
821 const environments = [
822 {
823 environmentId,
824 label: "Laptop",
825 connection: { phase: "connected" as const },
826 serverConfig: {
827 settings: {
828 ...DEFAULT_SERVER_SETTINGS,
829 projectSettingsOverrides: {
830 [projectId]: { defaultThreadEnvMode: "worktree" as const, defaultAutoPull: true },
831 },
832 },
833 environment: { capabilities: { projectSettingsOverrides: true } },
834 },
835 },
836 ];
837 expect(
838 planScopedSettingsPatch(scope, environments, { defaultThreadEnvMode: null }).serverWrites[0]
839 ?.patch,
840 ).toEqual({ projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } } });
841 expect(
842 planScopedSettingsPatch(scope, environments, { defaultModelSelection: null }).serverWrites[0]
843 ?.patch,
844 ).toEqual({
845 projectSettingsOverrides: {
846 [projectId]: {
847 defaultThreadEnvMode: "worktree",
848 defaultAutoPull: true,
849 defaultModelSelection: null,
850 },
851 },
852 });
853 });
854});
855
856describe("partial object patches at project scope", () => {
857 it("completes a writing style field patch from the target's effective value", () => {
858 const environmentId = EnvironmentId.make("laptop");
859 const projectId = ProjectId.make("fleet");
860 const member = {
861 id: projectId,
862 environmentId,
863 physicalProjectKey: "laptop:/repo",
864 environmentLabel: "Laptop",
865 title: "fleet",
866 workspaceRoot: "/repo",
867 defaultModelSelection: null,
868 scripts: [],
869 createdAt: "2026-01-01T00:00:00.000Z",
870 updatedAt: "2026-01-01T00:00:00.000Z",
871 };
872 const settings = {
873 ...DEFAULT_SERVER_SETTINGS,
874 projectSettingsOverrides: {
875 [projectId]: {
876 sourceControlWritingStyle: {
877 mode: "custom" as const,
878 customInstructions: "Keep it short.",
879 followChangeRequestTemplates: false,
880 },
881 },
882 },
883 };
884 const plan = planScopedSettingsPatch(
885 {
886 kind: "project",
887 group: {} as never,
888 environmentId: null,
889 label: "fleet",
890 members: [member as never],
891 environmentIds: [environmentId],
892 },
893 [
894 {
895 environmentId,
896 label: "Laptop",
897 connection: { phase: "connected" },
898 serverConfig: {
899 settings,
900 },
901 },
902 ],
903 { sourceControlWritingStyle: { customInstructions: "Be terse." } },
904 );
905 expect(plan.serverWrites[0]?.patch).toEqual({
906 projectSettingsOverrides: {
907 [projectId]: {
908 sourceControlWritingStyle: {
909 mode: "custom",
910 customInstructions: "Be terse.",
911 followChangeRequestTemplates: false,
912 },
913 },
914 },
915 });
916 });
917});
918
919it("routes legacy model preference keys exclusively to the selected environment", () => {
920 const patch = {
921 favorites: [{ provider: ProviderInstanceId.make("codex"), model: "sol" }],
922 providerModelPreferences: {},
923 };
924 const plan = planScopedSettingsPatch(named, environments, patch);
925 expect(plan.clientPatch).toEqual({});
926 expect(plan.hasClientWrite).toBe(false);
927 expect(plan.serverWrites).toEqual([
928 {
929 environmentId: server.environmentId,
930 label: server.label,
931 previous: expect.anything(),
932 patch,
933 },
934 ]);
935});
936
937describe("model selection patches at project scope", () => {
938 const codex = ProviderInstanceId.make("codex");
939 const claude = ProviderInstanceId.make("claudeAgent");
940 const saved = {
941 instanceId: codex,
942 model: "gpt-5.6",
943 options: [{ id: "reasoningEffort", value: "high" }],
944 };
945 const customized = environment("Server", {
946 settings: {
947 projectSettingsOverrides: {
948 [projectId]: {
949 defaultModelSelection: saved,
950 sourceControlWriterModelSelection: saved,
951 reviewGuideModelSelection: saved,
952 textGenerationModelSelection: saved,
953 },
954 },
955 },
956 });
957
958 // regression: #16581 changing a project's model kept the old model's options
959 it("replaces old options when changing model selections", () => {
960 const plan = planScopedSettingsPatch(checkout, [customized], {
961 defaultModelSelection: { instanceId: claude, model: "claude-opus-4-8" },
962 sourceControlWriterModelSelection: { instanceId: codex, model: "gpt-5.6-mini" },
963 reviewGuideModelSelection: { instanceId: codex, model: "gpt-5.6-mini" },
964 textGenerationModelSelection: { instanceId: claude, model: "claude-sonnet-4-6" },
965 });
966 expect(plan.serverWrites[0]?.patch.projectSettingsOverrides?.[projectId]).toEqual({
967 defaultModelSelection: { instanceId: claude, model: "claude-opus-4-8" },
968 sourceControlWriterModelSelection: { instanceId: codex, model: "gpt-5.6-mini" },
969 reviewGuideModelSelection: { instanceId: codex, model: "gpt-5.6-mini" },
970 textGenerationModelSelection: { instanceId: claude, model: "claude-sonnet-4-6" },
971 });
972 });
973
974 it("completes partial text-generation selections without retaining old options", () => {
975 const modelPlan = planScopedSettingsPatch(checkout, [customized], {
976 textGenerationModelSelection: { model: "gpt-5.6-mini" },
977 });
978 expect(
979 modelPlan.serverWrites[0]?.patch.projectSettingsOverrides?.[projectId]
980 ?.textGenerationModelSelection,
981 ).toEqual({ instanceId: codex, model: "gpt-5.6-mini" });
982
983 const options = [{ id: "reasoningEffort", value: "low" }];
984 const optionsPlan = planScopedSettingsPatch(checkout, [customized], {
985 textGenerationModelSelection: { options },
986 });
987 expect(
988 optionsPlan.serverWrites[0]?.patch.projectSettingsOverrides?.[projectId]
989 ?.textGenerationModelSelection,
990 ).toEqual({ ...saved, options });
991 });
992});