From 96c4bfa0a27f776f7cd17df7c7115c3de79ee399 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 22 Sep 2026 18:38:20 -0700 Subject: [PATCH] feat(providers): check remote compatibility ranges (#13130) --- .../src/features/threads/ThreadComposer.tsx | 17 ++ .../provider/Layers/ProviderRegistry.test.ts | 3 +- .../src/provider/Layers/ProviderRegistry.ts | 38 ++- .../server/src/provider/ModelManifest.test.ts | 40 +++ apps/server/src/provider/ModelManifest.ts | 2 + apps/server/src/provider/model-manifest.json | 10 +- .../provider/providerCompatibility.test.ts | 258 ++++++++++++++++++ .../src/provider/providerCompatibility.ts | 119 ++++++++ .../src/provider/providerMaintenance.test.ts | 43 +++ .../src/provider/providerMaintenance.ts | 28 ++ .../providerMaintenanceRunner.test.ts | 103 ++++++- .../src/provider/providerMaintenanceRunner.ts | 59 +++- ...iderUpdateLaunchNotification.logic.test.ts | 20 ++ .../ProviderUpdateLaunchNotification.logic.ts | 4 + .../chat/ProviderStatusBanner.test.ts | 72 +++++ .../components/chat/ProviderStatusBanner.tsx | 27 +- .../settings/ProviderInstanceCard.tsx | 141 +++++++--- .../settings/ProviderSettingsPanel.tsx | 28 +- .../settings/providerStatus.test.ts | 72 ++++- .../src/components/settings/providerStatus.ts | 49 +++- docs/user/install.md | 6 + packages/contracts/src/server.ts | 19 ++ 22 files changed, 1099 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/provider/providerCompatibility.test.ts create mode 100644 apps/server/src/provider/providerCompatibility.ts create mode 100644 apps/web/src/components/chat/ProviderStatusBanner.test.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c747a4ed8171dbe9b2ced3e16f99df3f9be280fe..23ddc65896b98428439ecca26755f0d5f2e0e437 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -651,6 +651,23 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {selectedProviderStatus?.compatibilityAdvisory?.message && + (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" || + selectedProviderStatus.compatibilityAdvisory.status === "broken") ? ( + + {selectedProviderStatus.compatibilityAdvisory.message} + + ) : null} {modelUnavailable ? ( Model unavailable. Open model settings. diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f3fd269a42ed3161818dfbf5a1ea3..354484e6d39533d98b3feb7f638e556c7947f1f8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -77,12 +77,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; const encoder = new TextEncoder(); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +// Provider metadata checks use a bundled manifest and stubbed HTTP. const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), ), -); +).pipe(Layer.provideMerge(ModelManifest.layerTest)); const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ reportClientActivity: () => Effect.void, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7d98cb011660adb4838a2f1ece357..a65bc66edf3208314382983c5c04bb2cd20ede12 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; +import * as ModelManifest from "../ModelManifest.ts"; +import { applyProviderCompatibility } from "../providerCompatibility.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry; + const manifestService = yield* ModelManifest.ModelManifest; + const serviceScope = yield* Effect.scope; const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect( ), ), ); - const providersRef = yield* Ref.make>(cachedProviders); + const initialManifest = yield* manifestService.current; + const classifyCompatibility = ( + provider: ServerProvider, + manifest: ModelManifest.ModelManifestData, + ) => + applyProviderCompatibility( + provider, + manifest.compatibility, + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + ); + const providersRef = yield* Ref.make>( + cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), + ); const workspaceRefreshesRef = yield* Ref.make< ReadonlyMap> >(new Map()); @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect( readonly replace?: boolean; }, ) { + const manifest = yield* manifestService.current; const nextProvidersWithUpdateState = yield* Effect.forEach( nextProviders, applyProviderUpdateState, @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect( ); } - const providers = orderProviderSnapshots([...mergedProviders.values()]); + const providers = orderProviderSnapshots( + [...mergedProviders.values()].map((provider) => + classifyCompatibility(provider, manifest), + ), + ); const providersToPersist = providers.filter((provider) => updatedKeys.has(snapshotInstanceKey(provider)), ); @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect( return providers; }); + const compatibilityRefreshRunning = yield* Ref.make(false); const syncProvider = Effect.fn("syncProvider")(function* ( provider: ServerProvider, options?: { readonly publish?: boolean; }, ) { - return yield* upsertProviders([provider], options); + const providers = yield* upsertProviders([provider], options); + // Reclassify the current read model after fetching. Never republish the + // probe captured before the fetch: a newer health result may have landed. + if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { + yield* manifestService.refresh.pipe( + Effect.andThen(upsertProviders([], { persist: false })), + Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), + Effect.forkIn(serviceScope), + ); + } + return providers; }); const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 1f00cb56040a5663f6c43ba6c6b1944bb9f7caa7..77e06530f2e5f76e6bb3b6c114c11618f2c85c6c 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -549,3 +549,43 @@ describe("ModelManifest service", () => { ), ); }); + +it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { + const remote: ModelManifestData = { + ...REMOTE_MANIFEST, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [{ range: "=2.0.0", status: "supported" }], + }, + ], + }; + let invalid = false; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + invalid = true; + yield* TestClock.adjust("1 hour"); + assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); + const rebooted = yield* make; + assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-compatibility-test", + response: () => + Response.json( + invalid + ? { + ...remote, + compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], + } + : remote, + ), + }), + ), + ); +}); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 7f2305097b789facdbe01dec2e4649e00be3cecb..b698ed6c0c82932e80cc2db290b615292ffc8cb4 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; +import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; import type { ServerProviderDraft } from "./providerSnapshot.ts"; const MODEL_MANIFEST_URL = @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ * files still decode; they count as older than any dated bundle. */ updatedAt: Schema.optional(Schema.String), + compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index a5924f9cadf22789070314c32275cdde654f15b5..32a103be9d16aaa4c8b42d9acb370a122b489458 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,14 @@ { "version": 1, - "updatedAt": "2026-09-22T19:42:55Z", + "updatedAt": "2026-09-22T21:40:00Z", + "compatibility": [ + { + "driver": "opencode", + "t3CodeRange": ">=0.0.42", + "recommendedRange": ">=1.14.19", + "ranges": [{ "range": "<1.14.19", "status": "broken" }] + } + ], "currentModels": { "codex": [ "gpt-6-astra", diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6b4e1a608b97b28d6bda6a6fec5308883508ecc --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -0,0 +1,258 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as ServerConfig from "../config.ts"; +import * as ModelManifest from "./ModelManifest.ts"; +import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "./ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; +import * as Schema from "effect/Schema"; +import { + applyProviderCompatibility, + ProviderCompatibilityPolicy, + resolveProviderCompatibility, +} from "./providerCompatibility.ts"; + +const driver = ProviderDriverKind.make("codex"); +const policy: ProviderCompatibilityPolicy = { + driver, + t3CodeRange: ">=0.0.42 <0.1.0", + recommendedVersion: "2.0.0", + recommendedRange: ">=2.0.0 <3.0.0", + ranges: [ + { range: "<1.0.0", status: "broken" }, + { range: ">=1.0.0 <1.5.0", status: "unsupported" }, + { range: ">=1.5.0 <2.0.0", status: "graceful" }, + { range: ">=2.0.0 <3.0.0", status: "supported" }, + ], +}; +const provider: ServerProvider = { + driver, + instanceId: ProviderInstanceId.make("codex-work"), + enabled: true, + installed: true, + version: "0.9.0", + status: "error", + message: "Authentication failed", + checkedAt: "2026-09-22T00:00:00Z", + auth: { status: "unauthenticated" }, + models: [], + skills: [], + slashCommands: [], +}; + +describe("provider compatibility", () => { + it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { + for (const [version, expected] of [ + ["0.9.9", "broken"], + ["1.0.0", "unsupported"], + ["1.5.0", "graceful"], + ["2.0.0", "supported"], + ["v2.0.0", "supported"], + ["3.0.0", "unknown"], + ["2.0.0-beta.1", "unknown"], + ["agy_acp_server_20260818_01_RC01", "unknown"], + [null, "unknown"], + ] as const) { + assert.strictEqual(resolveProviderCompatibility([policy], driver, version)?.status, expected); + } + assert.isUndefined(resolveProviderCompatibility([policy], driver, "0.9.0", "0.1.0")); + }); + + it("supports every driver without inventing policies for uncovered adapters", () => { + for (const kind of [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "antigravity", + "customDriver", + ]) { + const adapter = ProviderDriverKind.make(kind); + assert.strictEqual( + resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, + "supported", + ); + assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); + } + }); + + it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { + const broken = applyProviderCompatibility(provider, [], [policy]); + assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); + const relaxed = { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }; + const supported = applyProviderCompatibility(broken, [relaxed], [policy]); + assert.strictEqual(supported.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(supported.status, "error"); + assert.strictEqual(supported.message, "Authentication failed"); + assert.strictEqual( + applyProviderCompatibility(supported, [{ ...policy, t3CodeRange: ">=9.0.0" }], [policy]) + .compatibilityAdvisory?.status, + "broken", + ); + const removed = applyProviderCompatibility(supported, [], []); + assert.isUndefined(removed.compatibilityAdvisory); + assert.strictEqual(removed.status, "error"); + assert.strictEqual(removed.message, "Authentication failed"); + assert.isUndefined( + applyProviderCompatibility({ ...broken, enabled: false }, [], [policy]).compatibilityAdvisory, + ); + assert.isUndefined( + applyProviderCompatibility({ ...broken, installed: false }, [], [policy]) + .compatibilityAdvisory, + ); + }); + + it("rejects invalid ranges and recommendations outside the first supported match", () => { + const decode = Schema.decodeUnknownSync(ProviderCompatibilityPolicy); + assert.doesNotThrow(() => decode(policy)); + const prefixed = decode({ + ...policy, + t3CodeRange: ">=v0.0.42 =v2.0 =2.0.0 garbage", status: "supported" }] }, + { ...policy, recommendedVersion: "2.0.0; echo unsafe" }, + { ...policy, ranges: [{ range: ">=0.0.0", status: "broken" }, ...policy.ranges] }, + ]) + assert.throws(() => decode(invalid)); + }); +}); + +it.effect("a remote policy refresh preserves a newer health result on the registry stream", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const health = yield* Ref.make({ + ...provider, + status: "ready" as ServerProvider["status"], + message: "Healthy", + }); + const manifest = yield* Ref.make({ + version: 1, + currentModels: {}, + compatibility: [policy], + }); + const instance: ProviderInstance = { + instanceId: provider.instanceId, + driverKind: driver, + enabled: true, + displayName: undefined, + continuationIdentity: { driverKind: driver, continuationKey: "test-codex" }, + snapshot: { + getSnapshot: Ref.get(health), + refresh: Ref.get(health), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider: driver, packageName: null }), + ), + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const refresh = Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen( + Ref.updateAndGet(manifest, (current) => ({ + ...current, + compatibility: [ + { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }, + ], + })), + ), + ); + const dependencies = Layer.mergeAll( + Layer.succeed(ModelManifest.ModelManifest, { + current: Ref.get(manifest), + refresh, + forceRefresh: refresh, + refreshInBackground: Effect.void, + }), + Layer.succeed(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), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "compatibility-registry-test" }).pipe( + Layer.provideMerge(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + yield* Deferred.await(started); + assert.strictEqual( + (yield* registry.getProviders)[0]?.compatibilityAdvisory?.status, + "broken", + ); + yield* Ref.set(health, provider); + const latestHealth = yield* registry.refreshInstance(provider.instanceId); + assert.strictEqual(latestHealth[0]?.message, "Authentication failed"); + const supported = yield* Stream.toPull( + registry.streamChanges.pipe( + Stream.filter((snapshots) => snapshots[0]?.compatibilityAdvisory?.status === "supported"), + ), + ); + const subscribed = yield* supported.pipe(Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + const updated = (yield* Fiber.join(subscribed))[0]?.[0]; + assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); + assert.strictEqual(updated?.status, "error"); + assert.strictEqual(updated?.message, "Authentication failed"); + }).pipe(Effect.provide(ProviderRegistryLive.pipe(Layer.provide(dependencies)))); + }).pipe(Effect.scoped), +); + +it("recomputes latest-version compatibility without losing the underlying update advisory", () => { + const snapshot: ServerProvider = { + ...provider, + version: "2.0.0", + versionAdvisory: { + status: "behind_latest", + currentVersion: "2.0.0", + latestVersion: "4.0.0", + canUpdate: true, + updateCommand: "npm install -g fixture@latest", + checkedAt: provider.checkedAt, + message: null, + }, + }; + const blockedPolicy: ProviderCompatibilityPolicy = { + ...policy, + ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], + }; + const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); + assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); + const relaxed = applyProviderCompatibility( + blocked, + [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], + [], + ); + assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); + assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); +}); diff --git a/apps/server/src/provider/providerCompatibility.ts b/apps/server/src/provider/providerCompatibility.ts new file mode 100644 index 0000000000000000000000000000000000000000..11aedf8dca0db82cc12210492de0bed0b83b89f4 --- /dev/null +++ b/apps/server/src/provider/providerCompatibility.ts @@ -0,0 +1,119 @@ +import { + TrimmedNonEmptyString, + ServerProviderCompatibilityStatus, + type ProviderDriverKind, + type ServerProvider, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Schema from "effect/Schema"; +import packageJson from "../../package.json" with { type: "json" }; + +// Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. +// Release tags and prereleases remain unknown instead of matching stable ranges. +const StableVersion = TrimmedNonEmptyString.pipe( + Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))), +); +const VersionRange = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((value) => + value.split("||").every((group) => { + const tokens = group.trim().split(/\s+/); + return tokens.every((token) => /^(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}$/.test(token)); + }), + ), + ), +); +const Policy = Schema.Struct({ + driver: TrimmedNonEmptyString, + t3CodeRange: VersionRange, + recommendedRange: Schema.optionalKey(VersionRange), + recommendedVersion: Schema.optionalKey(StableVersion), + ranges: Schema.Array( + Schema.Struct({ + range: VersionRange, + status: ServerProviderCompatibilityStatus, + }), + ), +}); + +export const ProviderCompatibilityPolicy = Policy.pipe( + Schema.check( + Schema.makeFilter( + (policy) => { + const version = policy.recommendedVersion; + if (version === undefined) return true; + return ( + (policy.recommendedRange === undefined || + satisfiesSemverRange(version, policy.recommendedRange)) && + policy.ranges.find((entry) => satisfiesSemverRange(version, entry.range))?.status === + "supported" + ); + }, + { expected: "a recommended version in a supported range" }, + ), + ), +); +export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type; + +export function resolveProviderCompatibility( + policies: ReadonlyArray | undefined, + driver: ProviderDriverKind, + version: string | null, + t3CodeVersion = packageJson.version, +): ServerProviderCompatibilityAdvisory | undefined { + const policy = policies?.find( + (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), + ); + if (!policy) return undefined; + const stable = version?.replace(/^v/, ""); + const status = + stable && /^\d+\.\d+\.\d+$/.test(stable) + ? (policy.ranges.find((entry) => satisfiesSemverRange(stable, entry.range))?.status ?? + "unknown") + : "unknown"; + const message = + status === "broken" + ? "This provider version is known to be incompatible with this T3 Code release." + : status === "unsupported" + ? "This provider version is outside the supported range for this T3 Code release." + : status === "graceful" + ? "This provider version has limited compatibility with this T3 Code release." + : null; + const recommendedVersion = policy.recommendedVersion ?? null; + const recommendedRange = policy.recommendedRange ?? null; + const recommendation = recommendedVersion ?? recommendedRange; + return { + status, + message: message && recommendation ? `${message} Use ${recommendation}.` : message, + recommendedVersion, + recommendedRange, + }; +} + +/** A remote policy replaces its matching bundled policy; omission keeps the bundle. */ +export function applyProviderCompatibility( + snapshot: ServerProvider, + policies: ReadonlyArray | undefined, + fallback: ReadonlyArray | undefined, +): ServerProvider { + const { compatibilityAdvisory: _previous, ...base } = snapshot; + if (!snapshot.enabled || !snapshot.installed) return base; + const advisory = + resolveProviderCompatibility(policies, snapshot.driver, snapshot.version) ?? + resolveProviderCompatibility(fallback, snapshot.driver, snapshot.version); + const latestVersion = snapshot.versionAdvisory?.latestVersion; + const latestAdvisory = latestVersion + ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? + resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) + : undefined; + return advisory + ? { + ...base, + compatibilityAdvisory: { + ...advisory, + ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), + }, + } + : base; +} diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 3e0810f51b7a991928e58dd5153096b25ebc8198..50aee2b08181456ad1d115125a6735f6f9a0e317 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { createProviderVersionAdvisory, + makeTargetedProviderUpdateAction, enrichProviderSnapshotWithVersionAdvisory, homebrewOwnershipFromCommandPath, makeCachedProviderMaintenanceResolution, @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); }); + +it("pins only owned package-manager installs and preserves their execution context", () => { + const capabilities = makeProviderMaintenanceCapabilities({ + provider: driver("codex"), + packageName: "@openai/codex", + updateExecutable: "npm", + updateLockKey: "npm-global:/opt/node", + updateArgs: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + env: { PATH: "/opt/node/bin" }, + }); + const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); + expect(pinned).toMatchObject({ + executable: "npm", + lockKey: capabilities.update?.lockKey, + env: capabilities.update?.env, + args: [ + "install", + "-g", + "--prefix", + "/opt/node", + "--allow-scripts=@openai/codex", + "@openai/codex@2.0.0", + ], + }); + expect(pinned?.command).toContain("@openai/codex@2.0.0"); + for (const lockKey of ["codex-native", "homebrew", "manual"]) { + expect( + makeTargetedProviderUpdateAction( + { ...capabilities, update: { ...capabilities.update!, lockKey } }, + "2.0.0", + ), + ).toBeNull(); + } + expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); +}); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 6a11b22462eef10166742ca2476f66615c4b9274..ace445b0306056b8badd6eb9d4349133c728c6ee 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { }; } +/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ +export function makeTargetedProviderUpdateAction( + capabilities: ProviderMaintenanceCapabilities, + version: string, +): ProviderMaintenanceCommandAction | null { + if (!/^\d+\.\d+\.\d+$/.test(version)) return null; + const update = capabilities.update; + const packageName = capabilities.packageName; + if (!update || !packageName) return null; + if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) + return null; + const packageIndex = update.args.findIndex( + (arg) => arg === `${packageName}@latest` || arg === packageName, + ); + if (packageIndex < 0) return null; + const args = update.args.map((arg, index) => + index === packageIndex ? `${packageName}@${version}` : arg, + ); + const previous = update.args[packageIndex]!; + const commandIndex = update.command.lastIndexOf(previous); + const command = + commandIndex < 0 + ? update.command + : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`; + return { ...update, args, command }; +} + export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { latestVersion, updateCommand: capabilities.update?.command ?? null, canUpdate: capabilities.update !== null, + canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, checkedAt: input.checkedAt ?? null, message: advisory.message, }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 19af22c882ae0ad22d0bb9479146a3f6d44542c5..2da7c9183365768bb32f64a98a424236e5f130bb 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; +import * as ModelManifest from "./ModelManifest.ts"; import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; import { makeProviderMaintenanceCapabilities, @@ -208,13 +209,22 @@ function makeRegistry( }); } -const makeTestRunner = (registry: ProviderRegistryShape) => +const makeTestRunner = ( + registry: ProviderRegistryShape, + manifest = ModelManifest.BUNDLED_MODEL_MANIFEST, +) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( Effect.provide( ProviderMaintenanceRunner.layer.pipe( Layer.provide( Layer.mergeAll( Layer.succeed(ProviderRegistry, registry), + Layer.succeed(ModelManifest.ModelManifest, { + current: Effect.succeed(manifest), + refresh: Effect.succeed(manifest), + forceRefresh: Effect.succeed(manifest), + refreshInBackground: Effect.void, + }), // Fresh per runner so a version cached by one test cannot leak into another. Layer.sync(ProviderVersionCache, () => new Map()), ), @@ -743,7 +753,7 @@ describe("providerMaintenanceRunner", () => { Effect.provide( Layer.mergeAll( NonWindowsPlatform, - latestVersionHttpClient("0.0.0"), + latestVersionHttpClient("2.0.0"), mockSpawnerLayer((_command, args) => { calls.push(args.join(" ")); if (calls.length === 1) { @@ -911,3 +921,92 @@ describe("providerMaintenanceRunner", () => { ); }); }); + +it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { + const calls: string[] = []; + const manifest: ModelManifest.ModelManifestData = { + version: 1, + currentModels: {}, + compatibility: [ + { + driver: "codex", + t3CodeRange: ">=0.0.42", + recommendedVersion: "2.0.0", + ranges: [ + { range: "=2.0.0", status: "supported" }, + { range: ">2.0.0", status: "broken" }, + ], + }, + ], + }; + return Effect.gen(function* () { + const { registry, providersRef } = yield* makeRegistry(); + const pinnedCapabilities = makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global:/fixture", + }); + const updater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), + }, + manifest, + ); + for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) { + const result = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + ...(targetVersion !== undefined ? { targetVersion } : {}), + }); + assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); + } + assert.deepStrictEqual(calls, []); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "2.0.0" })), + ); + const installed = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]); + assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded"); + yield* Ref.update(providersRef, (providers) => + providers.map((entry) => ({ ...entry, version: "1.0.0" })), + ); + const unchanged = yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); + const nativeUpdater = yield* makeTestRunner( + { + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed({ + ...pinnedCapabilities, + update: { ...pinnedCapabilities.update!, lockKey: "codex-native" }, + }), + }, + manifest, + ); + const refused = yield* nativeUpdater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "2.0.0", + }); + assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); + assert.strictEqual(calls.length, 2); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("3.0.0"), + mockSpawnerLayer((_command, args) => { + calls.push(args.join(" ")); + return { stdout: "installed" }; + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 121e5aeab7d151b43e332fcb65588cf75a79ccb1..17be8eb087f67ece82f69c36e5fb0a7304a64f66 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ModelManifest from "./ModelManifest.ts"; +import { resolveProviderCompatibility } from "./providerCompatibility.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeTargetedProviderUpdateAction, + resolveLatestProviderVersion, type ProviderMaintenanceCommandAction, ProviderVersionCache, } from "./providerMaintenance.ts"; @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; + readonly targetVersion?: string | undefined; }, ) => Effect.Effect; } @@ -214,6 +219,7 @@ function makeUpdateState(input: { /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; + const manifestService = yield* ModelManifest.ModelManifest; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { typeof target === "string" ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetVersion = typeof target === "string" ? undefined : target.targetVersion; const targetKey = `instance:${instanceId}`; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } - const result = yield* runMaintenanceCommand(fresh.update); + const manifest = yield* manifestService.current; + const candidateVersion = + targetVersion ?? + (yield* resolveLatestProviderVersion(fresh).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderVersionCache, versionCache), + )); + const advisory = + resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? + resolveProviderCompatibility( + ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, + provider, + candidateVersion, + ); + const command = + targetVersion !== undefined + ? makeTargetedProviderUpdateAction(fresh, targetVersion) + : fresh.update; + const rejected = + targetVersion !== undefined + ? !command || + advisory?.recommendedVersion !== targetVersion || + advisory.status !== "supported" + : advisory?.status === "broken" || advisory?.status === "unsupported"; + if (rejected || !command) { + return yield* finish( + makeUpdateState({ + status: "failed", + startedAt, + finishedAt: yield* nowIso, + message: + targetVersion !== undefined + ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings." + : "The latest provider version is incompatible with this T3 Code release. Review provider settings.", + }), + ); + } + const result = yield* runMaintenanceCommand(command); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { // Cursor's `about` probe can fail transiently on a healthy binary. const couldNotVerify = verifiedProviders.length === 0 || - verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); - const stillOutdated = verifiedProviders.some((verifiedProvider) => - isOutdatedProvider(verifiedProvider), - ); + verifiedProviders.some( + (verifiedProvider) => + !isStillInstalled(verifiedProvider) || + (targetVersion !== undefined && + verifiedProvider.version?.replace(/^v/, "") !== targetVersion), + ); + const stillOutdated = + targetVersion === undefined && + verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); return yield* finish( makeUpdateState({ status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 12f63f3e16fdb1df1d1790abc7fbf0108ce60144..c55338cd5c469e45f35efe7ba2dac69a3aaee48b 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -24,6 +24,7 @@ import { getProviderUpdateSidebarPillView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, + isProviderSettingsUpdateCandidate, isTerminalProviderUpdatePhase, localEnvironmentUpdateNotificationKey, providerUpdateNotificationKey, @@ -1038,3 +1039,22 @@ describe("provider update launch notification logic", () => { }); }); }); + +it("does not offer incompatible latest versions and restores suggestions after policy relaxation", () => { + const installed = provider({ driver: driver("codex") }); + for (const latestVersionStatus of ["broken", "unsupported", "supported", "unknown"] as const) { + const snapshot: ServerProvider = { + ...installed, + compatibilityAdvisory: { + status: "supported", + latestVersionStatus, + message: null, + recommendedRange: null, + recommendedVersion: null, + }, + }; + const expected = latestVersionStatus === "supported" || latestVersionStatus === "unknown"; + expect(isProviderUpdateCandidate(snapshot)).toBe(expected); + expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected); + } +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 6da4eaac6dded426bb38a0fed656629db12d8e72..dd7b25a62cad22f5105aa233f95f19006b8f9c3e 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -141,6 +141,8 @@ export function isProviderUpdateCandidate( ): provider is ProviderUpdateCandidate { return ( provider.enabled && + provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && + provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && provider.versionAdvisory?.status === "behind_latest" && provider.versionAdvisory.latestVersion !== null ); @@ -161,6 +163,8 @@ export function isProviderSettingsUpdateCandidate( ): provider is ProviderSettingsUpdateCandidate { return ( provider.enabled && + provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && + provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && provider.versionAdvisory?.status === "behind_latest" && provider.versionAdvisory.canUpdate === true && provider.versionAdvisory.updateCommand !== null diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.ts b/apps/web/src/components/chat/ProviderStatusBanner.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ebaf180b51aea08874906d257df1f3713d53541 --- /dev/null +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + getProviderStatusBannerKey, + getProviderStatusMessage, + shouldShowProviderStatusBanner, +} from "./ProviderStatusBanner"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex-work"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-22T00:00:00Z", + models: [], + skills: [], + slashCommands: [], + compatibilityAdvisory: { + status: "unsupported", + message: "Unsupported version. Use 2.0.0.", + recommendedVersion: "2.0.0", + recommendedRange: null, + }, +}; + +describe("compatibility banners", () => { + it("shows and dismisses a warning on a healthy provider, then clears it after policy relaxation", () => { + expect(shouldShowProviderStatusBanner(provider, null)).toBe(true); + expect(shouldShowProviderStatusBanner(provider, getProviderStatusBannerKey(provider))).toBe( + false, + ); + expect( + shouldShowProviderStatusBanner( + { ...provider, version: "1.0.1" }, + getProviderStatusBannerKey(provider), + ), + ).toBe(true); + const relaxed: ServerProvider = { + ...provider, + compatibilityAdvisory: { + ...provider.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }; + expect(getProviderStatusBannerKey(relaxed)).toBeNull(); + expect(getProviderStatusBannerKey({ ...provider, status: "disabled" })).toBeNull(); + expect( + getProviderStatusBannerKey({ + ...provider, + compatibilityAdvisory: { ...provider.compatibilityAdvisory!, status: "graceful" }, + }), + ).toBeNull(); + }); + + it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => { + const unauthenticated: ServerProvider = { + ...provider, + status: "error", + auth: { status: "unauthenticated" }, + }; + expect(getProviderStatusMessage(unauthenticated)).toBe( + "Sign in via the CLI to authenticate again.", + ); + expect(getProviderStatusMessage({ ...unauthenticated, message: "Credentials expired" })).toBe( + "Credentials expired", + ); + }); +}); diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index a35be6ed0951ffbd1092884dd5745ed05487d69b..58d723c9a92a6224f046262abb33bd4fdaf6f325 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -6,8 +6,22 @@ import { Button, InlineButton } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +/** Unsupported and broken versions fail mid-turn, so they warn even when ready. */ +function getIncompatibleVersion(status: ServerProvider) { + const compatibility = status.compatibilityAdvisory; + return compatibility?.status === "unsupported" || compatibility?.status === "broken" + ? compatibility + : null; +} + export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { - if (!status || status.status === "ready" || status.status === "disabled") return null; + if (!status || status.status === "disabled") return null; + if (status.status === "ready") { + const incompatible = getIncompatibleVersion(status); + return incompatible + ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000") + : null; + } // Antigravity checks saved credentials when a session starts. Its local // health check leaves auth unknown after a restart, which is not a failure. if ( @@ -76,15 +90,20 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; + const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null; const title = isUnauthenticated ? `${providerName} is unauthenticated` - : `${providerName} provider status`; - const message = getProviderStatusMessage(status); + : incompatible + ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}` + : `${providerName} provider status`; + const message = incompatible?.message ?? getProviderStatusMessage(status); + const isWarning = status.status === "warning" || incompatible !== null; return (
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 2aa765df06b726a8ee32679fe0a5f01c289ba323..58ca11b0808c34e4f0b1e9ac95cfc8f0ce45b4db 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -3,6 +3,7 @@ import { Spinner } from "~/components/ui/spinner"; import { + AlertTriangleIcon, ArrowUpCircleIcon, CopyIcon, DownloadIcon, @@ -14,7 +15,7 @@ import { } from "lucide-react"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; import { isProviderDriverKind, resolveProviderInstanceEnabled, @@ -58,6 +59,22 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +function ProviderStatusDiagnostic({ + detail, + children, +}: { + detail: string | null; + children: ReactElement; +}) { + if (!detail) return children; + return ( + + + {detail} + + ); +} + let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -377,6 +394,7 @@ interface ProviderInstanceCardProps { readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; + readonly onInstallRecommended?: (() => void) | undefined; readonly isUpdating?: boolean | undefined; } @@ -419,9 +437,11 @@ export function ProviderInstanceCard({ onFavoriteModelsChange, onModelOrderChange, onRunUpdate, + onInstallRecommended, isUpdating = false, }: ProviderInstanceCardProps) { const enabled = resolveProviderInstanceEnabled(instance); + const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; // A locally disabled provider reads "Disabled" with a muted dot even if its // last server status is stale. Enabled providers use the server status. const statusKey: ProviderStatusKey = enabled @@ -438,8 +458,18 @@ export function ProviderInstanceCard({ ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) : null; const versionLabel = getProviderVersionLabel(liveProvider?.version); - const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); + const versionAdvisory = getProviderVersionAdvisoryPresentation( + liveProvider?.versionAdvisory, + liveProvider?.compatibilityAdvisory, + enabled, + ); const updateCommand = versionAdvisory?.updateCommand ?? null; + const hasCompatibilityWarning = + compatibility !== undefined && + compatibility.status !== "supported" && + compatibility.status !== "unknown"; + const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon; + const onRunVersionAction = versionAdvisory?.targetVersion ? onInstallRecommended : onRunUpdate; const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); @@ -568,10 +598,16 @@ export function ProviderInstanceCard({ statusKey === "warning" || statusKey === "error" ? ( ) : null; - // Trouble states carry the server's explanation (a failed probe, a shadow - // home entry that is not a symlink, a missing binary). Show it wherever the - // headline shows so the user can act without opening the editor. const needsAttention = statusKey === "warning" || statusKey === "error"; + const statusDiagnostic = hasCompatibilityWarning && needsAttention ? summary.detail : null; + // Keep compatibility copy compact; the version popover carries the explanation. + const inlineStatusDetail = hasCompatibilityWarning + ? compatibility?.status === "broken" + ? "Incompatible" + : compatibility?.status === "unsupported" + ? "Unsupported" + : "Limited support" + : summary.detail; const editorStatusNode = isAuthenticated && authEmail ? ( <> @@ -579,16 +615,16 @@ export function ProviderInstanceCard({ Authenticated as {authLabel ? · {authLabel} : null} - {summary.detail ? ( - · {summary.detail} + {inlineStatusDetail ? ( + · {inlineStatusDetail} ) : null} ) : ( <> {statusDotNode} {summary.headline} - {summary.detail ? ( - · {summary.detail} + {inlineStatusDetail ? ( + · {inlineStatusDetail} ) : null} ); @@ -629,7 +665,23 @@ export function ProviderInstanceCard({ ) : null} {versionAdvisory ? ( - updateCommand ? ( + hasCompatibilityWarning ? ( + + + + + } + /> + {versionAdvisory.detail} + + ) : updateCommand ? ( {statusDotNode} ) : null} - - {summary.headline} - {needsAttention && summary.detail ? ` · ${summary.detail}` : null} - + + + {summary.headline} + {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + +
@@ -694,25 +751,32 @@ export function ProviderInstanceCard({ > {versionAdvisory ? ( - - + + + + } /> - - } - /> + } + /> + {versionAdvisory.title} +

- Update available + {versionAdvisory.title}

- {onRunUpdate ? ( + {onRunVersionAction ? ( ) : null} - {onRunUpdate && updateCommand ? ( + {onRunVersionAction && updateCommand ? (
or, update manually using @@ -798,7 +866,14 @@ export function ProviderInstanceCard({ {editorStatusNode}
+ +
+ {editorStatusNode} +
+
} control={
{ + async ( + candidate: Pick, + targetVersion?: string, + ) => { // Ref-based re-entry guard, mirroring refreshProviders: a state updater // may run after this function returns, so it cannot gate the dispatch. if (updatingInstanceIdsRef.current.has(candidate.instanceId)) { @@ -663,6 +666,7 @@ export function EnvironmentProviderSettings({ input: { provider: candidate.driver, instanceId: candidate.instanceId, + ...(targetVersion ? { targetVersion } : {}), }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { @@ -887,9 +891,8 @@ export function EnvironmentProviderSettings({ ); const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId); const isInstanceUpdateRunning = - updateCandidate !== undefined && - (updatingProviderInstanceIds.has(updateCandidate.instanceId) || - isProviderUpdateActive(updateCandidate)); + updatingProviderInstanceIds.has(row.instanceId) || + (liveProvider !== undefined && isProviderUpdateActive(liveProvider)); const showInlineUpdateButton = updateCandidate !== undefined; const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning; const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { @@ -971,6 +974,19 @@ export function EnvironmentProviderSettings({ modelOrder, }) } + onInstallRecommended={ + mode === "editor" && + liveProvider?.compatibilityAdvisory?.message && + liveProvider.compatibilityAdvisory.recommendedVersion && + liveProvider.versionAdvisory?.canInstallVersion + ? () => { + void runProviderUpdate( + liveProvider, + liveProvider.compatibilityAdvisory?.recommendedVersion ?? undefined, + ); + } + : undefined + } onRunUpdate={ mode === "editor" && showInlineUpdateButton && updateCandidate ? () => { @@ -978,9 +994,7 @@ export function EnvironmentProviderSettings({ } : undefined } - isUpdating={ - mode === "editor" && showInlineUpdateButton ? isInstanceUpdateRunning : undefined - } + isUpdating={mode === "editor" ? isInstanceUpdateRunning : undefined} /> ); }; diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts index 46dc7e262512b2019e11967f97428552d69c6e2d..5e2a93f48ca998387476521f12025848914cf397 100644 --- a/apps/web/src/components/settings/providerStatus.test.ts +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -1,7 +1,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { getProviderSummary } from "./providerStatus"; +import { getProviderSummary, getProviderVersionAdvisoryPresentation } from "./providerStatus"; const provider: ServerProvider = { instanceId: ProviderInstanceId.make("codex"), @@ -69,3 +69,73 @@ describe("getProviderSummary", () => { expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); }); }); + +it("does not suggest copying a command that installs an incompatible latest version", () => { + const advisory = { + status: "behind_latest" as const, + currentVersion: "1.0.0", + latestVersion: "2.0.0", + updateCommand: "npm install -g fixture@latest", + canUpdate: true, + checkedAt: provider.checkedAt, + message: null, + }; + const compatibility = { + status: "supported" as const, + latestVersionStatus: "broken" as const, + message: null, + recommendedRange: null, + recommendedVersion: null, + }; + expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toBeNull(); + expect( + getProviderVersionAdvisoryPresentation(advisory, { ...compatibility, status: "broken" }, false), + ).toBeNull(); + expect( + getProviderVersionAdvisoryPresentation(advisory, { + ...compatibility, + latestVersionStatus: "supported", + }), + ).not.toBeNull(); +}); + +it("shows compatibility in the version popover even when the installed version is current", () => { + const advisory = { + status: "current" as const, + currentVersion: "2.0.0", + latestVersion: "2.0.0", + updateCommand: "npm install -g fixture@latest", + canUpdate: true, + checkedAt: provider.checkedAt, + message: null, + }; + const compatibility = { + status: "broken" as const, + latestVersionStatus: "broken" as const, + message: "This version drops turns. Use 1.9.0.", + recommendedRange: null, + recommendedVersion: "1.9.0", + }; + expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toEqual({ + title: "Known broken version", + detail: compatibility.message, + updateCommand: null, + emphasis: "strong", + targetVersion: "1.9.0", + }); + expect( + getProviderVersionAdvisoryPresentation(undefined, { + ...compatibility, + status: "graceful", + recommendedVersion: null, + recommendedRange: ">=2.1.0", + message: null, + }), + ).toEqual({ + title: "Limited support", + detail: "Use >=2.1.0 for full support.", + updateCommand: null, + emphasis: "normal", + targetVersion: null, + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 90c618f5daa7e5a8e99414d36019f35e890e15a4..8b6b95479686aaf74c2a543eb0aa54dbea5661b4 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -1,4 +1,8 @@ -import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; +import type { + ServerProvider, + ServerProviderVersionAdvisory, + ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; /** * Visual treatment for each server-reported provider status. Centralized so @@ -99,14 +103,53 @@ export function getProviderVersionLabel(version: string | null | undefined) { return /^\d/.test(version) ? `v${version}` : version; } +const COMPATIBILITY_TITLES = { + graceful: "Limited support", + unsupported: "Unsupported version", + broken: "Known broken version", +} as const; + +/** Compatibility guidance shares the version popover, with safe install actions. */ export function getProviderVersionAdvisoryPresentation( advisory: ServerProviderVersionAdvisory | undefined, + compatibility?: ServerProviderCompatibilityAdvisory | undefined, + showCompatibility = true, ): { + readonly title: string; readonly detail: string; readonly updateCommand: string | null; readonly emphasis: "normal" | "strong"; + readonly targetVersion: string | null; } | null { - if (!advisory || advisory.status === "current" || advisory.status === "unknown") { + const latestIsIncompatible = + compatibility?.latestVersionStatus === "broken" || + compatibility?.latestVersionStatus === "unsupported"; + if ( + showCompatibility && + compatibility && + (compatibility.status === "graceful" || + compatibility.status === "unsupported" || + compatibility.status === "broken") + ) { + const targetVersion = compatibility.recommendedVersion; + const recommendation = getProviderVersionLabel(targetVersion) ?? compatibility.recommendedRange; + return { + title: COMPATIBILITY_TITLES[compatibility.status], + detail: + compatibility.message ?? + (recommendation ? `Use ${recommendation} for full support.` : "Update for full support."), + updateCommand: + targetVersion || latestIsIncompatible ? null : (advisory?.updateCommand ?? null), + emphasis: compatibility.status === "graceful" ? "normal" : "strong", + targetVersion, + }; + } + if ( + !advisory || + advisory.status === "current" || + advisory.status === "unknown" || + latestIsIncompatible + ) { return null; } @@ -115,6 +158,7 @@ export function getProviderVersionAdvisoryPresentation( const versionLabel = getProviderVersionLabel(version); return { + title: label, detail: advisory.message ?? (versionLabel @@ -122,5 +166,6 @@ export function getProviderVersionAdvisoryPresentation( : `${label}: install the latest provider version.`), updateCommand: advisory.updateCommand, emphasis: "normal" as const, + targetVersion: null, }; } diff --git a/docs/user/install.md b/docs/user/install.md index 2a7e7dc7e749c6769bca2097cccc992fd02c4820..773979e7730200ee8a246ce2704009055b75ffa7 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -120,6 +120,12 @@ Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set it Cursor's executable is `cursor-agent`, although its login command is `agent login`. Antigravity can use its managed runtime without a `PATH` entry. +T3 Code warns when a provider version has known compatibility problems with your +release. Check **Settings → Providers** on that environment for the recommended +version or range. When its package manager supports installing a specific version, +you can install the recommendation there. Otherwise use the provider's installer +on the environment's machine. An unlisted version is unverified. + When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** appears only when T3 Code can tell which installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 49a377208799fe9c65d03fd86ab8c82c913fe406..c137cac9ac7a7dcdf232475bf37378a71d3df266 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -148,6 +148,22 @@ export const ServerProviderContinuation = Schema.Struct({ }); export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; +export const ServerProviderCompatibilityStatus = Schema.Literals([ + "unknown", + "supported", + "graceful", + "unsupported", + "broken", +]); +export const ServerProviderCompatibilityAdvisory = Schema.Struct({ + status: ServerProviderCompatibilityStatus, + latestVersionStatus: Schema.optionalKey(ServerProviderCompatibilityStatus), + message: Schema.NullOr(TrimmedNonEmptyString), + recommendedVersion: Schema.NullOr(TrimmedNonEmptyString), + recommendedRange: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type; + export const ServerProviderVersionAdvisoryStatus = Schema.Literals([ "unknown", "current", @@ -161,6 +177,7 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ latestVersion: Schema.NullOr(TrimmedNonEmptyString), updateCommand: Schema.NullOr(TrimmedNonEmptyString), canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + canInstallVersion: Schema.optionalKey(Schema.Boolean), checkedAt: Schema.NullOr(IsoDateTime), message: Schema.NullOr(TrimmedNonEmptyString), }); @@ -234,6 +251,7 @@ export const ServerProvider = Schema.Struct({ // Absent when the driver has no notion of subscription usage. usageLimits: Schema.optional(ServerProviderUsageLimits), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), + compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); export type ServerProvider = typeof ServerProvider.Type; @@ -799,6 +817,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T export const ServerProviderUpdateInput = Schema.Struct({ provider: ProviderDriverKind, + targetVersion: Schema.optionalKey(TrimmedNonEmptyString), instanceId: Schema.optionalKey(ProviderInstanceId), }); export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; -- 2.54.0