| 1 | import { |
| 2 | DEFAULT_SERVER_SETTINGS, |
| 3 | ProjectId, |
| 4 | ProviderDriverKind, |
| 5 | ProviderInstanceId, |
| 6 | UsageLimitSourceId, |
| 7 | type ServerProvider, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import * as Duration from "effect/Duration"; |
| 10 | import { describe, expect, it } from "vite-plus/test"; |
| 11 | import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; |
| 12 | import { createModelSelection } from "./model.ts"; |
| 13 | import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; |
| 14 | import { |
| 15 | applyServerSettingsPatch, |
| 16 | fallbackTextGenerationModelSelection, |
| 17 | modelPreferencesMigrationPatch, |
| 18 | resolveModelPreferences, |
| 19 | isModelSelectionProviderEnabled, |
| 20 | parsePersistedServerObservabilitySettings, |
| 21 | resolveSourceControlWriterModelSelection, |
| 22 | resolveProjectAgentBrowserAccess, |
| 23 | resolveProjectAutoPull, |
| 24 | } from "./serverSettings.ts"; |
| 25 | |
| 26 | /** Settings after the server has folded legacy per-project fields into `projectSettingsOverrides`. */ |
| 27 | const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; |
| 28 | |
| 29 | describe("fallbackTextGenerationModelSelection", () => { |
| 30 | it("ignores a configured non-default instance at machine scope, matching no fallback", () => { |
| 31 | const configured = ProviderInstanceId.make("codex_work"); |
| 32 | const providerInstances = { |
| 33 | [configured]: { driver: ProviderDriverKind.make("codex"), config: {} }, |
| 34 | }; |
| 35 | const fallback = fallbackTextGenerationModelSelection(providerInstances, (instanceId) => |
| 36 | instanceId === configured ? true : false, |
| 37 | ); |
| 38 | expect(fallback).toBeNull(); |
| 39 | }); |
| 40 | |
| 41 | it("considers a configured non-default instance only when asked to", () => { |
| 42 | const configured = ProviderInstanceId.make("codex_work"); |
| 43 | const providerInstances = { |
| 44 | [configured]: { driver: ProviderDriverKind.make("codex"), config: {} }, |
| 45 | }; |
| 46 | const fallback = fallbackTextGenerationModelSelection( |
| 47 | providerInstances, |
| 48 | (instanceId) => instanceId === configured, |
| 49 | { includeConfiguredInstances: true }, |
| 50 | ); |
| 51 | expect(fallback?.instanceId).toBe(configured); |
| 52 | }); |
| 53 | }); |
| 54 | |
| 55 | describe("serverSettings helpers", () => { |
| 56 | it("changes a cleanup rule without replacing the machine's other rules", () => { |
| 57 | const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 58 | storageCleanup: { worktreeAfterDays: 8, worktreeOnMerge: true, logsAfterDays: 30 }, |
| 59 | }); |
| 60 | expect( |
| 61 | applyServerSettingsPatch(enabled, { |
| 62 | storageCleanup: { worktreeAfterDays: null }, |
| 63 | }).storageCleanup, |
| 64 | ).toEqual({ |
| 65 | worktreeAfterDays: null, |
| 66 | worktreeSettledAfterDays: null, |
| 67 | worktreeOnMerge: true, |
| 68 | worktreeOnDelete: false, |
| 69 | worktreeUnchanged: false, |
| 70 | worktreeKeepWhen: "uncommitted-changes", |
| 71 | browserArtifactsAfterDays: null, |
| 72 | logsAfterDays: 30, |
| 73 | }); |
| 74 | }); |
| 75 | it("replaces a source control host's patched field so a cleared account pin does not survive", () => { |
| 76 | const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 77 | sourceControlHosts: { |
| 78 | github: { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} }, |
| 79 | }, |
| 80 | }); |
| 81 | const unpinned = applyServerSettingsPatch(pinned, { |
| 82 | sourceControlHosts: { github: { hosts: { "github.com": { enabled: false } } } }, |
| 83 | }); |
| 84 | expect(unpinned.sourceControlHosts.github).toEqual({ |
| 85 | hosts: { "github.com": { enabled: false } }, |
| 86 | tokens: {}, |
| 87 | }); |
| 88 | }); |
| 89 | it("replaces SSH host lists when saving, editing, and removing hosts", () => { |
| 90 | const host = { id: "mini", label: "Mac mini", target: "mini" }; |
| 91 | const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] }); |
| 92 | expect(saved.deviceHosts).toEqual([host]); |
| 93 | const replacement = { ...host, target: "other-mini" }; |
| 94 | const edited = applyServerSettingsPatch(saved, { deviceHosts: [replacement] }); |
| 95 | expect(edited.deviceHosts).toEqual([replacement]); |
| 96 | expect(applyServerSettingsPatch(edited, { deviceHosts: [] }).deviceHosts).toEqual([]); |
| 97 | }); |
| 98 | |
| 99 | it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { |
| 100 | const project = { id: ProjectId.make("project-actions"), scripts: [] }; |
| 101 | const action = { |
| 102 | id: "check", |
| 103 | name: "Check", |
| 104 | command: "npm test", |
| 105 | icon: "play" as const, |
| 106 | runOnWorktreeCreate: false, |
| 107 | }; |
| 108 | const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; |
| 109 | // Before the one-time fold, scripts stored on the project aggregate still apply. |
| 110 | const unfolded = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 111 | defaultProjectScripts: [action], |
| 112 | }); |
| 113 | expect(resolveProjectScripts(unfolded, existing)).toEqual(existing.scripts); |
| 114 | expect(projectScriptsInheritDefaults(unfolded, existing)).toBe(false); |
| 115 | const defaults = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, { |
| 116 | defaultProjectScripts: [action], |
| 117 | }); |
| 118 | expect(resolveProjectScripts(defaults, project)).toEqual([action]); |
| 119 | expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); |
| 120 | expect(resolveProjectScripts(defaults, existing)).toEqual([action]); |
| 121 | const disabled = applyServerSettingsPatch(defaults, { |
| 122 | projectScriptOverrides: { [project.id]: [] }, |
| 123 | }); |
| 124 | expect(resolveProjectScripts(disabled, project)).toEqual([]); |
| 125 | expect(projectScriptsInheritDefaults(disabled, project)).toBe(false); |
| 126 | const changedDefault = applyServerSettingsPatch(disabled, { |
| 127 | defaultProjectScripts: [{ ...action, command: "npm run build" }], |
| 128 | }); |
| 129 | expect(resolveProjectScripts(changedDefault, project)).toEqual([]); |
| 130 | const reset = applyServerSettingsPatch(changedDefault, { |
| 131 | projectScriptOverrides: { [project.id]: null }, |
| 132 | }); |
| 133 | expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts); |
| 134 | expect(projectScriptsInheritDefaults(reset, existing)).toBe(true); |
| 135 | expect( |
| 136 | resolveProjectScripts( |
| 137 | applyServerSettingsPatch(reset, { defaultProjectScripts: [] }), |
| 138 | existing, |
| 139 | ), |
| 140 | ).toEqual([]); |
| 141 | }); |
| 142 | |
| 143 | it("preserves other projects' actions when overriding, clearing, or resetting one project", () => { |
| 144 | const firstProject = { id: ProjectId.make("first-project"), scripts: [] }; |
| 145 | const secondProject = { id: ProjectId.make("second-project"), scripts: [] }; |
| 146 | const defaultAction = { |
| 147 | id: "check", |
| 148 | name: "Check", |
| 149 | command: "npm test", |
| 150 | icon: "play" as const, |
| 151 | runOnWorktreeCreate: false, |
| 152 | }; |
| 153 | const firstAction = { ...defaultAction, command: "npm run lint" }; |
| 154 | const secondAction = { ...defaultAction, command: "npm run build" }; |
| 155 | const firstUpdate = applyServerSettingsPatch(FOLDED_SERVER_SETTINGS, { |
| 156 | defaultProjectScripts: [defaultAction], |
| 157 | projectScriptOverrides: { [firstProject.id]: [firstAction] }, |
| 158 | }); |
| 159 | const secondUpdate = applyServerSettingsPatch(firstUpdate, { |
| 160 | projectScriptOverrides: { [secondProject.id]: [secondAction] }, |
| 161 | }); |
| 162 | expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); |
| 163 | expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]); |
| 164 | |
| 165 | const cleared = applyServerSettingsPatch(secondUpdate, { |
| 166 | projectScriptOverrides: { [firstProject.id]: [] }, |
| 167 | }); |
| 168 | expect(resolveProjectScripts(cleared, firstProject)).toEqual([]); |
| 169 | expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]); |
| 170 | |
| 171 | const reset = applyServerSettingsPatch(cleared, { |
| 172 | projectScriptOverrides: { [firstProject.id]: null }, |
| 173 | }); |
| 174 | expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([ |
| 175 | defaultAction, |
| 176 | ]); |
| 177 | expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]); |
| 178 | expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); |
| 179 | }); |
| 180 | |
| 181 | it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => { |
| 182 | const projectId = ProjectId.make("project-pull"); |
| 183 | expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false); |
| 184 | expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true); |
| 185 | const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true }); |
| 186 | expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true); |
| 187 | const overridden = applyServerSettingsPatch(enabled, { |
| 188 | projectAutoPullOverrides: { [projectId]: false }, |
| 189 | }); |
| 190 | expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false); |
| 191 | const reset = applyServerSettingsPatch(overridden, { |
| 192 | projectAutoPullOverrides: { [projectId]: null }, |
| 193 | }); |
| 194 | expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true); |
| 195 | const disabled = applyServerSettingsPatch(reset, { |
| 196 | defaultAutoPull: false, |
| 197 | projectAutoPullOverrides: { [projectId]: true }, |
| 198 | }); |
| 199 | expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true); |
| 200 | expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false); |
| 201 | }); |
| 202 | |
| 203 | it("inherits browser access and restores inheritance when a project override is removed", () => { |
| 204 | const projectId = ProjectId.make("project-browser"); |
| 205 | const otherProjectId = ProjectId.make("other-project"); |
| 206 | const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 207 | projectAgentBrowserAccessOverrides: { [projectId]: false }, |
| 208 | }); |
| 209 | expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false); |
| 210 | expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true); |
| 211 | const reset = applyServerSettingsPatch(overridden, { |
| 212 | projectAgentBrowserAccessOverrides: { [projectId]: null }, |
| 213 | }); |
| 214 | expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true); |
| 215 | const enabled = applyServerSettingsPatch(reset, { |
| 216 | enableAgentBrowserAccess: false, |
| 217 | projectAgentBrowserAccessOverrides: { [projectId]: true }, |
| 218 | }); |
| 219 | expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true); |
| 220 | expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false); |
| 221 | }); |
| 222 | |
| 223 | it("preserves other projects' boolean overrides across separate updates and resets", () => { |
| 224 | const firstProjectId = ProjectId.make("first-project"); |
| 225 | const secondProjectId = ProjectId.make("second-project"); |
| 226 | const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 227 | defaultAutoPull: true, |
| 228 | projectAutoPullOverrides: { [firstProjectId]: false }, |
| 229 | projectAgentBrowserAccessOverrides: { [firstProjectId]: false }, |
| 230 | }); |
| 231 | const secondUpdate = applyServerSettingsPatch(firstUpdate, { |
| 232 | projectAutoPullOverrides: { [secondProjectId]: false }, |
| 233 | projectAgentBrowserAccessOverrides: { [secondProjectId]: false }, |
| 234 | }); |
| 235 | for (const projectId of [firstProjectId, secondProjectId]) { |
| 236 | expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false); |
| 237 | expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false); |
| 238 | } |
| 239 | |
| 240 | const reset = applyServerSettingsPatch(secondUpdate, { |
| 241 | projectAutoPullOverrides: { [firstProjectId]: null }, |
| 242 | projectAgentBrowserAccessOverrides: { [firstProjectId]: null }, |
| 243 | }); |
| 244 | expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true); |
| 245 | expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true); |
| 246 | expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false); |
| 247 | expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false); |
| 248 | expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined(); |
| 249 | expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined(); |
| 250 | expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false); |
| 251 | expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false); |
| 252 | }); |
| 253 | |
| 254 | it("replaces and clears conversation model defaults without retaining old options", () => { |
| 255 | const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 256 | defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ |
| 257 | { id: "reasoningEffort", value: "high" }, |
| 258 | ]), |
| 259 | }); |
| 260 | const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet"); |
| 261 | const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection }); |
| 262 | expect(updated.defaultModelSelection).toEqual(selection); |
| 263 | expect( |
| 264 | applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection, |
| 265 | ).toBeNull(); |
| 266 | }); |
| 267 | |
| 268 | it("ignores missing and blank persisted observability URLs", () => { |
| 269 | expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ |
| 270 | otlpTracesUrl: undefined, |
| 271 | otlpMetricsUrl: undefined, |
| 272 | otlpLogsUrl: undefined, |
| 273 | }); |
| 274 | expect( |
| 275 | parsePersistedServerObservabilitySettings( |
| 276 | JSON.stringify({ |
| 277 | observability: { otlpTracesUrl: " ", otlpMetricsUrl: "", otlpLogsUrl: " " }, |
| 278 | }), |
| 279 | ), |
| 280 | ).toEqual({ |
| 281 | otlpTracesUrl: undefined, |
| 282 | otlpMetricsUrl: undefined, |
| 283 | otlpLogsUrl: undefined, |
| 284 | }); |
| 285 | }); |
| 286 | |
| 287 | it("parses lenient persisted settings JSON and trims observability URLs", () => { |
| 288 | expect( |
| 289 | parsePersistedServerObservabilitySettings( |
| 290 | JSON.stringify({ |
| 291 | observability: { |
| 292 | otlpTracesUrl: " http://localhost:4318/v1/traces ", |
| 293 | otlpMetricsUrl: " http://localhost:4318/v1/metrics ", |
| 294 | otlpLogsUrl: " http://localhost:4318/v1/logs ", |
| 295 | }, |
| 296 | }), |
| 297 | ), |
| 298 | ).toEqual({ |
| 299 | otlpTracesUrl: "http://localhost:4318/v1/traces", |
| 300 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", |
| 301 | otlpLogsUrl: "http://localhost:4318/v1/logs", |
| 302 | }); |
| 303 | }); |
| 304 | |
| 305 | it("falls back cleanly when persisted settings are invalid", () => { |
| 306 | expect(parsePersistedServerObservabilitySettings("{")).toEqual({ |
| 307 | otlpTracesUrl: undefined, |
| 308 | otlpMetricsUrl: undefined, |
| 309 | otlpLogsUrl: undefined, |
| 310 | }); |
| 311 | }); |
| 312 | |
| 313 | it("replaces text generation selection when provider/model are provided", () => { |
| 314 | const current = { |
| 315 | ...DEFAULT_SERVER_SETTINGS, |
| 316 | textGenerationModelSelection: createModelSelection( |
| 317 | ProviderInstanceId.make("codex"), |
| 318 | "gpt-5.4-mini", |
| 319 | [ |
| 320 | { id: "reasoningEffort", value: "high" }, |
| 321 | { id: "fastMode", value: true }, |
| 322 | ], |
| 323 | ), |
| 324 | }; |
| 325 | |
| 326 | expect( |
| 327 | applyServerSettingsPatch(current, { |
| 328 | textGenerationModelSelection: { |
| 329 | instanceId: ProviderInstanceId.make("codex"), |
| 330 | model: "gpt-5.4-mini", |
| 331 | }, |
| 332 | }).textGenerationModelSelection, |
| 333 | ).toEqual({ |
| 334 | instanceId: "codex", |
| 335 | model: "gpt-5.4-mini", |
| 336 | }); |
| 337 | }); |
| 338 | |
| 339 | it("still deep merges text generation selection when only options are provided", () => { |
| 340 | const current = { |
| 341 | ...DEFAULT_SERVER_SETTINGS, |
| 342 | textGenerationModelSelection: createModelSelection( |
| 343 | ProviderInstanceId.make("codex"), |
| 344 | "gpt-5.4-mini", |
| 345 | [ |
| 346 | { id: "reasoningEffort", value: "high" }, |
| 347 | { id: "fastMode", value: true }, |
| 348 | ], |
| 349 | ), |
| 350 | }; |
| 351 | |
| 352 | expect( |
| 353 | applyServerSettingsPatch(current, { |
| 354 | textGenerationModelSelection: { |
| 355 | options: [{ id: "fastMode", value: false }], |
| 356 | }, |
| 357 | }).textGenerationModelSelection, |
| 358 | ).toEqual({ |
| 359 | instanceId: "codex", |
| 360 | model: "gpt-5.4-mini", |
| 361 | options: [ |
| 362 | { id: "reasoningEffort", value: "high" }, |
| 363 | { id: "fastMode", value: false }, |
| 364 | ], |
| 365 | }); |
| 366 | }); |
| 367 | |
| 368 | it("replaces text generation selection across providers without leaking stale options", () => { |
| 369 | const current = { |
| 370 | ...DEFAULT_SERVER_SETTINGS, |
| 371 | textGenerationModelSelection: createModelSelection( |
| 372 | ProviderInstanceId.make("codex"), |
| 373 | "gpt-5.4-mini", |
| 374 | [ |
| 375 | { id: "reasoningEffort", value: "high" }, |
| 376 | { id: "fastMode", value: true }, |
| 377 | ], |
| 378 | ), |
| 379 | }; |
| 380 | |
| 381 | expect( |
| 382 | applyServerSettingsPatch(current, { |
| 383 | textGenerationModelSelection: { |
| 384 | instanceId: ProviderInstanceId.make("opencode"), |
| 385 | model: "openai/gpt-5", |
| 386 | }, |
| 387 | }).textGenerationModelSelection, |
| 388 | ).toEqual({ |
| 389 | instanceId: "opencode", |
| 390 | model: "openai/gpt-5", |
| 391 | }); |
| 392 | }); |
| 393 | |
| 394 | it("accepts array-based text generation selection patches", () => { |
| 395 | expect( |
| 396 | applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 397 | textGenerationModelSelection: { |
| 398 | instanceId: ProviderInstanceId.make("opencode"), |
| 399 | model: "openai/gpt-5", |
| 400 | options: [ |
| 401 | { id: "variant", value: "prod" }, |
| 402 | { id: "agent", value: "build" }, |
| 403 | ], |
| 404 | }, |
| 405 | }).textGenerationModelSelection, |
| 406 | ).toEqual({ |
| 407 | instanceId: "opencode", |
| 408 | model: "openai/gpt-5", |
| 409 | options: [ |
| 410 | { id: "variant", value: "prod" }, |
| 411 | { id: "agent", value: "build" }, |
| 412 | ], |
| 413 | }); |
| 414 | }); |
| 415 | |
| 416 | it("replaces source control writer selection without retaining stale options", () => { |
| 417 | const current = { |
| 418 | ...DEFAULT_SERVER_SETTINGS, |
| 419 | sourceControlWriterModelSelection: createModelSelection( |
| 420 | ProviderInstanceId.make("codex"), |
| 421 | "gpt-5.4-mini", |
| 422 | [{ id: "reasoningEffort", value: "high" }], |
| 423 | ), |
| 424 | }; |
| 425 | |
| 426 | expect( |
| 427 | applyServerSettingsPatch(current, { |
| 428 | sourceControlWriterModelSelection: { |
| 429 | instanceId: ProviderInstanceId.make("opencode"), |
| 430 | model: "openai/gpt-5", |
| 431 | }, |
| 432 | }).sourceControlWriterModelSelection, |
| 433 | ).toEqual({ |
| 434 | instanceId: "opencode", |
| 435 | model: "openai/gpt-5", |
| 436 | }); |
| 437 | }); |
| 438 | |
| 439 | it("clears source control writer selection with null", () => { |
| 440 | const current = { |
| 441 | ...DEFAULT_SERVER_SETTINGS, |
| 442 | sourceControlWriterModelSelection: createModelSelection( |
| 443 | ProviderInstanceId.make("codex"), |
| 444 | "gpt-5.4-mini", |
| 445 | ), |
| 446 | }; |
| 447 | |
| 448 | expect( |
| 449 | applyServerSettingsPatch(current, { |
| 450 | sourceControlWriterModelSelection: null, |
| 451 | }).sourceControlWriterModelSelection, |
| 452 | ).toBeNull(); |
| 453 | }); |
| 454 | |
| 455 | it("falls back from a disabled source control writer provider without clearing its selection", () => { |
| 456 | const instanceId = ProviderInstanceId.make("codex_writer"); |
| 457 | const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini"); |
| 458 | const settings = { |
| 459 | ...DEFAULT_SERVER_SETTINGS, |
| 460 | providerInstances: { |
| 461 | [instanceId]: { |
| 462 | driver: ProviderDriverKind.make("codex"), |
| 463 | enabled: false, |
| 464 | config: {}, |
| 465 | }, |
| 466 | }, |
| 467 | sourceControlWriterModelSelection, |
| 468 | }; |
| 469 | |
| 470 | expect(isModelSelectionProviderEnabled(settings, sourceControlWriterModelSelection)).toBe( |
| 471 | false, |
| 472 | ); |
| 473 | expect(resolveSourceControlWriterModelSelection(settings, undefined, null)).toBe( |
| 474 | settings.textGenerationModelSelection, |
| 475 | ); |
| 476 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); |
| 477 | }); |
| 478 | |
| 479 | it("falls back from an unavailable source control writer provider", () => { |
| 480 | const instanceId = ProviderInstanceId.make("missing_writer"); |
| 481 | const sourceControlWriterModelSelection = createModelSelection(instanceId, "missing-model"); |
| 482 | const settings = { |
| 483 | ...DEFAULT_SERVER_SETTINGS, |
| 484 | providerInstances: { |
| 485 | [instanceId]: { |
| 486 | driver: ProviderDriverKind.make("missing-driver"), |
| 487 | config: {}, |
| 488 | }, |
| 489 | }, |
| 490 | sourceControlWriterModelSelection, |
| 491 | }; |
| 492 | const unavailableProvider = { |
| 493 | instanceId, |
| 494 | driver: ProviderDriverKind.make("missing-driver"), |
| 495 | enabled: false, |
| 496 | installed: false, |
| 497 | version: null, |
| 498 | status: "disabled", |
| 499 | auth: { status: "unknown" }, |
| 500 | checkedAt: "2026-07-27T00:00:00.000Z", |
| 501 | availability: "unavailable", |
| 502 | unavailableReason: "This provider driver is not available in this build.", |
| 503 | models: [], |
| 504 | slashCommands: [], |
| 505 | skills: [], |
| 506 | } satisfies ServerProvider; |
| 507 | |
| 508 | expect(resolveSourceControlWriterModelSelection(settings, [unavailableProvider], null)).toBe( |
| 509 | settings.textGenerationModelSelection, |
| 510 | ); |
| 511 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); |
| 512 | }); |
| 513 | |
| 514 | it("falls back from a writer provider that cannot generate application text", () => { |
| 515 | const instanceId = ProviderInstanceId.make("acp_writer"); |
| 516 | const sourceControlWriterModelSelection = createModelSelection(instanceId, "default"); |
| 517 | const settings = { |
| 518 | ...DEFAULT_SERVER_SETTINGS, |
| 519 | providerInstances: { |
| 520 | [instanceId]: { |
| 521 | driver: ProviderDriverKind.make("acpRegistry"), |
| 522 | enabled: true, |
| 523 | config: {}, |
| 524 | }, |
| 525 | }, |
| 526 | sourceControlWriterModelSelection, |
| 527 | }; |
| 528 | const incapableProvider = { |
| 529 | instanceId, |
| 530 | driver: ProviderDriverKind.make("acpRegistry"), |
| 531 | supportsTextGeneration: false, |
| 532 | enabled: true, |
| 533 | installed: true, |
| 534 | version: null, |
| 535 | status: "ready", |
| 536 | auth: { status: "authenticated" }, |
| 537 | checkedAt: "2026-07-27T00:00:00.000Z", |
| 538 | models: [], |
| 539 | slashCommands: [], |
| 540 | skills: [], |
| 541 | } satisfies ServerProvider; |
| 542 | |
| 543 | expect(resolveSourceControlWriterModelSelection(settings, [incapableProvider], null)).toBe( |
| 544 | settings.textGenerationModelSelection, |
| 545 | ); |
| 546 | expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection); |
| 547 | }); |
| 548 | |
| 549 | it("falls back from a writer provider a project turned off, keeping it for other projects", () => { |
| 550 | const instanceId = ProviderInstanceId.make("codex_writer"); |
| 551 | const projectId = ProjectId.make("project-a"); |
| 552 | const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini"); |
| 553 | const settings = { |
| 554 | ...DEFAULT_SERVER_SETTINGS, |
| 555 | providerInstances: { |
| 556 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: true, config: {} }, |
| 557 | }, |
| 558 | projectSettingsOverrides: { |
| 559 | [projectId]: { providerInstances: { [instanceId]: { enabled: false } } }, |
| 560 | }, |
| 561 | sourceControlWriterModelSelection, |
| 562 | }; |
| 563 | const provider = { |
| 564 | instanceId, |
| 565 | driver: ProviderDriverKind.make("codex"), |
| 566 | enabled: true, |
| 567 | installed: true, |
| 568 | version: null, |
| 569 | status: "ready", |
| 570 | auth: { status: "authenticated" }, |
| 571 | checkedAt: "2026-07-27T00:00:00.000Z", |
| 572 | availability: "available", |
| 573 | models: [], |
| 574 | slashCommands: [], |
| 575 | skills: [], |
| 576 | } satisfies ServerProvider; |
| 577 | |
| 578 | expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe( |
| 579 | settings.textGenerationModelSelection, |
| 580 | ); |
| 581 | expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe( |
| 582 | sourceControlWriterModelSelection, |
| 583 | ); |
| 584 | }); |
| 585 | |
| 586 | it("keeps a writer on an instance the project turned on while the machine has it off", () => { |
| 587 | const instanceId = ProviderInstanceId.make("codex_writer"); |
| 588 | const projectId = ProjectId.make("project-a"); |
| 589 | const sourceControlWriterModelSelection = createModelSelection(instanceId, "gpt-5.4-mini"); |
| 590 | const settings = { |
| 591 | ...DEFAULT_SERVER_SETTINGS, |
| 592 | providerInstances: { |
| 593 | [instanceId]: { driver: ProviderDriverKind.make("codex"), enabled: false, config: {} }, |
| 594 | }, |
| 595 | projectSettingsOverrides: { |
| 596 | [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, |
| 597 | }, |
| 598 | sourceControlWriterModelSelection, |
| 599 | }; |
| 600 | const provider = { |
| 601 | instanceId, |
| 602 | driver: ProviderDriverKind.make("codex"), |
| 603 | enabled: true, |
| 604 | installed: true, |
| 605 | version: null, |
| 606 | status: "ready", |
| 607 | auth: { status: "authenticated" }, |
| 608 | checkedAt: "2026-07-27T00:00:00.000Z", |
| 609 | availability: "available", |
| 610 | models: [], |
| 611 | slashCommands: [], |
| 612 | skills: [], |
| 613 | } satisfies ServerProvider; |
| 614 | |
| 615 | expect(resolveSourceControlWriterModelSelection(settings, [provider], projectId)).toBe( |
| 616 | sourceControlWriterModelSelection, |
| 617 | ); |
| 618 | expect(resolveSourceControlWriterModelSelection(settings, [provider], null)).toBe( |
| 619 | settings.textGenerationModelSelection, |
| 620 | ); |
| 621 | }); |
| 622 | |
| 623 | it("replaces providerInstances maps so omitted instance fields are cleared", () => { |
| 624 | const codexId = ProviderInstanceId.make("codex"); |
| 625 | const current = { |
| 626 | ...DEFAULT_SERVER_SETTINGS, |
| 627 | providerInstances: { |
| 628 | [codexId]: { |
| 629 | driver: ProviderDriverKind.make("codex"), |
| 630 | displayName: "Codex Work", |
| 631 | accentColor: "#7c3aed", |
| 632 | enabled: true, |
| 633 | config: { homePath: "~/.codex" }, |
| 634 | }, |
| 635 | }, |
| 636 | }; |
| 637 | |
| 638 | expect( |
| 639 | applyServerSettingsPatch(current, { |
| 640 | providerInstances: { |
| 641 | [codexId]: { |
| 642 | driver: ProviderDriverKind.make("codex"), |
| 643 | displayName: "Codex Work", |
| 644 | enabled: true, |
| 645 | config: { homePath: "~/.codex" }, |
| 646 | }, |
| 647 | }, |
| 648 | }).providerInstances[codexId], |
| 649 | ).toEqual({ |
| 650 | driver: ProviderDriverKind.make("codex"), |
| 651 | displayName: "Codex Work", |
| 652 | enabled: true, |
| 653 | config: { homePath: "~/.codex" }, |
| 654 | }); |
| 655 | }); |
| 656 | |
| 657 | it("keeps a project's providerInstances override when a patch entry omits the key", () => { |
| 658 | // An older client that predates per-project provider overrides reads and |
| 659 | // resends a project's whole entry without knowing about the field. A |
| 660 | // naive whole-entry replace would silently wipe it. |
| 661 | const instanceId = ProviderInstanceId.make("codex_work"); |
| 662 | const projectId = ProjectId.make("project-a"); |
| 663 | const current = { |
| 664 | ...DEFAULT_SERVER_SETTINGS, |
| 665 | projectSettingsOverrides: { |
| 666 | [projectId]: { |
| 667 | newWorktreesStartFromOrigin: true, |
| 668 | providerInstances: { [instanceId]: { enabled: false } }, |
| 669 | }, |
| 670 | }, |
| 671 | }; |
| 672 | |
| 673 | const patched = applyServerSettingsPatch(current, { |
| 674 | projectSettingsOverrides: { |
| 675 | [projectId]: { newWorktreesStartFromOrigin: false }, |
| 676 | }, |
| 677 | }); |
| 678 | |
| 679 | expect(patched.projectSettingsOverrides[projectId]).toEqual({ |
| 680 | newWorktreesStartFromOrigin: false, |
| 681 | providerInstances: { [instanceId]: { enabled: false } }, |
| 682 | }); |
| 683 | }); |
| 684 | |
| 685 | it("clears a project's providerInstances override when a patch entry sends an empty map", () => { |
| 686 | // A client that knows the field expresses "clear every provider override |
| 687 | // for this project" by sending providerInstances explicitly, not by |
| 688 | // omitting it. |
| 689 | const instanceId = ProviderInstanceId.make("codex_work"); |
| 690 | const projectId = ProjectId.make("project-a"); |
| 691 | const current = { |
| 692 | ...DEFAULT_SERVER_SETTINGS, |
| 693 | projectSettingsOverrides: { |
| 694 | [projectId]: { |
| 695 | newWorktreesStartFromOrigin: true, |
| 696 | providerInstances: { [instanceId]: { enabled: false } }, |
| 697 | }, |
| 698 | }, |
| 699 | }; |
| 700 | |
| 701 | const patched = applyServerSettingsPatch(current, { |
| 702 | projectSettingsOverrides: { |
| 703 | [projectId]: { newWorktreesStartFromOrigin: true, providerInstances: {} }, |
| 704 | }, |
| 705 | }); |
| 706 | |
| 707 | expect(patched.projectSettingsOverrides[projectId]).toEqual({ |
| 708 | newWorktreesStartFromOrigin: true, |
| 709 | providerInstances: {}, |
| 710 | }); |
| 711 | }); |
| 712 | |
| 713 | it("upserts and removes usageLimitSources per entry so concurrent edits cannot clobber", () => { |
| 714 | const hubA = UsageLimitSourceId.make("cliproxy-a"); |
| 715 | const hubB = UsageLimitSourceId.make("cliproxy-b"); |
| 716 | const source = (url: string) => ({ |
| 717 | kind: "cliproxy" as const, |
| 718 | url, |
| 719 | managementKey: "secret", |
| 720 | enabled: true, |
| 721 | }); |
| 722 | const current = { |
| 723 | ...DEFAULT_SERVER_SETTINGS, |
| 724 | usageLimitSources: { [hubA]: source("http://a:8318") }, |
| 725 | }; |
| 726 | |
| 727 | const added = applyServerSettingsPatch(current, { |
| 728 | usageLimitSources: { [hubB]: source("http://b:8318") }, |
| 729 | }); |
| 730 | expect(Object.keys(added.usageLimitSources)).toEqual([hubA, hubB]); |
| 731 | |
| 732 | const removed = applyServerSettingsPatch(added, { usageLimitSources: { [hubA]: null } }); |
| 733 | expect(Object.keys(removed.usageLimitSources)).toEqual([hubB]); |
| 734 | }); |
| 735 | |
| 736 | it("replaces and removes individual usage prices without clobbering other models", () => { |
| 737 | const prices = { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }; |
| 738 | const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 739 | usagePriceOverrides: { "example-model": { ...prices, cacheReadCostPerMillionTokens: 0.5 } }, |
| 740 | }); |
| 741 | const added = applyServerSettingsPatch(current, { |
| 742 | usagePriceOverrides: { "other-model": prices }, |
| 743 | }); |
| 744 | const replaced = applyServerSettingsPatch(added, { |
| 745 | usagePriceOverrides: { "example-model": prices }, |
| 746 | }); |
| 747 | expect(replaced.usagePriceOverrides).toEqual({ |
| 748 | "example-model": prices, |
| 749 | "other-model": prices, |
| 750 | }); |
| 751 | const removed = applyServerSettingsPatch(replaced, { |
| 752 | usagePriceOverrides: { "example-model": null }, |
| 753 | }); |
| 754 | expect(removed.usagePriceOverrides).toEqual({ "other-model": prices }); |
| 755 | expect(current.usagePriceOverrides["example-model"]?.cacheReadCostPerMillionTokens).toBe(0.5); |
| 756 | }); |
| 757 | |
| 758 | it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { |
| 759 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 760 | backgroundActivity: { |
| 761 | schemaVersion: 1, |
| 762 | profile: "battery-saver", |
| 763 | overrides: {}, |
| 764 | }, |
| 765 | }); |
| 766 | |
| 767 | expect(next.backgroundActivity).toEqual({ |
| 768 | schemaVersion: 1, |
| 769 | profile: "battery-saver", |
| 770 | overrides: {}, |
| 771 | }); |
| 772 | expect(next.backgroundActivityProfile).toBe("battery-saver"); |
| 773 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0); |
| 774 | expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe( |
| 775 | Duration.toMillis(Duration.minutes(15)), |
| 776 | ); |
| 777 | }); |
| 778 | |
| 779 | it("turns legacy interval patches into custom background activity overrides", () => { |
| 780 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 781 | automaticGitFetchInterval: Duration.seconds(15), |
| 782 | }); |
| 783 | |
| 784 | expect(next.backgroundActivity).toEqual({ |
| 785 | schemaVersion: 1, |
| 786 | profile: "custom", |
| 787 | baseProfile: "balanced", |
| 788 | overrides: { |
| 789 | automaticGitFetchInterval: Duration.seconds(15), |
| 790 | }, |
| 791 | }); |
| 792 | expect(resolveServerBackgroundActivitySettings(next).profile).toBe("balanced"); |
| 793 | expect( |
| 794 | Duration.toMillis(resolveServerBackgroundActivitySettings(next).automaticGitFetchInterval), |
| 795 | ).toBe(15_000); |
| 796 | }); |
| 797 | |
| 798 | it("preserves legacy background activity settings when applying an unrelated patch", () => { |
| 799 | const current = { |
| 800 | ...DEFAULT_SERVER_SETTINGS, |
| 801 | backgroundActivityProfile: "performance" as const, |
| 802 | automaticGitFetchInterval: Duration.seconds(7), |
| 803 | providerHealthRefreshInterval: Duration.minutes(4), |
| 804 | }; |
| 805 | |
| 806 | const next = applyServerSettingsPatch(current, { |
| 807 | sourceControlWriterModelSelection: createModelSelection( |
| 808 | ProviderInstanceId.make("codex"), |
| 809 | "gpt-5.4-mini", |
| 810 | ), |
| 811 | }); |
| 812 | |
| 813 | expect(next.backgroundActivity).toEqual({ |
| 814 | schemaVersion: 1, |
| 815 | profile: "custom", |
| 816 | baseProfile: "performance", |
| 817 | overrides: { |
| 818 | automaticGitFetchInterval: Duration.seconds(7), |
| 819 | providerHealthRefreshInterval: Duration.minutes(4), |
| 820 | }, |
| 821 | }); |
| 822 | expect(next.backgroundActivityProfile).toBe("performance"); |
| 823 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(7_000); |
| 824 | expect(Duration.toMillis(next.providerHealthRefreshInterval)).toBe(240_000); |
| 825 | }); |
| 826 | |
| 827 | it("does not reactivate dormant overrides from a concrete profile", () => { |
| 828 | const current = { |
| 829 | ...DEFAULT_SERVER_SETTINGS, |
| 830 | backgroundActivity: { |
| 831 | schemaVersion: 1 as const, |
| 832 | profile: "battery-saver" as const, |
| 833 | overrides: { |
| 834 | providerHealthRefreshInterval: Duration.seconds(5), |
| 835 | }, |
| 836 | }, |
| 837 | }; |
| 838 | |
| 839 | const next = applyServerSettingsPatch(current, { |
| 840 | automaticGitFetchInterval: Duration.seconds(15), |
| 841 | }); |
| 842 | |
| 843 | expect(next.backgroundActivity).toEqual({ |
| 844 | schemaVersion: 1, |
| 845 | profile: "custom", |
| 846 | baseProfile: "battery-saver", |
| 847 | overrides: { |
| 848 | automaticGitFetchInterval: Duration.seconds(15), |
| 849 | }, |
| 850 | }); |
| 851 | }); |
| 852 | |
| 853 | it("prefers structured background activity settings over legacy aliases", () => { |
| 854 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 855 | backgroundActivity: { |
| 856 | schemaVersion: 1, |
| 857 | profile: "battery-saver", |
| 858 | overrides: {}, |
| 859 | }, |
| 860 | automaticGitFetchInterval: Duration.seconds(5), |
| 861 | backgroundActivityProfile: "performance", |
| 862 | }); |
| 863 | |
| 864 | expect(next.backgroundActivity).toEqual({ |
| 865 | schemaVersion: 1, |
| 866 | profile: "battery-saver", |
| 867 | overrides: {}, |
| 868 | }); |
| 869 | expect(next.backgroundActivityProfile).toBe("battery-saver"); |
| 870 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(0); |
| 871 | }); |
| 872 | |
| 873 | it("reconciles custom background activity back to a preset when overrides match the preset", () => { |
| 874 | const custom = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 875 | automaticGitFetchInterval: Duration.seconds(15), |
| 876 | }); |
| 877 | const next = applyServerSettingsPatch(custom, { |
| 878 | automaticGitFetchInterval: Duration.seconds(30), |
| 879 | }); |
| 880 | |
| 881 | expect(next.backgroundActivity).toEqual({ |
| 882 | schemaVersion: 1, |
| 883 | profile: "balanced", |
| 884 | overrides: {}, |
| 885 | }); |
| 886 | expect(next.backgroundActivityProfile).toBe("balanced"); |
| 887 | expect(Duration.toMillis(next.automaticGitFetchInterval)).toBe(30_000); |
| 888 | }); |
| 889 | |
| 890 | it("drops custom overrides that duplicate the base profile", () => { |
| 891 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 892 | backgroundActivity: { |
| 893 | schemaVersion: 1, |
| 894 | profile: "custom", |
| 895 | baseProfile: "balanced", |
| 896 | overrides: { |
| 897 | automaticGitFetchInterval: Duration.seconds(30), |
| 898 | }, |
| 899 | }, |
| 900 | }); |
| 901 | |
| 902 | expect(next.backgroundActivity).toEqual({ |
| 903 | schemaVersion: 1, |
| 904 | profile: "balanced", |
| 905 | overrides: {}, |
| 906 | }); |
| 907 | }); |
| 908 | |
| 909 | it("replaces the complete background override record", () => { |
| 910 | const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 911 | backgroundActivity: { |
| 912 | schemaVersion: 1, |
| 913 | profile: "custom", |
| 914 | baseProfile: "balanced", |
| 915 | overrides: { |
| 916 | automaticGitFetchInterval: Duration.seconds(15), |
| 917 | providerHealthRefreshInterval: Duration.minutes(3), |
| 918 | }, |
| 919 | }, |
| 920 | }); |
| 921 | |
| 922 | const next = applyServerSettingsPatch(current, { |
| 923 | backgroundActivity: { |
| 924 | overrides: { |
| 925 | automaticGitFetchInterval: Duration.seconds(10), |
| 926 | }, |
| 927 | }, |
| 928 | }); |
| 929 | |
| 930 | expect(next.backgroundActivity).toEqual({ |
| 931 | schemaVersion: 1, |
| 932 | profile: "custom", |
| 933 | baseProfile: "balanced", |
| 934 | overrides: { |
| 935 | automaticGitFetchInterval: Duration.seconds(10), |
| 936 | }, |
| 937 | }); |
| 938 | }); |
| 939 | |
| 940 | it("keeps interval overrides supplied with a profile patch", () => { |
| 941 | const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 942 | backgroundActivityProfile: "performance", |
| 943 | automaticGitFetchInterval: Duration.seconds(0), |
| 944 | providerHealthRefreshInterval: Duration.minutes(4), |
| 945 | }); |
| 946 | |
| 947 | expect(next.backgroundActivity).toEqual({ |
| 948 | schemaVersion: 1, |
| 949 | profile: "custom", |
| 950 | baseProfile: "performance", |
| 951 | overrides: { |
| 952 | automaticGitFetchInterval: Duration.seconds(0), |
| 953 | providerHealthRefreshInterval: Duration.minutes(4), |
| 954 | }, |
| 955 | }); |
| 956 | }); |
| 957 | |
| 958 | it("ignores overrides attached to a concrete background profile", () => { |
| 959 | const resolved = resolveServerBackgroundActivitySettings({ |
| 960 | ...DEFAULT_SERVER_SETTINGS, |
| 961 | backgroundActivity: { |
| 962 | schemaVersion: 1, |
| 963 | profile: "balanced", |
| 964 | overrides: { |
| 965 | pauseWhenOnBattery: true, |
| 966 | }, |
| 967 | }, |
| 968 | }); |
| 969 | |
| 970 | expect(resolved.pauseWhenOnBattery).toBe(false); |
| 971 | }); |
| 972 | }); |
| 973 | |
| 974 | describe("worktreesDirectory", () => { |
| 975 | it("remembers previous custom locations so their worktrees stay managed", () => { |
| 976 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { worktreesDirectory: "/a" }); |
| 977 | expect(first.previousWorktreesDirectories).toEqual([]); |
| 978 | const second = applyServerSettingsPatch(first, { worktreesDirectory: "/b" }); |
| 979 | expect(second.previousWorktreesDirectories).toEqual(["/a"]); |
| 980 | const reset = applyServerSettingsPatch(second, { worktreesDirectory: "" }); |
| 981 | expect(reset.previousWorktreesDirectories).toEqual(["/a", "/b"]); |
| 982 | const back = applyServerSettingsPatch(reset, { worktreesDirectory: "/a" }); |
| 983 | expect(back.previousWorktreesDirectories).toEqual(["/b"]); |
| 984 | }); |
| 985 | }); |
| 986 | |
| 987 | describe("synchronized model preferences", () => { |
| 988 | const provider = ProviderInstanceId.make("codex_work"); |
| 989 | const legacy = { |
| 990 | favorites: [{ provider, model: "sol" }], |
| 991 | providerModelPreferences: { [provider]: { hiddenModels: ["astra"], modelOrder: ["sol"] } }, |
| 992 | }; |
| 993 | |
| 994 | it("keeps concurrent adds and removes of the same favorite idempotent", () => { |
| 995 | const add = { setModelFavorites: [{ provider, model: "sol", favorite: true }] }; |
| 996 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, add); |
| 997 | expect(applyServerSettingsPatch(first, add).favorites).toEqual([{ provider, model: "sol" }]); |
| 998 | const remove = { setModelFavorites: [{ provider, model: "sol", favorite: false }] }; |
| 999 | const removed = applyServerSettingsPatch(first, remove); |
| 1000 | expect(applyServerSettingsPatch(removed, remove).favorites).toEqual([]); |
| 1001 | }); |
| 1002 | |
| 1003 | it("preserves unrelated models and providers during bulk visibility and order edits", () => { |
| 1004 | const otherProvider = ProviderInstanceId.make("claude_work"); |
| 1005 | const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 1006 | providerModelPreferences: { |
| 1007 | ...legacy.providerModelPreferences, |
| 1008 | [otherProvider]: { hiddenModels: ["opus"], modelOrder: ["sonnet", "opus"] }, |
| 1009 | }, |
| 1010 | }); |
| 1011 | const mobile = applyServerSettingsPatch(initial, { |
| 1012 | setModelsHidden: [{ provider, model: "luna", hidden: true }], |
| 1013 | }); |
| 1014 | const web = applyServerSettingsPatch(mobile, { |
| 1015 | setModelsHidden: [ |
| 1016 | { provider, model: "astra", hidden: false }, |
| 1017 | { provider, model: "sol", hidden: true }, |
| 1018 | ], |
| 1019 | setProviderModelOrder: { provider, modelOrder: ["sol", "luna", "astra"] }, |
| 1020 | }); |
| 1021 | expect(web.providerModelPreferences?.[provider]).toEqual({ |
| 1022 | hiddenModels: ["luna", "sol"], |
| 1023 | modelOrder: ["sol", "luna", "astra"], |
| 1024 | }); |
| 1025 | expect(web.providerModelPreferences?.[otherProvider]).toEqual( |
| 1026 | initial.providerModelPreferences?.[otherProvider], |
| 1027 | ); |
| 1028 | expect( |
| 1029 | applyServerSettingsPatch(web, { |
| 1030 | setProviderModelOrder: { provider, modelOrder: [] }, |
| 1031 | }).providerModelPreferences?.[provider], |
| 1032 | ).toEqual({ hiddenModels: ["luna", "sol"], modelOrder: [] }); |
| 1033 | }); |
| 1034 | |
| 1035 | it("migrates saved lists independently without claiming empty legacy lists", () => { |
| 1036 | expect(modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, {})).toBeNull(); |
| 1037 | const patch = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!; |
| 1038 | const migrated = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, patch); |
| 1039 | expect(resolveModelPreferences(migrated)).toEqual(legacy); |
| 1040 | expect(modelPreferencesMigrationPatch(migrated, legacy)).toBeNull(); |
| 1041 | const favoritesOnly = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { favorites: [] }); |
| 1042 | expect(modelPreferencesMigrationPatch(favoritesOnly, legacy)).toEqual({ |
| 1043 | migrateModelPreferences: { providerModelPreferences: legacy.providerModelPreferences }, |
| 1044 | }); |
| 1045 | }); |
| 1046 | |
| 1047 | it("keeps explicit clears when a delayed migration arrives from another device", () => { |
| 1048 | const staleMigration = modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!; |
| 1049 | const cleared = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 1050 | favorites: [], |
| 1051 | providerModelPreferences: {}, |
| 1052 | }); |
| 1053 | expect( |
| 1054 | resolveModelPreferences(applyServerSettingsPatch(cleared, staleMigration), legacy), |
| 1055 | ).toEqual({ |
| 1056 | favorites: [], |
| 1057 | providerModelPreferences: {}, |
| 1058 | }); |
| 1059 | }); |
| 1060 | |
| 1061 | it("replaces visibility records so showing every model clears removed entries", () => { |
| 1062 | const migrated = applyServerSettingsPatch( |
| 1063 | DEFAULT_SERVER_SETTINGS, |
| 1064 | modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy)!, |
| 1065 | ); |
| 1066 | const cleared = applyServerSettingsPatch(migrated, { providerModelPreferences: {} }); |
| 1067 | expect(cleared.providerModelPreferences).toEqual({}); |
| 1068 | expect(cleared.favorites).toEqual(legacy.favorites); |
| 1069 | }); |
| 1070 | |
| 1071 | it("sets favorites without losing changes from other clients", () => { |
| 1072 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 1073 | setModelFavorites: [{ provider, model: "sol", favorite: true }], |
| 1074 | }); |
| 1075 | const second = applyServerSettingsPatch(first, { |
| 1076 | setModelFavorites: [{ provider, model: "astra", favorite: true }], |
| 1077 | migrateModelPreferences: { favorites: [] }, |
| 1078 | }); |
| 1079 | expect(second.favorites).toEqual([ |
| 1080 | { provider, model: "sol" }, |
| 1081 | { provider, model: "astra" }, |
| 1082 | ]); |
| 1083 | const removed = applyServerSettingsPatch(second, { |
| 1084 | setModelFavorites: [{ provider, model: "sol", favorite: false }], |
| 1085 | }); |
| 1086 | expect(removed.favorites).toEqual([{ provider, model: "astra" }]); |
| 1087 | expect( |
| 1088 | applyServerSettingsPatch(removed, { |
| 1089 | setModelFavorites: [{ provider, model: "astra", favorite: false }], |
| 1090 | }).favorites, |
| 1091 | ).toEqual([]); |
| 1092 | }); |
| 1093 | |
| 1094 | it("sets visibility against the latest state without replacing other models or their order", () => { |
| 1095 | const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 1096 | providerModelPreferences: legacy.providerModelPreferences, |
| 1097 | }); |
| 1098 | const first = applyServerSettingsPatch(initial, { |
| 1099 | setModelsHidden: [{ provider, model: "sol", hidden: true }], |
| 1100 | }); |
| 1101 | const second = applyServerSettingsPatch(first, { |
| 1102 | setModelsHidden: [{ provider, model: "luna", hidden: true }], |
| 1103 | }); |
| 1104 | expect(second.providerModelPreferences?.[provider]).toEqual({ |
| 1105 | hiddenModels: ["astra", "sol", "luna"], |
| 1106 | modelOrder: ["sol"], |
| 1107 | }); |
| 1108 | const restored = applyServerSettingsPatch(second, { |
| 1109 | setModelsHidden: [{ provider, model: "sol", hidden: false }], |
| 1110 | }); |
| 1111 | expect(restored.providerModelPreferences?.[provider]).toEqual({ |
| 1112 | hiddenModels: ["astra", "luna"], |
| 1113 | modelOrder: ["sol"], |
| 1114 | }); |
| 1115 | expect( |
| 1116 | applyServerSettingsPatch(restored, { |
| 1117 | setModelsHidden: [{ provider, model: "sol", hidden: false }], |
| 1118 | }).providerModelPreferences, |
| 1119 | ).toEqual(restored.providerModelPreferences); |
| 1120 | expect( |
| 1121 | applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 1122 | setModelsHidden: [{ provider, model: "sol", hidden: true }], |
| 1123 | }).providerModelPreferences?.[provider]?.hiddenModels, |
| 1124 | ).toEqual(["sol"]); |
| 1125 | }); |
| 1126 | |
| 1127 | it("keeps the same model on different provider instances as separate favorites", () => { |
| 1128 | const otherProvider = ProviderInstanceId.make("codex_personal"); |
| 1129 | const first = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 1130 | setModelFavorites: [{ provider, model: "sol", favorite: true }], |
| 1131 | }); |
| 1132 | const second = applyServerSettingsPatch(first, { |
| 1133 | setModelFavorites: [{ provider: otherProvider, model: "sol", favorite: true }], |
| 1134 | }); |
| 1135 | expect(second.favorites).toEqual([ |
| 1136 | { provider, model: "sol" }, |
| 1137 | { provider: otherProvider, model: "sol" }, |
| 1138 | ]); |
| 1139 | expect( |
| 1140 | applyServerSettingsPatch(second, { |
| 1141 | setModelFavorites: [{ provider, model: "sol", favorite: false }], |
| 1142 | }).favorites, |
| 1143 | ).toEqual([{ provider: otherProvider, model: "sol" }]); |
| 1144 | }); |
| 1145 | |
| 1146 | it("keeps explicit edits when the same patch also carries a legacy migration", () => { |
| 1147 | const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { |
| 1148 | ...modelPreferencesMigrationPatch(DEFAULT_SERVER_SETTINGS, legacy), |
| 1149 | favorites: [], |
| 1150 | providerModelPreferences: {}, |
| 1151 | }); |
| 1152 | expect(resolveModelPreferences(edited, legacy)).toEqual({ |
| 1153 | favorites: [], |
| 1154 | providerModelPreferences: {}, |
| 1155 | }); |
| 1156 | }); |
| 1157 | }); |