1import {
2 DEFAULT_SERVER_SETTINGS,
3 PROJECT_SCOPED_SERVER_SETTING_KEYS,
4 ProjectId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9import { createModelSelection } from "./model.ts";
10import {
11 clearProjectSettingsOverrides,
12 hasProjectSettingsOverrides,
13 resolveNewThreadEnvMode,
14 resolveProjectFileBackedSetting,
15 resolveProjectSettings,
16 resolveWorktreeCleanup,
17 withProjectSettingsOverrides,
18} from "./projectSettings.ts";
19import { applyServerSettingsPatch } from "./serverSettings.ts";
20
21const projectId = ProjectId.make("project-a");
22const otherProjectId = ProjectId.make("project-b");
23
24describe("resolveProjectSettings", () => {
25 it("inherits every scopable key when the project has no overrides", () => {
26 const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId);
27 expect(resolved.settings).toBe(DEFAULT_SERVER_SETTINGS);
28 for (const key of PROJECT_SCOPED_SERVER_SETTING_KEYS) {
29 expect(resolved.sources[key]).toBe("environment");
30 }
31 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null).settings).toBe(
32 DEFAULT_SERVER_SETTINGS,
33 );
34 });
35
36 it("ignores an override left undefined by a forward-compatible decode", () => {
37 const resolved = resolveProjectSettings(
38 {
39 ...DEFAULT_SERVER_SETTINGS,
40 defaultRuntimeMode: "full-access",
41 projectSettingsOverrides: { [projectId]: { defaultRuntimeMode: undefined } as never },
42 },
43 projectId,
44 );
45 expect(resolved.settings.defaultRuntimeMode).toBe("full-access");
46 expect(resolved.sources.defaultRuntimeMode).toBe("environment");
47 expect(
48 hasProjectSettingsOverrides({
49 projectSettingsOverrides: { [projectId]: { defaultRuntimeMode: undefined } as never },
50 }),
51 ).toBe(false);
52 });
53
54 it("treats a null project like an absent one before the shell snapshot arrives", () => {
55 // The mobile new-task flow resolves settings while its selected project is
56 // still null; reading the aggregate's legacy fields off null crashed launch.
57 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, null, null).settings).toBe(
58 DEFAULT_SERVER_SETTINGS,
59 );
60 });
61
62 it("applies overrides per key and reports their source", () => {
63 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
64 defaultAutoPull: true,
65 sidebarAutoSettleAfterDays: 3,
66 projectSettingsOverrides: {
67 [projectId]: { defaultAutoPull: false, sidebarAutoSettleAfterDays: null },
68 },
69 });
70 const resolved = resolveProjectSettings(settings, projectId);
71 expect(resolved.settings.defaultAutoPull).toBe(false);
72 expect(resolved.settings.sidebarAutoSettleAfterDays).toBeNull();
73 expect(resolved.settings.defaultThreadEnvMode).toBe(settings.defaultThreadEnvMode);
74 expect(resolved.sources.defaultAutoPull).toBe("project");
75 expect(resolved.sources.sidebarAutoSettleAfterDays).toBe("project");
76 expect(resolved.sources.defaultThreadEnvMode).toBe("environment");
77 expect(resolveProjectSettings(settings, otherProjectId).settings.defaultAutoPull).toBe(true);
78 });
79
80 it("keeps the environment text generation model when the override's provider is disabled", () => {
81 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
82 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
83 providerInstances: {
84 [ProviderInstanceId.make("claudeAgent")]: {
85 driver: ProviderDriverKind.make("claudeAgent"),
86 enabled: false,
87 },
88 },
89 projectSettingsOverrides: {
90 [projectId]: { textGenerationModelSelection: disabledSelection },
91 },
92 });
93 const resolved = resolveProjectSettings(settings, projectId);
94 expect(resolved.settings.textGenerationModelSelection).toEqual(
95 settings.textGenerationModelSelection,
96 );
97 expect(resolved.sources.textGenerationModelSelection).toBe("environment");
98 });
99
100 it("honours the aggregate's own fields only until the server has folded them", () => {
101 const aggregateModel = createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.5");
102 const project = {
103 defaultModelSelection: aggregateModel,
104 defaultThreadEnvMode: "local" as const,
105 };
106 const unfolded = resolveProjectSettings(
107 { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: false },
108 projectId,
109 project,
110 );
111 expect(unfolded.settings.defaultModelSelection).toEqual(aggregateModel);
112 expect(unfolded.settings.defaultThreadEnvMode).toBe("local");
113 expect(unfolded.sources.defaultModelSelection).toBe("project");
114 // A stored override still beats the aggregate before the fold.
115 const overridden = resolveProjectSettings(
116 {
117 ...DEFAULT_SERVER_SETTINGS,
118 projectSettingsFolded: false,
119 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } },
120 },
121 projectId,
122 project,
123 );
124 expect(overridden.settings.defaultThreadEnvMode).toBe("worktree");
125 // After the fold a reset in the record wins over the stale aggregate.
126 const folded = resolveProjectSettings(
127 { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true },
128 projectId,
129 project,
130 );
131 expect(folded.settings.defaultModelSelection).toBeNull();
132 expect(folded.sources.defaultModelSelection).toBe("environment");
133 });
134
135 it("keeps the environment default model when the override's provider is disabled", () => {
136 const disabledSelection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus");
137 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
138 providerInstances: {
139 [ProviderInstanceId.make("claudeAgent")]: {
140 driver: ProviderDriverKind.make("claudeAgent"),
141 enabled: false,
142 },
143 },
144 projectSettingsOverrides: { [projectId]: { defaultModelSelection: disabledSelection } },
145 });
146 const resolved = resolveProjectSettings(settings, projectId);
147 expect(resolved.settings.defaultModelSelection).toBeNull();
148 expect(resolved.sources.defaultModelSelection).toBe("environment");
149 });
150
151 it("moves text generation off an instance the project turned off", () => {
152 const codex = ProviderInstanceId.make("codex");
153 const settings = {
154 ...DEFAULT_SERVER_SETTINGS,
155 textGenerationModelSelection: createModelSelection(codex, "gpt-5"),
156 projectSettingsOverrides: {
157 [projectId]: { providerInstances: { [codex]: { enabled: false } } },
158 },
159 };
160 expect(
161 resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId,
162 ).toBe(ProviderInstanceId.make("claudeAgent"));
163 expect(
164 resolveProjectSettings(settings, otherProjectId).settings.textGenerationModelSelection,
165 ).toEqual(settings.textGenerationModelSelection);
166 });
167
168 it("falls back to a configured instance when every built-in one is off for the project", () => {
169 const codex = ProviderInstanceId.make("codex");
170 const codexWork = ProviderInstanceId.make("codex_work");
171 const settings = {
172 ...DEFAULT_SERVER_SETTINGS,
173 textGenerationModelSelection: createModelSelection(codex, "gpt-5"),
174 providerInstances: { [codexWork]: { driver: ProviderDriverKind.make("codex"), config: {} } },
175 projectSettingsOverrides: {
176 [projectId]: {
177 providerInstances: {
178 [codex]: { enabled: false },
179 [ProviderInstanceId.make("claudeAgent")]: { enabled: false },
180 },
181 },
182 },
183 };
184 expect(
185 resolveProjectSettings(settings, projectId).settings.textGenerationModelSelection.instanceId,
186 ).toBe(codexWork);
187 });
188});
189
190describe("resolveProjectSettings with a t3.json", () => {
191 it("walks project override, environment value, file, then built-in for file-backed keys", () => {
192 const file = { defaultThreadEnvMode: "worktree" as const };
193 const fromOverride = resolveProjectSettings(
194 {
195 ...DEFAULT_SERVER_SETTINGS,
196 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } },
197 },
198 projectId,
199 null,
200 file,
201 );
202 expect(fromOverride.settings.defaultThreadEnvMode).toBe("local");
203 expect(fromOverride.sources.defaultThreadEnvMode).toBe("project");
204
205 const fromEnvironment = resolveProjectSettings(
206 { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "local" },
207 projectId,
208 null,
209 file,
210 );
211 expect(fromEnvironment.settings.defaultThreadEnvMode).toBe("local");
212 expect(fromEnvironment.sources.defaultThreadEnvMode).toBe("environment");
213
214 const fromFile = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, file);
215 expect(fromFile.settings.defaultThreadEnvMode).toBe("worktree");
216 expect(fromFile.sources.defaultThreadEnvMode).toBe("t3.json");
217
218 const builtIn = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, null);
219 expect(builtIn.settings.defaultThreadEnvMode).toBe("local");
220 expect(builtIn.sources.defaultThreadEnvMode).toBe("environment");
221 // A stored null override defers like an unset one and is not reported
222 // as the project's value.
223 const nullOverride = resolveProjectSettings(
224 {
225 ...DEFAULT_SERVER_SETTINGS,
226 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: null } as never },
227 },
228 projectId,
229 null,
230 file,
231 );
232 expect(nullOverride.settings.defaultThreadEnvMode).toBe("worktree");
233 expect(nullOverride.sources.defaultThreadEnvMode).toBe("t3.json");
234 // A file that does not mention the key leaves the source alone too.
235 expect(
236 resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, {}).sources
237 .defaultThreadEnvMode,
238 ).toBe("environment");
239 });
240
241 it("resolves one key from the settings tier, then the file, then the built-in", () => {
242 expect(
243 resolveProjectFileBackedSetting("worktreeSubmodules", "none", {
244 worktreeSubmodules: "top-level",
245 }),
246 ).toEqual({ value: "none", source: "environment" });
247 expect(
248 resolveProjectFileBackedSetting("worktreeSubmodules", null, {
249 worktreeSubmodules: "top-level",
250 }),
251 ).toEqual({ value: "top-level", source: "t3.json" });
252 expect(resolveProjectFileBackedSetting("worktreeSubmodules", null, null)).toEqual({
253 value: "recursive",
254 source: "environment",
255 });
256 });
257
258 it("leaves settings untouched when no file is passed", () => {
259 expect(resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId).settings).toBe(
260 DEFAULT_SERVER_SETTINGS,
261 );
262 });
263});
264
265describe("resolveNewThreadEnvMode", () => {
266 const worktreeEnvironment = resolveProjectSettings(
267 { ...DEFAULT_SERVER_SETTINGS, defaultThreadEnvMode: "worktree" },
268 projectId,
269 null,
270 null,
271 );
272 const newProject = {
273 projectSettings: worktreeEnvironment,
274 workspaceRoot: "/data/projects/pinball-stats",
275 newProjectsRoot: "/data/projects/",
276 projectHasThreads: false,
277 };
278
279 it("starts a new project's first thread in its checkout", () => {
280 expect(resolveNewThreadEnvMode(newProject)).toBe("local");
281 expect(
282 resolveNewThreadEnvMode({
283 ...newProject,
284 workspaceRoot: "C:\\Data\\Projects\\Pinball-Stats",
285 newProjectsRoot: "c:/data/projects",
286 }),
287 ).toBe("local");
288 });
289
290 it("keeps the configured default once the project has a thread or is not a new project", () => {
291 expect(resolveNewThreadEnvMode({ ...newProject, projectHasThreads: true })).toBe("worktree");
292 expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects" })).toBe(
293 "worktree",
294 );
295 expect(
296 resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects/a/nested" }),
297 ).toBe("worktree");
298 expect(resolveNewThreadEnvMode({ ...newProject, workspaceRoot: "/data/projects-old/a" })).toBe(
299 "worktree",
300 );
301 expect(resolveNewThreadEnvMode({ ...newProject, newProjectsRoot: undefined })).toBe("worktree");
302 });
303
304 it("lets a project override, or a t3.json the environment defers to, keep worktree mode", () => {
305 for (const projectSettings of [
306 resolveProjectSettings(
307 {
308 ...DEFAULT_SERVER_SETTINGS,
309 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "worktree" } },
310 },
311 projectId,
312 null,
313 null,
314 ),
315 resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId, null, {
316 defaultThreadEnvMode: "worktree",
317 }),
318 ]) {
319 expect(resolveNewThreadEnvMode({ ...newProject, projectSettings })).toBe("worktree");
320 }
321 });
322});
323
324describe("projectSettingsOverrides patches", () => {
325 it("replaces a project's entry, removes it with null, and drops empty entries", () => {
326 const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
327 projectSettingsOverrides: {
328 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
329 [otherProjectId]: { defaultAutoPull: false },
330 },
331 });
332 expect(hasProjectSettingsOverrides(first)).toBe(true);
333 const replaced = applyServerSettingsPatch(first, {
334 projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: false } },
335 });
336 expect(replaced.projectSettingsOverrides[projectId]).toEqual({
337 enableAgentBrowserAccess: false,
338 });
339 expect(replaced.projectSettingsOverrides[otherProjectId]).toEqual({ defaultAutoPull: false });
340 const emptied = applyServerSettingsPatch(replaced, {
341 projectSettingsOverrides: { [projectId]: {} },
342 });
343 expect(emptied.projectSettingsOverrides[projectId]).toBeUndefined();
344 const removed = applyServerSettingsPatch(replaced, {
345 projectSettingsOverrides: { [projectId]: null },
346 });
347 expect(removed.projectSettingsOverrides).toEqual({
348 [otherProjectId]: { defaultAutoPull: false },
349 });
350 expect(hasProjectSettingsOverrides(DEFAULT_SERVER_SETTINGS)).toBe(false);
351 });
352
353 it("derives the legacy per-key maps from the generic record", () => {
354 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
355 projectSettingsOverrides: {
356 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
357 [otherProjectId]: { defaultProjectScripts: [] },
358 },
359 });
360 expect(settings.projectAutoPullOverrides).toEqual({ [projectId]: true });
361 expect(settings.projectAgentBrowserAccessOverrides).toEqual({ [projectId]: false });
362 expect(settings.projectScriptOverrides).toEqual({ [otherProjectId]: [] });
363 });
364
365 it("translates legacy per-key patches into the generic record", () => {
366 const written = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
367 projectAutoPullOverrides: { [projectId]: true },
368 projectAgentBrowserAccessOverrides: { [projectId]: false, [otherProjectId]: true },
369 });
370 expect(written.projectSettingsOverrides).toEqual({
371 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
372 [otherProjectId]: { enableAgentBrowserAccess: true },
373 });
374 const cleared = applyServerSettingsPatch(written, {
375 projectAgentBrowserAccessOverrides: { [projectId]: null, [otherProjectId]: null },
376 });
377 expect(cleared.projectSettingsOverrides).toEqual({ [projectId]: { defaultAutoPull: true } });
378 });
379
380 it("lets a canonical entry win over a legacy map for the same project", () => {
381 const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
382 projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
383 });
384 // The canonical entry omits defaultAutoPull to clear it; the stale legacy
385 // map in the same patch must not put it back.
386 const next = applyServerSettingsPatch(current, {
387 projectSettingsOverrides: { [projectId]: { defaultThreadEnvMode: "local" } },
388 projectAutoPullOverrides: { [projectId]: true, [otherProjectId]: false },
389 });
390 expect(next.projectSettingsOverrides).toEqual({
391 [projectId]: { defaultThreadEnvMode: "local" },
392 [otherProjectId]: { defaultAutoPull: false },
393 });
394 });
395
396 it("builds replacement entries and clears individual keys", () => {
397 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
398 projectSettingsOverrides: {
399 [projectId]: { defaultAutoPull: true, enableAgentBrowserAccess: false },
400 },
401 });
402 expect(clearProjectSettingsOverrides(settings, projectId, ["defaultAutoPull"])).toEqual({
403 enableAgentBrowserAccess: false,
404 });
405 expect(
406 clearProjectSettingsOverrides(settings, projectId, [
407 "defaultAutoPull",
408 "enableAgentBrowserAccess",
409 ]),
410 ).toBeNull();
411 expect(clearProjectSettingsOverrides(settings, otherProjectId, ["defaultAutoPull"])).toBeNull();
412 expect(withProjectSettingsOverrides(settings, projectId, null)).toEqual({});
413 expect(
414 withProjectSettingsOverrides(settings, otherProjectId, { defaultThreadEnvMode: "worktree" }),
415 ).toEqual({
416 ...settings.projectSettingsOverrides,
417 [otherProjectId]: { defaultThreadEnvMode: "worktree" },
418 });
419 });
420});
421
422describe("resolveWorktreeCleanup", () => {
423 it("inherits machine rules, disables one project and keeps custom rules isolated", () => {
424 const machine = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
425 storageCleanup: {
426 worktreeAfterDays: 8,
427 worktreeSettledAfterDays: 2,
428 worktreeKeepWhen: "any-local-files",
429 worktreeOnDelete: true,
430 logsAfterDays: 3,
431 },
432 });
433 const inherited = resolveWorktreeCleanup(machine, projectId);
434 expect(inherited).toMatchObject({
435 worktreeSettledAfterDays: 2,
436 worktreeKeepWhen: "any-local-files",
437 });
438 const off = applyServerSettingsPatch(machine, {
439 projectSettingsOverrides: {
440 [projectId]: { worktreeCleanup: { mode: "off" } },
441 },
442 });
443 expect(resolveWorktreeCleanup(off, projectId)).toEqual({
444 worktreeAfterDays: null,
445 worktreeSettledAfterDays: null,
446 worktreeOnDelete: false,
447 worktreeOnMerge: false,
448 worktreeUnchanged: false,
449 worktreeKeepWhen: "any-local-files",
450 });
451 expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited);
452 const custom = applyServerSettingsPatch(off, {
453 projectSettingsOverrides: {
454 [projectId]: {
455 worktreeCleanup: {
456 mode: "custom",
457 rules: { ...inherited, worktreeAfterDays: 15, worktreeSettledAfterDays: 0 },
458 },
459 },
460 },
461 });
462 expect(resolveWorktreeCleanup(custom, projectId).worktreeAfterDays).toBe(15);
463 expect(resolveWorktreeCleanup(custom, projectId).worktreeSettledAfterDays).toBe(0);
464 expect(resolveWorktreeCleanup(custom, otherProjectId)).toEqual(inherited);
465 expect(custom.storageCleanup.logsAfterDays).toBe(3);
466 const reset = applyServerSettingsPatch(custom, {
467 projectSettingsOverrides: {
468 [projectId]: clearProjectSettingsOverrides(custom, projectId, ["worktreeCleanup"]),
469 },
470 });
471 expect(resolveWorktreeCleanup(reset, projectId)).toEqual(inherited);
472 });
473 it("completes partial machine custom rules and preserves them across edits", () => {
474 const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
475 storageCleanup: { worktreeAfterDays: 8, worktreeSettledAfterDays: 0, worktreeOnDelete: true },
476 });
477 const custom = applyServerSettingsPatch(initial, {
478 worktreeCleanup: { mode: "custom", rules: { worktreeOnMerge: true } },
479 });
480 const edited = applyServerSettingsPatch(custom, {
481 worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 15 } },
482 });
483 expect(resolveWorktreeCleanup(edited, null)).toEqual({
484 worktreeAfterDays: 15,
485 worktreeSettledAfterDays: 0,
486 worktreeOnDelete: true,
487 worktreeOnMerge: true,
488 worktreeUnchanged: false,
489 worktreeKeepWhen: "uncommitted-changes",
490 });
491 expect(
492 resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null)
493 .worktreeAfterDays,
494 ).toBe(8);
495 });
496});
497
498it("inherits branch naming defaults and applies project overrides independently", () => {
499 const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
500 branchNamingMode: "static",
501 branchNamePrefix: "team/",
502 branchNameInstructions: "Use issue IDs.",
503 projectSettingsOverrides: { [projectId]: { branchNamingMode: "custom" } },
504 });
505 expect(resolveProjectSettings(settings, projectId).settings).toMatchObject({
506 branchNamingMode: "custom",
507 branchNamePrefix: "team/",
508 branchNameInstructions: "Use issue IDs.",
509 });
510 expect(resolveProjectSettings(settings, otherProjectId).settings).toMatchObject({
511 branchNamingMode: "static",
512 branchNamePrefix: "team/",
513 });
514 const cleared = applyServerSettingsPatch(settings, {
515 projectSettingsOverrides: {
516 [projectId]: clearProjectSettingsOverrides(settings, projectId, ["branchNamingMode"]),
517 },
518 });
519 expect(resolveProjectSettings(cleared, projectId).settings.branchNamingMode).toBe("static");
520});