authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 18:38:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 18:38:20-07:00
log96c4bfa0a27f776f7cd17df7c7115c3de79ee399
tree0cbdd1ee4c8000d21323e2c473b9d749480d27ef
parenta493946bb42ab16e1d18285dac3e9e4603651330
signature Signed by PGP key B5690EEEBB952194

feat(providers): check remote compatibility ranges (#13130)


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
651651 </View>
652652 ) : null}
653653
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}
654671 {modelUnavailable ? (
655672 <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}>
656673 <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";
7777const encoder = new TextEncoder();
7878const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
7979
80// Provider metadata checks use a bundled manifest and stubbed HTTP.
8081const TestHttpClientLive = Layer.succeed(
8182 HttpClient.HttpClient,
8283 HttpClient.make((request) =>
8384 Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))),
8485 ),
85);
86).pipe(Layer.provideMerge(ModelManifest.layerTest));
8687
8788const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({
8889 reportClientActivity: () => Effect.void,
apps/server/src/provider/Layers/ProviderRegistry.ts+35-3
......@@ -40,6 +40,8 @@ import * as Ref from "effect/Ref";
4040import * as Stream from "effect/Stream";
4141import * as Semaphore from "effect/Semaphore";
4242
43import * as ModelManifest from "../ModelManifest.ts";
44import { applyProviderCompatibility } from "../providerCompatibility.ts";
4345import { ServerConfig } from "../../config.ts";
4446import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
4547import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
......@@ -278,6 +280,8 @@ export const ProviderRegistryLive = Layer.effect(
278280 ProviderRegistry,
279281 Effect.gen(function* () {
280282 const instanceRegistry = yield* ProviderInstanceRegistry;
283 const manifestService = yield* ModelManifest.ModelManifest;
284 const serviceScope = yield* Effect.scope;
281285 const config = yield* ServerConfig;
282286 const fileSystem = yield* FileSystem.FileSystem;
283287 const path = yield* Path.Path;
......@@ -356,7 +360,19 @@ export const ProviderRegistryLive = Layer.effect(
356360 ),
357361 ),
358362 );
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 );
360376 const workspaceRefreshesRef = yield* Ref.make<
361377 ReadonlyMap<ProviderInstance, ReadonlySet<string>>
362378 >(new Map());
......@@ -424,6 +440,7 @@ export const ProviderRegistryLive = Layer.effect(
424440 readonly replace?: boolean;
425441 },
426442 ) {
443 const manifest = yield* manifestService.current;
427444 const nextProvidersWithUpdateState = yield* Effect.forEach(
428445 nextProviders,
429446 applyProviderUpdateState,
......@@ -450,7 +467,11 @@ export const ProviderRegistryLive = Layer.effect(
450467 );
451468 }
452469
453 const providers = orderProviderSnapshots([...mergedProviders.values()]);
470 const providers = orderProviderSnapshots(
471 [...mergedProviders.values()].map((provider) =>
472 classifyCompatibility(provider, manifest),
473 ),
474 );
454475 const providersToPersist = providers.filter((provider) =>
455476 updatedKeys.has(snapshotInstanceKey(provider)),
456477 );
......@@ -473,13 +494,24 @@ export const ProviderRegistryLive = Layer.effect(
473494 return providers;
474495 });
475496
497 const compatibilityRefreshRunning = yield* Ref.make(false);
476498 const syncProvider = Effect.fn("syncProvider")(function* (
477499 provider: ServerProvider,
478500 options?: {
479501 readonly publish?: boolean;
480502 },
481503 ) {
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;
483515 });
484516
485517 const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")(
apps/server/src/provider/ModelManifest.test.ts+40
......@@ -549,3 +549,43 @@ describe("ModelManifest service", () => {
549549 ),
550550 );
551551});
552
553it.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";
3434import * as ServerSettings from "../serverSettings.ts";
3535import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
3636import bundledManifestJson from "./model-manifest.json" with { type: "json" };
37import { ProviderCompatibilityPolicy } from "./providerCompatibility.ts";
3738import type { ServerProviderDraft } from "./providerSnapshot.ts";
3839
3940const MODEL_MANIFEST_URL =
......@@ -89,6 +90,7 @@ const ModelManifestEnvelopeSchema = Schema.Struct({
8990 * files still decode; they count as older than any dated bundle.
9091 */
9192 updatedAt: Schema.optional(Schema.String),
93 compatibility: Schema.optional(Schema.Array(ProviderCompatibilityPolicy)),
9294 currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)),
9395 providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)),
9496});
apps/server/src/provider/model-manifest.json+9-1
......@@ -1,6 +1,14 @@
11{
22 "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 ],
412 "currentModels": {
513 "codex": [
614 "gpt-6-astra",
apps/server/src/provider/providerCompatibility.test.ts created+258
......@@ -0,0 +1,258 @@
1import { assert, describe, it } from "@effect/vitest";
2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as PubSub from "effect/PubSub";
9import * as Ref from "effect/Ref";
10import * as Stream from "effect/Stream";
11import * as ServerConfig from "../config.ts";
12import * as ModelManifest from "./ModelManifest.ts";
13import { ProviderRegistryLive } from "./Layers/ProviderRegistry.ts";
14import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
15import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts";
16import type { ProviderInstance } from "./ProviderDriver.ts";
17import { makeManualOnlyProviderMaintenanceCapabilities } from "./providerMaintenance.ts";
18import * as Schema from "effect/Schema";
19import {
20 applyProviderCompatibility,
21 ProviderCompatibilityPolicy,
22 resolveProviderCompatibility,
23} from "./providerCompatibility.ts";
24
25const driver = ProviderDriverKind.make("codex");
26const 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};
38const 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
53describe("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
144it.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
231it("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 @@
1import {
2 TrimmedNonEmptyString,
3 ServerProviderCompatibilityStatus,
4 type ProviderDriverKind,
5 type ServerProvider,
6 type ServerProviderCompatibilityAdvisory,
7} from "@t3tools/contracts";
8import { satisfiesSemverRange } from "@t3tools/shared/semver";
9import * as Schema from "effect/Schema";
10import 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.
14const StableVersion = TrimmedNonEmptyString.pipe(
15 Schema.check(Schema.makeFilter((value) => /^\d+\.\d+\.\d+$/.test(value))),
16);
17const 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);
27const 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
40export 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);
57export type ProviderCompatibilityPolicy = typeof ProviderCompatibilityPolicy.Type;
58
59export 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. */
95export 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";
1515import { ChildProcessSpawner } from "effect/unstable/process";
1616import {
1717 createProviderVersionAdvisory,
18 makeTargetedProviderUpdateAction,
1819 enrichProviderSnapshotWithVersionAdvisory,
1920 homebrewOwnershipFromCommandPath,
2021 makeCachedProviderMaintenanceResolution,
......@@ -786,3 +787,45 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
786787 }),
787788 );
788789});
790
791it("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: {
186186 };
187187}
188188
189/** Pin only package-manager actions we own, preserving prefix, scripts, env and lock. */
190export 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
189216export function makeManualOnlyProviderMaintenanceCapabilities(input: {
190217 readonly provider: ProviderDriverKind;
191218 readonly packageName: string | null;
......@@ -627,6 +654,7 @@ export function createProviderVersionAdvisory(input: {
627654 latestVersion,
628655 updateCommand: capabilities.update?.command ?? null,
629656 canUpdate: capabilities.update !== null,
657 canInstallVersion: makeTargetedProviderUpdateAction(capabilities, "0.0.0") !== null,
630658 checkedAt: input.checkedAt ?? null,
631659 message: advisory.message,
632660 };
apps/server/src/provider/providerMaintenanceRunner.test.ts+101-2
......@@ -21,6 +21,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos
2121import { SpawnExecutableResolution } from "@t3tools/shared/shell";
2222
2323import { ProviderRegistry, type ProviderRegistryShape } from "./Services/ProviderRegistry.ts";
24import * as ModelManifest from "./ModelManifest.ts";
2425import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
2526import {
2627 makeProviderMaintenanceCapabilities,
......@@ -208,13 +209,22 @@ function makeRegistry(
208209 });
209210}
210211
211const makeTestRunner = (registry: ProviderRegistryShape) =>
212const makeTestRunner = (
213 registry: ProviderRegistryShape,
214 manifest = ModelManifest.BUNDLED_MODEL_MANIFEST,
215) =>
212216 Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe(
213217 Effect.provide(
214218 ProviderMaintenanceRunner.layer.pipe(
215219 Layer.provide(
216220 Layer.mergeAll(
217221 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 }),
218228 // Fresh per runner so a version cached by one test cannot leak into another.
219229 Layer.sync(ProviderVersionCache, () => new Map()),
220230 ),
......@@ -743,7 +753,7 @@ describe("providerMaintenanceRunner", () => {
743753 Effect.provide(
744754 Layer.mergeAll(
745755 NonWindowsPlatform,
746 latestVersionHttpClient("0.0.0"),
756 latestVersionHttpClient("2.0.0"),
747757 mockSpawnerLayer((_command, args) => {
748758 calls.push(args.join(" "));
749759 if (calls.length === 1) {
......@@ -911,3 +921,92 @@ describe("providerMaintenanceRunner", () => {
911921 );
912922 });
913923});
924
925it.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";
2121import { HttpClient } from "effect/unstable/http";
2222import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2323
24import * as ModelManifest from "./ModelManifest.ts";
25import { resolveProviderCompatibility } from "./providerCompatibility.ts";
2426import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
2527import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts";
2628import {
2729 enrichProviderSnapshotWithVersionAdvisory,
30 makeTargetedProviderUpdateAction,
31 resolveLatestProviderVersion,
2832 type ProviderMaintenanceCommandAction,
2933 ProviderVersionCache,
3034} from "./providerMaintenance.ts";
......@@ -51,6 +55,7 @@ export interface ProviderMaintenanceRunnerShape {
5155 | {
5256 readonly provider: ProviderDriverKind;
5357 readonly instanceId?: ProviderInstanceId | undefined;
58 readonly targetVersion?: string | undefined;
5459 },
5560 ) => Effect.Effect<ServerProviderUpdatedPayload, ServerProviderUpdateError>;
5661}
......@@ -214,6 +219,7 @@ function makeUpdateState(input: {
214219/** @public Service construction is part of the canonical Effect module API. */
215220export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
216221 const providerRegistry = yield* ProviderRegistry;
222 const manifestService = yield* ModelManifest.ModelManifest;
217223 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
218224 const httpClient = yield* HttpClient.HttpClient;
219225 const versionCache = yield* ProviderVersionCache;
......@@ -310,6 +316,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
310316 typeof target === "string"
311317 ? defaultInstanceIdForDriver(provider)
312318 : (target.instanceId ?? defaultInstanceIdForDriver(provider));
319 const targetVersion = typeof target === "string" ? undefined : target.targetVersion;
313320 const targetKey = `instance:${instanceId}`;
314321 const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance(
315322 instanceId,
......@@ -376,7 +383,44 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
376383 );
377384 }
378385
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);
380424 const finishedAt = yield* nowIso;
381425 if (result.timedOut || result.exitCode !== 0) {
382426 return yield* finish(
......@@ -407,10 +451,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
407451 // Cursor's `about` probe can fail transiently on a healthy binary.
408452 const couldNotVerify =
409453 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));
414463 return yield* finish(
415464 makeUpdateState({
416465 status: couldNotVerify || stillOutdated ? "unchanged" : "succeeded",
apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+20
......@@ -24,6 +24,7 @@ import {
2424 getProviderUpdateSidebarPillView,
2525 hasOneClickUpdateProviderCandidate,
2626 isProviderUpdateCandidate,
27 isProviderSettingsUpdateCandidate,
2728 isTerminalProviderUpdatePhase,
2829 localEnvironmentUpdateNotificationKey,
2930 providerUpdateNotificationKey,
......@@ -1038,3 +1039,22 @@ describe("provider update launch notification logic", () => {
10381039 });
10391040 });
10401041});
1042
1043it("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(
141141): provider is ProviderUpdateCandidate {
142142 return (
143143 provider.enabled &&
144 provider.compatibilityAdvisory?.latestVersionStatus !== "broken" &&
145 provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" &&
144146 provider.versionAdvisory?.status === "behind_latest" &&
145147 provider.versionAdvisory.latestVersion !== null
146148 );
......@@ -161,6 +163,8 @@ export function isProviderSettingsUpdateCandidate(
161163): provider is ProviderSettingsUpdateCandidate {
162164 return (
163165 provider.enabled &&
166 provider.compatibilityAdvisory?.latestVersionStatus !== "broken" &&
167 provider.compatibilityAdvisory?.latestVersionStatus !== "unsupported" &&
164168 provider.versionAdvisory?.status === "behind_latest" &&
165169 provider.versionAdvisory.canUpdate === true &&
166170 provider.versionAdvisory.updateCommand !== null
apps/web/src/components/chat/ProviderStatusBanner.test.ts created+72
......@@ -0,0 +1,72 @@
1import { describe, expect, it } from "vite-plus/test";
2import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
3import {
4 getProviderStatusBannerKey,
5 getProviderStatusMessage,
6 shouldShowProviderStatusBanner,
7} from "./ProviderStatusBanner";
8
9const 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
29describe("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";
66import { formatProviderDriverKindLabel } from "../../providerModels";
77import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
88
9/** Unsupported and broken versions fail mid-turn, so they warn even when ready. */
10function getIncompatibleVersion(status: ServerProvider) {
11 const compatibility = status.compatibilityAdvisory;
12 return compatibility?.status === "unsupported" || compatibility?.status === "broken"
13 ? compatibility
14 : null;
15}
16
917export 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 }
1125 // Antigravity checks saved credentials when a session starts. Its local
1226 // health check leaves auth unknown after a restart, which is not a failure.
1327 if (
......@@ -76,15 +90,20 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
7690
7791 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
7892 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
93 const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null;
7994 const title = isUnauthenticated
8095 ? `${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;
83101
84102 return (
85103 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">
86104 <Alert
87 variant={status.status === "warning" ? "warning" : "error"}
105 variant={isWarning ? "warning" : "error"}
106 role={incompatible && incompatible.status !== "broken" ? "status" : "alert"}
88107 surface="glass"
89108 controlAlignment="first-line"
90109 >
apps/web/src/components/settings/ProviderInstanceCard.tsx+108-33
......@@ -3,6 +3,7 @@
33import { Spinner } from "~/components/ui/spinner";
44
55import {
6 AlertTriangleIcon,
67 ArrowUpCircleIcon,
78 CopyIcon,
89 DownloadIcon,
......@@ -14,7 +15,7 @@ import {
1415} from "lucide-react";
1516import * as Arr from "effect/Array";
1617import * as Result from "effect/Result";
17import { useEffect, useRef, useState, type ReactNode } from "react";
18import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
1819import {
1920 isProviderDriverKind,
2021 resolveProviderInstanceEnabled,
......@@ -58,6 +59,22 @@ import {
5859
5960const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
6061
62function 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
6178let environmentVariableDraftId = 0;
6279const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
6380
......@@ -377,6 +394,7 @@ interface ProviderInstanceCardProps {
377394 readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void;
378395 readonly onModelOrderChange: (next: ReadonlyArray<string>) => void;
379396 readonly onRunUpdate?: (() => void) | undefined;
397 readonly onInstallRecommended?: (() => void) | undefined;
380398 readonly isUpdating?: boolean | undefined;
381399}
382400
......@@ -419,9 +437,11 @@ export function ProviderInstanceCard({
419437 onFavoriteModelsChange,
420438 onModelOrderChange,
421439 onRunUpdate,
440 onInstallRecommended,
422441 isUpdating = false,
423442}: ProviderInstanceCardProps) {
424443 const enabled = resolveProviderInstanceEnabled(instance);
444 const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined;
425445 // A locally disabled provider reads "Disabled" with a muted dot even if its
426446 // last server status is stale. Enabled providers use the server status.
427447 const statusKey: ProviderStatusKey = enabled
......@@ -438,8 +458,18 @@ export function ProviderInstanceCard({
438458 ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null)
439459 : null;
440460 const versionLabel = getProviderVersionLabel(liveProvider?.version);
441 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
461 const versionAdvisory = getProviderVersionAdvisoryPresentation(
462 liveProvider?.versionAdvisory,
463 liveProvider?.compatibilityAdvisory,
464 enabled,
465 );
442466 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;
443473 const FallbackIconComponent = driverOption?.icon;
444474 const displayName =
445475 instance.displayName?.trim() || driverOption?.label || String(instance.driver);
......@@ -568,10 +598,16 @@ export function ProviderInstanceCard({
568598 statusKey === "warning" || statusKey === "error" ? (
569599 <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden />
570600 ) : 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.
574601 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;
575611 const editorStatusNode =
576612 isAuthenticated && authEmail ? (
577613 <>
......@@ -579,16 +615,16 @@ export function ProviderInstanceCard({
579615 <span>Authenticated as</span>
580616 <ProviderAuthEmail email={authEmail} />
581617 {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>
584620 ) : null}
585621 </>
586622 ) : (
587623 <>
588624 {statusDotNode}
589625 <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>
592628 ) : null}
593629 </>
594630 );
......@@ -629,7 +665,23 @@ export function ProviderInstanceCard({
629665 </code>
630666 ) : null}
631667 {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 ? (
633685 <Tooltip>
634686 <TooltipTrigger
635687 render={
......@@ -660,10 +712,15 @@ export function ProviderInstanceCard({
660712 {statusDotNode ? (
661713 <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span>
662714 ) : 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>
667724 </span>
668725 </span>
669726 </div>
......@@ -694,25 +751,32 @@ export function ProviderInstanceCard({
694751 >
695752 {versionAdvisory ? (
696753 <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 }
707770 />
708 </Button>
709 }
710 />
771 }
772 />
773 <TooltipPopup side="top">{versionAdvisory.title}</TooltipPopup>
774 </Tooltip>
711775 <PopoverPopup side="bottom" align="end" width="md">
712776 <div className="grid min-w-0 gap-3">
713777 <div className="grid gap-0.5">
714778 <p className="text-[13px] font-semibold leading-tight text-foreground">
715 Update available
779 {versionAdvisory.title}
716780 </p>
717781 <p
718782 className={cn(
......@@ -725,20 +789,24 @@ export function ProviderInstanceCard({
725789 {versionAdvisory.detail}
726790 </p>
727791 </div>
728 {onRunUpdate ? (
792 {onRunVersionAction ? (
729793 <Button
730794 type="button"
731795 size="xs"
732796 variant="outline"
733797 className="w-full"
734798 disabled={isUpdating}
735 onClick={onRunUpdate}
799 onClick={onRunVersionAction}
736800 >
737801 {isUpdating ? <Spinner /> : <DownloadIcon />}
738 {isUpdating ? "Updating" : "Update now"}
802 {isUpdating
803 ? "Updating"
804 : versionAdvisory.targetVersion
805 ? `Install ${getProviderVersionLabel(versionAdvisory.targetVersion)}`
806 : "Update now"}
739807 </Button>
740808 ) : null}
741 {onRunUpdate && updateCommand ? (
809 {onRunVersionAction && updateCommand ? (
742810 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
743811 <span aria-hidden className="h-px flex-1 bg-border" />
744812 or, update manually using
......@@ -798,7 +866,14 @@ export function ProviderInstanceCard({
798866 <SettingsRow
799867 title="Display name"
800868 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>
802877 }
803878 control={
804879 <div
apps/web/src/components/settings/ProviderSettingsPanel.tsx+21-7
......@@ -649,7 +649,10 @@ export function EnvironmentProviderSettings({
649649 }, [environmentId, refreshServerProviders]);
650650
651651 const runProviderUpdate = useCallback(
652 async (candidate: ProviderSettingsUpdateCandidate) => {
652 async (
653 candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">,
654 targetVersion?: string,
655 ) => {
653656 // Ref-based re-entry guard, mirroring refreshProviders: a state updater
654657 // may run after this function returns, so it cannot gate the dispatch.
655658 if (updatingInstanceIdsRef.current.has(candidate.instanceId)) {
......@@ -663,6 +666,7 @@ export function EnvironmentProviderSettings({
663666 input: {
664667 provider: candidate.driver,
665668 instanceId: candidate.instanceId,
669 ...(targetVersion ? { targetVersion } : {}),
666670 },
667671 });
668672 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
......@@ -887,9 +891,8 @@ export function EnvironmentProviderSettings({
887891 );
888892 const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId);
889893 const isInstanceUpdateRunning =
890 updateCandidate !== undefined &&
891 (updatingProviderInstanceIds.has(updateCandidate.instanceId) ||
892 isProviderUpdateActive(updateCandidate));
894 updatingProviderInstanceIds.has(row.instanceId) ||
895 (liveProvider !== undefined && isProviderUpdateActive(liveProvider));
893896 const showInlineUpdateButton = updateCandidate !== undefined;
894897 const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning;
895898 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
......@@ -971,6 +974,19 @@ export function EnvironmentProviderSettings({
971974 modelOrder,
972975 })
973976 }
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 }
974990 onRunUpdate={
975991 mode === "editor" && showInlineUpdateButton && updateCandidate
976992 ? () => {
......@@ -978,9 +994,7 @@ export function EnvironmentProviderSettings({
978994 }
979995 : undefined
980996 }
981 isUpdating={
982 mode === "editor" && showInlineUpdateButton ? isInstanceUpdateRunning : undefined
983 }
997 isUpdating={mode === "editor" ? isInstanceUpdateRunning : undefined}
984998 />
985999 );
9861000 };
apps/web/src/components/settings/providerStatus.test.ts+71-1
......@@ -1,7 +1,7 @@
11import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
4import { getProviderSummary } from "./providerStatus";
4import { getProviderSummary, getProviderVersionAdvisoryPresentation } from "./providerStatus";
55
66const provider: ServerProvider = {
77 instanceId: ProviderInstanceId.make("codex"),
......@@ -69,3 +69,73 @@ describe("getProviderSummary", () => {
6969 expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled");
7070 });
7171});
72
73it("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
102it("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 @@
1import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts";
1import type {
2 ServerProvider,
3 ServerProviderVersionAdvisory,
4 ServerProviderCompatibilityAdvisory,
5} from "@t3tools/contracts";
26
37/**
48 * Visual treatment for each server-reported provider status. Centralized so
......@@ -99,14 +103,53 @@ export function getProviderVersionLabel(version: string | null | undefined) {
99103 return /^\d/.test(version) ? `v${version}` : version;
100104}
101105
106const 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. */
102113export function getProviderVersionAdvisoryPresentation(
103114 advisory: ServerProviderVersionAdvisory | undefined,
115 compatibility?: ServerProviderCompatibilityAdvisory | undefined,
116 showCompatibility = true,
104117): {
118 readonly title: string;
105119 readonly detail: string;
106120 readonly updateCommand: string | null;
107121 readonly emphasis: "normal" | "strong";
122 readonly targetVersion: string | null;
108123} | 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 ) {
110153 return null;
111154 }
112155
......@@ -115,6 +158,7 @@ export function getProviderVersionAdvisoryPresentation(
115158 const versionLabel = getProviderVersionLabel(version);
116159
117160 return {
161 title: label,
118162 detail:
119163 advisory.message ??
120164 (versionLabel
......@@ -122,5 +166,6 @@ export function getProviderVersionAdvisoryPresentation(
122166 : `${label}: install the latest provider version.`),
123167 updateCommand: advisory.updateCommand,
124168 emphasis: "normal" as const,
169 targetVersion: null,
125170 };
126171}
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
120120Cursor's executable is `cursor-agent`, although its login command is
121121`agent login`. Antigravity can use its managed runtime without a `PATH` entry.
122122
123T3 Code warns when a provider version has known compatibility problems with your
124release. Check **Settings → Providers** on that environment for the recommended
125version or range. When its package manager supports installing a specific version,
126you can install the recommendation there. Otherwise use the provider's installer
127on the environment's machine. An unlisted version is unverified.
128
123129When a provider CLI is behind its latest release, its provider card shows the
124130available version. **Update now** appears only when T3 Code can tell which
125131installer 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({
148148});
149149export type ServerProviderContinuation = typeof ServerProviderContinuation.Type;
150150
151export const ServerProviderCompatibilityStatus = Schema.Literals([
152 "unknown",
153 "supported",
154 "graceful",
155 "unsupported",
156 "broken",
157]);
158export 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});
165export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type;
166
151167export const ServerProviderVersionAdvisoryStatus = Schema.Literals([
152168 "unknown",
153169 "current",
......@@ -161,6 +177,7 @@ export const ServerProviderVersionAdvisory = Schema.Struct({
161177 latestVersion: Schema.NullOr(TrimmedNonEmptyString),
162178 updateCommand: Schema.NullOr(TrimmedNonEmptyString),
163179 canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
180 canInstallVersion: Schema.optionalKey(Schema.Boolean),
164181 checkedAt: Schema.NullOr(IsoDateTime),
165182 message: Schema.NullOr(TrimmedNonEmptyString),
166183});
......@@ -234,6 +251,7 @@ export const ServerProvider = Schema.Struct({
234251 // Absent when the driver has no notion of subscription usage.
235252 usageLimits: Schema.optional(ServerProviderUsageLimits),
236253 versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory),
254 compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory),
237255 updateState: Schema.optionalKey(ServerProviderUpdateState),
238256});
239257export type ServerProvider = typeof ServerProvider.Type;
......@@ -799,6 +817,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T
799817
800818export const ServerProviderUpdateInput = Schema.Struct({
801819 provider: ProviderDriverKind,
820 targetVersion: Schema.optionalKey(TrimmedNonEmptyString),
802821 instanceId: Schema.optionalKey(ProviderInstanceId),
803822});
804823export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type;