| ... | ... | @@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 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 |
| 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"); |