Open History to see the last commit
1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3
4import { type CommandId, ProjectId } from "./baseSchemas.ts";
5import {
6 ProviderDriverKind,
7 ProviderInstanceEnvironment,
8 ProviderInstanceId,
9} from "./providerInstance.ts";
10import {
11 ClientSettingsSchema,
12 ClientSettingsPatch,
13 ClientServerSettingsPatch,
14 ClaudeSettings,
15 DEFAULT_SERVER_SETTINGS,
16 globalIdleCompaction,
17 idleCompactionPatch,
18 PROJECT_SCOPED_SERVER_SETTING_KEYS,
19 requiredScopesForProjectMutation,
20 resolveIdleCompaction,
21 resolveProjectProviderInstanceEnabled,
22 resolveProviderInstanceEnabled,
23 ServerSettings,
24 ServerSettingsPatch,
25 withIdleCompaction,
26} from "./settings.ts";
27
28const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema);
29const decodeClientSettingsPatch = Schema.decodeUnknownSync(ClientSettingsPatch);
30const encodeClientSettings = Schema.encodeSync(ClientSettingsSchema);
31const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings);
32const encodeProviderInstanceEnvironment = Schema.encodeSync(ProviderInstanceEnvironment);
33const decodeProviderInstanceEnvironment = Schema.decodeSync(ProviderInstanceEnvironment);
34// The environment variable shape builds released before secret references decode.
35const decodeStringOnlyEnvironment = Schema.decodeUnknownSync(
36 Schema.Array(
37 Schema.Struct({ name: Schema.String, value: Schema.String, sensitive: Schema.Boolean }),
38 ),
39);
40const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
41const decodeClientServerSettingsPatch = Schema.decodeUnknownSync(ClientServerSettingsPatch);
42const encodeServerSettings = Schema.encodeSync(ServerSettings);
43const encodeServerSettingsPatch = Schema.encodeSync(ServerSettingsPatch);
44const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
45
46it("drops theme defaults from a client's settings patch", () => {
47 expect(
48 decodeClientServerSettingsPatch({
49 newWorktreesStartFromOrigin: false,
50 defaultTheme: "grove",
51 defaultThemeSetAt: "2026-11-13T00:00:00.000Z",
52 defaultAppearance: "dark",
53 defaultAppearanceSetAt: "2026-11-13T00:00:00.000Z",
54 }),
55 ).toEqual({ newWorktreesStartFromOrigin: false });
56});
57
58describe("storage cleanup settings", () => {
59 it("keeps cleanup disabled for existing installations", () => {
60 expect(decodeServerSettings({}).worktreeCleanup).toBeNull();
61 expect(decodeServerSettings({}).storageCleanup).toEqual({
62 worktreeAfterDays: null,
63 worktreeSettledAfterDays: null,
64 worktreeOnMerge: false,
65 worktreeOnDelete: false,
66 worktreeUnchanged: false,
67 worktreeKeepWhen: "uncommitted-changes",
68 browserArtifactsAfterDays: null,
69 logsAfterDays: null,
70 });
71 });
72
73 it("accepts partial custom patches but requires complete stored project rules", () => {
74 expect(
75 decodeServerSettingsPatch({
76 worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } },
77 }),
78 ).toEqual({ worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } } });
79 expect(() =>
80 decodeServerSettings({
81 projectSettingsOverrides: {
82 project: { worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } } },
83 },
84 }),
85 ).toThrow();
86 expect(
87 decodeServerSettings({
88 projectSettingsOverrides: {
89 project: {
90 worktreeCleanup: {
91 mode: "custom",
92 rules: {
93 worktreeAfterDays: null,
94 worktreeOnMerge: false,
95 worktreeOnDelete: false,
96 worktreeUnchanged: false,
97 },
98 },
99 },
100 },
101 }).projectSettingsOverrides[ProjectId.make("project")]?.worktreeCleanup,
102 ).toMatchObject({
103 mode: "custom",
104 rules: { worktreeSettledAfterDays: null, worktreeKeepWhen: "uncommitted-changes" },
105 });
106 });
107
108 it("decodes older custom rules with the default local-file policy", () => {
109 const settings = decodeServerSettings({
110 worktreeCleanup: {
111 mode: "custom",
112 rules: {
113 worktreeAfterDays: 8,
114 worktreeOnMerge: false,
115 worktreeOnDelete: false,
116 worktreeUnchanged: false,
117 },
118 },
119 });
120 expect(settings.worktreeCleanup).toMatchObject({
121 rules: { worktreeKeepWhen: "uncommitted-changes" },
122 });
123 expect(
124 decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }),
125 ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } });
126 expect(() =>
127 decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }),
128 ).toThrow();
129 });
130});
131
132describe("ServerSettings usage price overrides", () => {
133 const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 };
134
135 it("rejects empty model IDs and incomplete input/output pricing", () => {
136 for (const usagePriceOverrides of [
137 { " ": prices },
138 { "example-model": { inputCostPerMillionTokens: 2 } },
139 { "example-model": { outputCostPerMillionTokens: 8 } },
140 ]) {
141 expect(() => decodeServerSettingsPatch({ usagePriceOverrides })).toThrow();
142 }
143 });
144
145 it("ignores an invalid model filter without dropping the other settings", () => {
146 const valid = { include: [{ provider: "newer-provider", model: "orbit-1" }] };
147 expect(decodeServerSettings({ usageModelFilter: valid }).usageModelFilter).toEqual(valid);
148 const settings = decodeServerSettings({
149 usageModelFilter: { exclude: [{ model: "" }] },
150 usageModelAliases: { "drift-9": "orbit-1" },
151 });
152 expect(settings.usageModelFilter).toBeUndefined();
153 expect(settings.usageModelAliases).toEqual({ "drift-9": "orbit-1" });
154 });
155});
156
157it("drops a hand-edited non-string hidden tag and keeps the rest of the settings", () => {
158 const settings = decodeServerSettings({
159 hiddenThreadTags: ["private", 123],
160 hideTaggedThreads: true,
161 });
162 expect(settings.hiddenThreadTags).toEqual(["private"]);
163 expect(settings.hideTaggedThreads).toBe(true);
164});
165
166describe("ServerSettings secret providers", () => {
167 const onePassword = (account: string) => ({
168 kind: "one_password",
169 label: "Personal",
170 config: { account },
171 });
172
173 it("keeps an unknown kind or a longer label from invalidating the rest of the settings", () => {
174 const openbao = { kind: "openbao", label: "V".repeat(65), config: { address: "https://x" } };
175 const decoded = decodeServerSettings({
176 worktreesDirectory: "/work",
177 secretProviders: { vault: openbao, personal: onePassword("my.1password.com") },
178 });
179 expect(decoded.worktreesDirectory).toBe("/work");
180 expect(encodeServerSettings(decoded).secretProviders).toEqual({
181 vault: openbao,
182 personal: onePassword("my.1password.com"),
183 });
184 });
185
186 it("accepts slug ids and null removals but no id that is not a slug", () => {
187 expect(
188 decodeServerSettingsPatch({
189 secretProviders: { "1password": onePassword("my.1password.com"), work: null },
190 }).secretProviders,
191 ).toEqual({ "1password": onePassword("my.1password.com"), work: null });
192 for (const id of ["", "My Vault", "a/b", "-vault"]) {
193 expect(() =>
194 decodeServerSettingsPatch({ secretProviders: { [id]: onePassword("my.1password.com") } }),
195 ).toThrow();
196 }
197 expect(() =>
198 decodeServerSettingsPatch({
199 secretProviders: { work: { ...onePassword("my.1password.com"), label: "V".repeat(65) } },
200 }),
201 ).toThrow();
202 });
203
204 it("stores a provider environment secret reference in a layout older builds still decode", () => {
205 const providerInstances = (secret: unknown) => ({
206 codex: {
207 driver: "codex",
208 environment: [
209 { name: "OPENAI_API_KEY", sensitive: true, value: "", secret },
210 { name: "BASE_URL", value: "https://example.test" },
211 ],
212 },
213 });
214 const secret = { provider: "personal", reference: "op://Private/OpenAI/credential" };
215 const environment = decodeServerSettings({ providerInstances: providerInstances(secret) })
216 .providerInstances[ProviderInstanceId.make("codex")]?.environment;
217 expect(environment).toEqual([
218 { name: "OPENAI_API_KEY", value: secret, sensitive: true },
219 { name: "BASE_URL", value: "https://example.test", sensitive: false },
220 ]);
221
222 const encoded = encodeProviderInstanceEnvironment(environment ?? []);
223 expect(encoded).toEqual([
224 { name: "OPENAI_API_KEY", value: "", sensitive: true, secret },
225 { name: "BASE_URL", value: "https://example.test", sensitive: false },
226 ]);
227 expect(decodeProviderInstanceEnvironment(encoded)).toEqual(environment);
228 expect(decodeStringOnlyEnvironment(encoded)).toEqual([
229 { name: "OPENAI_API_KEY", value: "", sensitive: true },
230 { name: "BASE_URL", value: "https://example.test", sensitive: false },
231 ]);
232
233 for (const invalid of [
234 { provider: "My Vault", reference: "op://a/b/c" },
235 { provider: "personal", reference: " " },
236 { provider: "personal", reference: "op://Private/OpenAI\n/credential" },
237 { provider: "personal", reference: "x".repeat(1025) },
238 ]) {
239 expect(() =>
240 decodeServerSettings({ providerInstances: providerInstances(invalid) }),
241 ).toThrow();
242 }
243 });
244});
245
246describe("custom model settings", () => {
247 const capabilities = {
248 optionDescriptors: [
249 {
250 id: "effort",
251 label: "Reasoning",
252 type: "select",
253 options: [{ id: "high", label: "High", isDefault: true }],
254 },
255 ],
256 };
257
258 it("accepts legacy bare slugs alongside full entries", () => {
259 const decoded = decodeClaudeSettings({
260 customModels: ["bare-slug", { slug: "named", name: "Named", capabilities }],
261 });
262 expect(decoded.customModels).toEqual([
263 "bare-slug",
264 { slug: "named", name: "Named", capabilities },
265 ]);
266 });
267});
268
269describe("resolveIdleCompaction", () => {
270 it("lets a Claude instance override the global default", () => {
271 const settings = decodeServerSettings({
272 idleCompaction: "off",
273 providerInstances: {
274 claude_work: { driver: "claudeAgent", config: { idleCompaction: "onReturn" } },
275 claude_personal: { driver: "claudeAgent" },
276 },
277 });
278 expect(resolveIdleCompaction(settings, ProviderInstanceId.make("claude_work"))).toBe(
279 "onReturn",
280 );
281 expect(resolveIdleCompaction(settings, ProviderInstanceId.make("claude_personal"))).toBe("off");
282 expect(resolveIdleCompaction(settings, ProviderInstanceId.make("claudeAgent"))).toBe("off");
283 });
284
285 it("reads the mode from the legacy switch, in either form servers saved it", () => {
286 expect(globalIdleCompaction(decodeServerSettings({ compactIdleThreads: true }))).toBe(
287 "beforeCacheExpires",
288 );
289 expect(globalIdleCompaction(decodeServerSettings({ compactIdleThreads: "off" }))).toBe("off");
290 expect(globalIdleCompaction(decodeServerSettings({}))).toBe("onReturn");
291 });
292
293 it("keeps the legacy switch a boolean, as shipped clients decode it", () => {
294 const encode = Schema.encodeSync(Schema.toCodecJson(ServerSettings));
295 for (const mode of ["onReturn", "beforeCacheExpires", "off"] as const) {
296 const encoded = encode(withIdleCompaction(DEFAULT_SERVER_SETTINGS, mode)) as {
297 readonly compactIdleThreads: unknown;
298 readonly idleCompaction: unknown;
299 };
300 expect(encoded.compactIdleThreads).toBe(mode === "beforeCacheExpires");
301 expect(encoded.idleCompaction).toBe(mode);
302 }
303 });
304
305 it("patches the field the server reads", () => {
306 expect(
307 idleCompactionPatch({ compactIdleThreads: false, idleCompaction: "onReturn" }, "off"),
308 ).toEqual({
309 idleCompaction: "off",
310 });
311 expect(idleCompactionPatch({ compactIdleThreads: "onReturn" }, "off")).toEqual({
312 compactIdleThreads: "off",
313 });
314 expect(idleCompactionPatch({ compactIdleThreads: false }, "beforeCacheExpires")).toEqual({
315 compactIdleThreads: true,
316 });
317 });
318});
319
320describe("ClientSettings notifications", () => {
321 it("requires opt-in when existing settings omit notification preferences", () => {
322 expect(decodeClientSettings({}).notificationMode).toBe("off");
323 expect(decodeClientSettings({}).inAppNotificationsEnabled).toBe(false);
324 expect(decodeClientSettingsPatch({})).not.toHaveProperty("inAppNotificationsEnabled");
325 expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");
326 });
327});
328
329describe("ClientSettings quit confirmation", () => {
330 it.each([
331 [true, "hold"],
332 [false, "direct"],
333 ] as const)("migrates the legacy %s value to %s", (legacyValue, mode) => {
334 const settings = decodeClientSettings({ confirmQuit: legacyValue });
335
336 expect(settings.confirmQuit).toBe(mode);
337 expect(encodeClientSettings(settings).confirmQuit).toBe(mode);
338 });
339});
340
341describe("provider enabled defaults", () => {
342 it("resolves instance enabled state with explicit false winning", () => {
343 const grok = ProviderDriverKind.make("grok");
344 const codex = ProviderDriverKind.make("codex");
345 expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false);
346 expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true);
347 expect(
348 resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }),
349 ).toBe(true);
350 expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true);
351 expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe(
352 false,
353 );
354 expect(resolveProviderInstanceEnabled({ driver: grok, config: { enabled: true } })).toBe(true);
355 expect(
356 resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: { enabled: false } }),
357 ).toBe(false);
358 expect(
359 resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: { enabled: true } }),
360 ).toBe(false);
361 });
362});
363
364describe("ServerSettings worktree defaults", () => {
365 it("defaults the thread env mode to inherit and keeps stored values", () => {
366 expect(decodeServerSettings({}).defaultThreadEnvMode).toBeNull();
367 expect(decodeServerSettings({ defaultThreadEnvMode: "worktree" }).defaultThreadEnvMode).toBe(
368 "worktree",
369 );
370 expect(
371 decodeServerSettings({ defaultThreadEnvMode: "remote" }).defaultThreadEnvMode,
372 ).toBeNull();
373 expect(
374 decodeServerSettingsPatch({ defaultThreadEnvMode: null }).defaultThreadEnvMode,
375 ).toBeNull();
376 });
377
378 it("keeps an inherited thread env mode off the wire for older clients", () => {
379 const encode = Schema.encodeSync(ServerSettings);
380 expect("defaultThreadEnvMode" in encode(decodeServerSettings({}))).toBe(false);
381 expect(
382 encode(decodeServerSettings({ defaultThreadEnvMode: "worktree" })).defaultThreadEnvMode,
383 ).toBe("worktree");
384 });
385});
386
387describe("ServerSettings.sourceControlWritingStyle", () => {
388 it.each([
389 { legacy: "", mode: "repo_conventions", customInstructions: "" },
390 { legacy: " \t\n ", mode: "repo_conventions", customInstructions: "" },
391 {
392 legacy: " Prefer concise wording.\nKeep issue references. ",
393 mode: "custom",
394 customInstructions: "Prefer concise wording.\nKeep issue references.",
395 },
396 ])("normalizes legacy writing style $legacy in snapshots only", ({ legacy, ...expected }) => {
397 const style = { ...expected, followChangeRequestTemplates: true };
398 for (const [input, normalized] of [
399 [{ sourceControlWritingStyle: legacy }, { sourceControlWritingStyle: style }],
400 [
401 { projectSettingsOverrides: { project: { sourceControlWritingStyle: legacy } } },
402 { projectSettingsOverrides: { project: { sourceControlWritingStyle: style } } },
403 ],
404 ] as const) {
405 const settings = decodeServerSettings(input);
406 expect(settings).toMatchObject(normalized);
407 expect(encodeServerSettings(settings)).toMatchObject(normalized);
408 expect(() => decodeServerSettingsPatch(input)).toThrow();
409 }
410 });
411});
412
413describe("ServerSettings environment icon", () => {
414 it("decodes a kind from a newer server as null instead of failing the snapshot", () => {
415 expect(decodeServerSettings({ environmentIcon: "toaster" }).environmentIcon).toBeNull();
416 });
417});
418
419const decodeDeviceHostSettings = Schema.decodeSync(ServerSettings);
420
421// `deviceHosts` as it was decoded before EAS hosts existed: an SSH host needs a target.
422const decodeOlderDeviceHostSettings = Schema.decodeUnknownSync(
423 Schema.Struct({
424 deviceHosts: Schema.Array(Schema.Struct({ id: Schema.String, target: Schema.String })),
425 }),
426);
427
428it("keeps EAS hosts out of the SSH host list servers and clients before EAS hosts decode", () => {
429 const settings = decodeDeviceHostSettings({
430 deviceHosts: [{ id: "mini", label: "Mac mini", target: "user@mini" }],
431 easDeviceHosts: [{ kind: "eas", id: "eas", label: "EAS Simulator" }],
432 });
433 const wire = encodeServerSettings(settings);
434 expect(decodeOlderDeviceHostSettings(wire).deviceHosts).toEqual([
435 { id: "mini", target: "user@mini" },
436 ]);
437 expect(settings.easDeviceHosts.map((host) => host.id)).toEqual(["eas"]);
438});
439
440it("validates remote device hosts and rejects ambiguous host ids", () => {
441 const host = { id: "mini", label: "Mac mini", target: "user@mini", port: 2222 };
442 // Hosts saved before EAS hosts existed have no `kind`; they are SSH hosts.
443 expect(decodeDeviceHostSettings({ deviceHosts: [host] }).deviceHosts).toEqual([
444 { kind: "ssh", ...host },
445 ]);
446 expect(() => decodeDeviceHostSettings({ deviceHosts: [host, host] })).toThrow();
447 expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, id: "local" }] })).toThrow();
448 expect(() =>
449 decodeDeviceHostSettings({ deviceHosts: [{ ...host, target: "-oProxyCommand=bad" }] }),
450 ).toThrow();
451 expect(() => decodeDeviceHostSettings({ deviceHosts: [{ ...host, port: 0 }] })).toThrow();
452});
453
454describe("ServerSettings project overrides across versions", () => {
455 it("sends back an override key from a newer server when an older client edits the entry", () => {
456 const projectId = ProjectId.make("project-a");
457 // As an older client reads a newer server's settings, with one key it does not know.
458 const entry = decodeServerSettings({
459 projectSettingsOverrides: {
460 [projectId]: { defaultAutoPull: true, addedByNewerServer: { mode: "kept" } },
461 },
462 }).projectSettingsOverrides[projectId];
463
464 const sent = encodeServerSettingsPatch({
465 projectSettingsOverrides: { [projectId]: { ...entry, defaultAutoPull: false } },
466 });
467
468 expect(decodeServerSettingsPatch(sent).projectSettingsOverrides?.[projectId]).toEqual({
469 defaultAutoPull: false,
470 addedByNewerServer: { mode: "kept" },
471 });
472 });
473});
474
475describe("resolveProjectProviderInstanceEnabled", () => {
476 const projectId = "project-a" as ProjectId;
477 const instanceId = ProviderInstanceId.make("codex_work");
478
479 it("lets a project disable an instance the machine enabled", () => {
480 const settings: ServerSettings = {
481 ...DEFAULT_SERVER_SETTINGS,
482 providerInstances: {
483 [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
484 },
485 projectSettingsOverrides: {
486 [projectId]: { providerInstances: { [instanceId]: { enabled: false } } },
487 },
488 };
489 expect(resolveProjectProviderInstanceEnabled(settings, projectId, instanceId)).toBe(false);
490 });
491});
492
493describe("requiredScopesForProjectMutation", () => {
494 it("adds the settings scope only when a mutation carries scripts", () => {
495 const projectId = "project-1" as ProjectId;
496 const commandId = "command-1" as CommandId;
497 expect(
498 requiredScopesForProjectMutation({ type: "project.update", commandId, projectId }),
499 ).toEqual(["orchestration:operate"]);
500 expect(
501 requiredScopesForProjectMutation({
502 type: "project.update",
503 commandId,
504 projectId,
505 scripts: [],
506 }),
507 ).toEqual(["orchestration:operate", "settings:write"]);
508 });
509});
510
511describe("voice settings", () => {
512 it("does not import voice consent from ordinary device preferences", () => {
513 const imported = {
514 voiceTranscriptionEnvironmentId: "env-1",
515 voiceTranscriptionDestination: { kind: "environment", environmentId: "env-1" },
516 speechPostProcessingEnabled: true,
517 };
518 expect(decodeClientSettings({}).speechSilenceFiltering).toBe(true);
519 expect(decodeClientSettingsPatch({ speechSilenceFiltering: false })).toEqual({
520 speechSilenceFiltering: false,
521 });
522 for (const key of Object.keys(imported)) {
523 expect(decodeClientSettings(imported)).not.toHaveProperty(key);
524 expect(decodeClientSettingsPatch(imported)).not.toHaveProperty(key);
525 }
526 });
527
528 it("keeps the correction cue personal and project vocabulary on the environment", () => {
529 expect(decodeClientSettings({}).speechCorrectionWord).toBe("");
530 expect(() => decodeClientSettingsPatch({ speechCorrectionWord: "x".repeat(51) })).toThrow();
531 expect(DEFAULT_SERVER_SETTINGS.speechProjectCustomWords).toEqual([]);
532 expect(PROJECT_SCOPED_SERVER_SETTING_KEYS).toContain("speechProjectCustomWords");
533 });
534});
535
536describe("server model preferences", () => {
537 it("distinguishes unmigrated lists from deliberately cleared lists", () => {
538 expect(decodeServerSettings({})).toMatchObject({
539 favorites: null,
540 providerModelPreferences: null,
541 });
542 const input = { favorites: [], providerModelPreferences: {} };
543 expect(encodeServerSettings(decodeServerSettings(input))).toMatchObject(input);
544 expect(decodeServerSettingsPatch(input)).toEqual(input);
545 });
546});