authorgravatar for 92423096+nateEc@users.noreply.github.comYukun Shan <92423096+nateEc@users.noreply.github.com> 2026-07-22 21:57:29+08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-22 15:57:29+02:00
log4e09cddb40eb1bb1e111a0374b46e73b38ffbb29
tree4d84e83ed5a24136ba043ca71b074887aa3fdc23
parent783692afc10dd63693b3f65ca334322762293e67
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 372 insertions(+), 21 deletions(-)

apps/server/src/provider/Layers/OpenCodeProvider.test.ts+13-6
...@@ -97,10 +97,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -97,10 +97,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
97 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),97 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
98 loadInventoryFromCli: () =>98 loadInventoryFromCli: () =>
99 runtimeMock.state.inventoryError99 runtimeMock.state.inventoryError
100 ? Effect.succeed({100 ? Effect.fail(
101 providerList: { all: [], default: {}, connected: [] as string[] },101 new OpenCodeRuntimeError({
102 agents: [],102 operation: "loadInventoryFromCli",
103 } as OpenCodeInventory)103 detail: runtimeMock.state.inventoryError.message,
104 cause: runtimeMock.state.inventoryError,
105 }),
106 )
104 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),107 : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory),
105};108};
106109
...@@ -212,14 +215,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {...@@ -212,14 +215,18 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
212 }),215 }),
213 );216 );
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", () =>
216 Effect.gen(function* () {219 Effect.gen(function* () {
217 runtimeMock.state.inventoryError = new Error("opencode models failed");220 runtimeMock.state.inventoryError = new Error("opencode models failed");
218 const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());221 const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd());
219222
220 NodeAssert.equal(snapshot.status, "warning");223 NodeAssert.equal(snapshot.status, "error");
221 NodeAssert.equal(snapshot.installed, true);224 NodeAssert.equal(snapshot.installed, true);
222 NodeAssert.equal(snapshot.models.length, 0);225 NodeAssert.equal(snapshot.models.length, 0);
226 NodeAssert.equal(
227 snapshot.message,
228 "Failed to execute OpenCode CLI health check: opencode models failed",
229 );
223 }),230 }),
224 );231 );
225});232});
apps/server/src/provider/Layers/ProviderRegistry.test.ts+308
...@@ -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 });
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
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 );
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
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");
apps/server/src/provider/Layers/ProviderRegistry.ts+25-3
...@@ -78,11 +78,31 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind)...@@ -78,11 +78,31 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind)
78const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>78const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean =>
79 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;79 (model.capabilities?.optionDescriptors?.length ?? 0) > 0;
8080
81const 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
81const mergeProviderModels = (98const mergeProviderModels = (
99 provider: ServerProvider,
82 previousModels: ReadonlyArray<ServerProvider["models"][number]>,100 previousModels: ReadonlyArray<ServerProvider["models"][number]>,
83 nextModels: ReadonlyArray<ServerProvider["models"][number]>,101 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
84): ReadonlyArray<ServerProvider["models"][number]> => {102): 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) {
86 return previousModels;106 return previousModels;
87 }107 }
88108
...@@ -98,7 +118,9 @@ const mergeProviderModels = (...@@ -98,7 +118,9 @@ const mergeProviderModels = (
98 };118 };
99 });119 });
100 const nextSlugs = new Set(nextModels.map((model) => model.slug));120 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;
102};124};
103125
104export const mergeProviderSnapshot = (126export const mergeProviderSnapshot = (
...@@ -109,7 +131,7 @@ export const mergeProviderSnapshot = (...@@ -109,7 +131,7 @@ export const mergeProviderSnapshot = (
109 ? nextProvider131 ? nextProvider
110 : {132 : {
111 ...nextProvider,133 ...nextProvider,
112 models: mergeProviderModels(previousProvider.models, nextProvider.models),134 models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models),
113 };135 };
114136
115export const mergeProviderSnapshots = (137export const mergeProviderSnapshots = (
apps/server/src/provider/opencodeRuntime.ts+26-12
...@@ -690,22 +690,36 @@ const makeOpenCodeRuntime = Effect.gen(function* () {...@@ -690,22 +690,36 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
690 agentsResult = a2;690 agentsResult = a2;
691 }691 }
692692
693 // Degrade gracefully on failure — return empty inventory (warning status, not error)693 if (modelsResult._tag === "Failure") {
694 let connected: string[] = [];694 const cause = Cause.squash(modelsResult.cause);
695 let allProviders: ProviderListResponse["all"] = [];695 return yield* ensureRuntimeError(
696 if (modelsResult._tag === "Success" && modelsResult.value.code === 0) {696 "loadInventoryFromCli",
697 const parsed = parseModelsCliOutput(modelsResult.value.stdout);697 `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`,
698 connected = [...parsed.connected];698 cause,
699 allProviders = [...parsed.providers.values()].map((p) => ({699 );
700 id: p.id,700 }
701 name: p.name,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,
702 source: "config" as const,714 source: "config" as const,
703 env: [],715 env: [],
704 options: {},716 options: {},
705 models: p.models,717 models: provider.models,
706 }));718 }),
707 }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.
709 let agents: ReadonlyArray<Agent> = [];723 let agents: ReadonlyArray<Agent> = [];
710 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {724 if (agentsResult._tag === "Success" && agentsResult.value.code === 0) {
711 agents = parseAgentListCliOutput(agentsResult.value.stdout);725 agents = parseAgentListCliOutput(agentsResult.value.stdout);