@ilya-lesikov/pi-pi 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/3p/pi-ask-user/index.ts +1 -1
  2. package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
  3. package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
  4. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
  5. package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
  6. package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
  7. package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
  8. package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
  9. package/3p/pi-subagents/src/agent-manager.ts +34 -1
  10. package/3p/pi-subagents/src/agent-runner.ts +66 -33
  11. package/3p/pi-subagents/src/index.ts +3 -38
  12. package/3p/pi-subagents/src/types.ts +4 -0
  13. package/extensions/orchestrator/agents/advisor.ts +35 -0
  14. package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
  15. package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
  16. package/extensions/orchestrator/agents/constraints.test.ts +44 -0
  17. package/extensions/orchestrator/agents/constraints.ts +3 -0
  18. package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
  19. package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
  20. package/extensions/orchestrator/agents/planner.ts +9 -8
  21. package/extensions/orchestrator/agents/prompts.test.ts +120 -0
  22. package/extensions/orchestrator/agents/reviewer.ts +48 -0
  23. package/extensions/orchestrator/agents/task.ts +6 -20
  24. package/extensions/orchestrator/agents/tool-routing.ts +23 -1
  25. package/extensions/orchestrator/command-handlers.ts +1 -1
  26. package/extensions/orchestrator/config.ts +8 -4
  27. package/extensions/orchestrator/context.test.ts +54 -0
  28. package/extensions/orchestrator/context.ts +65 -2
  29. package/extensions/orchestrator/custom-footer.ts +5 -2
  30. package/extensions/orchestrator/doctor.test.ts +3 -1
  31. package/extensions/orchestrator/doctor.ts +40 -2
  32. package/extensions/orchestrator/event-handlers.test.ts +97 -1
  33. package/extensions/orchestrator/event-handlers.ts +222 -48
  34. package/extensions/orchestrator/flant-infra.test.ts +312 -0
  35. package/extensions/orchestrator/flant-infra.ts +407 -44
  36. package/extensions/orchestrator/index.ts +1 -1
  37. package/extensions/orchestrator/integration.test.ts +312 -18
  38. package/extensions/orchestrator/messages.test.ts +30 -0
  39. package/extensions/orchestrator/messages.ts +6 -0
  40. package/extensions/orchestrator/model-registry.test.ts +124 -13
  41. package/extensions/orchestrator/model-registry.ts +91 -33
  42. package/extensions/orchestrator/orchestrator.test.ts +113 -0
  43. package/extensions/orchestrator/orchestrator.ts +163 -3
  44. package/extensions/orchestrator/phases/brainstorm.ts +9 -20
  45. package/extensions/orchestrator/phases/implementation.test.ts +11 -0
  46. package/extensions/orchestrator/phases/implementation.ts +4 -6
  47. package/extensions/orchestrator/phases/planning.test.ts +16 -0
  48. package/extensions/orchestrator/phases/planning.ts +11 -4
  49. package/extensions/orchestrator/phases/review-task.ts +1 -4
  50. package/extensions/orchestrator/phases/review.test.ts +62 -0
  51. package/extensions/orchestrator/phases/review.ts +58 -15
  52. package/extensions/orchestrator/plannotator.ts +9 -6
  53. package/extensions/orchestrator/pp-menu.test.ts +74 -1
  54. package/extensions/orchestrator/pp-menu.ts +366 -94
  55. package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
  56. package/extensions/orchestrator/pp-state-tools.ts +249 -0
  57. package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
  58. package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
  59. package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
  60. package/extensions/orchestrator/state.ts +41 -20
  61. package/extensions/orchestrator/test-helpers.ts +18 -3
  62. package/extensions/orchestrator/usage-tracker.test.ts +131 -3
  63. package/extensions/orchestrator/usage-tracker.ts +78 -11
  64. package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
  65. package/extensions/orchestrator/validate-artifacts.ts +2 -2
  66. package/package.json +1 -1
@@ -1,8 +1,14 @@
1
1
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { readFileSync } from "node:fs";
4
5
  import { afterEach, describe, expect, it, vi } from "vitest";
5
6
 
7
+ const refreshAnthropicTokenMock = vi.fn();
8
+ vi.mock("@earendil-works/pi-ai/oauth", () => ({
9
+ refreshAnthropicToken: (...args: unknown[]) => refreshAnthropicTokenMock(...args),
10
+ }));
11
+
6
12
  const tempDirs: string[] = [];
7
13
 
