| author | |
| committer | |
| log | 96c4bfa0a27f776f7cd17df7c7115c3de79ee399 |
| tree | 0cbdd1ee4c8000d21323e2c473b9d749480d27ef |
| parent | a493946bb42ab16e1d18285dac3e9e4603651330 |
| signature | Signed by PGP key B5690EEEBB952194 |
22 files changed, 1099 insertions(+), 59 deletions(-)
apps/mobile/src/features/threads/ThreadComposer.tsx+17| ... | ... | @@ -651,6 +651,23 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 651 | 651 | </View> |
| 652 | 652 | ) : null} |
| 653 | 653 | |
| 654 | {selectedProviderStatus?.compatibilityAdvisory?.message && | |
| 655 | (selectedProviderStatus.compatibilityAdvisory.status === "unsupported" || | |
| 656 | selectedProviderStatus.compatibilityAdvisory.status === "broken") ? ( | |
| 657 | <Text | |
| 658 | accessibilityRole={ | |
| 659 | selectedProviderStatus.compatibilityAdvisory.status === "broken" ? "alert" : undefined | |
| 660 | } | |
| 661 | accessibilityLiveRegion={ | |
| 662 | selectedProviderStatus.compatibilityAdvisory.status === "broken" | |
| 663 | ? "assertive" | |
| 664 | : "polite" | |
| 665 | } | |
| 666 | className="px-3 py-2 text-xs text-foreground" | |
| 667 | > | |
| 668 | {selectedProviderStatus.compatibilityAdvisory.message} | |
| 669 | </Text> | |
| 670 | ) : null} | |
| 654 | 671 | {modelUnavailable ? ( |
| 655 | 672 | <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}> |
| 656 | 673 | <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text> |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+2-1| ... | ... | @@ -77,12 +77,13 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; |
| 77 | 77 | const encoder = new TextEncoder(); |
| 78 | 78 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 79 | 79 | |
| 80 | // Provider metadata checks use a bundled manifest and stubbed HTTP. | |
| 80 | 81 | const TestHttpClientLive = Layer.succeed( |
| 81 | 82 | HttpClient.HttpClient, |
| 82 | 83 | HttpClient.make((request) => |
| 83 | 84 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), |
| 84 | 85 | ), |
| 85 | ); | |
| 86 | ).pipe(Layer.provideMerge(ModelManifest.layerTest)); | |
| 86 | 87 | |
| 87 | 88 | const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ |
| 88 | 89 | reportClientActivity: () => Effect.void, |
apps/server/src/provider/Layers/ProviderRegistry.ts+35-3| ... | ... | @@ -40,6 +40,8 @@ import * as Ref from "effect/Ref"; |
| 40 | 40 | import * as Stream from "effect/Stream"; |
| 41 | 41 | import * as Semaphore from "effect/Semaphore"; |
| 42 | 42 | |
| 43 | import * as ModelManifest from "../ModelManifest.ts"; | |
| 44 | import { applyProviderCompatibility } from "../providerCompatibility.ts"; | |
| 43 | 45 | import { ServerConfig } from "../../config.ts"; |
| 44 | 46 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 45 | 47 | import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; |
| ... | ... | @@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect( |
| 278 | 280 | ProviderRegistry, |
| 279 | 281 | Effect.gen(function* () { |
| 280 | 282 | const instanceRegistry = yield* ProviderInstanceRegistry; |
| 283 | const manifestService = yield* ModelManifest.ModelManifest; | |
| 284 | const serviceScope = yield* Effect.scope; | |
| 281 | 285 | const config = yield* ServerConfig; |
| 282 | 286 | const fileSystem = yield* FileSystem.FileSystem; |
| 283 | 287 | const path = yield* Path.Path; |
| ... | ... | @@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect( |
| 356 | 360 | ), |
| 357 | 361 | ), |
| 358 | 362 | ); |
| 359 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders); | |
| 363 | const initialManifest = yield* manifestService.current; | |
| 364 | const classifyCompatibility = ( | |
| 365 | provider: ServerProvider, | |
| 366 | manifest: ModelManifest.ModelManifestData, | |
| 367 | ) => | |
| 368 | applyProviderCompatibility( | |
| 369 | provider, | |
| 370 | manifest.compatibility, | |
| 371 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | |
| 372 | ); | |
| 373 | const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>( | |
| 374 | cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), | |
| 375 | ); | |
| 360 | 376 | const workspaceRefreshesRef = yield* Ref.make< |
| 361 | 377 | ReadonlyMap<ProviderInstance, ReadonlySet<string>> |
| 362 | 378 | >(new Map()); |
| ... | ... | @@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect( |
| 424 | 440 | readonly replace?: boolean; |
| 425 | 441 | }, |
| 426 | 442 | ) { |
| 443 | const manifest = yield* manifestService.current; | |
| 427 | 444 | const nextProvidersWithUpdateState = yield* Effect.forEach( |
| 428 | 445 | nextProviders, |
| 429 | 446 | applyProviderUpdateState, |
| ... | ... | @@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect( |
| 450 | 467 | ); |
| 451 | 468 | } |
| 452 | 469 | |
| 453 | const providers = orderProviderSnapshots([...mergedProviders.values()]); | |
| 470 | const providers = orderProviderSnapshots( | |
| 471 | [...mergedProviders.values()].map((provider) => | |
| 472 | classifyCompatibility(provider, manifest), | |
| 473 | ), | |
| 474 | ); | |
| 454 | 475 | const providersToPersist = providers.filter((provider) => |
| 455 | 476 | updatedKeys.has(snapshotInstanceKey(provider)), |
| 456 | 477 | ); |
| ... | ... | @@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect( |
| 473 | 494 | return providers; |
| 474 | 495 | }); |
| 475 | 496 | |
| 497 | const compatibilityRefreshRunning = yield* Ref.make(false); | |
| 476 | 498 | const syncProvider = Effect.fn("syncProvider")(function* ( |
| 477 | 499 | provider: ServerProvider, |
| 478 | 500 | options?: { |
| 479 | 501 | readonly publish?: boolean; |
| 480 | 502 | }, |
| 481 | 503 | ) { |
| 482 | return yield* upsertProviders([provider], options); | |
| 504 | const providers = yield* upsertProviders([provider], options); | |
| 505 | // Reclassify the current read model after fetching. Never republish the | |
| 506 | // probe captured before the fetch: a newer health result may have landed. | |
| 507 | if (!(yield* Ref.getAndSet(compatibilityRefreshRunning, true))) { | |
| 508 | yield* manifestService.refresh.pipe( | |
| 509 | Effect.andThen(upsertProviders([], { persist: false })), | |
| 510 | Effect.ensuring(Ref.set(compatibilityRefreshRunning, false)), | |
| 511 | Effect.forkIn(serviceScope), | |
| 512 | ); | |
| 513 | } | |
| 514 | return providers; | |
| 483 | 515 | }); |
| 484 | 516 | |
| 485 | 517 | const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")( |
apps/server/src/provider/ModelManifest.test.ts+40| ... | ... | @@ -549,3 +549,43 @@ describe("ModelManifest service", () => { |
| 549 | 549 | ), |
| 550 | 550 | ); |
| 551 | 551 | }); |
| 552 | ||
| 553 | it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => { | |
| 554 | const remote: ModelManifestData = { | |
| 555 | ...REMOTE_MANIFEST, | |
| 556 | compatibility: [ | |
| 557 | { | |
| 558 | driver: "codex", | |
| 559 | t3CodeRange: ">=0.0.42", | |
| 560 | recommendedVersion: "2.0.0", | |
| 561 | ranges: [{ range: "=2.0.0", status: "supported" }], | |
| 562 | }, | |
| 563 | ], | |
| 564 | }; | |
| 565 | let invalid = false; | |
| 566 | return Effect.gen(function* () { | |
| 567 | const service = yield* make; | |
| 568 | assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); | |
| 569 | invalid = true; | |
| 570 | yield* TestClock.adjust("1 hour"); | |
| 571 | assert.deepStrictEqual((yield* service.refresh).compatibility, remote.compatibility); | |
| 572 | const rebooted = yield* make; | |
| 573 | assert.deepStrictEqual((yield* rebooted.current).compatibility, remote.compatibility); | |
| 574 | }).pipe( | |
| 575 | Effect.scoped, | |
| 576 | Effect.provide( | |
| 577 | serviceLayers({ | |
| 578 | prefix: "model-manifest-compatibility-test", | |
| 579 | response: () => | |
| 580 | Response.json( | |
| 581 | invalid | |
| 582 | ? { | |
| 583 | ...remote, | |
| 584 | compatibility: [{ ...remote.compatibility![0], recommendedVersion: "3.0.0" }], | |
| 585 | } | |
| 586 | : remote, | |
| 587 | ), | |
| 588 | }), | |
| 589 | ), | |
| 590 | ); | |
| 591 | }); |
apps/server/src/provider/ModelManifest.ts+2| ... | ... | @@ -34,6 +34,7 @@ import { ServerConfig } from "../config.ts"; |
| 34 | 34 | import * as ServerSettings from "../serverSettings.ts"; |
| 35 | 35 | import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; |
| 36 | 36 | import bundledManifestJson from "./model-manifest.json" with { type: "json" }; |
| 37 | import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts"; | |
| 37 | 38 | import type { ServerProviderDraft } from "./providerSnapshot.ts"; |
| 38 | 39 | |
| 39 | 40 | const MODEL_MANIFEST_URL = |
| ... | ... | @@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({ |
| 89 | 90 | * files still decode; they count as older than any dated bundle. |
| 90 | 91 | */ |
| 91 | 92 | updatedAt: Schema.optional(Schema.String), |
| 93 | compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)), | |
| 92 | 94 | currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), |
| 93 | 95 | providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), |
| 94 | 96 | }); |
apps/server/src/provider/model-manifest.json+9-1| ... | ... | @@ -1,6 +1,14 @@ |
| 1 | 1 | { |
| 2 | 2 | "version": 1, |
| 3 | "updatedAt": "2026-09-22T19:42:55Z", | |
| 3 | "updatedAt": "2026-09-22T21:40:00Z", | |
| 4 | "compatibility": [ | |
| 5 | { | |
| 6 | "driver": "opencode", | |
| 7 | "t3CodeRange": ">=0.0.42", | |
| 8 | "recommendedRange": ">=1.14.19", | |
| 9 | "ranges": [{ "range": "<1.14.19", "status": "broken" }] | |
| 10 | } | |
| 11 | ], | |
| 4 | 12 | "currentModels": { |
| 5 | 13 | "codex": [ |
| 6 | 14 | "gpt-6-astra", |
apps/server/src/provider/providerCompatibility.test.ts created+258| ... | ... | @@ -0,0 +1,258 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import * as Deferred from "effect/Deferred"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as PubSub from "effect/PubSub"; | |
| 9 | import * as Ref from "effect/Ref"; | |
| 10 | import * as Stream from "effect/Stream"; | |
| 11 | import * as ServerConfig from "../config.ts"; | |
| 12 | import * as ModelManifest from "./ModelManifest.ts"; | |
| 13 | import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts"; | |
| 14 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; | |
| 15 | import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; | |
| 16 | import type { ProviderInstance } from "./ProviderDriver.ts"; | |
| 17 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; | |
| 18 | import * as Schema from "effect/Schema"; | |
| 19 | import { | |
| 20 | applyProviderCompatibility, | |
| 21 | ProviderCompatibilityPolicy, | |
| 22 | resolveProviderCompatibility, | |
| 23 | } from "./providerCompatibility.ts"; | |
| 24 | ||
| 25 | const driver = ProviderDriverKind.make("codex"); | |
| 26 | const policy: ProviderCompatibilityPolicy = { | |
| 27 | driver, | |
| 28 | t3CodeRange: ">=0.0.42 <0.1.0", | |
| 29 | recommendedVersion: "2.0.0", | |
| 30 | recommendedRange: ">=2.0.0 <3.0.0", | |
| 31 | ranges: [ | |
| 32 | { range: "<1.0.0", status: "broken" }, | |
| 33 | { range: ">=1.0.0 <1.5.0", status: "unsupported" }, | |
| 34 | { range: ">=1.5.0 <2.0.0", status: "graceful" }, | |
| 35 | { range: ">=2.0.0 <3.0.0", status: "supported" }, | |
| 36 | ], | |
| 37 | }; | |
| 38 | const provider: ServerProvider = { | |
| 39 | driver, | |
| 40 | instanceId: ProviderInstanceId.make("codex-work"), | |
| 41 | enabled: true, | |
| 42 | installed: true, | |
| 43 | version: "0.9.0", | |
| 44 | status: "error", | |
| 45 | message: "Authentication failed", | |
| 46 | checkedAt: "2026-09-22T00:00:00Z", | |
| 47 | auth: { status: "unauthenticated" }, | |
| 48 | models: [], | |
| 49 | skills: [], | |
| 50 | slashCommands: [], | |
| 51 | }; | |
| 52 | ||
| 53 | describe("provider compatibility", () => { | |
| 54 | it("classifies boundaries and treats unlisted versions and release tags as unknown", () => { | |
| 55 | for (const [version, expected] of [ | |
| 56 | ["0.9.9", "broken"], | |
| 57 | ["1.0.0", "unsupported"], | |
| 58 | ["1.5.0", "graceful"], | |
| 59 | ["2.0.0", "supported"], | |
| 60 | ["v2.0.0", "supported"], | |
| 61 | ["3.0.0", "unknown"], | |
| 62 | ["2.0.0-beta.1", "unknown"], | |
| 63 | ["agy_acp_server_20260818_01_RC01", "unknown"], | |
| 64 | [null, "unknown"], | |
| 65 | ] as const) { | |
| 66 | assert.strictEqual(resolveProviderCompatibility([policy], driver, version)?.status, expected); | |
| 67 | } | |
| 68 | assert.isUndefined(resolveProviderCompatibility([policy], driver, "0.9.0", "0.1.0")); | |
| 69 | }); | |
| 70 | ||
| 71 | it("supports every driver without inventing policies for uncovered adapters", () => { | |
| 72 | for (const kind of [ | |
| 73 | "codex", | |
| 74 | "claudeAgent", | |
| 75 | "cursor", | |
| 76 | "grok", | |
| 77 | "opencode", | |
| 78 | "antigravity", | |
| 79 | "customDriver", | |
| 80 | ]) { | |
| 81 | const adapter = ProviderDriverKind.make(kind); | |
| 82 | assert.strictEqual( | |
| 83 | resolveProviderCompatibility([{ ...policy, driver: adapter }], adapter, "2.0.0")?.status, | |
| 84 | "supported", | |
| 85 | ); | |
| 86 | assert.isUndefined(resolveProviderCompatibility([], adapter, "2.0.0")); | |
| 87 | } | |
| 88 | }); | |
| 89 | ||
| 90 | it("relaxes remote policy without losing probe errors, and falls back when a policy is omitted", () => { | |
| 91 | const broken = applyProviderCompatibility(provider, [], [policy]); | |
| 92 | assert.strictEqual(broken.compatibilityAdvisory?.status, "broken"); | |
| 93 | const relaxed = { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }; | |
| 94 | const supported = applyProviderCompatibility(broken, [relaxed], [policy]); | |
| 95 | assert.strictEqual(supported.compatibilityAdvisory?.status, "supported"); | |
| 96 | assert.strictEqual(supported.status, "error"); | |
| 97 | assert.strictEqual(supported.message, "Authentication failed"); | |
| 98 | assert.strictEqual( | |
| 99 | applyProviderCompatibility(supported, [{ ...policy, t3CodeRange: ">=9.0.0" }], [policy]) | |
| 100 | .compatibilityAdvisory?.status, | |
| 101 | "broken", | |
| 102 | ); | |
| 103 | const removed = applyProviderCompatibility(supported, [], []); | |
| 104 | assert.isUndefined(removed.compatibilityAdvisory); | |
| 105 | assert.strictEqual(removed.status, "error"); | |
| 106 | assert.strictEqual(removed.message, "Authentication failed"); | |
| 107 | assert.isUndefined( | |
| 108 | applyProviderCompatibility({ ...broken, enabled: false }, [], [policy]).compatibilityAdvisory, | |
| 109 | ); | |
| 110 | assert.isUndefined( | |
| 111 | applyProviderCompatibility({ ...broken, installed: false }, [], [policy]) | |
| 112 | .compatibilityAdvisory, | |
| 113 | ); | |
| 114 | }); | |
| 115 | ||
| 116 | it("rejects invalid ranges and recommendations outside the first supported match", () => { | |
| 117 | const decode = Schema.decodeUnknownSync(ProviderCompatibilityPolicy); | |
| 118 | assert.doesNotThrow(() => decode(policy)); | |
| 119 | const prefixed = decode({ | |
| 120 | ...policy, | |
| 121 | t3CodeRange: ">=v0.0.42 <v0.1", | |
| 122 | recommendedRange: "^v2", | |
| 123 | ranges: [{ range: ">=v2.0 <v3", status: "supported" }], | |
| 124 | }); | |
| 125 | assert.strictEqual( | |
| 126 | resolveProviderCompatibility([prefixed], driver, "2.0.0")?.status, | |
| 127 | "supported", | |
| 128 | ); | |
| 129 | assert.strictEqual( | |
| 130 | resolveProviderCompatibility([prefixed], driver, "3.0.0")?.status, | |
| 131 | "unknown", | |
| 132 | ); | |
| 133 | for (const invalid of [ | |
| 134 | { ...policy, t3CodeRange: "*" }, | |
| 135 | { ...policy, recommendedVersion: "3.0.0" }, | |
| 136 | { ...policy, ranges: [{ range: ">=2.0.0 garbage", status: "supported" }] }, | |
| 137 | { ...policy, recommendedVersion: "2.0.0; echo unsafe" }, | |
| 138 | { ...policy, ranges: [{ range: ">=0.0.0", status: "broken" }, ...policy.ranges] }, | |
| 139 | ]) | |
| 140 | assert.throws(() => decode(invalid)); | |
| 141 | }); | |
| 142 | }); | |
| 143 | ||
| 144 | it.effect("a remote policy refresh preserves a newer health result on the registry stream", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | const started = yield* Deferred.make<void>(); | |
| 147 | const release = yield* Deferred.make<void>(); | |
| 148 | const health = yield* Ref.make<ServerProvider>({ | |
| 149 | ...provider, | |
| 150 | status: "ready" as ServerProvider["status"], | |
| 151 | message: "Healthy", | |
| 152 | }); | |
| 153 | const manifest = yield* Ref.make<ModelManifest.ModelManifestData>({ | |
| 154 | version: 1, | |
| 155 | currentModels: {}, | |
| 156 | compatibility: [policy], | |
| 157 | }); | |
| 158 | const instance: ProviderInstance = { | |
| 159 | instanceId: provider.instanceId, | |
| 160 | driverKind: driver, | |
| 161 | enabled: true, | |
| 162 | displayName: undefined, | |
| 163 | continuationIdentity: { driverKind: driver, continuationKey: "test-codex" }, | |
| 164 | snapshot: { | |
| 165 | getSnapshot: Ref.get(health), | |
| 166 | refresh: Ref.get(health), | |
| 167 | streamChanges: Stream.empty, | |
| 168 | applyUsageLimits: () => Effect.void, | |
| 169 | resolveMaintenance: () => | |
| 170 | Effect.succeed( | |
| 171 | makeManualOnlyProviderMaintenanceCapabilities({ provider: driver, packageName: null }), | |
| 172 | ), | |
| 173 | }, | |
| 174 | adapter: {} as ProviderInstance["adapter"], | |
| 175 | textGeneration: {} as ProviderInstance["textGeneration"], | |
| 176 | }; | |
| 177 | const refresh = Deferred.succeed(started, undefined).pipe( | |
| 178 | Effect.andThen(Deferred.await(release)), | |
| 179 | Effect.andThen( | |
| 180 | Ref.updateAndGet(manifest, (current) => ({ | |
| 181 | ...current, | |
| 182 | compatibility: [ | |
| 183 | { ...policy, ranges: [{ range: ">=0.0.0", status: "supported" as const }] }, | |
| 184 | ], | |
| 185 | })), | |
| 186 | ), | |
| 187 | ); | |
| 188 | const dependencies = Layer.mergeAll( | |
| 189 | Layer.succeed(ModelManifest.ModelManifest, { | |
| 190 | current: Ref.get(manifest), | |
| 191 | refresh, | |
| 192 | forceRefresh: refresh, | |
| 193 | refreshInBackground: Effect.void, | |
| 194 | }), | |
| 195 | Layer.succeed(ProviderInstanceRegistry, { | |
| 196 | getInstance: (id) => Effect.succeed(id === instance.instanceId ? instance : undefined), | |
| 197 | listInstances: Effect.succeed([instance]), | |
| 198 | listUnavailable: Effect.succeed([]), | |
| 199 | streamChanges: Stream.empty, | |
| 200 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), PubSub.subscribe), | |
| 201 | }), | |
| 202 | ServerConfig.layerTest(process.cwd(), { prefix: "compatibility-registry-test" }).pipe( | |
| 203 | Layer.provideMerge(NodeServices.layer), | |
| 204 | ), | |
| 205 | ); | |
| 206 | yield* Effect.gen(function* () { | |
| 207 | const registry = yield* ProviderRegistry; | |
| 208 | yield* Deferred.await(started); | |
| 209 | assert.strictEqual( | |
| 210 | (yield* registry.getProviders)[0]?.compatibilityAdvisory?.status, | |
| 211 | "broken", | |
| 212 | ); | |
| 213 | yield* Ref.set(health, provider); | |
| 214 | const latestHealth = yield* registry.refreshInstance(provider.instanceId); | |
| 215 | assert.strictEqual(latestHealth[0]?.message, "Authentication failed"); | |
| 216 | const supported = yield* Stream.toPull( | |
| 217 | registry.streamChanges.pipe( | |
| 218 | Stream.filter((snapshots) => snapshots[0]?.compatibilityAdvisory?.status === "supported"), | |
| 219 | ), | |
| 220 | ); | |
| 221 | const subscribed = yield* supported.pipe(Effect.forkScoped({ startImmediately: true })); | |
| 222 | yield* Deferred.succeed(release, undefined); | |
| 223 | const updated = (yield* Fiber.join(subscribed))[0]?.[0]; | |
| 224 | assert.strictEqual(updated?.compatibilityAdvisory?.status, "supported"); | |
| 225 | assert.strictEqual(updated?.status, "error"); | |
| 226 | assert.strictEqual(updated?.message, "Authentication failed"); | |
| 227 | }).pipe(Effect.provide(ProviderRegistryLive.pipe(Layer.provide(dependencies)))); | |
| 228 | }).pipe(Effect.scoped), | |
| 229 | ); | |
| 230 | ||
| 231 | it("recomputes latest-version compatibility without losing the underlying update advisory", () => { | |
| 232 | const snapshot: ServerProvider = { | |
| 233 | ...provider, | |
| 234 | version: "2.0.0", | |
| 235 | versionAdvisory: { | |
| 236 | status: "behind_latest", | |
| 237 | currentVersion: "2.0.0", | |
| 238 | latestVersion: "4.0.0", | |
| 239 | canUpdate: true, | |
| 240 | updateCommand: "npm install -g fixture@latest", | |
| 241 | checkedAt: provider.checkedAt, | |
| 242 | message: null, | |
| 243 | }, | |
| 244 | }; | |
| 245 | const blockedPolicy: ProviderCompatibilityPolicy = { | |
| 246 | ...policy, | |
| 247 | ranges: [...policy.ranges, { range: ">=3.0.0", status: "broken" }], | |
| 248 | }; | |
| 249 | const blocked = applyProviderCompatibility(snapshot, [blockedPolicy], []); | |
| 250 | assert.strictEqual(blocked.compatibilityAdvisory?.latestVersionStatus, "broken"); | |
| 251 | const relaxed = applyProviderCompatibility( | |
| 252 | blocked, | |
| 253 | [{ ...policy, ranges: [{ range: ">=2.0.0", status: "supported" }] }], | |
| 254 | [], | |
| 255 | ); | |
| 256 | assert.strictEqual(relaxed.compatibilityAdvisory?.latestVersionStatus, "supported"); | |
| 257 | assert.deepStrictEqual(relaxed.versionAdvisory, snapshot.versionAdvisory); | |
| 258 | }); |
apps/server/src/provider/providerCompatibility.ts created+119| ... | ... | @@ -0,0 +1,119 @@ |
| 1 | import { | |
| 2 | TrimmedNonEmptyString, | |
| 3 | ServerProviderCompatibilityStatus, | |
| 4 | type ProviderDriverKind, | |
| 5 | type ServerProvider, | |
| 6 | type ServerProviderCompatibilityAdvisory, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import { satisfiesSemverRange } from "@t3tools/shared/semver"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import packageJson from "../../package.json" with { type: "json" }; | |
| 11 | ||
| 12 | // Deliberately uses the shared CLI gate syntax: comparator groups joined by ||. | |
| 13 | // Release tags and prereleases remain unknown instead of matching stable ranges. | |
| 14 | const StableVersion = TrimmedNonEmptyString.pipe( | |
| 15 | Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))), | |
| 16 | ); | |
| 17 | const VersionRange = TrimmedNonEmptyString.pipe( | |
| 18 | Schema.check( | |
| 19 | Schema.makeFilter((value) => | |
| 20 | value.split("||").every((group) => { | |
| 21 | const tokens = group.trim().split(/\s+/); | |
| 22 | return tokens.every((token) => /^(?:\^|>=|>|<=|<|=)?v?\d+(?:\.\d+){0,2}$/.test(token)); | |
| 23 | }), | |
| 24 | ), | |
| 25 | ), | |
| 26 | ); | |
| 27 | const Policy = Schema.Struct({ | |
| 28 | driver: TrimmedNonEmptyString, | |
| 29 | t3CodeRange: VersionRange, | |
| 30 | recommendedRange: Schema.optionalKey(VersionRange), | |
| 31 | recommendedVersion: Schema.optionalKey(StableVersion), | |
| 32 | ranges: Schema.Array( | |
| 33 | Schema.Struct({ | |
| 34 | range: VersionRange, | |
| 35 | status: ServerProviderCompatibilityStatus, | |
| 36 | }), | |
| 37 | ), | |
| 38 | }); | |
| 39 | ||
| 40 | export const ProviderCompatibilityPolicy = Policy.pipe( | |
| 41 | Schema.check( | |
| 42 | Schema.makeFilter( | |
| 43 | (policy) => { | |
| 44 | const version = policy.recommendedVersion; | |
| 45 | if (version === undefined) return true; | |
| 46 | return ( | |
| 47 | (policy.recommendedRange === undefined || | |
| 48 | satisfiesSemverRange(version, policy.recommendedRange)) && | |
| 49 | policy.ranges.find((entry) => satisfiesSemverRange(version, entry.range))?.status === | |
| 50 | "supported" | |
| 51 | ); | |
| 52 | }, | |
| 53 | { expected: "a recommended version in a supported range" }, | |
| 54 | ), | |
| 55 | ), | |
| 56 | ); | |
| 57 | export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type; | |
| 58 | ||
| 59 | export function resolveProviderCompatibility( | |
| 60 | policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, | |
| 61 | driver: ProviderDriverKind, | |
| 62 | version: string | null, | |
| 63 | t3CodeVersion = packageJson.version, | |
| 64 | ): ServerProviderCompatibilityAdvisory | undefined { | |
| 65 | const policy = policies?.find( | |
| 66 | (entry) => entry.driver === driver && satisfiesSemverRange(t3CodeVersion, entry.t3CodeRange), | |
| 67 | ); | |
| 68 | if (!policy) return undefined; | |
| 69 | const stable = version?.replace(/^v/, ""); | |
| 70 | const status = | |
| 71 | stable && /^\d+\.\d+\.\d+$/.test(stable) | |
| 72 | ? (policy.ranges.find((entry) => satisfiesSemverRange(stable, entry.range))?.status ?? | |
| 73 | "unknown") | |
| 74 | : "unknown"; | |
| 75 | const message = | |
| 76 | status === "broken" | |
| 77 | ? "This provider version is known to be incompatible with this T3 Code release." | |
| 78 | : status === "unsupported" | |
| 79 | ? "This provider version is outside the supported range for this T3 Code release." | |
| 80 | : status === "graceful" | |
| 81 | ? "This provider version has limited compatibility with this T3 Code release." | |
| 82 | : null; | |
| 83 | const recommendedVersion = policy.recommendedVersion ?? null; | |
| 84 | const recommendedRange = policy.recommendedRange ?? null; | |
| 85 | const recommendation = recommendedVersion ?? recommendedRange; | |
| 86 | return { | |
| 87 | status, | |
| 88 | message: message && recommendation ? `${message} Use ${recommendation}.` : message, | |
| 89 | recommendedVersion, | |
| 90 | recommendedRange, | |
| 91 | }; | |
| 92 | } | |
| 93 | ||
| 94 | /** A remote policy replaces its matching bundled policy; omission keeps the bundle. */ | |
| 95 | export function applyProviderCompatibility( | |
| 96 | snapshot: ServerProvider, | |
| 97 | policies: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, | |
| 98 | fallback: ReadonlyArray<ProviderCompatibilityPolicy> | undefined, | |
| 99 | ): ServerProvider { | |
| 100 | const { compatibilityAdvisory: _previous, ...base } = snapshot; | |
| 101 | if (!snapshot.enabled || !snapshot.installed) return base; | |
| 102 | const advisory = | |
| 103 | resolveProviderCompatibility(policies, snapshot.driver, snapshot.version) ?? | |
| 104 | resolveProviderCompatibility(fallback, snapshot.driver, snapshot.version); | |
| 105 | const latestVersion = snapshot.versionAdvisory?.latestVersion; | |
| 106 | const latestAdvisory = latestVersion | |
| 107 | ? (resolveProviderCompatibility(policies, snapshot.driver, latestVersion) ?? | |
| 108 | resolveProviderCompatibility(fallback, snapshot.driver, latestVersion)) | |
| 109 | : undefined; | |
| 110 | return advisory | |
| 111 | ? { | |
| 112 | ...base, | |
| 113 | compatibilityAdvisory: { | |
| 114 | ...advisory, | |
| 115 | ...(latestAdvisory ? { latestVersionStatus: latestAdvisory.status } : {}), | |
| 116 | }, | |
| 117 | } | |
| 118 | : base; | |
| 119 | } |
apps/server/src/provider/providerMaintenance.test.ts+43| ... | ... | @@ -15,6 +15,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 15 | 15 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 16 | 16 | import { |
| 17 | 17 | createProviderVersionAdvisory, |
| 18 | makeTargetedProviderUpdateAction, | |
| 18 | 19 | enrichProviderSnapshotWithVersionAdvisory, |
| 19 | 20 | homebrewOwnershipFromCommandPath, |
| 20 | 21 | makeCachedProviderMaintenanceResolution, |
| ... | ... | @@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 786 | 787 | }), |
| 787 | 788 | ); |
| 788 | 789 | }); |
| 790 | ||
| 791 | it("pins only owned package-manager installs and preserves their execution context", () => { | |
| 792 | const capabilities = makeProviderMaintenanceCapabilities({ | |
| 793 | provider: driver("codex"), | |
| 794 | packageName: "@openai/codex", | |
| 795 | updateExecutable: "npm", | |
| 796 | updateLockKey: "npm-global:/opt/node", | |
| 797 | updateArgs: [ | |
| 798 | "install", | |
| 799 | "-g", | |
| 800 | "--prefix", | |
| 801 | "/opt/node", | |
| 802 | "--allow-scripts=@openai/codex", | |
| 803 | "@openai/codex@latest", | |
| 804 | ], | |
| 805 | env: { PATH: "/opt/node/bin" }, | |
| 806 | }); | |
| 807 | const pinned = makeTargetedProviderUpdateAction(capabilities, "2.0.0"); | |
| 808 | expect(pinned).toMatchObject({ | |
| 809 | executable: "npm", | |
| 810 | lockKey: capabilities.update?.lockKey, | |
| 811 | env: capabilities.update?.env, | |
| 812 | args: [ | |
| 813 | "install", | |
| 814 | "-g", | |
| 815 | "--prefix", | |
| 816 | "/opt/node", | |
| 817 | "--allow-scripts=@openai/codex", | |
| 818 | "@openai/codex@2.0.0", | |
| 819 | ], | |
| 820 | }); | |
| 821 | expect(pinned?.command).toContain("@openai/codex@2.0.0"); | |
| 822 | for (const lockKey of ["codex-native", "homebrew", "manual"]) { | |
| 823 | expect( | |
| 824 | makeTargetedProviderUpdateAction( | |
| 825 | { ...capabilities, update: { ...capabilities.update!, lockKey } }, | |
| 826 | "2.0.0", | |
| 827 | ), | |
| 828 | ).toBeNull(); | |
| 829 | } | |
| 830 | expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0; rm -rf /")).toBeNull(); | |
| 831 | }); |
apps/server/src/provider/providerMaintenance.ts+28| ... | ... | @@ -186,6 +186,33 @@ export function makeProviderMaintenanceCapabilities(input: { |
| 186 | 186 | }; |
| 187 | 187 | } |
| 188 | 188 | |
| 189 | /** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */ | |
| 190 | export function makeTargetedProviderUpdateAction( | |
| 191 | capabilities: ProviderMaintenanceCapabilities, | |
| 192 | version: string, | |
| 193 | ): ProviderMaintenanceCommandAction | null { | |
| 194 | if (!/^\d+\.\d+\.\d+$/.test(version)) return null; | |
| 195 | const update = capabilities.update; | |
| 196 | const packageName = capabilities.packageName; | |
| 197 | if (!update || !packageName) return null; | |
| 198 | if (!/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$)/.test(update.lockKey)) | |
| 199 | return null; | |
| 200 | const packageIndex = update.args.findIndex( | |
| 201 | (arg) => arg === `${packageName}@latest` || arg === packageName, | |
| 202 | ); | |
| 203 | if (packageIndex < 0) return null; | |
| 204 | const args = update.args.map((arg, index) => | |
| 205 | index === packageIndex ? `${packageName}@${version}` : arg, | |
| 206 | ); | |
| 207 | const previous = update.args[packageIndex]!; | |
| 208 | const commandIndex = update.command.lastIndexOf(previous); | |
| 209 | const command = | |
| 210 | commandIndex < 0 | |
| 211 | ? update.command | |
| 212 | : `${update.command.slice(0, commandIndex)}${packageName}@${version}${update.command.slice(commandIndex + previous.length)}`; | |
| 213 | return { ...update, args, command }; | |
| 214 | } | |
| 215 | ||
| 189 | 216 | export function makeManualOnlyProviderMaintenanceCapabilities(input: { |
| 190 | 217 | readonly provider: ProviderDriverKind; |
| 191 | 218 | readonly packageName: string | null; |
| ... | ... | @@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: { |
| 627 | 654 | latestVersion, |
| 628 | 655 | updateCommand: capabilities.update?.command ?? null, |
| 629 | 656 | canUpdate: capabilities.update !== null, |
| 657 | canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null, | |
| 630 | 658 | checkedAt: input.checkedAt ?? null, |
| 631 | 659 | message: advisory.message, |
| 632 | 660 | }; |
apps/server/src/provider/providerMaintenanceRunner.test.ts+101-2| ... | ... | @@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos |
| 21 | 21 | import { SpawnExecutableResolution } from "@t3tools/shared/shell"; |
| 22 | 22 | |
| 23 | 23 | import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts"; |
| 24 | import * as ModelManifest from "./ModelManifest.ts"; | |
| 24 | 25 | import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts"; |
| 25 | 26 | import { |
| 26 | 27 | makeProviderMaintenanceCapabilities, |
| ... | ... | @@ -208,13 +209,22 @@ function makeRegistry( |
| 208 | 209 | }); |
| 209 | 210 | } |
| 210 | 211 | |
| 211 | const makeTestRunner = (registry: ProviderRegistryShape) => | |
| 212 | const makeTestRunner = ( | |
| 213 | registry: ProviderRegistryShape, | |
| 214 | manifest = ModelManifest.BUNDLED_MODEL_MANIFEST, | |
| 215 | ) => | |
| 212 | 216 | Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( |
| 213 | 217 | Effect.provide( |
| 214 | 218 | ProviderMaintenanceRunner.layer.pipe( |
| 215 | 219 | Layer.provide( |
| 216 | 220 | Layer.mergeAll( |
| 217 | 221 | Layer.succeed(ProviderRegistry, registry), |
| 222 | Layer.succeed(ModelManifest.ModelManifest, { | |
| 223 | current: Effect.succeed(manifest), | |
| 224 | refresh: Effect.succeed(manifest), | |
| 225 | forceRefresh: Effect.succeed(manifest), | |
| 226 | refreshInBackground: Effect.void, | |
| 227 | }), | |
| 218 | 228 | // Fresh per runner so a version cached by one test cannot leak into another. |
| 219 | 229 | Layer.sync(ProviderVersionCache, () => new Map()), |
| 220 | 230 | ), |
| ... | ... | @@ -743,7 +753,7 @@ describe("providerMaintenanceRunner", () => { |
| 743 | 753 | Effect.provide( |
| 744 | 754 | Layer.mergeAll( |
| 745 | 755 | NonWindowsPlatform, |
| 746 | latestVersionHttpClient("0.0.0"), | |
| 756 | latestVersionHttpClient("2.0.0"), | |
| 747 | 757 | mockSpawnerLayer((_command, args) => { |
| 748 | 758 | calls.push(args.join(" ")); |
| 749 | 759 | if (calls.length === 1) { |
| ... | ... | @@ -911,3 +921,92 @@ describe("providerMaintenanceRunner", () => { |
| 911 | 921 | ); |
| 912 | 922 | }); |
| 913 | 923 | }); |
| 924 | ||
| 925 | it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => { | |
| 926 | const calls: string[] = []; | |
| 927 | const manifest: ModelManifest.ModelManifestData = { | |
| 928 | version: 1, | |
| 929 | currentModels: {}, | |
| 930 | compatibility: [ | |
| 931 | { | |
| 932 | driver: "codex", | |
| 933 | t3CodeRange: ">=0.0.42", | |
| 934 | recommendedVersion: "2.0.0", | |
| 935 | ranges: [ | |
| 936 | { range: "=2.0.0", status: "supported" }, | |
| 937 | { range: ">2.0.0", status: "broken" }, | |
| 938 | ], | |
| 939 | }, | |
| 940 | ], | |
| 941 | }; | |
| 942 | return Effect.gen(function* () { | |
| 943 | const { registry, providersRef } = yield* makeRegistry(); | |
| 944 | const pinnedCapabilities = makeProviderMaintenanceCapabilities({ | |
| 945 | provider: CODEX_DRIVER, | |
| 946 | packageName: "@openai/codex", | |
| 947 | updateExecutable: "npm", | |
| 948 | updateArgs: ["install", "-g", "@openai/codex@latest"], | |
| 949 | updateLockKey: "npm-global:/fixture", | |
| 950 | }); | |
| 951 | const updater = yield* makeTestRunner( | |
| 952 | { | |
| 953 | ...registry, | |
| 954 | getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), | |
| 955 | }, | |
| 956 | manifest, | |
| 957 | ); | |
| 958 | for (const targetVersion of [undefined, "", "1.0.0", "2.0.0; echo unsafe"]) { | |
| 959 | const result = yield* updater.updateProvider({ | |
| 960 | provider: CODEX_DRIVER, | |
| 961 | ...(targetVersion !== undefined ? { targetVersion } : {}), | |
| 962 | }); | |
| 963 | assert.strictEqual(result.providers[0]?.updateState?.status, "failed"); | |
| 964 | } | |
| 965 | assert.deepStrictEqual(calls, []); | |
| 966 | yield* Ref.update(providersRef, (providers) => | |
| 967 | providers.map((entry) => ({ ...entry, version: "2.0.0" })), | |
| 968 | ); | |
| 969 | const installed = yield* updater.updateProvider({ | |
| 970 | provider: CODEX_DRIVER, | |
| 971 | targetVersion: "2.0.0", | |
| 972 | }); | |
| 973 | assert.deepStrictEqual(calls, ["install -g @openai/codex@2.0.0"]); | |
| 974 | assert.strictEqual(installed.providers[0]?.updateState?.status, "succeeded"); | |
| 975 | yield* Ref.update(providersRef, (providers) => | |
| 976 | providers.map((entry) => ({ ...entry, version: "1.0.0" })), | |
| 977 | ); | |
| 978 | const unchanged = yield* updater.updateProvider({ | |
| 979 | provider: CODEX_DRIVER, | |
| 980 | targetVersion: "2.0.0", | |
| 981 | }); | |
| 982 | assert.strictEqual(unchanged.providers[0]?.updateState?.status, "unchanged"); | |
| 983 | const nativeUpdater = yield* makeTestRunner( | |
| 984 | { | |
| 985 | ...registry, | |
| 986 | getProviderMaintenanceCapabilitiesForInstance: () => | |
| 987 | Effect.succeed({ | |
| 988 | ...pinnedCapabilities, | |
| 989 | update: { ...pinnedCapabilities.update!, lockKey: "codex-native" }, | |
| 990 | }), | |
| 991 | }, | |
| 992 | manifest, | |
| 993 | ); | |
| 994 | const refused = yield* nativeUpdater.updateProvider({ | |
| 995 | provider: CODEX_DRIVER, | |
| 996 | targetVersion: "2.0.0", | |
| 997 | }); | |
| 998 | assert.strictEqual(refused.providers[0]?.updateState?.status, "failed"); | |
| 999 | assert.strictEqual(calls.length, 2); | |
| 1000 | }).pipe( | |
| 1001 | Effect.provide( | |
| 1002 | Layer.mergeAll( | |
| 1003 | NonWindowsPlatform, | |
| 1004 | latestVersionHttpClient("3.0.0"), | |
| 1005 | mockSpawnerLayer((_command, args) => { | |
| 1006 | calls.push(args.join(" ")); | |
| 1007 | return { stdout: "installed" }; | |
| 1008 | }), | |
| 1009 | ), | |
| 1010 | ), | |
| 1011 | ); | |
| 1012 | }); |
apps/server/src/provider/providerMaintenanceRunner.ts+54-5| ... | ... | @@ -21,10 +21,14 @@ import * as Schema from "effect/Schema"; |
| 21 | 21 | import { HttpClient } from "effect/unstable/http"; |
| 22 | 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 23 | 23 | |
| 24 | import * as ModelManifest from "./ModelManifest.ts"; | |
| 25 | import { resolveProviderCompatibility } from "./providerCompatibility.ts"; | |
| 24 | 26 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; |
| 25 | 27 | import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; |
| 26 | 28 | import { |
| 27 | 29 | enrichProviderSnapshotWithVersionAdvisory, |
| 30 | makeTargetedProviderUpdateAction, | |
| 31 | resolveLatestProviderVersion, | |
| 28 | 32 | type ProviderMaintenanceCommandAction, |
| 29 | 33 | ProviderVersionCache, |
| 30 | 34 | } from "./providerMaintenance.ts"; |
| ... | ... | @@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape { |
| 51 | 55 | | { |
| 52 | 56 | readonly provider: ProviderDriverKind; |
| 53 | 57 | readonly instanceId?: ProviderInstanceId | undefined; |
| 58 | readonly targetVersion?: string | undefined; | |
| 54 | 59 | }, |
| 55 | 60 | ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>; |
| 56 | 61 | } |
| ... | ... | @@ -214,6 +219,7 @@ function makeUpdateState(input: { |
| 214 | 219 | /** @public Service construction is part of the canonical Effect module API. */ |
| 215 | 220 | export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 216 | 221 | const providerRegistry = yield* ProviderRegistry; |
| 222 | const manifestService = yield* ModelManifest.ModelManifest; | |
| 217 | 223 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 218 | 224 | const httpClient = yield* HttpClient.HttpClient; |
| 219 | 225 | const versionCache = yield* ProviderVersionCache; |
| ... | ... | @@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 310 | 316 | typeof target === "string" |
| 311 | 317 | ? defaultInstanceIdForDriver(provider) |
| 312 | 318 | : (target.instanceId ?? defaultInstanceIdForDriver(provider)); |
| 319 | const targetVersion = typeof target === "string" ? undefined : target.targetVersion; | |
| 313 | 320 | const targetKey = `instance:${instanceId}`; |
| 314 | 321 | const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( |
| 315 | 322 | instanceId, |
| ... | ... | @@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 376 | 383 | ); |
| 377 | 384 | } |
| 378 | 385 | |
| 379 | const result = yield* runMaintenanceCommand(fresh.update); | |
| 386 | const manifest = yield* manifestService.current; | |
| 387 | const candidateVersion = | |
| 388 | targetVersion ?? | |
| 389 | (yield* resolveLatestProviderVersion(fresh).pipe( | |
| 390 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 391 | Effect.provideService(ProviderVersionCache, versionCache), | |
| 392 | )); | |
| 393 | const advisory = | |
| 394 | resolveProviderCompatibility(manifest.compatibility, provider, candidateVersion) ?? | |
| 395 | resolveProviderCompatibility( | |
| 396 | ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility, | |
| 397 | provider, | |
| 398 | candidateVersion, | |
| 399 | ); | |
| 400 | const command = | |
| 401 | targetVersion !== undefined | |
| 402 | ? makeTargetedProviderUpdateAction(fresh, targetVersion) | |
| 403 | : fresh.update; | |
| 404 | const rejected = | |
| 405 | targetVersion !== undefined | |
| 406 | ? !command || | |
| 407 | advisory?.recommendedVersion !== targetVersion || | |
| 408 | advisory.status !== "supported" | |
| 409 | : advisory?.status === "broken" || advisory?.status === "unsupported"; | |
| 410 | if (rejected || !command) { | |
| 411 | return yield* finish( | |
| 412 | makeUpdateState({ | |
| 413 | status: "failed", | |
| 414 | startedAt, | |
| 415 | finishedAt: yield* nowIso, | |
| 416 | message: | |
| 417 | targetVersion !== undefined | |
| 418 | ? "This version is no longer recommended or this installer cannot install a specific version. Refresh provider settings." | |
| 419 | : "The latest provider version is incompatible with this T3 Code release. Review provider settings.", | |
| 420 | }), | |
| 421 | ); | |
| 422 | } | |
| 423 | const result = yield* runMaintenanceCommand(command); | |
| 380 | 424 | const finishedAt = yield* nowIso; |
| 381 | 425 | if (result.timedOut || result.exitCode !== 0) { |
| 382 | 426 | return yield* finish( |
| ... | ... | @@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { |
| 407 | 451 | // Cursor's `about` probe can fail transiently on a healthy binary. |
| 408 | 452 | const couldNotVerify = |
| 409 | 453 | verifiedProviders.length === 0 || |
| 410 | verifiedProviders.some((verifiedProvider) => !isStillInstalled(verifiedProvider)); | |
| 411 | const stillOutdated = verifiedProviders.some((verifiedProvider) => | |
| 412 | isOutdatedProvider(verifiedProvider), | |
| 413 | ); | |
| 454 | verifiedProviders.some( | |
| 455 | (verifiedProvider) => | |
| 456 | !isStillInstalled(verifiedProvider) || | |
| 457 | (targetVersion !== undefined && | |
| 458 | verifiedProvider.version?.replace(/^v/, "") !== targetVersion), | |
| 459 | ); | |
| 460 | const stillOutdated = | |
| 461 | targetVersion === undefined && | |
| 462 | verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider)); | |
| 414 | 463 | return yield* finish( |
| 415 | 464 | makeUpdateState({ |
| 416 | 465 | status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded", |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+20| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | getProviderUpdateSidebarPillView, |
| 25 | 25 | hasOneClickUpdateProviderCandidate, |
| 26 | 26 | isProviderUpdateCandidate, |
| 27 | isProviderSettingsUpdateCandidate, | |
| 27 | 28 | isTerminalProviderUpdatePhase, |
| 28 | 29 | localEnvironmentUpdateNotificationKey, |
| 29 | 30 | providerUpdateNotificationKey, |
| ... | ... | @@ -1038,3 +1039,22 @@ describe("provider update launch notification logic", () => { |
| 1038 | 1039 | }); |
| 1039 | 1040 | }); |
| 1040 | 1041 | }); |
| 1042 | ||
| 1043 | it("does not offer incompatible latest versions and restores suggestions after policy relaxation", () => { | |
| 1044 | const installed = provider({ driver: driver("codex") }); | |
| 1045 | for (const latestVersionStatus of ["broken", "unsupported", "supported", "unknown"] as const) { | |
| 1046 | const snapshot: ServerProvider = { | |
| 1047 | ...installed, | |
| 1048 | compatibilityAdvisory: { | |
| 1049 | status: "supported", | |
| 1050 | latestVersionStatus, | |
| 1051 | message: null, | |
| 1052 | recommendedRange: null, | |
| 1053 | recommendedVersion: null, | |
| 1054 | }, | |
| 1055 | }; | |
| 1056 | const expected = latestVersionStatus === "supported" || latestVersionStatus === "unknown"; | |
| 1057 | expect(isProviderUpdateCandidate(snapshot)).toBe(expected); | |
| 1058 | expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected); | |
| 1059 | } | |
| 1060 | }); |
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+4| ... | ... | @@ -141,6 +141,8 @@ export function isProviderUpdateCandidate( |
| 141 | 141 | ): provider is ProviderUpdateCandidate { |
| 142 | 142 | return ( |
| 143 | 143 | provider.enabled && |
| 144 | provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && | |
| 145 | provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && | |
| 144 | 146 | provider.versionAdvisory?.status === "behind_latest" && |
| 145 | 147 | provider.versionAdvisory.latestVersion !== null |
| 146 | 148 | ); |
| ... | ... | @@ -161,6 +163,8 @@ export function isProviderSettingsUpdateCandidate( |
| 161 | 163 | ): provider is ProviderSettingsUpdateCandidate { |
| 162 | 164 | return ( |
| 163 | 165 | provider.enabled && |
| 166 | provider.compatibilityAdvisory?.latestVersionStatus !== "broken" && | |
| 167 | provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" && | |
| 164 | 168 | provider.versionAdvisory?.status === "behind_latest" && |
| 165 | 169 | provider.versionAdvisory.canUpdate === true && |
| 166 | 170 | provider.versionAdvisory.updateCommand !== null |
apps/web/src/components/chat/ProviderStatusBanner.test.ts created+72| ... | ... | @@ -0,0 +1,72 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | |
| 3 | import { | |
| 4 | getProviderStatusBannerKey, | |
| 5 | getProviderStatusMessage, | |
| 6 | shouldShowProviderStatusBanner, | |
| 7 | } from "./ProviderStatusBanner"; | |
| 8 | ||
| 9 | const provider: ServerProvider = { | |
| 10 | instanceId: ProviderInstanceId.make("codex-work"), | |
| 11 | driver: ProviderDriverKind.make("codex"), | |
| 12 | enabled: true, | |
| 13 | installed: true, | |
| 14 | version: "1.0.0", | |
| 15 | status: "ready", | |
| 16 | auth: { status: "authenticated" }, | |
| 17 | checkedAt: "2026-09-22T00:00:00Z", | |
| 18 | models: [], | |
| 19 | skills: [], | |
| 20 | slashCommands: [], | |
| 21 | compatibilityAdvisory: { | |
| 22 | status: "unsupported", | |
| 23 | message: "Unsupported version. Use 2.0.0.", | |
| 24 | recommendedVersion: "2.0.0", | |
| 25 | recommendedRange: null, | |
| 26 | }, | |
| 27 | }; | |
| 28 | ||
| 29 | describe("compatibility banners", () => { | |
| 30 | it("shows and dismisses a warning on a healthy provider, then clears it after policy relaxation", () => { | |
| 31 | expect(shouldShowProviderStatusBanner(provider, null)).toBe(true); | |
| 32 | expect(shouldShowProviderStatusBanner(provider, getProviderStatusBannerKey(provider))).toBe( | |
| 33 | false, | |
| 34 | ); | |
| 35 | expect( | |
| 36 | shouldShowProviderStatusBanner( | |
| 37 | { ...provider, version: "1.0.1" }, | |
| 38 | getProviderStatusBannerKey(provider), | |
| 39 | ), | |
| 40 | ).toBe(true); | |
| 41 | const relaxed: ServerProvider = { | |
| 42 | ...provider, | |
| 43 | compatibilityAdvisory: { | |
| 44 | ...provider.compatibilityAdvisory!, | |
| 45 | status: "supported", | |
| 46 | message: null, | |
| 47 | }, | |
| 48 | }; | |
| 49 | expect(getProviderStatusBannerKey(relaxed)).toBeNull(); | |
| 50 | expect(getProviderStatusBannerKey({ ...provider, status: "disabled" })).toBeNull(); | |
| 51 | expect( | |
| 52 | getProviderStatusBannerKey({ | |
| 53 | ...provider, | |
| 54 | compatibilityAdvisory: { ...provider.compatibilityAdvisory!, status: "graceful" }, | |
| 55 | }), | |
| 56 | ).toBeNull(); | |
| 57 | }); | |
| 58 | ||
| 59 | it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => { | |
| 60 | const unauthenticated: ServerProvider = { | |
| 61 | ...provider, | |
| 62 | status: "error", | |
| 63 | auth: { status: "unauthenticated" }, | |
| 64 | }; | |
| 65 | expect(getProviderStatusMessage(unauthenticated)).toBe( | |
| 66 | "Sign in via the CLI to authenticate again.", | |
| 67 | ); | |
| 68 | expect(getProviderStatusMessage({ ...unauthenticated, message: "Credentials expired" })).toBe( | |
| 69 | "Credentials expired", | |
| 70 | ); | |
| 71 | }); | |
| 72 | }); |
apps/web/src/components/chat/ProviderStatusBanner.tsx+23-4| ... | ... | @@ -6,8 +6,22 @@ import { Button, InlineButton } from "../ui/button"; |
| 6 | 6 | import { formatProviderDriverKindLabel } from "../../providerModels"; |
| 7 | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 8 | 8 | |
| 9 | /** Unsupported and broken versions fail mid-turn, so they warn even when ready. */ | |
| 10 | function getIncompatibleVersion(status: ServerProvider) { | |
| 11 | const compatibility = status.compatibilityAdvisory; | |
| 12 | return compatibility?.status === "unsupported" || compatibility?.status === "broken" | |
| 13 | ? compatibility | |
| 14 | : null; | |
| 15 | } | |
| 16 | ||
| 9 | 17 | export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { |
| 10 | if (!status || status.status === "ready" || status.status === "disabled") return null; | |
| 18 | if (!status || status.status === "disabled") return null; | |
| 19 | if (status.status === "ready") { | |
| 20 | const incompatible = getIncompatibleVersion(status); | |
| 21 | return incompatible | |
| 22 | ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000") | |
| 23 | : null; | |
| 24 | } | |
| 11 | 25 | // Antigravity checks saved credentials when a session starts. Its local |
| 12 | 26 | // health check leaves auth unknown after a restart, which is not a failure. |
| 13 | 27 | if ( |
| ... | ... | @@ -76,15 +90,20 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ |
| 76 | 90 | |
| 77 | 91 | const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); |
| 78 | 92 | const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; |
| 93 | const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null; | |
| 79 | 94 | const title = isUnauthenticated |
| 80 | 95 | ? `${providerName} is unauthenticated` |
| 81 | : `${providerName} provider status`; | |
| 82 | const message = getProviderStatusMessage(status); | |
| 96 | : incompatible | |
| 97 | ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}` | |
| 98 | : `${providerName} provider status`; | |
| 99 | const message = incompatible?.message ?? getProviderStatusMessage(status); | |
| 100 | const isWarning = status.status === "warning" || incompatible !== null; | |
| 83 | 101 | |
| 84 | 102 | return ( |
| 85 | 103 | <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3"> |
| 86 | 104 | <Alert |
| 87 | variant={status.status === "warning" ? "warning" : "error"} | |
| 105 | variant={isWarning ? "warning" : "error"} | |
| 106 | role={incompatible && incompatible.status !== "broken" ? "status" : "alert"} | |
| 88 | 107 | surface="glass" |
| 89 | 108 | controlAlignment="first-line" |
| 90 | 109 | > |
apps/web/src/components/settings/ProviderInstanceCard.tsx+108-33| ... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 | import { Spinner } from "~/components/ui/spinner"; |
| 4 | 4 | |
| 5 | 5 | import { |
| 6 | AlertTriangleIcon, | |
| 6 | 7 | ArrowUpCircleIcon, |
| 7 | 8 | CopyIcon, |
| 8 | 9 | DownloadIcon, |
| ... | ... | @@ -14,7 +15,7 @@ import { |
| 14 | 15 | } from "lucide-react"; |
| 15 | 16 | import * as Arr from "effect/Array"; |
| 16 | 17 | import * as Result from "effect/Result"; |
| 17 | import { useEffect, useRef, useState, type ReactNode } from "react"; | |
| 18 | import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; | |
| 18 | 19 | import { |
| 19 | 20 | isProviderDriverKind, |
| 20 | 21 | resolveProviderInstanceEnabled, |
| ... | ... | @@ -58,6 +59,22 @@ import { |
| 58 | 59 | |
| 59 | 60 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 60 | 61 | |
| 62 | function ProviderStatusDiagnostic({ | |
| 63 | detail, | |
| 64 | children, | |
| 65 | }: { | |
| 66 | detail: string | null; | |
| 67 | children: ReactElement; | |
| 68 | }) { | |
| 69 | if (!detail) return children; | |
| 70 | return ( | |
| 71 | <Tooltip> | |
| 72 | <TooltipTrigger render={children} /> | |
| 73 | <TooltipPopup side="top">{detail}</TooltipPopup> | |
| 74 | </Tooltip> | |
| 75 | ); | |
| 76 | } | |
| 77 | ||
| 61 | 78 | let environmentVariableDraftId = 0; |
| 62 | 79 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 63 | 80 | |
| ... | ... | @@ -377,6 +394,7 @@ interface ProviderInstanceCardProps { |
| 377 | 394 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; |
| 378 | 395 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; |
| 379 | 396 | readonly onRunUpdate?: (() => void) | undefined; |
| 397 | readonly onInstallRecommended?: (() => void) | undefined; | |
| 380 | 398 | readonly isUpdating?: boolean | undefined; |
| 381 | 399 | } |
| 382 | 400 | |
| ... | ... | @@ -419,9 +437,11 @@ export function ProviderInstanceCard({ |
| 419 | 437 | onFavoriteModelsChange, |
| 420 | 438 | onModelOrderChange, |
| 421 | 439 | onRunUpdate, |
| 440 | onInstallRecommended, | |
| 422 | 441 | isUpdating = false, |
| 423 | 442 | }: ProviderInstanceCardProps) { |
| 424 | 443 | const enabled = resolveProviderInstanceEnabled(instance); |
| 444 | const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; | |
| 425 | 445 | // A locally disabled provider reads "Disabled" with a muted dot even if its |
| 426 | 446 | // last server status is stale. Enabled providers use the server status. |
| 427 | 447 | const statusKey: ProviderStatusKey = enabled |
| ... | ... | @@ -438,8 +458,18 @@ export function ProviderInstanceCard({ |
| 438 | 458 | ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) |
| 439 | 459 | : null; |
| 440 | 460 | const versionLabel = getProviderVersionLabel(liveProvider?.version); |
| 441 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); | |
| 461 | const versionAdvisory = getProviderVersionAdvisoryPresentation( | |
| 462 | liveProvider?.versionAdvisory, | |
| 463 | liveProvider?.compatibilityAdvisory, | |
| 464 | enabled, | |
| 465 | ); | |
| 442 | 466 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| 467 | const hasCompatibilityWarning = | |
| 468 | compatibility !== undefined && | |
| 469 | compatibility.status !== "supported" && | |
| 470 | compatibility.status !== "unknown"; | |
| 471 | const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon; | |
| 472 | const onRunVersionAction = versionAdvisory?.targetVersion ? onInstallRecommended : onRunUpdate; | |
| 443 | 473 | const FallbackIconComponent = driverOption?.icon; |
| 444 | 474 | const displayName = |
| 445 | 475 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); |
| ... | ... | @@ -568,10 +598,16 @@ export function ProviderInstanceCard({ |
| 568 | 598 | statusKey === "warning" || statusKey === "error" ? ( |
| 569 | 599 | <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden /> |
| 570 | 600 | ) : null; |
| 571 | // Trouble states carry the server's explanation (a failed probe, a shadow | |
| 572 | // home entry that is not a symlink, a missing binary). Show it wherever the | |
| 573 | // headline shows so the user can act without opening the editor. | |
| 574 | 601 | const needsAttention = statusKey === "warning" || statusKey === "error"; |
| 602 | const statusDiagnostic = hasCompatibilityWarning && needsAttention ? summary.detail : null; | |
| 603 | // Keep compatibility copy compact; the version popover carries the explanation. | |
| 604 | const inlineStatusDetail = hasCompatibilityWarning | |
| 605 | ? compatibility?.status === "broken" | |
| 606 | ? "Incompatible" | |
| 607 | : compatibility?.status === "unsupported" | |
| 608 | ? "Unsupported" | |
| 609 | : "Limited support" | |
| 610 | : summary.detail; | |
| 575 | 611 | const editorStatusNode = |
| 576 | 612 | isAuthenticated && authEmail ? ( |
| 577 | 613 | <> |
| ... | ... | @@ -579,16 +615,16 @@ export function ProviderInstanceCard({ |
| 579 | 615 | <span>Authenticated as</span> |
| 580 | 616 | <ProviderAuthEmail email={authEmail} /> |
| 581 | 617 | {authLabel ? <span>· {authLabel}</span> : null} |
| 582 | {summary.detail ? ( | |
| 583 | <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span> | |
| 618 | {inlineStatusDetail ? ( | |
| 619 | <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span> | |
| 584 | 620 | ) : null} |
| 585 | 621 | </> |
| 586 | 622 | ) : ( |
| 587 | 623 | <> |
| 588 | 624 | {statusDotNode} |
| 589 | 625 | <span>{summary.headline}</span> |
| 590 | {summary.detail ? ( | |
| 591 | <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span> | |
| 626 | {inlineStatusDetail ? ( | |
| 627 | <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span> | |
| 592 | 628 | ) : null} |
| 593 | 629 | </> |
| 594 | 630 | ); |
| ... | ... | @@ -629,7 +665,23 @@ export function ProviderInstanceCard({ |
| 629 | 665 | </code> |
| 630 | 666 | ) : null} |
| 631 | 667 | {versionAdvisory ? ( |
| 632 | updateCommand ? ( | |
| 668 | hasCompatibilityWarning ? ( | |
| 669 | <Tooltip> | |
| 670 | <TooltipTrigger | |
| 671 | render={ | |
| 672 | <span | |
| 673 | tabIndex={0} | |
| 674 | role="img" | |
| 675 | aria-label={versionAdvisory.title} | |
| 676 | className="pointer-events-auto relative inline-flex shrink-0 text-warning" | |
| 677 | > | |
| 678 | <VersionAdvisoryIcon className="size-3.5" /> | |
| 679 | </span> | |
| 680 | } | |
| 681 | /> | |
| 682 | <TooltipPopup side="top">{versionAdvisory.detail}</TooltipPopup> | |
| 683 | </Tooltip> | |
| 684 | ) : updateCommand ? ( | |
| 633 | 685 | <Tooltip> |
| 634 | 686 | <TooltipTrigger |
| 635 | 687 | render={ |
| ... | ... | @@ -660,10 +712,15 @@ export function ProviderInstanceCard({ |
| 660 | 712 | {statusDotNode ? ( |
| 661 | 713 | <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span> |
| 662 | 714 | ) : null} |
| 663 | <span className="line-clamp-2 [overflow-wrap:anywhere]"> | |
| 664 | {summary.headline} | |
| 665 | {needsAttention && summary.detail ? ` · ${summary.detail}` : null} | |
| 666 | </span> | |
| 715 | <ProviderStatusDiagnostic detail={statusDiagnostic}> | |
| 716 | <span | |
| 717 | tabIndex={statusDiagnostic ? 0 : undefined} | |
| 718 | className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]" | |
| 719 | > | |
| 720 | {summary.headline} | |
| 721 | {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} | |
| 722 | </span> | |
| 723 | </ProviderStatusDiagnostic> | |
| 667 | 724 | </span> |
| 668 | 725 | </span> |
| 669 | 726 | </div> |
| ... | ... | @@ -694,25 +751,32 @@ export function ProviderInstanceCard({ |
| 694 | 751 | > |
| 695 | 752 | {versionAdvisory ? ( |
| 696 | 753 | <Popover> |
| 697 | <PopoverTrigger | |
| 698 | render={ | |
| 699 | <Button | |
| 700 | type="button" | |
| 701 | size="icon-xs" | |
| 702 | variant="ghost-muted" | |
| 703 | aria-label="Update available — view details" | |
| 704 | > | |
| 705 | <ArrowUpCircleIcon | |
| 706 | className={cn(versionAdvisory.emphasis === "strong" && "text-warning")} | |
| 754 | <Tooltip> | |
| 755 | <TooltipTrigger | |
| 756 | render={ | |
| 757 | <PopoverTrigger | |
| 758 | render={ | |
| 759 | <Button | |
| 760 | type="button" | |
| 761 | size="icon-xs" | |
| 762 | variant="ghost-muted" | |
| 763 | aria-label={`${versionAdvisory.title} — view details`} | |
| 764 | > | |
| 765 | <VersionAdvisoryIcon | |
| 766 | className={cn(hasCompatibilityWarning && "text-warning")} | |
| 767 | /> | |
| 768 | </Button> | |
| 769 | } | |
| 707 | 770 | /> |
| 708 | </Button> | |
| 709 | } | |
| 710 | /> | |
| 771 | } | |
| 772 | /> | |
| 773 | <TooltipPopup side="top">{versionAdvisory.title}</TooltipPopup> | |
| 774 | </Tooltip> | |
| 711 | 775 | <PopoverPopup side="bottom" align="end" width="md"> |
| 712 | 776 | <div className="grid min-w-0 gap-3"> |
| 713 | 777 | <div className="grid gap-0.5"> |
| 714 | 778 | <p className="text-[13px] font-semibold leading-tight text-foreground"> |
| 715 | Update available | |
| 779 | {versionAdvisory.title} | |
| 716 | 780 | </p> |
| 717 | 781 | <p |
| 718 | 782 | className={cn( |
| ... | ... | @@ -725,20 +789,24 @@ export function ProviderInstanceCard({ |
| 725 | 789 | {versionAdvisory.detail} |
| 726 | 790 | </p> |
| 727 | 791 | </div> |
| 728 | {onRunUpdate ? ( | |
| 792 | {onRunVersionAction ? ( | |
| 729 | 793 | <Button |
| 730 | 794 | type="button" |
| 731 | 795 | size="xs" |
| 732 | 796 | variant="outline" |
| 733 | 797 | className="w-full" |
| 734 | 798 | disabled={isUpdating} |
| 735 | onClick={onRunUpdate} | |
| 799 | onClick={onRunVersionAction} | |
| 736 | 800 | > |
| 737 | 801 | {isUpdating ? <Spinner /> : <DownloadIcon />} |
| 738 | {isUpdating ? "Updating" : "Update now"} | |
| 802 | {isUpdating | |
| 803 | ? "Updating" | |
| 804 | : versionAdvisory.targetVersion | |
| 805 | ? `Install ${getProviderVersionLabel(versionAdvisory.targetVersion)}` | |
| 806 | : "Update now"} | |
| 739 | 807 | </Button> |
| 740 | 808 | ) : null} |
| 741 | {onRunUpdate && updateCommand ? ( | |
| 809 | {onRunVersionAction && updateCommand ? ( | |
| 742 | 810 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> |
| 743 | 811 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| 744 | 812 | or, update manually using |
| ... | ... | @@ -798,7 +866,14 @@ export function ProviderInstanceCard({ |
| 798 | 866 | <SettingsRow |
| 799 | 867 | title="Display name" |
| 800 | 868 | status={ |
| 801 | <div className="flex min-w-0 flex-wrap items-center gap-x-1.5">{editorStatusNode}</div> | |
| 869 | <ProviderStatusDiagnostic detail={statusDiagnostic}> | |
| 870 | <div | |
| 871 | tabIndex={statusDiagnostic ? 0 : undefined} | |
| 872 | className="flex min-w-0 flex-wrap items-center gap-x-1.5" | |
| 873 | > | |
| 874 | {editorStatusNode} | |
| 875 | </div> | |
| 876 | </ProviderStatusDiagnostic> | |
| 802 | 877 | } |
| 803 | 878 | control={ |
| 804 | 879 | <div |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+21-7| ... | ... | @@ -649,7 +649,10 @@ export function EnvironmentProviderSettings({ |
| 649 | 649 | }, [environmentId, refreshServerProviders]); |
| 650 | 650 | |
| 651 | 651 | const runProviderUpdate = useCallback( |
| 652 | async (candidate: ProviderSettingsUpdateCandidate) => { | |
| 652 | async ( | |
| 653 | candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">, | |
| 654 | targetVersion?: string, | |
| 655 | ) => { | |
| 653 | 656 | // Ref-based re-entry guard, mirroring refreshProviders: a state updater |
| 654 | 657 | // may run after this function returns, so it cannot gate the dispatch. |
| 655 | 658 | if (updatingInstanceIdsRef.current.has(candidate.instanceId)) { |
| ... | ... | @@ -663,6 +666,7 @@ export function EnvironmentProviderSettings({ |
| 663 | 666 | input: { |
| 664 | 667 | provider: candidate.driver, |
| 665 | 668 | instanceId: candidate.instanceId, |
| 669 | ...(targetVersion ? { targetVersion } : {}), | |
| 666 | 670 | }, |
| 667 | 671 | }); |
| 668 | 672 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| ... | ... | @@ -887,9 +891,8 @@ export function EnvironmentProviderSettings({ |
| 887 | 891 | ); |
| 888 | 892 | const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId); |
| 889 | 893 | const isInstanceUpdateRunning = |
| 890 | updateCandidate !== undefined && | |
| 891 | (updatingProviderInstanceIds.has(updateCandidate.instanceId) || | |
| 892 | isProviderUpdateActive(updateCandidate)); | |
| 894 | updatingProviderInstanceIds.has(row.instanceId) || | |
| 895 | (liveProvider !== undefined && isProviderUpdateActive(liveProvider)); | |
| 893 | 896 | const showInlineUpdateButton = updateCandidate !== undefined; |
| 894 | 897 | const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning; |
| 895 | 898 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { |
| ... | ... | @@ -971,6 +974,19 @@ export function EnvironmentProviderSettings({ |
| 971 | 974 | modelOrder, |
| 972 | 975 | }) |
| 973 | 976 | } |
| 977 | onInstallRecommended={ | |
| 978 | mode === "editor" && | |
| 979 | liveProvider?.compatibilityAdvisory?.message && | |
| 980 | liveProvider.compatibilityAdvisory.recommendedVersion && | |
| 981 | liveProvider.versionAdvisory?.canInstallVersion | |
| 982 | ? () => { | |
| 983 | void runProviderUpdate( | |
| 984 | liveProvider, | |
| 985 | liveProvider.compatibilityAdvisory?.recommendedVersion ?? undefined, | |
| 986 | ); | |
| 987 | } | |
| 988 | : undefined | |
| 989 | } | |
| 974 | 990 | onRunUpdate={ |
| 975 | 991 | mode === "editor" && showInlineUpdateButton && updateCandidate |
| 976 | 992 | ? () => { |
| ... | ... | @@ -978,9 +994,7 @@ export function EnvironmentProviderSettings({ |
| 978 | 994 | } |
| 979 | 995 | : undefined |
| 980 | 996 | } |
| 981 | isUpdating={ | |
| 982 | mode === "editor" && showInlineUpdateButton ? isInstanceUpdateRunning : undefined | |
| 983 | } | |
| 997 | isUpdating={mode === "editor" ? isInstanceUpdateRunning : undefined} | |
| 984 | 998 | /> |
| 985 | 999 | ); |
| 986 | 1000 | }; |
apps/web/src/components/settings/providerStatus.test.ts+71-1| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { getProviderSummary } from "./providerStatus"; | |
| 4 | import { getProviderSummary, getProviderVersionAdvisoryPresentation } from "./providerStatus"; | |
| 5 | 5 | |
| 6 | 6 | const provider: ServerProvider = { |
| 7 | 7 | instanceId: ProviderInstanceId.make("codex"), |
| ... | ... | @@ -69,3 +69,73 @@ describe("getProviderSummary", () => { |
| 69 | 69 | expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); |
| 70 | 70 | }); |
| 71 | 71 | }); |
| 72 | ||
| 73 | it("does not suggest copying a command that installs an incompatible latest version", () => { | |
| 74 | const advisory = { | |
| 75 | status: "behind_latest" as const, | |
| 76 | currentVersion: "1.0.0", | |
| 77 | latestVersion: "2.0.0", | |
| 78 | updateCommand: "npm install -g fixture@latest", | |
| 79 | canUpdate: true, | |
| 80 | checkedAt: provider.checkedAt, | |
| 81 | message: null, | |
| 82 | }; | |
| 83 | const compatibility = { | |
| 84 | status: "supported" as const, | |
| 85 | latestVersionStatus: "broken" as const, | |
| 86 | message: null, | |
| 87 | recommendedRange: null, | |
| 88 | recommendedVersion: null, | |
| 89 | }; | |
| 90 | expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toBeNull(); | |
| 91 | expect( | |
| 92 | getProviderVersionAdvisoryPresentation(advisory, { ...compatibility, status: "broken" }, false), | |
| 93 | ).toBeNull(); | |
| 94 | expect( | |
| 95 | getProviderVersionAdvisoryPresentation(advisory, { | |
| 96 | ...compatibility, | |
| 97 | latestVersionStatus: "supported", | |
| 98 | }), | |
| 99 | ).not.toBeNull(); | |
| 100 | }); | |
| 101 | ||
| 102 | it("shows compatibility in the version popover even when the installed version is current", () => { | |
| 103 | const advisory = { | |
| 104 | status: "current" as const, | |
| 105 | currentVersion: "2.0.0", | |
| 106 | latestVersion: "2.0.0", | |
| 107 | updateCommand: "npm install -g fixture@latest", | |
| 108 | canUpdate: true, | |
| 109 | checkedAt: provider.checkedAt, | |
| 110 | message: null, | |
| 111 | }; | |
| 112 | const compatibility = { | |
| 113 | status: "broken" as const, | |
| 114 | latestVersionStatus: "broken" as const, | |
| 115 | message: "This version drops turns. Use 1.9.0.", | |
| 116 | recommendedRange: null, | |
| 117 | recommendedVersion: "1.9.0", | |
| 118 | }; | |
| 119 | expect(getProviderVersionAdvisoryPresentation(advisory, compatibility)).toEqual({ | |
| 120 | title: "Known broken version", | |
| 121 | detail: compatibility.message, | |
| 122 | updateCommand: null, | |
| 123 | emphasis: "strong", | |
| 124 | targetVersion: "1.9.0", | |
| 125 | }); | |
| 126 | expect( | |
| 127 | getProviderVersionAdvisoryPresentation(undefined, { | |
| 128 | ...compatibility, | |
| 129 | status: "graceful", | |
| 130 | recommendedVersion: null, | |
| 131 | recommendedRange: ">=2.1.0", | |
| 132 | message: null, | |
| 133 | }), | |
| 134 | ).toEqual({ | |
| 135 | title: "Limited support", | |
| 136 | detail: "Use >=2.1.0 for full support.", | |
| 137 | updateCommand: null, | |
| 138 | emphasis: "normal", | |
| 139 | targetVersion: null, | |
| 140 | }); | |
| 141 | }); |
apps/web/src/components/settings/providerStatus.ts+47-2| ... | ... | @@ -1,4 +1,8 @@ |
| 1 | import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; | |
| 1 | import type { | |
| 2 | ServerProvider, | |
| 3 | ServerProviderVersionAdvisory, | |
| 4 | ServerProviderCompatibilityAdvisory, | |
| 5 | } from "@t3tools/contracts"; | |
| 2 | 6 | |
| 3 | 7 | /** |
| 4 | 8 | * Visual treatment for each server-reported provider status. Centralized so |
| ... | ... | @@ -99,14 +103,53 @@ export function getProviderVersionLabel(version: string | null | undefined) { |
| 99 | 103 | return /^\d/.test(version) ? `v${version}` : version; |
| 100 | 104 | } |
| 101 | 105 | |
| 106 | const COMPATIBILITY_TITLES = { | |
| 107 | graceful: "Limited support", | |
| 108 | unsupported: "Unsupported version", | |
| 109 | broken: "Known broken version", | |
| 110 | } as const; | |
| 111 | ||
| 112 | /** Compatibility guidance shares the version popover, with safe install actions. */ | |
| 102 | 113 | export function getProviderVersionAdvisoryPresentation( |
| 103 | 114 | advisory: ServerProviderVersionAdvisory | undefined, |
| 115 | compatibility?: ServerProviderCompatibilityAdvisory | undefined, | |
| 116 | showCompatibility = true, | |
| 104 | 117 | ): { |
| 118 | readonly title: string; | |
| 105 | 119 | readonly detail: string; |
| 106 | 120 | readonly updateCommand: string | null; |
| 107 | 121 | readonly emphasis: "normal" | "strong"; |
| 122 | readonly targetVersion: string | null; | |
| 108 | 123 | } | null { |
| 109 | if (!advisory || advisory.status === "current" || advisory.status === "unknown") { | |
| 124 | const latestIsIncompatible = | |
| 125 | compatibility?.latestVersionStatus === "broken" || | |
| 126 | compatibility?.latestVersionStatus === "unsupported"; | |
| 127 | if ( | |
| 128 | showCompatibility && | |
| 129 | compatibility && | |
| 130 | (compatibility.status === "graceful" || | |
| 131 | compatibility.status === "unsupported" || | |
| 132 | compatibility.status === "broken") | |
| 133 | ) { | |
| 134 | const targetVersion = compatibility.recommendedVersion; | |
| 135 | const recommendation = getProviderVersionLabel(targetVersion) ?? compatibility.recommendedRange; | |
| 136 | return { | |
| 137 | title: COMPATIBILITY_TITLES[compatibility.status], | |
| 138 | detail: | |
| 139 | compatibility.message ?? | |
| 140 | (recommendation ? `Use ${recommendation} for full support.` : "Update for full support."), | |
| 141 | updateCommand: | |
| 142 | targetVersion || latestIsIncompatible ? null : (advisory?.updateCommand ?? null), | |
| 143 | emphasis: compatibility.status === "graceful" ? "normal" : "strong", | |
| 144 | targetVersion, | |
| 145 | }; | |
| 146 | } | |
| 147 | if ( | |
| 148 | !advisory || | |
| 149 | advisory.status === "current" || | |
| 150 | advisory.status === "unknown" || | |
| 151 | latestIsIncompatible | |
| 152 | ) { | |
| 110 | 153 | return null; |
| 111 | 154 | } |
| 112 | 155 | |
| ... | ... | @@ -115,6 +158,7 @@ export function getProviderVersionAdvisoryPresentation( |
| 115 | 158 | const versionLabel = getProviderVersionLabel(version); |
| 116 | 159 | |
| 117 | 160 | return { |
| 161 | title: label, | |
| 118 | 162 | detail: |
| 119 | 163 | advisory.message ?? |
| 120 | 164 | (versionLabel |
| ... | ... | @@ -122,5 +166,6 @@ export function getProviderVersionAdvisoryPresentation( |
| 122 | 166 | : `${label}: install the latest provider version.`), |
| 123 | 167 | updateCommand: advisory.updateCommand, |
| 124 | 168 | emphasis: "normal" as const, |
| 169 | targetVersion: null, | |
| 125 | 170 | }; |
| 126 | 171 | } |
docs/user/install.md+6| ... | ... | @@ -120,6 +120,12 @@ Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set it |
| 120 | 120 | Cursor's executable is `cursor-agent`, although its login command is |
| 121 | 121 | `agent login`. Antigravity can use its managed runtime without a `PATH` entry. |
| 122 | 122 | |
| 123 | T3 Code warns when a provider version has known compatibility problems with your | |
| 124 | release. Check **Settings → Providers** on that environment for the recommended | |
| 125 | version or range. When its package manager supports installing a specific version, | |
| 126 | you can install the recommendation there. Otherwise use the provider's installer | |
| 127 | on the environment's machine. An unlisted version is unverified. | |
| 128 | ||
| 123 | 129 | When a provider CLI is behind its latest release, its provider card shows the |
| 124 | 130 | available version. **Update now** appears only when T3 Code can tell which |
| 125 | 131 | installer owns the CLI (its own update command, Homebrew, or a global npm, pnpm, |
packages/contracts/src/server.ts+19| ... | ... | @@ -148,6 +148,22 @@ export const ServerProviderContinuation = Schema.Struct({ |
| 148 | 148 | }); |
| 149 | 149 | export type ServerProviderContinuation = typeof ServerProviderContinuation.Type; |
| 150 | 150 | |
| 151 | export const ServerProviderCompatibilityStatus = Schema.Literals([ | |
| 152 | "unknown", | |
| 153 | "supported", | |
| 154 | "graceful", | |
| 155 | "unsupported", | |
| 156 | "broken", | |
| 157 | ]); | |
| 158 | export const ServerProviderCompatibilityAdvisory = Schema.Struct({ | |
| 159 | status: ServerProviderCompatibilityStatus, | |
| 160 | latestVersionStatus: Schema.optionalKey(ServerProviderCompatibilityStatus), | |
| 161 | message: Schema.NullOr(TrimmedNonEmptyString), | |
| 162 | recommendedVersion: Schema.NullOr(TrimmedNonEmptyString), | |
| 163 | recommendedRange: Schema.NullOr(TrimmedNonEmptyString), | |
| 164 | }); | |
| 165 | export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type; | |
| 166 | ||
| 151 | 167 | export const ServerProviderVersionAdvisoryStatus = Schema.Literals([ |
| 152 | 168 | "unknown", |
| 153 | 169 | "current", |
| ... | ... | @@ -161,6 +177,7 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ |
| 161 | 177 | latestVersion: Schema.NullOr(TrimmedNonEmptyString), |
| 162 | 178 | updateCommand: Schema.NullOr(TrimmedNonEmptyString), |
| 163 | 179 | canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 180 | canInstallVersion: Schema.optionalKey(Schema.Boolean), | |
| 164 | 181 | checkedAt: Schema.NullOr(IsoDateTime), |
| 165 | 182 | message: Schema.NullOr(TrimmedNonEmptyString), |
| 166 | 183 | }); |
| ... | ... | @@ -234,6 +251,7 @@ export const ServerProvider = Schema.Struct({ |
| 234 | 251 | // Absent when the driver has no notion of subscription usage. |
| 235 | 252 | usageLimits: Schema.optional(ServerProviderUsageLimits), |
| 236 | 253 | versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), |
| 254 | compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), | |
| 237 | 255 | updateState: Schema.optionalKey(ServerProviderUpdateState), |
| 238 | 256 | }); |
| 239 | 257 | export type ServerProvider = typeof ServerProvider.Type; |
| ... | ... | @@ -799,6 +817,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T |
| 799 | 817 | |
| 800 | 818 | export const ServerProviderUpdateInput = Schema.Struct({ |
| 801 | 819 | provider: ProviderDriverKind, |
| 820 | targetVersion: Schema.optionalKey(TrimmedNonEmptyString), | |
| 802 | 821 | instanceId: Schema.optionalKey(ProviderInstanceId), |
| 803 | 822 | }); |
| 804 | 823 | export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; |