History

fix(server): stop restoring stale OpenCode models (#4095)

Yukun Shan committed 3 months ago

Commit 4e09cdd1 parent 783692a4 files · +372 −21Browse files

Unified · Split

4 files
  1. apps/server/src/provider/Layers/OpenCodeProvider.test.ts +13 −6
  2. apps/server/src/provider/Layers/ProviderRegistry.test.ts +308 −0
  3. apps/server/src/provider/Layers/ProviderRegistry.ts +25 −3
  4. apps/server/src/provider/opencodeRuntime.ts +26 −12
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+13−6
@@ -97,10 +97,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { · 96 unmodified lines
9797 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
9898 loadInventoryFromCli: () =>
9999 runtimeMock.state.inventoryError
100− ? Effect.succeed({
101− providerList: { all: [], default: {}, connected: [] as string[] },
102− agents: [],
103− } as OpenCodeInventory)
100+ ? Effect.fail(
101+ new OpenCodeRuntimeError({
102+ operation: "loadInventoryFromCli",
103+ detail: runtimeMock.state.inventoryError.message,
104+ cause: runtimeMock.state.inventoryError,
105+ }),
106+ )
104107 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
105108 };
106109
@@ -212,14 +215,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { · 105 unmodified lines
212215 }),
213216 );
214217
215− it.effect("degrades gracefully on CLI failure for local installs", () =>
218+ it.effect("reports local model inventory failures without treating them as empty", () =>
216219 Effect.gen(function* () {
217220 runtimeMock.state.inventoryError = new Error("opencode models failed");
218221 const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
219222
220− NodeAssert.equal(snapshot.status, "warning");
223+ NodeAssert.equal(snapshot.status, "error");
221224 NodeAssert.equal(snapshot.installed, true);
222225 NodeAssert.equal(snapshot.models.length, 0);
226+ NodeAssert.equal(
227+ snapshot.message,
228+ "Failed to execute OpenCode CLI health check: opencode models failed",
229+ );
223230 }),
224231 );
225232 });
apps/server/src/provider/Layers/ProviderRegistry.test.ts+308−0
@@ -563,6 +563,176 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 562 unmodified lines
563563 ]);
564564 });
565565
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+
566736 it("fills missing capabilities from the previous provider snapshot", () => {
567737 const previousProvider = {
568738 instanceId: ProviderInstanceId.make("cursor"),
@@ -866,6 +1036,144 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 297 unmodified lines
8661036 }),
8671037 );
8681038
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+
8691177 it.effect("returns the cached provider list when a manual refresh fails", () =>
8701178 Effect.gen(function* () {
8711179 const codexDriver = ProviderDriverKind.make("codex");
apps/server/src/provider/Layers/ProviderRegistry.ts+25−3
@@ -78,11 +78,31 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) · 77 unmodified lines
7878 const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
7979 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
8080
81+const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => {
82+ if (provider.driver !== ProviderDriverKind.make("opencode")) {
83+ return true;
84+ }
85+
86+ // OpenCode's initial snapshot is deliberately non-authoritative while its
87+ // first probe is still running. A probe error from an installed CLI/server
88+ // is likewise partial: it could not establish the current inventory.
89+ // Conversely, disabled and missing-CLI snapshots are authoritative removals,
90+ // as are successful ready/warning inventories (including an empty one after
91+ // logout or plugin removal).
92+ const isPendingInitialProbe =
93+ provider.enabled && !provider.installed && provider.status === "warning";
94+ const didInstalledProviderProbeFail = provider.installed && provider.status === "error";
95+ return isPendingInitialProbe || didInstalledProviderProbeFail;
96+};
97+
8198 const mergeProviderModels = (
99+ provider: ServerProvider,
82100 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
83101 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
84102 ): ReadonlyArray<ServerProvider["models"][number]> => {
85− if (nextModels.length === 0 && previousModels.length > 0) {
103+ const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider);
104+
105+ if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) {
86106 return previousModels;
87107 }
88108
@@ -98,7 +118,9 @@ const mergeProviderModels = ( · 9 unmodified lines
98118 };
99119 });
100120 const nextSlugs = new Set(nextModels.map((model) => model.slug));
101− return [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))];
121+ return shouldRetainMissingModels
122+ ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]
123+ : mergedModels;
102124 };
103125
104126 export const mergeProviderSnapshot = (
@@ -109,7 +131,7 @@ export const mergeProviderSnapshot = ( · 4 unmodified lines
109131 ? nextProvider
110132 : {
111133 ...nextProvider,
112− models: mergeProviderModels(previousProvider.models, nextProvider.models),
134+ models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models),
113135 };
114136
115137 export const mergeProviderSnapshots = (
apps/server/src/provider/opencodeRuntime.ts+26−12
@@ -690,22 +690,36 @@ const makeOpenCodeRuntime = Effect.gen(function* () { · 689 unmodified lines
690690 agentsResult = a2;
691691 }
692692
693− // Degrade gracefully on failure — return empty inventory (warning status, not error)
694− let connected: string[] = [];
695− let allProviders: ProviderListResponse["all"] = [];
696− if (modelsResult._tag === "Success" && modelsResult.value.code === 0) {
697− const parsed = parseModelsCliOutput(modelsResult.value.stdout);
698− connected = [...parsed.connected];
699− allProviders = [...parsed.providers.values()].map((p) => ({
700− id: p.id,
701− name: p.name,
693+ if (modelsResult._tag === "Failure") {
694+ const cause = Cause.squash(modelsResult.cause);
695+ return yield* ensureRuntimeError(
696+ "loadInventoryFromCli",
697+ `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`,
698+ cause,
699+ );
700+ }
701+ if (modelsResult.value.code !== 0) {
702+ return yield* new OpenCodeRuntimeError({
703+ operation: "loadInventoryFromCli",
704+ detail: `OpenCode models command exited with code ${modelsResult.value.code}.`,
705+ });
706+ }
707+
708+ const parsed = parseModelsCliOutput(modelsResult.value.stdout);
709+ const connected = [...parsed.connected];
710+ const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map(
711+ (provider) => ({
712+ id: provider.id,
713+ name: provider.name,
702714 source: "config" as const,
703715 env: [],
704716 options: {},
705− models: p.models,
706− }));
707− }
717+ models: provider.models,
718+ }),
719+ );
708720
721+ // Agent metadata enriches model capabilities but is not required for an
722+ // authoritative model inventory, so it may still degrade to an empty list.
709723 let agents: ReadonlyArray<Agent> = [];
710724 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {
711725 agents = parseAgentListCliOutput(agentsResult.value.stdout);