8
14
  function makeTempDir(): string {
@@ -32,6 +38,7 @@ function collectModelSpecs(value: unknown): string[] {
32
38
  }
33
39
 
34
40
  afterEach(() => {
41
+ refreshAnthropicTokenMock.mockReset();
35
42
  delete process.env.PI_CODING_AGENT_DIR;
36
43
  for (const dir of tempDirs.splice(0)) {
37
44
  rmSync(dir, { recursive: true, force: true });
@@ -57,6 +64,210 @@ describe("flant-infra", () => {
57
64
  expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
58
65
  });
59
66
 
67
+ it("does not register the sub provider when subscription is disabled", async () => {
68
+ const dir = makeTempDir();
69
+ const mod = await loadFlantInfraModule(dir);
70
+ const registered = new Map<string, unknown>();
71
+ const pi = {
72
+ registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)),
73
+ unregisterProvider: vi.fn((name: string) => registered.delete(name)),
74
+ } as any;
75
+
76
+ mod.registerFlantProviders(pi, ["claude-opus-4-8", "gpt-5"], {}, { subscription: false });
77
+ expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-openai"]);
78
+ });
79
+
80
+ it("registers the sub provider with sub/ models when subscription enabled and credentials present", async () => {
81
+ const dir = makeTempDir();
82
+ mkdirSync(dir, { recursive: true });
83
+ writeFileSync(
84
+ join(dir, "auth.json"),
85
+ JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }),
86
+ "utf-8",
87
+ );
88
+ const prevKey = process.env.LLM_API_KEY;
89
+ process.env.LLM_API_KEY = "sk-gateway-test";
90
+ try {
91
+ const mod = await loadFlantInfraModule(dir);
92
+ const registered = new Map<string, any>();
93
+ const pi = {
94
+ registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)),
95
+ unregisterProvider: vi.fn((name: string) => registered.delete(name)),
96
+ } as any;
97
+
98
+ mod.registerFlantProviders(pi, ["claude-opus-4-8", "claude-haiku-4-5", "gpt-5"], {}, { subscription: true });
99
+
100
+ expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic", "pp-flant-anthropic-sub", "pp-flant-openai"]);
101
+ const sub = registered.get("pp-flant-anthropic-sub");
102
+ expect(sub.api).toBe("anthropic-messages");
103
+ expect(sub.baseUrl).toBe("https://llm-api.flant.ru");
104
+ expect(sub.apiKey).toBe("sk-ant-oat01-test-token");
105
+ expect(sub.headers["x-litellm-api-key"]).toBe("Bearer sk-gateway-test");
106
+ expect(sub.models.map((m: any) => m.id).sort()).toEqual(["sub/claude-haiku-4-5", "sub/claude-opus-4-8"]);
107
+ } finally {
108
+ if (prevKey === undefined) delete process.env.LLM_API_KEY;
109
+ else process.env.LLM_API_KEY = prevKey;
110
+ }
111
+ });
112
+
113
+ it("skips the sub provider when subscription enabled but OAuth token missing", async () => {
114
+ const dir = makeTempDir();
115
+ const prevKey = process.env.LLM_API_KEY;
116
+ process.env.LLM_API_KEY = "sk-gateway-test";
117
+ try {
118
+ const mod = await loadFlantInfraModule(dir);
119
+ const registered = new Map<string, unknown>();
120
+ const pi = {
121
+ registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)),
122
+ unregisterProvider: vi.fn((name: string) => registered.delete(name)),
123
+ } as any;
124
+
125
+ mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true });
126
+ expect(registered.has("pp-flant-anthropic-sub")).toBe(false);
127
+ } finally {
128
+ if (prevKey === undefined) delete process.env.LLM_API_KEY;
129
+ else process.env.LLM_API_KEY = prevKey;
130
+ }
131
+ });
132
+
133
+ it("refreshSubProvider re-registers the sub provider when the token changes", async () => {
134
+ const dir = makeTempDir();
135
+ mkdirSync(dir, { recursive: true });
136
+ const authPath = join(dir, "auth.json");
137
+ // Start with an expired token that has a refresh token available.
138
+ writeFileSync(
139
+ authPath,
140
+ JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }),
141
+ "utf-8",
142
+ );
143
+ const prevKey = process.env.LLM_API_KEY;
144
+ process.env.LLM_API_KEY = "sk-gateway-test";
145
+ try {
146
+ const mod = await loadFlantInfraModule(dir);
147
+ const registered = new Map<string, any>();
148
+ const pi = {
149
+ registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)),
150
+ unregisterProvider: vi.fn((name: string) => registered.delete(name)),
151
+ } as any;
152
+
153
+ // Refresh yields a fresh token, which is what the initial registration reads.
154
+ refreshAnthropicTokenMock.mockResolvedValueOnce({ access: "sk-ant-oat01-fresh", refresh: "rt-fresh", expires: Date.now() + 3_600_000 });
155
+ await mod.refreshClaudeOAuthToken();
156
+ mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true });
157
+ expect(registered.get("pp-flant-anthropic-sub").apiKey).toBe("sk-ant-oat01-fresh");
158
+
159
+ // A no-op refresh (token unchanged) must not re-register.
160
+ const callsBefore = pi.registerProvider.mock.calls.length;
161
+ await mod.refreshSubProvider(pi);
162
+ expect(pi.registerProvider.mock.calls.length).toBe(callsBefore);
163
+
164
+ // Simulate the token expiring and a refresh minting a new one: the sub
165
+ // provider is re-registered with the new token.
166
+ writeFileSync(
167
+ authPath,
168
+ JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-expired", refresh: "rt-fresh", expires: Date.now() - 1000 } }),
169
+ "utf-8",
170
+ );
171
+ refreshAnthropicTokenMock.mockResolvedValueOnce({ access: "sk-ant-oat01-rotated", refresh: "rt-rotated", expires: Date.now() + 3_600_000 });
172
+ await mod.refreshSubProvider(pi);
173
+ expect(registered.get("pp-flant-anthropic-sub").apiKey).toBe("sk-ant-oat01-rotated");
174
+ } finally {
175
+ if (prevKey === undefined) delete process.env.LLM_API_KEY;
176
+ else process.env.LLM_API_KEY = prevKey;
177
+ }
178
+ });
179
+
180
+ it("refreshSubProvider is a no-op when subscription routing is inactive", async () => {
181
+ const dir = makeTempDir();
182
+ const mod = await loadFlantInfraModule(dir);
183
+ const pi = {
184
+ registerProvider: vi.fn(),
185
+ unregisterProvider: vi.fn(),
186
+ } as any;
187
+ // No registerFlantProviders({ subscription: true }) call happened, so there
188
+ // is no cached sub-provider context: refresh must do nothing.
189
+ await mod.refreshSubProvider(pi);
190
+ expect(pi.registerProvider).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it("readClaudeOAuthToken returns null for expired token", async () => {
194
+ const dir = makeTempDir();
195
+ mkdirSync(dir, { recursive: true });
196
+ writeFileSync(
197
+ join(dir, "auth.json"),
198
+ JSON.stringify({ anthropic: { access: "sk-ant-oat01-old", expires: Date.now() - 1000 } }),
199
+ "utf-8",
200
+ );
201
+ const mod = await loadFlantInfraModule(dir);
202
+ expect(mod.readClaudeOAuthToken()).toBeNull();
203
+ });
204
+
205
+ it("refreshClaudeOAuthToken returns the current token when not expired", async () => {
206
+ const dir = makeTempDir();
207
+ mkdirSync(dir, { recursive: true });
208
+ writeFileSync(
209
+ join(dir, "auth.json"),
210
+ JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-fresh", refresh: "rt", expires: Date.now() + 3_600_000 } }),
211
+ "utf-8",
212
+ );
213
+ const mod = await loadFlantInfraModule(dir);
214
+ await expect(mod.refreshClaudeOAuthToken()).resolves.toBe("sk-ant-oat01-fresh");
215
+ expect(refreshAnthropicTokenMock).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it("refreshClaudeOAuthToken refreshes an expired token and persists it", async () => {
219
+ const dir = makeTempDir();
220
+ mkdirSync(dir, { recursive: true });
221
+ const authPath = join(dir, "auth.json");
222
+ writeFileSync(
223
+ authPath,
224
+ JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }),
225
+ "utf-8",
226
+ );
227
+ const newExpires = Date.now() + 3_600_000;
228
+ refreshAnthropicTokenMock.mockResolvedValue({ access: "sk-ant-oat01-new", refresh: "rt-new", expires: newExpires });
229
+
230
+ const mod = await loadFlantInfraModule(dir);
231
+ await expect(mod.refreshClaudeOAuthToken()).resolves.toBe("sk-ant-oat01-new");
232
+ expect(refreshAnthropicTokenMock).toHaveBeenCalledWith("rt-old");
233
+
234
+ const persisted = JSON.parse(readFileSync(authPath, "utf-8"));
235
+ expect(persisted.anthropic).toMatchObject({
236
+ type: "oauth",
237
+ access: "sk-ant-oat01-new",
238
+ refresh: "rt-new",
239
+ expires: newExpires,
240
+ });
241
+ // A subsequent synchronous read now sees the fresh token.
242
+ expect(mod.readClaudeOAuthToken()).toBe("sk-ant-oat01-new");
243
+ });
244
+
245
+ it("refreshClaudeOAuthToken returns null when expired and no refresh token present", async () => {
246
+ const dir = makeTempDir();
247
+ mkdirSync(dir, { recursive: true });
248
+ writeFileSync(
249
+ join(dir, "auth.json"),
250
+ JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", expires: Date.now() - 1000 } }),
251
+ "utf-8",
252
+ );
253
+ const mod = await loadFlantInfraModule(dir);
254
+ await expect(mod.refreshClaudeOAuthToken()).resolves.toBeNull();
255
+ expect(refreshAnthropicTokenMock).not.toHaveBeenCalled();
256
+ });
257
+
258
+ it("refreshClaudeOAuthToken returns null when refresh fails", async () => {
259
+ const dir = makeTempDir();
260
+ mkdirSync(dir, { recursive: true });
261
+ writeFileSync(
262
+ join(dir, "auth.json"),
263
+ JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }),
264
+ "utf-8",
265
+ );
266
+ refreshAnthropicTokenMock.mockRejectedValue(new Error("refresh boom"));
267
+ const mod = await loadFlantInfraModule(dir);
268
+ await expect(mod.refreshClaudeOAuthToken()).resolves.toBeNull();
269
+ });
270
+
60
271
  it("generateDisplayName formats model ids", async () => {
61
272
  const dir = makeTempDir();
62
273
  const mod = await loadFlantInfraModule(dir);
@@ -94,6 +305,79 @@ describe("flant-infra", () => {
94
305
  expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gemini.model).toBe("pp-flant-openai/gemini-3-1-pro");
95
306
  });
96
307
 
308
+ it("generateFlantConfig routes Claude roles through subs when subscription active", async () => {
309
+ const dir = makeTempDir();
310
+ const mod = await loadFlantInfraModule(dir);
311
+
312
+ const config = mod.generateFlantConfig(
313
+ ["claude-opus-4-8", "claude-haiku-4-5", "gpt-5-4", "gemini-3-1-pro", "gemini-3-1-flash"],
314
+ true,
315
+ ) as any;
316
+
317
+ // Claude roles -> sub provider
318
+ expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
319
+ expect(config.agents.orchestrators.plan.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
320
+ expect(config.agents.orchestrators.brainstorm.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
321
+ expect(config.agents.subagents.simple.task.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
322
+ expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.opus.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8");
323
+ // Non-Claude roles stay on the openai (company-billed) provider
324
+ expect(config.agents.orchestrators.debug.model).toBe("pp-flant-openai/gpt-5-4");
325
+ expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gemini-3-1-flash");
326
+ expect(config.agents.subagents.presetGroups.planners.presets.regular.agents.gpt.model).toBe("pp-flant-openai/gpt-5-4");
327
+ });
328
+
329
+ it("generateFlantConfig keeps Claude roles on the company provider when subscription inactive", async () => {
330
+ const dir = makeTempDir();
331
+ const mod = await loadFlantInfraModule(dir);
332
+
333
+ const config = mod.generateFlantConfig(["claude-opus-4-8", "gpt-5-4"], false) as any;
334
+ expect(config.agents.orchestrators.implement.model).toBe("pp-flant-anthropic/claude-opus-4-8");
335
+ });
336
+
337
+ it("isSubscriptionActive requires flag + oauth token + gateway key", async () => {
338
+ const dir = makeTempDir();
339
+ mkdirSync(dir, { recursive: true });
340
+ writeFileSync(
341
+ join(dir, "auth.json"),
342
+ JSON.stringify({ anthropic: { access: "sk-ant-oat01-test", expires: Date.now() + 3_600_000 } }),
343
+ "utf-8",
344
+ );
345
+ const settingsDir = join(dir, "extensions", "pp", "cache");
346
+ mkdirSync(settingsDir, { recursive: true });
347
+ writeFileSync(join(settingsDir, "flant-models.json"), JSON.stringify({ enabled: true, subscription: true }), "utf-8");
348
+ const prevKey = process.env.LLM_API_KEY;
349
+ process.env.LLM_API_KEY = "sk-gateway-test";
350
+ try {
351
+ const mod = await loadFlantInfraModule(dir);
352
+ expect(mod.isSubscriptionActive()).toBe(true);
353
+ expect(mod.isSubscriptionActive({ subscription: false } as any)).toBe(false);
354
+ } finally {
355
+ if (prevKey === undefined) delete process.env.LLM_API_KEY;
356
+ else process.env.LLM_API_KEY = prevKey;
357
+ }
358
+ });
359
+
360
+ it("isSubscriptionActive is false without a gateway key", async () => {
361
+ const dir = makeTempDir();
362
+ mkdirSync(dir, { recursive: true });
363
+ writeFileSync(
364
+ join(dir, "auth.json"),
365
+ JSON.stringify({ anthropic: { access: "sk-ant-oat01-test", expires: Date.now() + 3_600_000 } }),
366
+ "utf-8",
367
+ );
368
+ const prevKey = process.env.LLM_API_KEY;
369
+ const prevFlant = process.env.FLANT_API_KEY;
370
+ delete process.env.LLM_API_KEY;
371
+ delete process.env.FLANT_API_KEY;
372
+ try {
373
+ const mod = await loadFlantInfraModule(dir);
374
+ expect(mod.isSubscriptionActive({ subscription: true } as any)).toBe(false);
375
+ } finally {
376
+ if (prevKey !== undefined) process.env.LLM_API_KEY = prevKey;
377
+ if (prevFlant !== undefined) process.env.FLANT_API_KEY = prevFlant;
378
+ }
379
+ });
380
+
97
381
  it("generateFlantConfig returns empty object for empty models", async () => {
98
382
  const dir = makeTempDir();
99
383
  const mod = await loadFlantInfraModule(dir);
@@ -143,6 +427,8 @@ describe("flant-infra", () => {
143
427
  enabled: false,
144
428
  autoUpdate: true,
145
429
  cacheTTLDays: 7,
430
+ switchBackIntervalMinutes: 30,
431
+ subscription: false,
146
432
  lastUpdated: null,
147
433
  cachedFlantModels: null,
148
434
  cachedOpenRouterData: null,
@@ -180,6 +466,8 @@ describe("flant-infra", () => {
180
466
  enabled: true,
181
467
  autoUpdate: false,
182
468
  cacheTTLDays: 3,
469
+ switchBackIntervalMinutes: 30,
470
+ subscription: false,
183
471
  lastUpdated: "2026-01-02T03:04:05.000Z",
184
472
  cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"],
185
473
  cachedOpenRouterData: {
@@ -202,6 +490,8 @@ describe("flant-infra", () => {
202
490
  enabled: true,
203
491
  autoUpdate: true,
204
492
  cacheTTLDays: 14,
493
+ switchBackIntervalMinutes: 30,
494
+ subscription: true,
205
495
  lastUpdated: "2026-02-01T00:00:00.000Z",
206
496
  cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"],
207
497
  cachedOpenRouterData: {
@@ -221,4 +511,26 @@ describe("flant-infra", () => {
221
511
  expect(loaded).toEqual(settings);
222
512
  expect(existsSync(join(dir, "extensions", "pp", "cache", "flant-models.json"))).toBe(true);
223
513
  });
514
+
515
+ it("normalizes switchBackIntervalMinutes: default when missing, parsed, floored to >=1", async () => {
516
+ const dir = makeTempDir();
517
+ const mod = await loadFlantInfraModule(dir);
518
+ const settingsDir = join(dir, "extensions", "pp", "cache");
519
+ const settingsPath = join(settingsDir, "flant-models.json");
520
+ mkdirSync(settingsDir, { recursive: true });
521
+
522
+ // Missing -> default 30.
523
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true }), "utf-8");
524
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
525
+
526
+ // String numeric -> parsed and rounded.
527
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "45" }), "utf-8");
528
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(45);
529
+
530
+ // Invalid / <1 -> floored to 1.
531
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: 0 }), "utf-8");
532
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(1);
533
+ writeFileSync(settingsPath, JSON.stringify({ enabled: true, switchBackIntervalMinutes: "nonsense" }), "utf-8");
534
+ expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(30);
535
+ });
224
536
  });