| @@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 562 unmodified lines |
| 563 | 563 | ]); |
| 564 | 564 | }); |
| 565 | 565 | |
| 566 | + it("drops stale OpenCode models missing from a successful refresh", () => { |
| 567 | + const previousProvider = { |
| 568 | + instanceId: ProviderInstanceId.make("opencode"), |
| 569 | + driver: ProviderDriverKind.make("opencode"), |
| 570 | + status: "ready", |
| 571 | + enabled: true, |
| 572 | + installed: true, |
| 573 | + auth: { status: "authenticated" }, |
| 574 | + checkedAt: "2026-07-17T00:00:00.000Z", |
| 575 | + version: "1.0.0", |
| 576 | + models: [ |
| 577 | + { |
| 578 | + slug: "github/gpt-5", |
| 579 | + name: "GPT-5", |
| 580 | + subProvider: "GitHub", |
| 581 | + isCustom: false, |
| 582 | + capabilities: null, |
| 583 | + }, |
| 584 | + { |
| 585 | + slug: "removed-plugin/model", |
| 586 | + name: "Removed Plugin Model", |
| 587 | + subProvider: "Removed Plugin", |
| 588 | + isCustom: false, |
| 589 | + capabilities: null, |
| 590 | + }, |
| 591 | + ], |
| 592 | + slashCommands: [], |
| 593 | + skills: [], |
| 594 | + } as const satisfies ServerProvider; |
| 595 | + const refreshedProvider = { |
| 596 | + ...previousProvider, |
| 597 | + checkedAt: "2026-07-17T00:01:00.000Z", |
| 598 | + models: [ |
| 599 | + { |
| 600 | + slug: "github/gpt-5", |
| 601 | + name: "GPT-5", |
| 602 | + subProvider: "GitHub", |
| 603 | + isCustom: false, |
| 604 | + capabilities: null, |
| 605 | + }, |
| 606 | + ], |
| 607 | + } satisfies ServerProvider; |
| 608 | + |
| 609 | + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ |
| 610 | + ...refreshedProvider.models, |
| 611 | + ]); |
| 612 | + }); |
| 613 | + |
| 614 | + it("retains stale OpenCode models when a refresh fails", () => { |
| 615 | + const previousProvider = { |
| 616 | + instanceId: ProviderInstanceId.make("opencode"), |
| 617 | + driver: ProviderDriverKind.make("opencode"), |
| 618 | + status: "ready", |
| 619 | + enabled: true, |
| 620 | + installed: true, |
| 621 | + auth: { status: "authenticated" }, |
| 622 | + checkedAt: "2026-07-17T00:00:00.000Z", |
| 623 | + version: "1.0.0", |
| 624 | + models: [ |
| 625 | + { |
| 626 | + slug: "github/gpt-5", |
| 627 | + name: "GPT-5", |
| 628 | + subProvider: "GitHub", |
| 629 | + isCustom: false, |
| 630 | + capabilities: null, |
| 631 | + }, |
| 632 | + ], |
| 633 | + slashCommands: [], |
| 634 | + skills: [], |
| 635 | + } as const satisfies ServerProvider; |
| 636 | + const refreshedProvider = { |
| 637 | + ...previousProvider, |
| 638 | + status: "error", |
| 639 | + auth: { status: "unknown" }, |
| 640 | + checkedAt: "2026-07-17T00:01:00.000Z", |
| 641 | + models: [], |
| 642 | + message: "Failed to refresh OpenCode models.", |
| 643 | + } satisfies ServerProvider; |
| 644 | + |
| 645 | + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ |
| 646 | + ...previousProvider.models, |
| 647 | + ]); |
| 648 | + }); |
| 649 | + |
| 650 | + it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { |
| 651 | + const previousProvider = { |
| 652 | + instanceId: ProviderInstanceId.make("opencode"), |
| 653 | + driver: ProviderDriverKind.make("opencode"), |
| 654 | + status: "ready", |
| 655 | + enabled: true, |
| 656 | + installed: true, |
| 657 | + auth: { status: "authenticated" }, |
| 658 | + checkedAt: "2026-07-17T00:00:00.000Z", |
| 659 | + version: "1.0.0", |
| 660 | + models: [ |
| 661 | + { |
| 662 | + slug: "github/gpt-5", |
| 663 | + name: "GPT-5", |
| 664 | + subProvider: "GitHub", |
| 665 | + isCustom: false, |
| 666 | + capabilities: null, |
| 667 | + }, |
| 668 | + { |
| 669 | + slug: "removed-plugin/model", |
| 670 | + name: "Removed Plugin Model", |
| 671 | + subProvider: "Removed Plugin", |
| 672 | + isCustom: false, |
| 673 | + capabilities: null, |
| 674 | + }, |
| 675 | + ], |
| 676 | + slashCommands: [], |
| 677 | + skills: [], |
| 678 | + } as const satisfies ServerProvider; |
| 679 | + const pendingProvider = { |
| 680 | + ...previousProvider, |
| 681 | + status: "warning", |
| 682 | + installed: false, |
| 683 | + auth: { status: "unknown" }, |
| 684 | + checkedAt: "2026-07-17T00:01:00.000Z", |
| 685 | + version: null, |
| 686 | + models: [], |
| 687 | + message: "OpenCode provider status has not been checked in this session yet.", |
| 688 | + } satisfies ServerProvider; |
| 689 | + const loggedOutProvider = { |
| 690 | + ...previousProvider, |
| 691 | + status: "warning", |
| 692 | + auth: { status: "unknown" }, |
| 693 | + checkedAt: "2026-07-17T00:02:00.000Z", |
| 694 | + models: [], |
| 695 | + message: "OpenCode is available, but it did not report any connected upstream providers.", |
| 696 | + } satisfies ServerProvider; |
| 697 | + const missingProvider = { |
| 698 | + ...previousProvider, |
| 699 | + status: "error", |
| 700 | + installed: false, |
| 701 | + auth: { status: "unknown" }, |
| 702 | + checkedAt: "2026-07-17T00:03:00.000Z", |
| 703 | + version: null, |
| 704 | + models: [], |
| 705 | + message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", |
| 706 | + } satisfies ServerProvider; |
| 707 | + const authoritativeProvider = { |
| 708 | + ...previousProvider, |
| 709 | + checkedAt: "2026-07-17T00:04:00.000Z", |
| 710 | + models: [previousProvider.models[0]!], |
| 711 | + } satisfies ServerProvider; |
| 712 | + const failedProvider = { |
| 713 | + ...authoritativeProvider, |
| 714 | + status: "error", |
| 715 | + auth: { status: "unknown" }, |
| 716 | + checkedAt: "2026-07-17T00:05:00.000Z", |
| 717 | + models: [], |
| 718 | + message: "Failed to refresh OpenCode models.", |
| 719 | + } satisfies ServerProvider; |
| 720 | + |
| 721 | + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ |
| 722 | + ...previousProvider.models, |
| 723 | + ]); |
| 724 | + assert.deepStrictEqual( |
| 725 | + mergeProviderSnapshot(previousProvider, loggedOutProvider).models, |
| 726 | + [], |
| 727 | + ); |
| 728 | + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); |
| 729 | + |
| 730 | + const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); |
| 731 | + const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); |
| 732 | + |
| 733 | + assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); |
| 734 | + }); |
| 735 | + |
| 566 | 736 | it("fills missing capabilities from the previous provider snapshot", () => { |
| 567 | 737 | const previousProvider = { |
| 568 | 738 | instanceId: ProviderInstanceId.make("cursor"), |
| @@ -866,6 +1036,144 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 297 unmodified lines |
| 866 | 1036 | }), |
| 867 | 1037 | ); |
| 868 | 1038 | |
| 1039 | + it.effect( |
| 1040 | + "persists authoritative OpenCode removals without resurrecting them on a failed live refresh", |
| 1041 | + () => |
| 1042 | + Effect.gen(function* () { |
| 1043 | + const openCodeDriver = ProviderDriverKind.make("opencode"); |
| 1044 | + const openCodeInstanceId = ProviderInstanceId.make("opencode"); |
| 1045 | + const initialProvider = { |
| 1046 | + instanceId: openCodeInstanceId, |
| 1047 | + driver: openCodeDriver, |
| 1048 | + status: "ready", |
| 1049 | + enabled: true, |
| 1050 | + installed: true, |
| 1051 | + auth: { status: "authenticated" }, |
| 1052 | + checkedAt: "2026-07-17T00:00:00.000Z", |
| 1053 | + version: "1.0.0", |
| 1054 | + models: [ |
| 1055 | + { |
| 1056 | + slug: "github/gpt-5", |
| 1057 | + name: "GPT-5", |
| 1058 | + subProvider: "GitHub", |
| 1059 | + isCustom: false, |
| 1060 | + capabilities: null, |
| 1061 | + }, |
| 1062 | + { |
| 1063 | + slug: "removed-plugin/model", |
| 1064 | + name: "Removed Plugin Model", |
| 1065 | + subProvider: "Removed Plugin", |
| 1066 | + isCustom: false, |
| 1067 | + capabilities: null, |
| 1068 | + }, |
| 1069 | + ], |
| 1070 | + slashCommands: [], |
| 1071 | + skills: [], |
| 1072 | + } as const satisfies ServerProvider; |
| 1073 | + const authoritativeProvider = { |
| 1074 | + ...initialProvider, |
| 1075 | + checkedAt: "2026-07-17T00:01:00.000Z", |
| 1076 | + models: [initialProvider.models[0]!], |
| 1077 | + } satisfies ServerProvider; |
| 1078 | + const failedProvider = { |
| 1079 | + ...authoritativeProvider, |
| 1080 | + status: "error", |
| 1081 | + auth: { status: "unknown" }, |
| 1082 | + checkedAt: "2026-07-17T00:02:00.000Z", |
| 1083 | + models: [], |
| 1084 | + message: "Failed to refresh OpenCode models.", |
| 1085 | + } satisfies ServerProvider; |
| 1086 | + const changes = yield* PubSub.unbounded<ServerProvider>(); |
| 1087 | + const instance = { |
| 1088 | + instanceId: openCodeInstanceId, |
| 1089 | + driverKind: openCodeDriver, |
| 1090 | + continuationIdentity: { |
| 1091 | + driverKind: openCodeDriver, |
| 1092 | + continuationKey: "opencode:instance:opencode", |
| 1093 | + }, |
| 1094 | + displayName: undefined, |
| 1095 | + enabled: true, |
| 1096 | + snapshot: { |
| 1097 | + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ |
| 1098 | + provider: openCodeDriver, |
| 1099 | + packageName: null, |
| 1100 | + }), |
| 1101 | + getSnapshot: Effect.succeed(initialProvider), |
| 1102 | + refresh: Effect.succeed(authoritativeProvider), |
| 1103 | + streamChanges: Stream.fromPubSub(changes), |
| 1104 | + }, |
| 1105 | + adapter: {} as ProviderInstance["adapter"], |
| 1106 | + textGeneration: {} as ProviderInstance["textGeneration"], |
| 1107 | + } satisfies ProviderInstance; |
| 1108 | + const instanceRegistryLayer = Layer.succeed( |
| 1109 | + ProviderInstanceRegistry.ProviderInstanceRegistry, |
| 1110 | + { |
| 1111 | + getInstance: (instanceId) => |
| 1112 | + Effect.succeed(instanceId === openCodeInstanceId ? instance : undefined), |
| 1113 | + listInstances: Effect.succeed([instance]), |
| 1114 | + listUnavailable: Effect.succeed([]), |
| 1115 | + streamChanges: Stream.empty, |
| 1116 | + subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => |
| 1117 | + PubSub.subscribe(pubsub), |
| 1118 | + ), |
| 1119 | + }, |
| 1120 | + ); |
| 1121 | + const scope = yield* Scope.make(); |
| 1122 | + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); |
| 1123 | + const runtimeServices = yield* Layer.build( |
| 1124 | + ProviderRegistryLive.pipe( |
| 1125 | + Layer.provideMerge(instanceRegistryLayer), |
| 1126 | + Layer.provideMerge( |
| 1127 | + ServerConfig.layerTest(process.cwd(), { |
| 1128 | + prefix: "t3-provider-registry-opencode-authoritative-persist-", |
| 1129 | + }), |
| 1130 | + ), |
| 1131 | + Layer.provideMerge(NodeServices.layer), |
| 1132 | + ), |
| 1133 | + ).pipe(Scope.provide(scope)); |
| 1134 | + |
| 1135 | + yield* Effect.gen(function* () { |
| 1136 | + const registry = yield* ProviderRegistry.ProviderRegistry; |
| 1137 | + const config = yield* ServerConfig.ServerConfig; |
| 1138 | + const filePath = yield* resolveProviderStatusCachePath({ |
| 1139 | + cacheDir: config.providerStatusCacheDir, |
| 1140 | + instanceId: openCodeInstanceId, |
| 1141 | + }); |
| 1142 | + |
| 1143 | + yield* PubSub.publish(changes, authoritativeProvider); |
| 1144 | + |
| 1145 | + let cachedProvider = yield* readProviderStatusCache(filePath); |
| 1146 | + for ( |
| 1147 | + let attempt = 0; |
| 1148 | + attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt; |
| 1149 | + attempt += 1 |
| 1150 | + ) { |
| 1151 | + yield* TestClock.adjust("10 millis"); |
| 1152 | + yield* Effect.yieldNow; |
| 1153 | + cachedProvider = yield* readProviderStatusCache(filePath); |
| 1154 | + } |
| 1155 | + |
| 1156 | + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); |
| 1157 | + |
| 1158 | + yield* PubSub.publish(changes, failedProvider); |
| 1159 | + for ( |
| 1160 | + let attempt = 0; |
| 1161 | + attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt; |
| 1162 | + attempt += 1 |
| 1163 | + ) { |
| 1164 | + yield* TestClock.adjust("10 millis"); |
| 1165 | + yield* Effect.yieldNow; |
| 1166 | + cachedProvider = yield* readProviderStatusCache(filePath); |
| 1167 | + } |
| 1168 | + |
| 1169 | + assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]); |
| 1170 | + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [ |
| 1171 | + authoritativeProvider.models[0]!, |
| 1172 | + ]); |
| 1173 | + }).pipe(Effect.provide(runtimeServices)); |
| 1174 | + }), |
| 1175 | + ); |
| 1176 | + |
| 869 | 1177 | it.effect("returns the cached provider list when a manual refresh fails", () => |
| 870 | 1178 | Effect.gen(function* () { |
| 871 | 1179 | const codexDriver = ProviderDriverKind.make("codex"); |