| ... | @@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -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 | it("fills missing capabilities from the previous provider snapshot", () => { | 736 | it("fills missing capabilities from the previous provider snapshot", () => { |
| 567 | const previousProvider = { | 737 | const previousProvider = { |
| 568 | instanceId: ProviderInstanceId.make("cursor"), | 738 | instanceId: ProviderInstanceId.make("cursor"), |
| ... | @@ -866,6 +1036,144 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -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 | it.effect("returns the cached provider list when a manual refresh fails", () => | 1177 | it.effect("returns the cached provider list when a manual refresh fails", () => |
| 870 | Effect.gen(function* () { | 1178 | Effect.gen(function* () { |
| 871 | const codexDriver = ProviderDriverKind.make("codex"); | 1179 | const codexDriver = ProviderDriverKind.make("codex"); |