diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 77c72dda7..093a57ca8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -48,7 +48,11 @@ import { } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; -import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; +import { + readProviderStatusCache, + resolveProviderStatusCachePath, + writeProviderStatusCache, +} from "../providerStatusCache.ts"; import { providerBackendsFromCapacityRefresh, type ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -880,6 +884,185 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + describe("Codex model inventories", () => { + const cachedProvider = { + instanceId: ProviderInstanceId.make("codex-personal"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-04T19:00:00.000Z", + version: "0.153.3", + models: [ + "vega-alpha", + "joule-alpha", + "kindle-alpha", + "ultima-alpha", + "solstice-alpha", + ].map((slug) => ({ slug, name: slug, isCustom: false, capabilities: null })), + slashCommands: [], + skills: [], + } satisfies ServerProvider; + const customModel = { + slug: "custom-model", + name: "Custom model", + isCustom: true, + capabilities: null, + } as const; + const refreshedProvider = { + ...cachedProvider, + checkedAt: "2026-09-04T19:01:00.000Z", + models: [ + { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }, + cachedProvider.models[0]!, + customModel, + ], + } satisfies ServerProvider; + const pendingProvider = { + ...cachedProvider, + status: "warning", + installed: false, + auth: { status: "unknown" }, + models: [customModel], + } satisfies ServerProvider; + const failedProvider = { + ...pendingProvider, + checkedAt: "2026-09-04T19:02:00.000Z", + status: "error", + installed: true, + } satisfies ServerProvider; + + it("drops retired alpha models after discovery, including without OpenAI authentication", () => { + for (const authStatus of ["authenticated", "unknown"] as const) { + assert.deepStrictEqual( + mergeProviderSnapshot(cachedProvider, { + ...refreshedProvider, + auth: { status: authStatus }, + }).models, + refreshedProvider.models, + ); + } + }); + + it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { + for (const provider of [pendingProvider, failedProvider]) { + assert.deepStrictEqual( + mergeProviderSnapshot( + { + ...cachedProvider, + models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], + }, + provider, + ).models, + [customModel, ...cachedProvider.models], + ); + } + }); + + it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { + const emptyProvider = { ...refreshedProvider, models: [customModel] }; + const clearedProviders = [ + { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, + { ...emptyProvider, status: "disabled", enabled: false }, + { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, + emptyProvider, + { ...emptyProvider, models: [] }, + ] satisfies ReadonlyArray; + + for (const provider of clearedProviders) { + assert.deepStrictEqual( + mergeProviderSnapshot(cachedProvider, provider).models, + provider.models, + ); + } + }); + + it.effect("persists removals across failed refreshes and registry restarts", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const filePath = yield* resolveProviderStatusCachePath({ + cacheDir: config.providerStatusCacheDir, + instanceId: cachedProvider.instanceId, + }); + yield* writeProviderStatusCache({ filePath, provider: cachedProvider }); + const nextProvider = yield* Ref.make(refreshedProvider); + const instance = { + instanceId: cachedProvider.instanceId, + driverKind: cachedProvider.driver, + continuationIdentity: { + driverKind: cachedProvider.driver, + continuationKey: "codex:instance:codex-personal", + }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: cachedProvider.driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(pendingProvider), + refresh: Ref.get(nextProvider), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (id) => + Effect.succeed(id === instance.instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const retainedModels = [ + customModel, + ...refreshedProvider.models.filter((model) => !model.isCustom), + ]; + + for (const restarted of [false, true]) { + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const expectedModels = restarted + ? retainedModels + : [customModel, ...cachedProvider.models]; + assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels); + + yield* registry.refreshInstance(instance.instanceId); + assert.deepStrictEqual( + (yield* readProviderStatusCache(filePath))?.models, + restarted ? retainedModels : refreshedProvider.models, + ); + + yield* Ref.set(nextProvider, failedProvider); + const afterFailure = yield* registry.refreshInstance(instance.instanceId); + assert.deepStrictEqual(afterFailure[0]?.models, retainedModels); + assert.deepStrictEqual( + (yield* readProviderStatusCache(filePath))?.models, + retainedModels, + ); + }).pipe( + Effect.provide(ProviderRegistryLive.pipe(Layer.provide(instanceRegistryLayer))), + Effect.scoped, + ); + } + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-codex-retired-model-cache-", + }).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ), + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 0bbd7e905..ced3ea63e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -118,11 +118,16 @@ export function upsertProviderWorkspaceSnapshot( } const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - if (provider.driver !== ProviderDriverKind.make("opencode")) { + const isCodex = provider.driver === ProviderDriverKind.make("codex"); + if (!isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { return true; } - // OpenCode's initial snapshot is deliberately non-authoritative while its + if (isCodex && (!provider.enabled || provider.auth.status === "unauthenticated")) { + return false; + } + + // Codex and OpenCode initial snapshots are deliberately non-authoritative while their // first probe is still running. A probe error from an installed CLI/server // is likewise partial: it could not establish the current inventory. // Conversely, disabled and missing-CLI snapshots are authoritative removals,