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