1import { describe, it, assert } from "@effect/vitest";
2import {
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6 type ServerProviderUpdateState,
7} from "@t3tools/contracts";
8import { ServerProviderUpdateError } from "@t3tools/contracts";
9import * as Cause from "effect/Cause";
10import * as Deferred from "effect/Deferred";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
15import * as Layer from "effect/Layer";
16import * as Ref from "effect/Ref";
17import * as Schema from "effect/Schema";
18import * as Sink from "effect/Sink";
19import * as Stream from "effect/Stream";
20import * as TestClock from "effect/testing/TestClock";
21import { HttpClient, HttpClientResponse } from "effect/http";
22import { ChildProcessSpawner } from "effect/process";
23import * as HostProcess from "@t3tools/shared/HostProcess";
24import { SpawnExecutableResolution } from "@t3tools/shared/shell";
25
26import * as ProviderRegistry from "./ProviderRegistry.ts";
27import * as ModelManifest from "./ModelManifest.ts";
28import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
29import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
30import {
31 makeProviderMaintenanceCapabilities,
32 type ProviderMaintenanceCapabilities,
33} from "@t3tools/provider-core/server/maintenanceResolver";
34const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError);
35
36const CODEX_DRIVER = ProviderDriverKind.make("codex");
37const NATIVE_CLI_DRIVER = ProviderDriverKind.make("nativeCli");
38const OPENCODE_DRIVER = ProviderDriverKind.make("opencode");
39const CODEX_INSTANCE_ID = ProviderInstanceId.make("codex");
40const NATIVE_CLI_INSTANCE_ID = ProviderInstanceId.make("nativeCli");
41const OPENCODE_INSTANCE_ID = ProviderInstanceId.make("opencode");
42const encoder = new TextEncoder();
43
44// Pin a non-win32 platform so `resolveSpawnCommand` is a no-op and the raw
45// `{ command, args }` assertions below hold deterministically on any host
46// (including Windows). Windows-specific resolution is covered by the dedicated
47// win32 case at the end of this suite.
48const layerNonWindowsPlatform = Layer.succeed(HostProcess.Platform, "linux");
49
50function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabilities {
51 if (provider === NATIVE_CLI_DRIVER) {
52 return makeProviderMaintenanceCapabilities({
53 provider,
54 packageName: null,
55 updateExecutable: "native-agent",
56 updateArgs: ["update"],
57 updateLockKey: "native-agent",
58 });
59 }
60 return makeProviderMaintenanceCapabilities({
61 provider,
62 packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex",
63 updateExecutable: "npm",
64 updateArgs:
65 provider === OPENCODE_DRIVER
66 ? ["install", "-g", "opencode-ai@latest"]
67 : ["install", "-g", "@openai/codex@latest"],
68 updateLockKey: "npm-global",
69 });
70}
71
72const baseProvider: ServerProvider = {
73 instanceId: CODEX_INSTANCE_ID,
74 driver: CODEX_DRIVER,
75 enabled: true,
76 installed: true,
77 version: "0.0.0",
78 status: "ready",
79 auth: { status: "authenticated" },
80 checkedAt: "2026-04-10T00:00:00.000Z",
81 models: [],
82 slashCommands: [],
83 skills: [],
84};
85
86const baseNativeCliProvider: ServerProvider = {
87 ...baseProvider,
88 instanceId: NATIVE_CLI_INSTANCE_ID,
89 driver: NATIVE_CLI_DRIVER,
90};
91
92const baseOpenCodeProvider: ServerProvider = {
93 ...baseProvider,
94 instanceId: OPENCODE_INSTANCE_ID,
95 driver: OPENCODE_DRIVER,
96};
97
98const layerLatestVersionHttpClient = (version: string) =>
99 Layer.succeed(
100 HttpClient.HttpClient,
101 HttpClient.make((request) =>
102 Effect.succeed(
103 HttpClientResponse.fromWeb(
104 request,
105 Response.json({ version }, { headers: { "content-type": "application/json" } }),
106 ),
107 ),
108 ),
109 );
110
111function mockHandle(result: {
112 readonly stdout?: string;
113 readonly stderr?: string;
114 readonly code?: number;
115 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
116}) {
117 return ChildProcessSpawner.makeHandle({
118 pid: ChildProcessSpawner.ProcessId(1),
119 exitCode: result.exitCode ?? Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)),
120 isRunning: Effect.succeed(false),
121 kill: () => Effect.void,
122 unref: Effect.succeed(Effect.void),
123 stdin: Sink.drain,
124 stdout: Stream.make(encoder.encode(result.stdout ?? "")),
125 stderr: Stream.make(encoder.encode(result.stderr ?? "")),
126 all: Stream.empty,
127 getInputFd: () => Sink.drain,
128 getOutputFd: () => Stream.empty,
129 });
130}
131
132function layerMockSpawner(
133 handler: (
134 command: string,
135 args: ReadonlyArray<string>,
136 options: { readonly env?: NodeJS.ProcessEnv | undefined },
137 ) => {
138 readonly stdout?: string;
139 readonly stderr?: string;
140 readonly code?: number;
141 readonly exitCode?: Effect.Effect<ChildProcessSpawner.ExitCode>;
142 },
143) {
144 return Layer.succeed(
145 ChildProcessSpawner.ChildProcessSpawner,
146 ChildProcessSpawner.make((command) => {
147 const childProcess = command as unknown as {
148 readonly command: string;
149 readonly args: ReadonlyArray<string>;
150 readonly options: { readonly env?: NodeJS.ProcessEnv | undefined };
151 };
152 return Effect.succeed(
153 mockHandle(handler(childProcess.command, childProcess.args, childProcess.options)),
154 );
155 }),
156 );
157}
158
159function makeRegistry(
160 initialProviders: ServerProvider | ReadonlyArray<ServerProvider> = baseProvider,
161) {
162 return Effect.gen(function* () {
163 const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(
164 Array.isArray(initialProviders) ? initialProviders : [initialProviders],
165 );
166 const updateStatesRef = yield* Ref.make<ReadonlyArray<ServerProviderUpdateState>>([]);
167
168 const setProviderMaintenanceActionState = Effect.fn(
169 "providerMaintenanceRunner.test.setProviderMaintenanceActionState",
170 )(function* (input: {
171 readonly instanceId: ProviderInstanceId;
172 readonly action: "update";
173 readonly state: ServerProviderUpdateState | null;
174 }) {
175 const updateState = input.state;
176 if (updateState) {
177 yield* Ref.update(updateStatesRef, (states) => [...states, updateState]);
178 }
179 return yield* Ref.updateAndGet(providersRef, (providers) =>
180 providers.map((candidate) => {
181 if (candidate.instanceId !== input.instanceId) {
182 return candidate;
183 }
184 if (!updateState) {
185 const { updateState: _updateState, ...providerWithoutUpdateState } = candidate;
186 return providerWithoutUpdateState;
187 }
188 return {
189 ...candidate,
190 updateState,
191 };
192 }),
193 );
194 });
195
196 const registry: ProviderRegistry.ProviderRegistry["Service"] = {
197 getProviders: Ref.get(providersRef),
198 refresh: () => Ref.get(providersRef),
199 refreshInstance: () => Ref.get(providersRef),
200 refreshWorkspaceSnapshot: () => Ref.get(providersRef),
201 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
202 Effect.succeed(lifecycleFor(provider)),
203 setProviderMaintenanceActionState,
204 streamChanges: Stream.empty,
205 };
206
207 return {
208 registry,
209 providersRef,
210 updateStatesRef,
211 };
212 });
213}
214
215const makeTestRunner = (
216 registry: ProviderRegistry.ProviderRegistry["Service"],
217 // Generic updater fixtures use synthetic versions. Keep their compatibility
218 // unknown so real harness minimums do not bypass the command under test.
219 manifest: ModelManifest.ModelManifestData = {
220 version: 1,
221 currentModels: {},
222 compatibility: [CODEX_DRIVER, OPENCODE_DRIVER].map((driver) => ({
223 driver,
224 t3CodeRange: ">=0.0.42",
225 ranges: [],
226 })),
227 },
228) =>
229 Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe(
230 Effect.provide(
231 ProviderMaintenanceRunner.layer.pipe(
232 Layer.provide(
233 Layer.mergeAll(
234 Layer.succeed(ProviderRegistry.ProviderRegistry, registry),
235 Layer.succeed(ModelManifest.ModelManifest, {
236 current: Effect.succeed(manifest),
237 refresh: Effect.succeed(manifest),
238 forceRefresh: Effect.succeed(manifest),
239 refreshInBackground: Effect.void,
240 }),
241 // Fresh per runner so a version cached by one test cannot leak into another.
242 ProviderLatestVersions.layer,
243 ),
244 ),
245 ),
246 ),
247 );
248
249describe("providerMaintenanceRunner", () => {
250 it.effect("runs the allowlisted provider update command and records success", () => {
251 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
252 return Effect.gen(function* () {
253 const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider);
254 const updater = yield* makeTestRunner(registry);
255
256 const result = yield* updater.updateProvider(NATIVE_CLI_DRIVER);
257 assert.deepStrictEqual(calls, [
258 {
259 command: "native-agent",
260 args: ["update"],
261 },
262 ]);
263 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
264 assert.deepStrictEqual(
265 (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]),
266 [
267 ["queued", "Waiting for another provider update to finish."],
268 ["running", "Checking for the latest version"],
269 ["running", "Running native-agent update"],
270 ["running", "Verifying the installed version"],
271 ["succeeded", "Provider updated."],
272 ],
273 );
274 }).pipe(
275 Effect.provide(
276 Layer.mergeAll(
277 layerNonWindowsPlatform,
278 layerLatestVersionHttpClient("0.0.0"),
279 layerMockSpawner((command, args) => {
280 calls.push({ command, args });
281 return { stdout: "updated" };
282 }),
283 ),
284 ),
285 );
286 });
287
288 it.effect("reports unchanged when the updater exits 0 but the provider is gone", () => {
289 return Effect.gen(function* () {
290 const { registry, providersRef } = yield* makeRegistry(baseProvider);
291 // After the update, the refreshed snapshot no longer sees an install.
292 const updater = yield* makeTestRunner({
293 ...registry,
294 refreshInstance: () =>
295 Ref.updateAndGet(providersRef, (providers) =>
296 providers.map((provider) => ({ ...provider, installed: false, version: null })),
297 ),
298 });
299
300 const result = yield* updater.updateProvider(CODEX_DRIVER);
301 assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged");
302 assert.match(result.providers[0]?.updateState?.message ?? "", /could not verify/);
303 }).pipe(
304 Effect.provide(
305 Layer.mergeAll(
306 layerNonWindowsPlatform,
307 layerLatestVersionHttpClient("0.0.0"),
308 layerMockSpawner(() => ({ stdout: "updated" })),
309 ),
310 ),
311 );
312 });
313
314 it.effect(
315 "keeps a successful update when the binary is present but its version is unreadable",
316 () => {
317 return Effect.gen(function* () {
318 const { registry, providersRef } = yield* makeRegistry(baseNativeCliProvider);
319 // A native CLI version probe can fail immediately after a successful update.
320 const updater = yield* makeTestRunner({
321 ...registry,
322 refreshInstance: () =>
323 Ref.updateAndGet(providersRef, (providers) =>
324 providers.map((provider) => ({ ...provider, installed: true, version: null })),
325 ),
326 });
327
328 const result = yield* updater.updateProvider(NATIVE_CLI_DRIVER);
329 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
330 }).pipe(
331 Effect.provide(
332 Layer.mergeAll(
333 layerNonWindowsPlatform,
334 layerLatestVersionHttpClient("0.0.0"),
335 layerMockSpawner(() => ({ stdout: "updated" })),
336 ),
337 ),
338 );
339 },
340 );
341
342 it.effect("spawns the updater with the environment its capabilities declare", () => {
343 const seen: Array<NodeJS.ProcessEnv | undefined> = [];
344 return Effect.gen(function* () {
345 const { registry } = yield* makeRegistry(baseProvider);
346 const updater = yield* makeTestRunner({
347 ...registry,
348 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
349 Effect.succeed({
350 ...lifecycleFor(provider),
351 update: {
352 command: "codex update",
353 executable: "/work/codex-home/packages/standalone/bin/codex",
354 args: ["update"],
355 lockKey: "codex-native",
356 env: { CODEX_HOME: "/work/codex-home" },
357 },
358 }),
359 });
360
361 yield* updater.updateProvider(CODEX_DRIVER);
362 assert.deepStrictEqual(seen, [{ CODEX_HOME: "/work/codex-home" }]);
363 }).pipe(
364 Effect.provide(
365 Layer.mergeAll(
366 layerNonWindowsPlatform,
367 layerLatestVersionHttpClient("0.0.0"),
368 layerMockSpawner((_command, _args, options) => {
369 seen.push(options.env);
370 return { stdout: "updated" };
371 }),
372 ),
373 ),
374 );
375 });
376
377 it.effect("re-resolves ownership before running and executes the fresh command", () => {
378 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
379 const fresh: Array<boolean> = [];
380 return Effect.gen(function* () {
381 const { registry } = yield* makeRegistry(baseProvider);
382 const updater = yield* makeTestRunner({
383 ...registry,
384 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider, options) => {
385 fresh.push(options?.fresh === true);
386 return Effect.succeed(
387 makeProviderMaintenanceCapabilities({
388 provider,
389 packageName: "@openai/codex",
390 updateExecutable: options?.fresh ? "/opt/homebrew/bin/brew" : "brew",
391 updateArgs: ["upgrade", "--cask", "codex"],
392 updateLockKey: "homebrew",
393 }),
394 );
395 },
396 });
397
398 yield* updater.updateProvider(CODEX_DRIVER);
399 // Cached read picks the lock; the two fresh reads bracket the command.
400 assert.deepStrictEqual(fresh, [false, true, true]);
401 assert.deepStrictEqual(calls, [
402 { command: "/opt/homebrew/bin/brew", args: ["upgrade", "--cask", "codex"] },
403 ]);
404 }).pipe(
405 Effect.provide(
406 Layer.mergeAll(
407 layerNonWindowsPlatform,
408 layerLatestVersionHttpClient("0.0.0"),
409 layerMockSpawner((command, args) => {
410 calls.push({ command, args });
411 return { stdout: "updated" };
412 }),
413 ),
414 ),
415 );
416 });
417
418 it.effect("reports the installer's latest output line while the update runs", () => {
419 const exited = Deferred.makeUnsafe<ChildProcessSpawner.ExitCode>();
420 return Effect.gen(function* () {
421 const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider);
422 const updater = yield* makeTestRunner(registry);
423 const runningMessages = Ref.get(updateStatesRef).pipe(
424 Effect.map((states) =>
425 states.filter((state) => state.status === "running").map((state) => state.message),
426 ),
427 );
428
429 const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild);
430 yield* TestClock.adjust(Duration.seconds(1));
431 assert.deepStrictEqual(yield* runningMessages, [
432 "Checking for the latest version",
433 "Running native-agent update",
434 "80%",
435 ]);
436
437 yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0));
438 const result = yield* Fiber.join(fiber);
439 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
440 }).pipe(
441 Effect.provide(
442 Layer.mergeAll(
443 layerNonWindowsPlatform,
444 layerLatestVersionHttpClient("0.0.0"),
445 layerMockSpawner(() => ({
446 stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%",
447 exitCode: Deferred.await(exited),
448 })),
449 ),
450 ),
451 );
452 });
453
454 it("splits installer output into clean status lines", () => {
455 assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), {
456 lines: ["Downloading"],
457 partialLine: " 5",
458 });
459 assert.strictEqual(
460 ProviderMaintenanceRunner.toProgressLine("\u001b[1m==>\u001b[0m Pouring"),
461 "==> Pouring",
462 );
463 assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(" \t "), null);
464 assert.strictEqual(
465 ProviderMaintenanceRunner.splitOutputLines("x".repeat(5_000), "y").partialLine.length,
466 4_096,
467 );
468 assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200);
469 });
470
471 it.effect("aborts without running when the installation changed since the advisory", () => {
472 const calls: Array<string> = [];
473 return Effect.gen(function* () {
474 const { registry, updateStatesRef } = yield* makeRegistry(baseProvider);
475 const updater = yield* makeTestRunner({
476 ...registry,
477 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider, options) =>
478 Effect.succeed(
479 options?.fresh
480 ? makeProviderMaintenanceCapabilities({
481 provider,
482 packageName: "@openai/codex",
483 updateExecutable: null,
484 updateArgs: [],
485 updateLockKey: null,
486 })
487 : lifecycleFor(provider),
488 ),
489 });
490
491 const result = yield* updater.updateProvider(CODEX_DRIVER);
492 assert.deepStrictEqual(calls, []);
493 assert.strictEqual(result.providers[0]?.updateState?.status, "failed");
494 assert.strictEqual(
495 result.providers[0]?.updateState?.message,
496 "Provider installation changed. Refresh and try again.",
497 );
498 assert.deepStrictEqual(
499 (yield* Ref.get(updateStatesRef)).map((state) => state.status),
500 ["queued", "running", "failed"],
501 );
502 }).pipe(
503 Effect.provide(
504 Layer.mergeAll(
505 layerNonWindowsPlatform,
506 layerLatestVersionHttpClient("0.0.0"),
507 layerMockSpawner((command) => {
508 calls.push(command);
509 return { stdout: "updated" };
510 }),
511 ),
512 ),
513 );
514 });
515
516 it.effect("uses the resolved provider capabilities when choosing the update executable", () => {
517 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
518 return Effect.gen(function* () {
519 const { registry } = yield* makeRegistry({
520 ...baseProvider,
521 versionAdvisory: {
522 status: "behind_latest",
523 currentVersion: "2.0.14",
524 latestVersion: "2.1.123",
525 updateCommand: "bun i -g @anthropic-ai/claude-code@latest",
526 canUpdate: true,
527 checkedAt: "2026-04-30T12:00:00.000Z",
528 message: "Update available.",
529 },
530 });
531 const updater = yield* makeTestRunner({
532 ...registry,
533 getProviderMaintenanceCapabilitiesForInstance: () =>
534 Effect.succeed(
535 makeProviderMaintenanceCapabilities({
536 provider: CODEX_DRIVER,
537 packageName: "@openai/codex",
538 updateExecutable: "bun",
539 updateArgs: ["i", "-g", "@openai/codex@latest"],
540 updateLockKey: "bun-global",
541 }),
542 ),
543 });
544
545 yield* updater.updateProvider(CODEX_DRIVER);
546 assert.deepStrictEqual(calls, [
547 {
548 command: "bun",
549 args: ["i", "-g", "@openai/codex@latest"],
550 },
551 ]);
552 }).pipe(
553 Effect.provide(
554 Layer.mergeAll(
555 layerNonWindowsPlatform,
556 layerLatestVersionHttpClient("0.0.0"),
557 layerMockSpawner((command, args) => {
558 calls.push({ command, args });
559 return { stdout: "updated" };
560 }),
561 ),
562 ),
563 );
564 });
565
566 it.effect(
567 "runs update commands through Effect ChildProcess when no test runner is injected",
568 () => {
569 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
570 return Effect.gen(function* () {
571 const { registry } = yield* makeRegistry(baseProvider);
572 const runner = yield* makeTestRunner(registry);
573
574 const result = yield* runner.updateProvider(CODEX_DRIVER);
575
576 assert.deepStrictEqual(calls, [
577 {
578 command: "npm",
579 args: ["install", "-g", "@openai/codex@latest"],
580 },
581 ]);
582 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
583 }).pipe(
584 Effect.provide(
585 Layer.mergeAll(
586 layerNonWindowsPlatform,
587 layerLatestVersionHttpClient("0.0.0"),
588 layerMockSpawner((command, args) => {
589 calls.push({ command, args });
590 return { stdout: "updated" };
591 }),
592 ),
593 ),
594 );
595 },
596 );
597
598 it.effect("updates a single provider instance without touching sibling instances", () => {
599 const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
600 return Effect.gen(function* () {
601 const personalInstanceId = ProviderInstanceId.make("codex_personal");
602 const workInstanceId = ProviderInstanceId.make("codex_work");
603 const refreshedInstanceIds: Array<ProviderInstanceId> = [];
604 const { registry } = yield* makeRegistry([
605 {
606 ...baseProvider,
607 instanceId: personalInstanceId,
608 version: "0.124.0-alpha.3",
609 },
610 {
611 ...baseProvider,
612 instanceId: workInstanceId,
613 version: "0.124.0-alpha.3",
614 },
615 ]);
616 const updater = yield* makeTestRunner({
617 ...registry,
618 getProviderMaintenanceCapabilitiesForInstance: (instanceId, provider) =>
619 Effect.succeed(
620 makeProviderMaintenanceCapabilities({
621 provider,
622 packageName: "@openai/codex-instance-test",
623 updateExecutable: "vp",
624 updateArgs: ["i", "-g", "@openai/codex"],
625 updateLockKey: "vite-plus-global",
626 }),
627 ).pipe(
628 Effect.tap(() => Effect.sync(() => assert.strictEqual(instanceId, personalInstanceId))),
629 ),
630 refreshInstance: (instanceId) =>
631 registry.refreshInstance(instanceId).pipe(
632 Effect.tap(() =>
633 Effect.sync(() => {
634 refreshedInstanceIds.push(instanceId);
635 }),
636 ),
637 ),
638 });
639
640 const result = yield* updater.updateProvider({
641 provider: CODEX_DRIVER,
642 instanceId: personalInstanceId,
643 });
644
645 assert.deepStrictEqual(calls, [
646 {
647 command: "vp",
648 args: ["i", "-g", "@openai/codex"],
649 },
650 ]);
651 assert.deepStrictEqual(refreshedInstanceIds, [personalInstanceId]);
652 assert.strictEqual(result.providers[0]?.instanceId, personalInstanceId);
653 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
654 assert.strictEqual(result.providers[1]?.instanceId, workInstanceId);
655 assert.strictEqual(result.providers[1]?.updateState, undefined);
656 }).pipe(
657 Effect.provide(
658 Layer.mergeAll(
659 layerNonWindowsPlatform,
660 layerLatestVersionHttpClient("0.124.0-alpha.3"),
661 layerMockSpawner((command, args) => {
662 calls.push({ command, args });
663 return { stdout: "updated" };
664 }),
665 ),
666 ),
667 );
668 });
669
670 it.effect("records command failure output in provider update state", () =>
671 Effect.gen(function* () {
672 const { registry } = yield* makeRegistry();
673 const updater = yield* makeTestRunner(registry);
674
675 const result = yield* updater.updateProvider(CODEX_DRIVER);
676 const updateState = result.providers[0]?.updateState;
677
678 assert.strictEqual(updateState?.status, "failed");
679 assert.strictEqual(updateState?.message, "Update command exited with code 1.");
680 assert.include(updateState?.output ?? "", "permission denied");
681 }).pipe(
682 Effect.provide(
683 Layer.mergeAll(
684 layerNonWindowsPlatform,
685 layerLatestVersionHttpClient("0.0.0"),
686 layerMockSpawner(() => ({ stderr: "permission denied", code: 1 })),
687 ),
688 ),
689 ),
690 );
691
692 it.effect(
693 "marks successful commands as unchanged when the refreshed provider is still outdated",
694 () =>
695 Effect.gen(function* () {
696 const { registry } = yield* makeRegistry({
697 ...baseProvider,
698 installed: true,
699 version: "0.1.0",
700 });
701 const updater = yield* makeTestRunner(registry);
702
703 const result = yield* updater.updateProvider(CODEX_DRIVER);
704
705 assert.strictEqual(result.providers[0]?.updateState?.status, "unchanged");
706 assert.include(result.providers[0]?.updateState?.message ?? "", "still detects");
707 }).pipe(
708 Effect.provide(
709 Layer.mergeAll(
710 layerNonWindowsPlatform,
711 layerLatestVersionHttpClient("9.9.9"),
712 layerMockSpawner(() => ({ stdout: "updated" })),
713 ),
714 ),
715 ),
716 );
717
718 it.effect("prevents concurrent updates for the same provider", () => {
719 const startedLatch: { resolve: () => void } = { resolve: () => {} };
720 const releaseLatch: { resolve: () => void } = { resolve: () => {} };
721 const started = new Promise<void>((resolve) => {
722 startedLatch.resolve = resolve;
723 });
724 const release = new Promise<void>((resolve) => {
725 releaseLatch.resolve = resolve;
726 });
727 return Effect.gen(function* () {
728 const { registry } = yield* makeRegistry();
729 const updater = yield* makeTestRunner(registry);
730
731 const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped);
732 yield* Effect.promise(() => started);
733
734 const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit);
735 assert.strictEqual(Exit.isFailure(second), true);
736 if (Exit.isFailure(second)) {
737 const error = Cause.squash(second.cause);
738 assert.strictEqual(isServerProviderUpdateError(error), true);
739 if (isServerProviderUpdateError(error)) {
740 assert.include(error.reason, "already running");
741 }
742 }
743
744 releaseLatch.resolve();
745 yield* Fiber.join(first);
746 }).pipe(
747 Effect.provide(
748 Layer.mergeAll(
749 layerNonWindowsPlatform,
750 layerLatestVersionHttpClient("0.0.0"),
751 layerMockSpawner(() => {
752 startedLatch.resolve();
753 return {
754 stdout: "updated",
755 exitCode: Effect.promise(() => release).pipe(
756 Effect.as(ChildProcessSpawner.ExitCode(0)),
757 ),
758 };
759 }),
760 ),
761 ),
762 );
763 });
764
765 it.effect("serializes different providers that share the same update lock key", () => {
766 const firstStartedLatch: { resolve: () => void } = { resolve: () => {} };
767 const releaseFirstLatch: { resolve: () => void } = { resolve: () => {} };
768 const firstStarted = new Promise<void>((resolve) => {
769 firstStartedLatch.resolve = resolve;
770 });
771 const releaseFirst = new Promise<void>((resolve) => {
772 releaseFirstLatch.resolve = resolve;
773 });
774 const calls: Array<string> = [];
775 return Effect.gen(function* () {
776 const { registry } = yield* makeRegistry([baseProvider, baseOpenCodeProvider]);
777 const updater = yield* makeTestRunner({
778 ...registry,
779 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
780 Effect.succeed(
781 makeProviderMaintenanceCapabilities({
782 provider,
783 packageName: provider === OPENCODE_DRIVER ? "opencode-ai" : "@openai/codex",
784 updateExecutable: "npm",
785 updateArgs:
786 provider === OPENCODE_DRIVER
787 ? ["install", "-g", "opencode-ai@latest"]
788 : ["install", "-g", "@openai/codex@latest"],
789 updateLockKey: "npm-global",
790 }),
791 ),
792 });
793
794 const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped);
795 yield* Effect.promise(() => firstStarted);
796
797 const second = yield* updater.updateProvider(OPENCODE_DRIVER).pipe(Effect.forkScoped);
798 let providersWhileQueued: ReadonlyArray<ServerProvider> = [];
799 for (let attempt = 0; attempt < 20; attempt += 1) {
800 providersWhileQueued = yield* registry.getProviders;
801 const queuedStatus = providersWhileQueued.find(
802 (provider) => provider.instanceId === OPENCODE_INSTANCE_ID,
803 )?.updateState?.status;
804 if (queuedStatus === "queued") {
805 break;
806 }
807 yield* Effect.yieldNow;
808 }
809 assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]);
810 assert.strictEqual(
811 providersWhileQueued.find((provider) => provider.instanceId === OPENCODE_INSTANCE_ID)
812 ?.updateState?.status,
813 "queued",
814 );
815
816 releaseFirstLatch.resolve();
817 yield* Fiber.join(first);
818 yield* Fiber.join(second);
819 assert.deepStrictEqual(calls, [
820 "install -g @openai/codex@latest",
821 "install -g opencode-ai@latest",
822 ]);
823 }).pipe(
824 Effect.provide(
825 Layer.mergeAll(
826 layerNonWindowsPlatform,
827 layerLatestVersionHttpClient("2.0.0"),
828 layerMockSpawner((_command, args) => {
829 calls.push(args.join(" "));
830 if (calls.length === 1) {
831 firstStartedLatch.resolve();
832 return {
833 stdout: "updated",
834 exitCode: Effect.promise(() => releaseFirst).pipe(
835 Effect.as(ChildProcessSpawner.ExitCode(0)),
836 ),
837 };
838 }
839 return { stdout: "updated" };
840 }),
841 ),
842 ),
843 );
844 });
845
846 it.effect("accepts arbitrary driver-provided update lock keys", () => {
847 const calls: Array<string> = [];
848 return Effect.gen(function* () {
849 const { registry } = yield* makeRegistry(baseProvider);
850 const updater = yield* makeTestRunner({
851 ...registry,
852 getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
853 Effect.succeed(
854 makeProviderMaintenanceCapabilities({
855 provider,
856 packageName: "@openai/codex",
857 updateExecutable: "npm",
858 updateArgs: ["install", "-g", "@openai/codex@latest"],
859 updateLockKey: "unknown-lock-key",
860 }),
861 ),
862 });
863
864 const result = yield* updater.updateProvider(CODEX_DRIVER);
865 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
866 assert.deepStrictEqual(calls, ["install -g @openai/codex@latest"]);
867 }).pipe(
868 Effect.provide(
869 Layer.mergeAll(
870 layerNonWindowsPlatform,
871 layerLatestVersionHttpClient("0.0.0"),
872 layerMockSpawner((_command, args) => {
873 calls.push(args.join(" "));
874 return { stdout: "updated" };
875 }),
876 ),
877 ),
878 );
879 });
880
881 it.effect(
882 "releases the running-provider marker when interrupted after queuing but before the lock run starts",
883 () =>
884 Effect.gen(function* () {
885 const { registry } = yield* makeRegistry(baseProvider);
886 let blockQueuedState = true;
887 const queuedStateWrittenLatch: { resolve: () => void } = { resolve: () => {} };
888 const releaseQueuedStateLatch: { resolve: () => void } = { resolve: () => {} };
889 const queuedStateWritten = new Promise<void>((resolve) => {
890 queuedStateWrittenLatch.resolve = resolve;
891 });
892 const releaseQueuedState = new Promise<void>((resolve) => {
893 releaseQueuedStateLatch.resolve = resolve;
894 });
895
896 const updater = yield* makeTestRunner({
897 ...registry,
898 setProviderMaintenanceActionState: Effect.fn(
899 "providerMaintenanceRunner.test.blockQueuedState",
900 )(function* (input) {
901 const providers = yield* registry.setProviderMaintenanceActionState(input);
902 if (input.state?.status === "queued" && blockQueuedState) {
903 queuedStateWrittenLatch.resolve();
904 yield* Effect.promise(() => releaseQueuedState);
905 }
906 return providers;
907 }),
908 });
909
910 const first = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.forkScoped);
911 yield* Effect.promise(() => queuedStateWritten);
912 blockQueuedState = false;
913
914 yield* Fiber.interrupt(first);
915 releaseQueuedStateLatch.resolve();
916
917 const second = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit);
918 assert.strictEqual(Exit.isSuccess(second), true);
919 if (Exit.isSuccess(second)) {
920 assert.strictEqual(second.value.providers[0]?.updateState?.status, "succeeded");
921 }
922 }).pipe(
923 Effect.provide(
924 Layer.mergeAll(
925 layerNonWindowsPlatform,
926 layerLatestVersionHttpClient("0.0.0"),
927 layerMockSpawner(() => ({ stdout: "updated" })),
928 ),
929 ),
930 ),
931 );
932
933 it.effect("resolves npm to a .cmd shim and routes through the shell on win32", () => {
934 const captured: Array<{
935 readonly command: string;
936 readonly args: ReadonlyArray<string>;
937 readonly shell: boolean | string | undefined;
938 }> = [];
939 return Effect.gen(function* () {
940 const { registry } = yield* makeRegistry(baseProvider);
941 const runner = yield* makeTestRunner(registry);
942
943 const result = yield* runner.updateProvider(CODEX_DRIVER);
944
945 // On win32, resolveSpawnCommand resolves `npm` to the `.cmd` shim and
946 // routes the spawn through cmd.exe (shell: true) as one escaped line.
947 assert.strictEqual(captured.length, 1);
948 const call = captured[0];
949 assert.ok(call, "expected the spawner to be invoked once");
950 assert.strictEqual(
951 call.command,
952 '^"C:\\fake\\npm\\npm.cmd^" ^"install^" ^"-g^" ^"@openai/codex@latest^"',
953 );
954 assert.deepEqual(call.args, []);
955 assert.strictEqual(call.shell, true);
956 assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded");
957 }).pipe(
958 Effect.provide(
959 Layer.mergeAll(
960 Layer.succeed(HostProcess.Platform, "win32"),
961 Layer.succeed(HostProcess.Environment, {
962 PATH: "C:\\fake\\npm",
963 PATHEXT: ".COM;.EXE;.BAT;.CMD",
964 }),
965 Layer.succeed(SpawnExecutableResolution, (command) =>
966 command === "npm" ? "C:\\fake\\npm\\npm.cmd" : undefined,
967 ),
968 layerLatestVersionHttpClient("0.0.0"),
969 Layer.succeed(
970 ChildProcessSpawner.ChildProcessSpawner,
971 ChildProcessSpawner.make((command) => {
972 const childProcess = command as unknown as {
973 readonly command: string;
974 readonly args: ReadonlyArray<string>;
975 readonly options: { readonly shell?: boolean | string | undefined };
976 };
977 captured.push({
978 command: childProcess.command,
979 args: childProcess.args,
980 shell: childProcess.options.shell,
981 });
982 return Effect.succeed(mockHandle({ stdout: "updated" }));
983 }),
984 ),
985 ),
986 ),
987 );
988 });
989});
990
991it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => {
992 const calls: string[] = [];
993 const manifest: ModelManifest.ModelManifestData = {
994 version: 1,
995 currentModels: {},
996 compatibility: [
997 {
998 driver: "codex",
999 t3CodeRange: ">=0.0.42",
1000 recommendedVersion: "2.0.0",
1001 ranges: [
1002 { range: "=2.0.0", status: "supported" },
1003 { range: ">2.0.0", status: "broken" },
1004 ],
1005 },
1006 ],
1007 };
1008 return Effect.gen(function* () {
1009 const { registry, providersRef } = yield* makeRegistry();
1010 const pinnedCapabilities = makeProviderMaintenanceCapabilities({
1011 provider: CODEX_DRIVER,
1012 packageName: "@openai/codex",
1013 updateExecutable: "npm",
1014 updateArgs: ["install", "-g", "@openai/codex@latest"],
1015 updateLockKey: "npm-global:/fixture",
1016 });
1017 const updater = yield* makeTestRunner(
1018 {
1019 ...registry,
1020 getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities),
1021 },
1022 manifest,
1023 );
1024 for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) {
1025 const result = yield* updater.updateProvider({
1026 provider: CODEX_DRIVER,
1027 ...(targetVersion !== undefined ? { targetVersion } : {}),
1028 });
1029 assert.strictEqual(result.providers[0]?.updateState?.status, "failed");
1030 }
1031 assert.deepStrictEqual(calls, []);
1032 yield* Ref.update(providersRef, (providers) =>
1033 providers.map((entry) => ({ ...entry, version: "2.0.0" })),
1034 );
1035 const installed = yield* updater.updateProvider({
1036 provider: CODEX_DRIVER,
1037 targetVersion: "2.0.0",
1038 });
1039 assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]);
1040 assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded");
1041 yield* Ref.update(providersRef, (providers) =>
1042 providers.map((entry) => ({ ...entry, version: "1.0.0" })),
1043 );
1044 const unchanged = yield* updater.updateProvider({
1045 provider: CODEX_DRIVER,
1046 targetVersion: "2.0.0",
1047 });
1048 assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged");
1049 const nativeUpdater = yield* makeTestRunner(
1050 {
1051 ...registry,
1052 getProviderMaintenanceCapabilitiesForInstance: () =>
1053 Effect.succeed({
1054 ...pinnedCapabilities,
1055 update: { ...pinnedCapabilities.update!, lockKey: "codex-native" },
1056 }),
1057 },
1058 manifest,
1059 );
1060 const refused = yield* nativeUpdater.updateProvider({
1061 provider: CODEX_DRIVER,
1062 targetVersion: "2.0.0",
1063 });
1064 assert.strictEqual(refused.providers[0]?.updateState?.status, "failed");
1065 assert.strictEqual(calls.length, 2);
1066 }).pipe(
1067 Effect.provide(
1068 Layer.mergeAll(
1069 layerNonWindowsPlatform,
1070 layerLatestVersionHttpClient("3.0.0"),
1071 layerMockSpawner((_command, args) => {
1072 calls.push(args.join(" "));
1073 return { stdout: "installed" };
1074 }),
1075 ),
1076 ),
1077 );
1078});