diff --git a/.gitignore b/.gitignore index 5e941c7b9f0..03556d80981 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ release-mock/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ +apps/marketing/public/provider-compatibility.v1.json .vitest-* __screenshots__/ .tanstack diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 1763a001026..4e01829b7fa 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@astrojs/check": "^0.9.7", + "@vercel/config": "^0.3.0", "typescript": "catalog:" } } diff --git a/apps/marketing/vercel.ts b/apps/marketing/vercel.ts new file mode 100644 index 00000000000..18e110db6f8 --- /dev/null +++ b/apps/marketing/vercel.ts @@ -0,0 +1,6 @@ +import { type VercelConfig } from "@vercel/config/v1"; + +export const config: VercelConfig = { + installCommand: "bun install --filter '@t3tools/scripts' --filter '@t3tools/marketing'", + buildCommand: "bun ../../scripts/mirror-provider-compatibility-map.ts && astro build", +}; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e3f15d865c9..5f92e70a363 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -47,6 +47,7 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import * as ProviderCompatibility from "../ProviderCompatibility.ts"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -80,6 +81,7 @@ export type ClaudeDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + | ProviderCompatibility.ProviderCompatibilityService | ProviderEventLoggers | ServerConfig; @@ -112,6 +114,7 @@ export const ClaudeDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const providerCompatibility = yield* ProviderCompatibility.ProviderCompatibilityService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ @@ -171,7 +174,17 @@ export const ClaudeDriver: ProviderDriver = { checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.flatMap((snapshot) => + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( + snapshot, + maintenanceCapabilities, + ), + ), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService( + ProviderCompatibility.ProviderCompatibilityService, + providerCompatibility, + ), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 48bc19e5612..33d6b8558a0 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -46,6 +46,7 @@ import { makePackageManagedProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import * as ProviderCompatibility from "../ProviderCompatibility.ts"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -72,6 +73,7 @@ export type CodexDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + | ProviderCompatibility.ProviderCompatibilityService | ProviderEventLoggers | ServerConfig; @@ -109,6 +111,7 @@ export const CodexDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const providerCompatibility = yield* ProviderCompatibility.ProviderCompatibilityService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); @@ -171,7 +174,17 @@ export const CodexDriver: ProviderDriver = { checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.flatMap((snapshot) => + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( + snapshot, + maintenanceCapabilities, + ), + ), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService( + ProviderCompatibility.ProviderCompatibilityService, + providerCompatibility, + ), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index b399f9aa948..a7fbecde4cf 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -45,6 +45,7 @@ import { makeStaticProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import * as ProviderCompatibility from "../ProviderCompatibility.ts"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); @@ -64,6 +65,7 @@ export type CursorDriverEnv = | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + | ProviderCompatibility.ProviderCompatibilityService | ProviderEventLoggers | ServerConfig; @@ -97,6 +99,7 @@ export const CursorDriver: ProviderDriver = { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; + const providerCompatibility = yield* ProviderCompatibility.ProviderCompatibilityService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -150,7 +153,13 @@ export const CursorDriver: ProviderDriver = { publishSnapshot, stampIdentity, httpClient, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)), + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService( + ProviderCompatibility.ProviderCompatibilityService, + providerCompatibility, + ), + ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( Effect.mapError( diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 816e8b70f55..910779dd29f 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -46,6 +46,7 @@ import { normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; +import * as ProviderCompatibility from "../ProviderCompatibility.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -77,6 +78,7 @@ export type OpenCodeDriverEnv = | HttpClient.HttpClient | OpenCodeRuntime | Path.Path + | ProviderCompatibility.ProviderCompatibilityService | ProviderEventLoggers | ServerConfig; @@ -109,6 +111,7 @@ export const OpenCodeDriver: ProviderDriver const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; + const providerCompatibility = yield* ProviderCompatibility.ProviderCompatibilityService; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ @@ -150,7 +153,17 @@ export const OpenCodeDriver: ProviderDriver checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.flatMap((snapshot) => + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( + snapshot, + maintenanceCapabilities, + ), + ), Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService( + ProviderCompatibility.ProviderCompatibilityService, + providerCompatibility, + ), Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 035c08437a9..29479cc8183 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -41,6 +41,7 @@ import { enrichProviderSnapshotWithVersionAdvisory, type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; +import * as ProviderCompatibility from "../ProviderCompatibility.ts"; import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; const PROVIDER = ProviderDriverKind.make("cursor"); @@ -1234,7 +1235,11 @@ export const enrichCursorSnapshot = (input: { readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; readonly httpClient: HttpClient.HttpClient; -}): Effect.Effect => { +}): Effect.Effect< + void, + never, + ChildProcessSpawner.ChildProcessSpawner | ProviderCompatibility.ProviderCompatibilityService +> => { const { settings, snapshot, publishSnapshot } = input; const stampIdentity = input.stampIdentity ?? ((value) => value); @@ -1242,6 +1247,12 @@ export const enrichCursorSnapshot = (input: { snapshot, input.maintenanceCapabilities, ).pipe( + Effect.flatMap((enrichedSnapshot) => + ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( + enrichedSnapshot, + input.maintenanceCapabilities, + ), + ), Effect.provideService(HttpClient.HttpClient, input.httpClient), Effect.flatMap((enrichedSnapshot) => publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 86f99c97326..02a52abd79a 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -43,6 +43,7 @@ import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import * as ProviderCompatibility from "../ProviderCompatibility.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -52,6 +53,9 @@ const TestHttpClientLive = Layer.succeed( Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), ), ); +const TestProviderCompatibilityLive = ProviderCompatibility.layer.pipe( + Layer.provide(TestHttpClientLive), +); const makeCodexConfig = (overrides: Partial): CodexSettings => ({ enabled: false, @@ -99,6 +103,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe( Layer.provideMerge(NodeServices.layer), Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestProviderCompatibilityLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); @@ -236,6 +241,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }).pipe( Layer.provideMerge(infraLayer), Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestProviderCompatibilityLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb6eb3b443d..404c1e05701 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -48,6 +48,7 @@ import { readProviderStatusCache, resolveProviderStatusCachePath } from "../prov import type { ProviderInstance } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import * as ProviderCompatibility from "../ProviderCompatibility.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -71,6 +72,9 @@ const TestHttpClientLive = Layer.succeed( Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), ), ); +const TestProviderCompatibilityLive = ProviderCompatibility.layer.pipe( + Layer.provide(TestHttpClientLive), +); function selectDescriptor( id: string, @@ -1027,6 +1031,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ), Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestProviderCompatibilityLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -1102,6 +1107,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ), Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestProviderCompatibilityLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), // `it.live` does not inherit layers from the outer `it.layer` @@ -1205,6 +1211,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ), Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestProviderCompatibilityLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), @@ -1256,6 +1263,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ), Layer.provideMerge(TestHttpClientLive), + Layer.provideMerge(TestProviderCompatibilityLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge( diff --git a/apps/server/src/provider/ProviderCompatibility.test.ts b/apps/server/src/provider/ProviderCompatibility.test.ts new file mode 100644 index 00000000000..c9c61cb3ccd --- /dev/null +++ b/apps/server/src/provider/ProviderCompatibility.test.ts @@ -0,0 +1,540 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; + +import * as ProviderCompatibility from "./ProviderCompatibility.ts"; +import { makeProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; + +const codexDriver = ProviderDriverKind.make("codex"); +const claudeDriver = ProviderDriverKind.make("claudeAgent"); +const opencodeDriver = ProviderDriverKind.make("opencode"); +const cursorDriver = ProviderDriverKind.make("cursor"); + +const baseProvider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: codexDriver, + displayName: "Codex", + enabled: true, + installed: true, + version: "0.130.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +const codexNpmUpdateCapabilities = makeProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: "@openai/codex", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@openai/codex@latest"], + updateLockKey: "npm-global", +}); + +function jsonHttpClient( + responseForUrl: (url: string) => { readonly payload: unknown; readonly status?: number }, +): HttpClient.HttpClient { + return HttpClient.make((request) => { + const response = responseForUrl(request.url); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(response.payload), { + status: response.status ?? 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }); +} + +const provideCompatibility = ( + responseForUrl: (url: string) => { readonly payload: unknown; readonly status?: number }, +) => + Effect.provide( + ProviderCompatibility.layer.pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, jsonHttpClient(responseForUrl))), + ), + ); + +describe("provider compatibility", () => { + it("selects policies by T3 Code version range", () => { + const document: ProviderCompatibility.ProviderCompatibilityDocument = { + version: 1, + policies: [ + { + t3CodeRange: "<0.1.0", + driver: codexDriver, + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + { + t3CodeRange: ">=0.1.0", + driver: codexDriver, + recommendedRange: ">=0.130.0", + recommendedVersion: "0.130.0", + ranges: [{ status: "supported", range: ">=0.130.0" }], + }, + ], + }; + + expect( + ProviderCompatibility.createProviderCompatibilityAdvisory({ + driver: codexDriver, + currentVersion: "0.130.0", + document, + t3CodeVersion: "0.0.22", + }), + ).toMatchObject({ + status: "broken", + recommendedVersion: "0.129.0", + }); + }); + + it("adds a targeted compatibility update command when a recommended version is available", () => { + const document: ProviderCompatibility.ProviderCompatibilityDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: codexDriver, + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: "<0.129.0" }], + }, + ], + }; + + expect( + ProviderCompatibility.createProviderCompatibilityAdvisory({ + driver: codexDriver, + currentVersion: "0.128.0", + document, + maintenanceCapabilities: codexNpmUpdateCapabilities, + }), + ).toMatchObject({ + status: "broken", + canUpdate: true, + updateCommand: "npm install -g @openai/codex@0.129.0", + }); + }); + + it("does not warn on disabled providers before their version has been probed", () => { + const enriched = ProviderCompatibility.applyBundledProviderCompatibilityAdvisory({ + snapshot: { + ...baseProvider, + driver: cursorDriver, + enabled: false, + version: null, + status: "disabled", + }, + driver: cursorDriver, + currentVersion: null, + }); + + expect(enriched.compatibilityAdvisory).toBeUndefined(); + expect(enriched.status).toBe("disabled"); + }); + + it("classifies the bundled compatibility policies for current T3 Code builds", () => { + const document: ProviderCompatibility.ProviderCompatibilityDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: codexDriver, + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + ranges: [ + { status: "supported", range: ">=0.129.0" }, + { status: "broken", range: "<0.129.0" }, + ], + }, + { + t3CodeRange: ">=0.0.0", + driver: claudeDriver, + recommendedRange: ">=0.2.111", + recommendedVersion: "0.2.111", + ranges: [{ status: "supported", range: ">=0.2.111" }], + }, + { + t3CodeRange: ">=0.0.0", + driver: opencodeDriver, + recommendedRange: ">=1.14.19", + recommendedVersion: "1.14.19", + ranges: [ + { status: "supported", range: ">=1.14.19" }, + { status: "broken", range: "<1.14.19" }, + ], + }, + { + t3CodeRange: ">=0.0.0", + driver: cursorDriver, + recommendedRange: ">=2026.05.09", + recommendedVersion: "2026.05.09", + ranges: [ + { status: "supported", range: ">=2026.05.09" }, + { status: "unknown", range: "<2026.05.09" }, + ], + }, + ], + }; + + const classify = (driver: ProviderDriverKind, currentVersion: string) => + ProviderCompatibility.createProviderCompatibilityAdvisory({ + driver, + currentVersion, + document, + t3CodeVersion: "0.0.22", + })?.status; + + expect(classify(codexDriver, "0.129.0")).toBe("supported"); + expect(classify(codexDriver, "0.128.0")).toBe("broken"); + expect(classify(claudeDriver, "0.2.111")).toBe("supported"); + expect(classify(opencodeDriver, "1.14.19")).toBe("supported"); + expect(classify(opencodeDriver, "1.14.18")).toBe("broken"); + expect(classify(cursorDriver, "2026.05.09")).toBe("supported"); + expect(classify(cursorDriver, "2026.05.09-0afadcc")).toBe("supported"); + expect(classify(cursorDriver, "2026.05.08")).toBe("unknown"); + }); + + it("marks OpenCode versions above the recommended compatibility cap as broken", () => { + const classify = (currentVersion: string) => + ProviderCompatibility.createProviderCompatibilityAdvisory({ + driver: opencodeDriver, + currentVersion, + })?.status; + + expect(classify("1.14.40")).toBe("supported"); + expect(classify("1.14.41")).toBe("broken"); + }); + + it("matches T3 Code nightly versions against their base release policy", () => { + const document: ProviderCompatibility.ProviderCompatibilityDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.24", + driver: codexDriver, + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "supported", range: ">=0.129.0" }], + }, + ], + }; + + expect( + ProviderCompatibility.createProviderCompatibilityAdvisory({ + driver: codexDriver, + currentVersion: "0.129.0", + document, + t3CodeVersion: "0.0.24-nightly.20260513.1", + })?.status, + ).toBe("supported"); + }); + + it.effect("enriches snapshots from the remote compatibility map when available", () => { + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + ], + }; + + return Effect.gen(function* () { + const enriched = + yield* ProviderCompatibility.enrichProviderSnapshotWithTargetedCompatibilityAdvisory( + baseProvider, + codexNpmUpdateCapabilities, + ).pipe(provideCompatibility(() => ({ payload: remoteDocument }))); + + expect(enriched.status).toBe("error"); + expect(enriched.compatibilityAdvisory).toMatchObject({ + status: "broken", + recommendedVersion: "0.129.0", + canUpdate: true, + updateCommand: "npm install -g @openai/codex@0.129.0", + }); + }); + }); + + it.effect("caches remote compatibility documents within the service layer", () => { + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + ], + }; + const requestedUrls: string[] = []; + + return Effect.gen(function* () { + yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory(baseProvider); + yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory(baseProvider); + + expect(requestedUrls).toEqual([ProviderCompatibility.DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL]); + }).pipe( + provideCompatibility((url) => { + requestedUrls.push(url); + return { payload: remoteDocument }; + }), + ); + }); + + it.effect("does not cache failed remote compatibility fetches", () => { + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + ], + }; + const requestedUrls: string[] = []; + let attempt = 0; + + return Effect.gen(function* () { + yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory(baseProvider); + const enriched = + yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory(baseProvider); + + expect(requestedUrls).toEqual([ + ProviderCompatibility.DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL, + ProviderCompatibility.GITHUB_PROVIDER_COMPATIBILITY_MAP_URL, + ProviderCompatibility.DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL, + ]); + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "broken" }); + }).pipe( + provideCompatibility((url) => { + requestedUrls.push(url); + attempt += 1; + return attempt === 3 ? { payload: remoteDocument } : { payload: {}, status: 404 }; + }), + ); + }); + + it.effect("falls back from the hosted map to the GitHub raw mirror", () => { + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + ], + }; + const requestedUrls: string[] = []; + + return Effect.gen(function* () { + const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory( + baseProvider, + ).pipe( + provideCompatibility((url) => { + requestedUrls.push(url); + return url === ProviderCompatibility.GITHUB_PROVIDER_COMPATIBILITY_MAP_URL + ? { payload: remoteDocument } + : { payload: {}, status: 404 }; + }), + ); + + expect(requestedUrls).toEqual([ + ProviderCompatibility.DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL, + ProviderCompatibility.GITHUB_PROVIDER_COMPATIBILITY_MAP_URL, + ]); + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "broken" }); + }); + }); + + it.effect("falls back to default remote URLs when the env override parses empty", () => { + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + ], + }; + const requestedUrls: string[] = []; + + return Effect.gen(function* () { + const previousOverride = process.env.T3_PROVIDER_COMPATIBILITY_MAP_URL; + process.env.T3_PROVIDER_COMPATIBILITY_MAP_URL = " , "; + const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory( + baseProvider, + ).pipe( + provideCompatibility((url) => { + requestedUrls.push(url); + return url === ProviderCompatibility.DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL + ? { payload: remoteDocument } + : { payload: {}, status: 404 }; + }), + Effect.ensuring( + Effect.sync(() => { + if (previousOverride === undefined) { + delete process.env.T3_PROVIDER_COMPATIBILITY_MAP_URL; + } else { + process.env.T3_PROVIDER_COMPATIBILITY_MAP_URL = previousOverride; + } + }), + ), + ); + + expect(requestedUrls).toEqual([ProviderCompatibility.DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL]); + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "broken" }); + }); + }); + + it.effect("lets the remote map relax a bundled compatibility error", () => { + const bundledMessage = + "This provider harness version 0.130.0 is known to be incompatible with this T3 Code release. Use 0.129.0."; + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: ">=0.130.0", + recommendedVersion: "0.130.0", + ranges: [{ status: "supported", range: ">=0.130.0" }], + }, + ], + }; + + return Effect.gen(function* () { + const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory( + { + ...baseProvider, + status: "error", + message: bundledMessage, + compatibilityAdvisory: { + status: "broken", + severity: "error", + currentVersion: "0.130.0", + message: bundledMessage, + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + }, + }, + ).pipe(provideCompatibility(() => ({ payload: remoteDocument }))); + + expect(enriched.status).toBe("ready"); + expect(enriched.message).toBeUndefined(); + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "supported" }); + }); + }); + + it.effect("preserves genuine probe errors when the remote map relaxes a bundled advisory", () => { + const bundledMessage = + "This provider harness version 0.130.0 is known to be incompatible with this T3 Code release. Use 0.129.0."; + const probeErrorMessage = "CLI health-check failed: process exited with code 1"; + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: ">=0.130.0", + recommendedVersion: "0.130.0", + ranges: [{ status: "supported", range: ">=0.130.0" }], + }, + ], + }; + + return Effect.gen(function* () { + const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory( + { + ...baseProvider, + status: "error", + message: bundledMessage, + compatibilityAdvisory: { + status: "broken", + severity: "error", + currentVersion: "0.130.0", + message: bundledMessage, + recommendedRange: "<0.130.0", + recommendedVersion: "0.129.0", + ranges: [{ status: "broken", range: ">=0.130.0" }], + preAdvisoryStatus: "error", + preAdvisoryMessage: probeErrorMessage, + }, + }, + ).pipe(provideCompatibility(() => ({ payload: remoteDocument }))); + + expect(enriched.status).toBe("error"); + expect(enriched.message).toBe(probeErrorMessage); + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "supported" }); + }); + }); + + it.effect("keeps probe error messages when warning advisories do not change status", () => { + const probeErrorMessage = "Authentication failed"; + const remoteDocument = { + version: 1, + policies: [ + { + t3CodeRange: ">=0.0.0", + driver: "codex", + recommendedRange: ">=0.130.0", + recommendedVersion: "0.130.0", + ranges: [{ status: "graceful", range: ">=0.130.0" }], + }, + ], + }; + + return Effect.gen(function* () { + const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory( + { + ...baseProvider, + status: "error", + message: probeErrorMessage, + }, + ).pipe(provideCompatibility(() => ({ payload: remoteDocument }))); + + expect(enriched.status).toBe("error"); + expect(enriched.message).toBe(probeErrorMessage); + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "graceful" }); + }); + }); + + it.effect("falls back to the bundled map when the remote compatibility fetch fails", () => + Effect.gen(function* () { + const enriched = yield* ProviderCompatibility.enrichProviderSnapshotWithCompatibilityAdvisory( + { + ...baseProvider, + version: "0.128.0", + }, + ).pipe(provideCompatibility(() => ({ payload: {}, status: 404 }))); + + expect(enriched.status).toBe("error"); + expect(enriched.compatibilityAdvisory).toMatchObject({ status: "broken" }); + }), + ); +}); diff --git a/apps/server/src/provider/ProviderCompatibility.ts b/apps/server/src/provider/ProviderCompatibility.ts new file mode 100644 index 00000000000..7d59a6703f3 --- /dev/null +++ b/apps/server/src/provider/ProviderCompatibility.ts @@ -0,0 +1,425 @@ +import { + ProviderDriverKind, + TrimmedNonEmptyString, + type ServerProvider, + type ServerProviderCompatibilityAdvisory, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import bundledCompatibilityDocumentJson from "../../../../provider-compatibility.v1.json" with { type: "json" }; +import packageJson from "../../package.json" with { type: "json" }; +import { + makeTargetedProviderUpdateAction, + type ProviderMaintenanceCapabilities, +} from "./providerMaintenance.ts"; + +const T3_CODE_VERSION = packageJson.version; +const REMOTE_COMPATIBILITY_CACHE_TTL = Duration.minutes(15); +const REMOTE_COMPATIBILITY_TIMEOUT = "2500 millis"; +const REMOTE_COMPATIBILITY_CACHE_CAPACITY = 8; + +export const DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL = + "https://t3.codes/provider-compatibility.v1.json"; +export const GITHUB_PROVIDER_COMPATIBILITY_MAP_URL = + "https://raw.githubusercontent.com/pingdotgg/t3code/main/provider-compatibility.v1.json"; +export const DEFAULT_PROVIDER_COMPATIBILITY_MAP_URLS = [ + DEFAULT_PROVIDER_COMPATIBILITY_MAP_URL, + GITHUB_PROVIDER_COMPATIBILITY_MAP_URL, +] as const; + +const RemoteCompatibilityRange = Schema.Struct({ + status: Schema.Literals(["unknown", "supported", "graceful", "unsupported", "broken"]), + range: TrimmedNonEmptyString, + label: Schema.optional(TrimmedNonEmptyString), +}); + +const RemoteCompatibilityPolicy = Schema.Struct({ + t3CodeRange: TrimmedNonEmptyString, + driver: TrimmedNonEmptyString, + recommendedRange: Schema.NullOr(TrimmedNonEmptyString), + recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + ranges: Schema.Array(RemoteCompatibilityRange), +}); + +const RemoteCompatibilityDocument = Schema.Struct({ + version: Schema.Literal(1), + policies: Schema.Array(RemoteCompatibilityPolicy), +}); + +export type ProviderCompatibilityDocument = typeof RemoteCompatibilityDocument.Type; + +type ProviderCompatibilitySnapshot = Pick & { + readonly compatibilityAdvisory?: ServerProviderCompatibilityAdvisory | undefined; +}; + +const decodeCompatibilityDocument = Schema.decodeUnknownEffect(RemoteCompatibilityDocument); + +/** + * Repo-root compatibility JSON bundled into the app. Used when the remote map + * cannot be fetched or has no matching policy for the current provider/build. + */ +const bundledProviderCompatibilityDocument = Schema.decodeUnknownSync(RemoteCompatibilityDocument)( + bundledCompatibilityDocumentJson, +); + +function remoteCompatibilityMapUrls(): ReadonlyArray { + const configured = process.env.T3_PROVIDER_COMPATIBILITY_MAP_URL?.trim(); + if (!configured) { + return DEFAULT_PROVIDER_COMPATIBILITY_MAP_URLS; + } + const urls = configured + .split(",") + .map((url) => url.trim()) + .filter((url) => url.length > 0); + return urls.length > 0 ? urls : DEFAULT_PROVIDER_COMPATIBILITY_MAP_URLS; +} + +function policyMatches(input: { + readonly policy: typeof RemoteCompatibilityPolicy.Type; + readonly driver: ProviderDriverKind; + readonly t3CodeVersion: string; +}): boolean { + return ( + input.policy.driver === input.driver && + satisfiesSemverRange(input.t3CodeVersion, input.policy.t3CodeRange) + ); +} + +function compatibilityPolicyForDriver(input: { + readonly document: typeof RemoteCompatibilityDocument.Type; + readonly driver: ProviderDriverKind; + readonly t3CodeVersion?: string; +}): typeof RemoteCompatibilityPolicy.Type | null { + const t3CodeVersion = input.t3CodeVersion ?? T3_CODE_VERSION; + return ( + input.document.policies.find((policy) => + policyMatches({ policy, driver: input.driver, t3CodeVersion }), + ) ?? null + ); +} + +function severityForStatus( + status: ServerProviderCompatibilityAdvisory["status"], +): ServerProviderCompatibilityAdvisory["severity"] { + switch (status) { + case "broken": + return "error"; + case "unsupported": + case "graceful": + return "warning"; + case "supported": + case "unknown": + return "info"; + } +} + +function messageForStatus(input: { + readonly status: ServerProviderCompatibilityAdvisory["status"]; + readonly currentVersion: string | null; + readonly recommendedRange: string | null; + readonly recommendedVersion: string | null; +}) { + const current = input.currentVersion ? ` ${input.currentVersion}` : ""; + const recommendedTarget = input.recommendedVersion ?? input.recommendedRange; + const recommended = recommendedTarget ? ` Use ${recommendedTarget}.` : ""; + switch (input.status) { + case "broken": + return `This provider harness version${current} is known to be incompatible with this T3 Code release.${recommended}`; + case "unsupported": + return `This provider harness version${current} is outside the compatibility range for this T3 Code release.${recommended}`; + case "graceful": + return `This provider harness version${current} should still work, but updating is recommended.${recommended}`; + case "unknown": + return `T3 Code could not determine whether this provider harness version is compatible.${recommended}`; + case "supported": + return null; + } +} + +function createProviderCompatibilityAdvisoryFromDocument(input: { + readonly document: typeof RemoteCompatibilityDocument.Type; + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined; + readonly t3CodeVersion?: string; +}): ServerProviderCompatibilityAdvisory | undefined { + const policy = compatibilityPolicyForDriver({ + document: input.document, + driver: input.driver, + ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}), + }); + if (!policy) { + return undefined; + } + + const currentVersion = input.currentVersion; + const matchedRange = + currentVersion === null + ? undefined + : policy.ranges.find((range) => satisfiesSemverRange(currentVersion, range.range)); + const status = matchedRange?.status ?? (currentVersion === null ? "unknown" : "unsupported"); + const recommendedVersion = policy.recommendedVersion ?? null; + const targetedUpdateAction = input.maintenanceCapabilities + ? makeTargetedProviderUpdateAction(input.maintenanceCapabilities, recommendedVersion) + : null; + + return { + status, + severity: severityForStatus(status), + currentVersion: input.currentVersion, + message: messageForStatus({ + status, + currentVersion: input.currentVersion, + recommendedRange: policy.recommendedRange, + recommendedVersion, + }), + recommendedRange: policy.recommendedRange, + recommendedVersion, + updateCommand: targetedUpdateAction?.command ?? null, + canUpdate: targetedUpdateAction !== null, + ranges: [...policy.ranges], + }; +} + +function shouldSkipCompatibilityAdvisory(input: { + readonly snapshot: ProviderCompatibilitySnapshot; + readonly currentVersion: string | null; +}): boolean { + return !input.snapshot.enabled && input.currentVersion === null; +} + +export function createProviderCompatibilityAdvisory(input: { + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly document?: typeof RemoteCompatibilityDocument.Type; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined; + readonly t3CodeVersion?: string; +}): ServerProviderCompatibilityAdvisory | undefined { + return createProviderCompatibilityAdvisoryFromDocument({ + document: input.document ?? bundledProviderCompatibilityDocument, + driver: input.driver, + currentVersion: input.currentVersion, + maintenanceCapabilities: input.maintenanceCapabilities, + ...(input.t3CodeVersion ? { t3CodeVersion: input.t3CodeVersion } : {}), + }); +} + +const fetchRemoteCompatibilityDocument = Effect.fn("fetchRemoteCompatibilityDocument")( + function* (url: string) { + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.get(url).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.setHeader("user-agent", `t3code/${T3_CODE_VERSION}`), + ), + ) + .pipe(Effect.timeoutOption(REMOTE_COMPATIBILITY_TIMEOUT)); + + if (Option.isNone(response)) { + return null; + } + + const httpResponse = response.value; + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return null; + } + + const payload = yield* httpResponse.json.pipe( + Effect.flatMap(decodeCompatibilityDocument), + Effect.catch(() => Effect.succeed(null)), + ); + return payload; + }, + (effect, url) => + effect.pipe( + Effect.tapError((cause) => + Effect.logWarning("provider compatibility map fetch failed", { + cause, + url, + }), + ), + Effect.catch(() => Effect.succeed(null)), + ), +); + +function applyCompatibilityAdvisory( + snapshot: Snapshot, + compatibilityAdvisory: ServerProviderCompatibilityAdvisory | undefined, +): Snapshot { + const baseSnapshot = removeExistingCompatibilityAdvisory(snapshot); + if (!compatibilityAdvisory) { + return baseSnapshot; + } + + const status = + baseSnapshot.enabled && compatibilityAdvisory.severity === "error" + ? "error" + : baseSnapshot.enabled && + compatibilityAdvisory.severity === "warning" && + baseSnapshot.status === "ready" + ? "warning" + : baseSnapshot.status; + const compatibilityMessage = + status !== baseSnapshot.status && compatibilityAdvisory.severity !== "info" + ? (compatibilityAdvisory.message ?? undefined) + : undefined; + const advisoryWithPreState: ServerProviderCompatibilityAdvisory = { + ...compatibilityAdvisory, + preAdvisoryStatus: baseSnapshot.status, + ...(baseSnapshot.message ? { preAdvisoryMessage: baseSnapshot.message } : {}), + }; + + return { + ...baseSnapshot, + status, + ...(compatibilityMessage || baseSnapshot.message + ? { message: compatibilityMessage ?? baseSnapshot.message } + : {}), + compatibilityAdvisory: advisoryWithPreState, + } as Snapshot; +} + +function removeExistingCompatibilityAdvisory( + snapshot: Snapshot, +): Snapshot { + if (!snapshot.compatibilityAdvisory) { + return snapshot; + } + + const { compatibilityAdvisory: existingCompatibilityAdvisory, ...baseSnapshot } = snapshot; + const compatibilityMessage = + existingCompatibilityAdvisory.severity !== "info" + ? (existingCompatibilityAdvisory.message ?? undefined) + : undefined; + if (compatibilityMessage && baseSnapshot.message === compatibilityMessage) { + const { message: _message, ...snapshotWithoutCompatibilityMessage } = baseSnapshot; + const restoredStatus = + existingCompatibilityAdvisory.preAdvisoryStatus ?? (snapshot.enabled ? "ready" : "disabled"); + return { + ...snapshotWithoutCompatibilityMessage, + status: restoredStatus, + ...(existingCompatibilityAdvisory.preAdvisoryMessage + ? { message: existingCompatibilityAdvisory.preAdvisoryMessage } + : {}), + } as Snapshot; + } + if (existingCompatibilityAdvisory.preAdvisoryStatus !== undefined) { + return { + ...baseSnapshot, + status: existingCompatibilityAdvisory.preAdvisoryStatus, + } as Snapshot; + } + return baseSnapshot as Snapshot; +} + +export function applyBundledProviderCompatibilityAdvisory< + Snapshot extends ProviderCompatibilitySnapshot, +>(input: { + readonly snapshot: Snapshot; + readonly driver: ProviderDriverKind; + readonly currentVersion: string | null; + readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined; +}): Snapshot { + if ( + shouldSkipCompatibilityAdvisory({ + snapshot: input.snapshot, + currentVersion: input.currentVersion, + }) + ) { + return removeExistingCompatibilityAdvisory(input.snapshot); + } + + return applyCompatibilityAdvisory( + input.snapshot, + createProviderCompatibilityAdvisory({ + driver: input.driver, + currentVersion: input.currentVersion, + maintenanceCapabilities: input.maintenanceCapabilities, + }), + ); +} + +export const enrichProviderSnapshotWithCompatibilityAdvisory = Effect.fn( + "enrichProviderSnapshotWithCompatibilityAdvisory", +)(function* (snapshot: ServerProvider) { + const compatibility = yield* ProviderCompatibilityService; + return yield* compatibility.enrichSnapshot(snapshot); +}); + +export const enrichProviderSnapshotWithTargetedCompatibilityAdvisory = Effect.fn( + "enrichProviderSnapshotWithTargetedCompatibilityAdvisory", +)(function* (snapshot: ServerProvider, maintenanceCapabilities: ProviderMaintenanceCapabilities) { + const compatibility = yield* ProviderCompatibilityService; + return yield* compatibility.enrichSnapshot(snapshot, maintenanceCapabilities); +}); + +export interface ProviderCompatibilityServiceShape { + readonly resolveRemoteDocument: Effect.Effect; + readonly enrichSnapshot: ( + snapshot: ServerProvider, + maintenanceCapabilities?: ProviderMaintenanceCapabilities | undefined, + ) => Effect.Effect; +} + +export class ProviderCompatibilityService extends Context.Service< + ProviderCompatibilityService, + ProviderCompatibilityServiceShape +>()("t3/provider/ProviderCompatibilityService") {} + +export const makeProviderCompatibilityService = Effect.fn("makeProviderCompatibilityService")( + function* () { + const remoteDocumentCache = yield* Cache.makeWith(fetchRemoteCompatibilityDocument, { + capacity: REMOTE_COMPATIBILITY_CACHE_CAPACITY, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value !== null + ? REMOTE_COMPATIBILITY_CACHE_TTL + : Duration.zero, + }); + + const resolveRemoteDocument = Effect.gen(function* () { + for (const url of remoteCompatibilityMapUrls()) { + const document = yield* Cache.get(remoteDocumentCache, url); + if (document) { + return document; + } + } + + return null; + }); + + return { + resolveRemoteDocument, + enrichSnapshot: (snapshot, maintenanceCapabilities) => + resolveRemoteDocument.pipe( + Effect.map((remoteDocument) => + shouldSkipCompatibilityAdvisory({ + snapshot, + currentVersion: snapshot.version, + }) + ? removeExistingCompatibilityAdvisory(snapshot) + : applyCompatibilityAdvisory( + snapshot, + createProviderCompatibilityAdvisory({ + driver: snapshot.driver, + currentVersion: snapshot.version, + maintenanceCapabilities, + ...(remoteDocument ? { document: remoteDocument } : {}), + }), + ), + ), + ), + } satisfies ProviderCompatibilityServiceShape; + }, +); + +export const layer = Layer.effect(ProviderCompatibilityService, makeProviderCompatibilityService()); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index e032b87a4d0..12151322c01 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -13,6 +13,7 @@ import { makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, + makeTargetedProviderUpdateAction, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, } from "./providerMaintenance.ts"; @@ -132,6 +133,24 @@ describe("providerMaintenance", () => { }); }); + it("targets package install commands that omit an explicit latest suffix", () => { + expect( + makeTargetedProviderUpdateAction( + makeProviderMaintenanceCapabilities({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + updateExecutable: "vp", + updateArgs: ["i", "-g", "@example/package-tool"], + updateLockKey: "vite-plus-global", + }), + "1.2.3", + ), + ).toMatchObject({ + command: "vp i -g @example/package-tool@1.2.3", + args: ["i", "-g", "@example/package-tool@1.2.3"], + }); + }); + it.effect( "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", () => diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 7f5e9d94dc5..c850cc3631e 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -108,6 +108,32 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { }); } +export function makeTargetedProviderUpdateAction( + capabilities: ProviderMaintenanceCapabilities, + targetVersion: string | null, +): ProviderMaintenanceCommandAction | null { + if (!capabilities.update || !capabilities.packageName || !targetVersion) { + return null; + } + + const versionedPackageArg = `${capabilities.packageName}@latest`; + const targetPackageArg = `${capabilities.packageName}@${targetVersion}`; + const packageArgIndex = capabilities.update.args.findIndex( + (arg) => arg === versionedPackageArg || arg === capabilities.packageName, + ); + if (packageArgIndex < 0) { + return null; + } + + const args = [...capabilities.update.args]; + args[packageArgIndex] = targetPackageArg; + return { + ...capabilities.update, + args, + command: [capabilities.update.executable, ...args].join(" "), + }; +} + function makeNpmGlobalProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, ): ProviderMaintenanceCapabilities { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 5f5f975a4e3..ca9f5a00d3e 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -288,6 +288,36 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("runs a targeted package update when a target version is supplied", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const updater = yield* makeTestRunner(registry); + + yield* updater.updateProvider({ + provider: CODEX_DRIVER, + targetVersion: "0.129.0", + }); + + assert.deepStrictEqual(calls, [ + { + command: "npm", + args: ["install", "-g", "@openai/codex@0.129.0"], + }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + latestVersionHttpClient("0.0.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + it.effect( "runs update commands through Effect ChildProcess when no test runner is injected", () => { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 5f76afb34d3..40be630051c 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -22,7 +22,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; -import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makeTargetedProviderUpdateAction, +} from "./providerMaintenance.ts"; import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -46,6 +49,7 @@ export interface ProviderMaintenanceRunnerShape { | { readonly provider: ProviderDriverKind; readonly instanceId?: ProviderInstanceId | undefined; + readonly targetVersion?: string | undefined; }, ) => Effect.Effect; } @@ -283,12 +287,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { typeof target === "string" ? defaultInstanceIdForDriver(provider) : (target.instanceId ?? defaultInstanceIdForDriver(provider)); + const targetVersion = typeof target === "string" ? undefined : target.targetVersion; const targetKey = `instance:${instanceId}`; const capabilities = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, provider, ); - const update = capabilities.update; + const update = targetVersion + ? makeTargetedProviderUpdateAction(capabilities, targetVersion) + : capabilities.update; if (!update) { return yield* new ServerProviderUpdateError({ provider, diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 449dca8fc5a..c72ea454e7a 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { ProviderDriverKind, type ModelCapabilities } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { providerModelsFromSettings } from "./providerSnapshot.ts"; +import { buildServerProvider, providerModelsFromSettings } from "./providerSnapshot.ts"; const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [ @@ -42,3 +42,25 @@ describe("providerModelsFromSettings", () => { ]); }); }); + +describe("buildServerProvider", () => { + it("leaves compatibility unset when no bundled policy matches this T3 Code version", () => { + const provider = buildServerProvider({ + driver: ProviderDriverKind.make("unmapped-test-driver"), + presentation: { displayName: "Test" }, + enabled: true, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + probe: { + installed: true, + version: "0.128.0", + status: "ready", + auth: { status: "authenticated" }, + }, + }); + + expect(provider.status).toBe("ready"); + expect(provider.compatibilityAdvisory).toBeUndefined(); + expect(provider.message).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index c40903e1b45..bd0e588f1e3 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -14,6 +14,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; +import * as ProviderCompatibility from "./ProviderCompatibility.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -208,7 +209,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, }) : undefined; - return { + const snapshot: ServerProviderDraft = { displayName: input.presentation.displayName, ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), ...(typeof input.presentation.showInteractionModeToggle === "boolean" @@ -226,6 +227,13 @@ export function buildServerProvider(input: { skills: [...(input.skills ?? [])], ...(versionAdvisory ? { versionAdvisory } : {}), }; + return input.driver + ? ProviderCompatibility.applyBundledProviderCompatibilityAdvisory({ + snapshot, + driver: input.driver, + currentVersion: input.probe.version, + }) + : snapshot; } export const collectStreamAsString = ( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c6780559204..12be2080e3f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -24,6 +24,7 @@ import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; +import * as ProviderCompatibility from "./provider/ProviderCompatibility.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -258,6 +259,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(ProviderCompatibility.layer), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..9ef13996d52 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -22,10 +22,18 @@ import { TerminalOpenInput, } from "@t3tools/contracts"; import { + beginProviderCompatibilityUpdate, + canRunProviderCompatibilityUpdate, + createProviderCompatibilityUpdateTracker, + endProviderCompatibilityUpdate, + getProviderCompatibilityUpdateCommand, + getProviderCompatibilityUpdateRequest, + isProviderCompatibilityUpdateRunning, parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, + stripProviderCompatibilityInstallHint, } from "@t3tools/client-runtime"; import { applyClaudePromptEffortPrefix, @@ -42,7 +50,7 @@ import { useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; -import { readLocalApi } from "../localApi"; +import { ensureLocalApi, readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { collapseExpandedComposerCursor, @@ -104,7 +112,7 @@ import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { ChevronDownIcon, CircleAlertIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -150,7 +158,6 @@ import { ChatHeader } from "./chat/ChatHeader"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; -import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { @@ -186,6 +193,7 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; import { Button } from "./ui/button"; +import { ProviderUpdateActionPopover } from "./ProviderUpdateActionPopover"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, @@ -200,6 +208,43 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; + +function ProviderCompatibilityBannerAction({ + providerLabel, + recommendedVersion, + updateCommand, + canRunUpdate, + updating, + onRunUpdate, +}: { + readonly providerLabel: string; + readonly recommendedVersion: string; + readonly updateCommand: string | null; + readonly canRunUpdate: boolean; + readonly updating: boolean; + readonly onRunUpdate: () => void; +}) { + return ( + + Install {recommendedVersion} + + } + title={`Install compatible ${providerLabel}`} + detail={`Install provider harness version ${recommendedVersion}.`} + updateCommand={updateCommand} + canRunUpdate={canRunUpdate} + isUpdating={updating} + runLabel="Install now" + runningLabel="Installing" + onRunUpdate={onRunUpdate} + side="top" + align="end" + /> + ); +} + type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; @@ -1150,6 +1195,9 @@ export default function ChatView(props: ChatViewProps) { const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( null, ); + const [compatibilityUpdateTracker, setCompatibilityUpdateTracker] = useState(() => + createProviderCompatibilityUpdateTracker(), + ); const versionMismatchDismissed = versionMismatchDismissKey === dismissedVersionMismatchKey || isVersionMismatchDismissed(versionMismatchDismissKey); @@ -1178,8 +1226,117 @@ export default function ChatView(props: ChatViewProps) { savedEnvironmentRuntimeById, serverConfig?.environment.label, ]); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; + const unlockedSelectedProvider = resolveSelectableProvider( + providerStatuses, + selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), + ); + const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + // Prefer an instance-id match so a custom Codex instance (e.g. + // `codex_personal`) surfaces its own status/message in the banner rather + // than the default Codex's. Falls back to first-match-by-kind when no + // saved instance id is available or the instance no longer exists. + const activeProviderInstanceId = + composerActiveProvider ?? + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + activeProject?.defaultModelSelection?.instanceId ?? + null; + const activeProviderStatus = useMemo(() => { + if (activeProviderInstanceId) { + return ( + providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null + ); + } + const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); + return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; + }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const runActiveProviderCompatibilityUpdate = useCallback(async () => { + const provider = activeProviderStatus; + const request = getProviderCompatibilityUpdateRequest(provider); + if (!provider || !request) { + return; + } + + let started = false; + setCompatibilityUpdateTracker((previous) => { + const result = beginProviderCompatibilityUpdate(previous, provider); + started = result.started; + return result.tracker; + }); + if (!started) { + return; + } + + try { + await ensureLocalApi().server.updateProvider(request); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${provider.displayName ?? provider.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } finally { + setCompatibilityUpdateTracker((current) => endProviderCompatibilityUpdate(current, provider)); + } + }, [activeProviderStatus]); const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; + if ( + activeProviderStatus && + activeProviderStatus.status !== "ready" && + activeProviderStatus.status !== "disabled" + ) { + const defaultMessage = + activeProviderStatus.status === "error" + ? "Provider is unavailable." + : "Provider needs attention."; + const recommendedVersion = + activeProviderStatus.compatibilityAdvisory?.recommendedVersion ?? null; + const compatibilityUpdateCommand = + getProviderCompatibilityUpdateCommand(activeProviderStatus); + const canRunCompatibilityUpdate = canRunProviderCompatibilityUpdate(activeProviderStatus); + const compatibilityUpdating = isProviderCompatibilityUpdateRunning( + compatibilityUpdateTracker, + activeProviderStatus, + ); + const hasCompatibilityInstallAction = + activeProviderStatus.compatibilityAdvisory?.status !== "supported" && + recommendedVersion !== null && + (canRunCompatibilityUpdate || compatibilityUpdateCommand !== null); + const rawMessage = activeProviderStatus.message ?? defaultMessage; + const message = hasCompatibilityInstallAction + ? stripProviderCompatibilityInstallHint(rawMessage, recommendedVersion) + : rawMessage; + const providerLabel = activeProviderStatus.displayName ?? activeProviderStatus.driver; + items.push({ + id: `provider-status:${activeProviderStatus.instanceId}:${activeProviderStatus.status}`, + variant: activeProviderStatus.status === "error" ? "error" : "warning", + icon: , + title: `${providerLabel} provider status`, + description: ( + + {message} + + ), + actions: + hasCompatibilityInstallAction && recommendedVersion ? ( + void runActiveProviderCompatibilityUpdate()} + /> + ) : undefined, + }); + } if (activeEnvironmentUnavailableState) { items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, @@ -1248,20 +1405,17 @@ export default function ChatView(props: ChatViewProps) { return items; }, [ activeEnvironmentUnavailableState, + activeProviderStatus, + compatibilityUpdateTracker, handleReconnectActiveEnvironment, navigate, reconnectingEnvironmentId, + runActiveProviderCompatibilityUpdate, showVersionMismatchBanner, versionMismatch, versionMismatchDismissKey, versionMismatchServerLabel, ]); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; - const unlockedSelectedProvider = resolveSelectableProvider( - providerStatuses, - selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), - ); - const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -1633,24 +1787,6 @@ export default function ChatView(props: ChatViewProps) { const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); - // Prefer an instance-id match so a custom Codex instance (e.g. - // `codex_personal`) surfaces its own status/message in the banner rather - // than the default Codex's. Falls back to first-match-by-kind when no - // saved instance id is available or the instance no longer exists. - const activeProviderInstanceId = - activeThread?.session?.providerInstanceId ?? - activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? - null; - const activeProviderStatus = useMemo(() => { - if (activeProviderInstanceId) { - return ( - providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null - ); - } - const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); - return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; - }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -3540,8 +3676,6 @@ export default function ChatView(props: ChatViewProps) { /> - {/* Error banner */} - setThreadError(activeThread.id, null)} diff --git a/apps/web/src/components/ProviderUpdateActionPopover.tsx b/apps/web/src/components/ProviderUpdateActionPopover.tsx new file mode 100644 index 00000000000..bd78b53d1fe --- /dev/null +++ b/apps/web/src/components/ProviderUpdateActionPopover.tsx @@ -0,0 +1,110 @@ +import type { ReactElement, ReactNode } from "react"; +import { CopyIcon, DownloadIcon, LoaderIcon } from "lucide-react"; + +import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { ScrollArea } from "./ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +interface ProviderUpdateActionPopoverProps { + readonly trigger: ReactElement; + readonly title: ReactNode; + readonly detail: ReactNode; + readonly updateCommand: string | null; + readonly canRunUpdate?: boolean; + readonly isUpdating?: boolean; + readonly runLabel?: string; + readonly runningLabel?: string; + readonly manualDividerLabel?: string; + readonly copyLabel?: string; + readonly onRunUpdate?: (() => void) | undefined; + readonly onCopyCommand?: ((command: string) => void) | undefined; + readonly side?: "top" | "right" | "bottom" | "left"; + readonly align?: "start" | "center" | "end"; +} + +export function ProviderUpdateActionPopover({ + trigger, + title, + detail, + updateCommand, + canRunUpdate = false, + isUpdating = false, + runLabel = "Update now", + runningLabel = "Updating", + manualDividerLabel = "or, update manually using", + copyLabel = "Copy update command", + onRunUpdate, + onCopyCommand, + side = "bottom", + align = "start", +}: ProviderUpdateActionPopoverProps) { + const showRunButton = canRunUpdate && onRunUpdate !== undefined; + const showManualDivider = showRunButton && updateCommand !== null; + + return ( + + + +
+
+

{title}

+

{detail}

+
+ {showRunButton ? ( + + ) : null} + {showManualDivider ? ( +
+ + {manualDividerLabel} + +
+ ) : null} + {updateCommand ? ( +
+ + + {updateCommand} + + + {onCopyCommand ? ( + + onCopyCommand(updateCommand)} + aria-label={copyLabel} + > + + + } + /> + Copy command + + ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 96fc34c1013..5b0c1a7b037 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -301,6 +301,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isSendBusy: boolean; isConnecting: boolean; isEnvironmentUnavailable: boolean; + isProviderUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; @@ -322,6 +323,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isSendBusy={props.isSendBusy} isConnecting={props.isConnecting} isEnvironmentUnavailable={props.isEnvironmentUnavailable} + isProviderUnavailable={props.isProviderUnavailable} isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} @@ -715,6 +717,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const selectedProviderUnavailableMessage = + selectedProviderStatus?.status === "error" + ? (selectedProviderStatus.message ?? "Selected provider is unavailable.") + : null; + const isSelectedProviderUnavailable = selectedProviderUnavailableMessage !== null; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -1614,12 +1621,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (isSelectedProviderUnavailable) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [ + blurMobileComposerAfterSend, + isSelectedProviderUnavailable, + onSend, + shouldBlurMobileComposerOnSubmit, + ], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -2074,6 +2090,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} + isProviderUnavailable={isSelectedProviderUnavailable} isPreparingWorktree={false} hasSendableContent={false} preserveComposerFocusOnPointerDown @@ -2259,12 +2276,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "connecting" : "disconnected" }` - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : selectedProviderUnavailableMessage + ? selectedProviderUnavailableMessage + : phase === "disconnected" + ? "Ask for follow-up changes or attach images" + : "Ask anything, @tag files/folders, $use skills, or / for commands" } disabled={ isConnecting || + isSelectedProviderUnavailable || isComposerApprovalState || (environmentUnavailable !== null && activePendingProgress === null) } @@ -2283,6 +2303,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} + isProviderUnavailable={isSelectedProviderUnavailable} isPreparingWorktree={false} hasSendableContent={false} preserveComposerFocusOnPointerDown @@ -2391,6 +2412,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} + isProviderUnavailable={isSelectedProviderUnavailable} isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index fbeb9de30b8..e6dbc74daaf 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -21,6 +21,7 @@ interface ComposerPrimaryActionsProps { isSendBusy: boolean; isConnecting: boolean; isEnvironmentUnavailable: boolean; + isProviderUnavailable: boolean; isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; @@ -60,6 +61,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isSendBusy, isConnecting, isEnvironmentUnavailable, + isProviderUnavailable, isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, @@ -107,6 +109,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ {...pointerFocusProps} disabled={ isEnvironmentUnavailable || + isProviderUnavailable || pendingAction.isResponding || (pendingAction.isLastQuestion ? !pendingAction.isComplete : !pendingAction.canAdvance) } @@ -146,7 +149,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" className={cn("rounded-full", compact ? "h-9 px-3 sm:h-8" : "h-9 px-4 sm:h-8")} {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable} > {isConnecting || isSendBusy ? "Sending..." : "Refine"} @@ -160,7 +163,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ size="sm" className="h-9 rounded-l-full rounded-r-none px-4 sm:h-8" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable} > {isConnecting || isSendBusy ? "Sending..." : "Implement"} @@ -173,7 +176,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ className="h-9 rounded-l-none rounded-r-full border-l-white/12 px-2 sm:h-8" aria-label="Implementation actions" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable} + disabled={ + isSendBusy || isConnecting || isEnvironmentUnavailable || isProviderUnavailable + } /> } > @@ -181,7 +186,9 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ void onImplementPlanInNewThread()} > Implement in a new thread @@ -197,17 +204,25 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="submit" className="flex h-9 w-9 enabled:cursor-pointer items-center justify-center rounded-full bg-primary/90 text-primary-foreground transition-all duration-150 hover:bg-primary hover:scale-105 disabled:pointer-events-none disabled:opacity-30 disabled:hover:scale-100 sm:h-8 sm:w-8" {...pointerFocusProps} - disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent} + disabled={ + isSendBusy || + isConnecting || + isEnvironmentUnavailable || + isProviderUnavailable || + !hasSendableContent + } aria-label={ - isEnvironmentUnavailable - ? "Environment disconnected" - : isConnecting - ? "Connecting" - : isPreparingWorktree - ? "Preparing worktree" - : isSendBusy - ? "Sending" - : "Send message" + isProviderUnavailable + ? "Provider unavailable" + : isEnvironmentUnavailable + ? "Environment disconnected" + : isConnecting + ? "Connecting" + : isPreparingWorktree + ? "Preparing worktree" + : isSendBusy + ? "Sending" + : "Send message" } > {isConnecting || isSendBusy ? ( diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx deleted file mode 100644 index a882942585f..00000000000 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { type ServerProvider } from "@t3tools/contracts"; -import { memo } from "react"; -import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; -import { CircleAlertIcon } from "lucide-react"; -import { formatProviderDriverKindLabel } from "../../providerModels"; - -export const ProviderStatusBanner = memo(function ProviderStatusBanner({ - status, -}: { - status: ServerProvider | null; -}) { - if (!status || status.status === "ready" || status.status === "disabled") { - return null; - } - - const providerLabel = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); - const defaultMessage = - status.status === "error" - ? `${providerLabel} provider is unavailable.` - : `${providerLabel} provider has limited availability.`; - const title = `${providerLabel} provider status`; - - return ( -
- - - {title} - - {status.message ?? defaultMessage} - - -
- ); -}); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index b48412453ce..53bf4acca7f 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,7 +1,8 @@ import { memo } from "react"; -import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { CircleAlertIcon, XIcon } from "lucide-react"; +import { Alert, AlertAction, AlertDescription } from "../ui/alert"; + export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, onDismiss, @@ -11,7 +12,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ }) { if (!error) return null; return ( -
+
diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 430ec3637e0..5c76c128176 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,11 +1,9 @@ "use client"; import { + AlertTriangleIcon, ArrowUpCircleIcon, ChevronDownIcon, - CopyIcon, - DownloadIcon, - LoaderIcon, PlusIcon, Trash2Icon, XIcon, @@ -28,19 +26,20 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { DraftInput } from "../ui/draft-input"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { ScrollArea } from "../ui/scroll-area"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { ProviderUpdateActionPopover } from "../ProviderUpdateActionPopover"; import type { DriverOption } from "./providerDriverMeta"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { + getProviderCompatibilityUpdateCommand, getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, + getProviderCompatibilityAdvisoryPresentation, getProviderSummary, getProviderVersionLabel, type ProviderStatusKey, @@ -56,6 +55,8 @@ const PROVIDER_ACCENT_SWATCHES = [ ] as const; const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const ADVISORY_ICON_CLUSTER_CLASS = "-my-0.5 inline-flex h-5 shrink-0 items-center gap-2"; +const ADVISORY_ICON_SLOT_CLASS = "inline-flex size-5 shrink-0 items-center justify-center"; let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -421,6 +422,8 @@ interface ProviderInstanceCardProps { readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; readonly isUpdating?: boolean | undefined; + readonly onRunCompatibilityUpdate?: (() => void) | undefined; + readonly isCompatibilityUpdating?: boolean | undefined; } /** @@ -465,6 +468,8 @@ export function ProviderInstanceCard({ onModelOrderChange, onRunUpdate, isUpdating = false, + onRunCompatibilityUpdate, + isCompatibilityUpdating = false, }: ProviderInstanceCardProps) { const enabled = instance.enabled ?? true; // The server-reported status wins when present; otherwise fall back to @@ -482,8 +487,13 @@ export function ProviderInstanceCard({ : null; const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); + const compatibilityAdvisory = getProviderCompatibilityAdvisoryPresentation( + liveProvider?.compatibilityAdvisory, + ); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); + const hasProviderAdvisoryIcons = compatibilityAdvisory !== null || versionAdvisory !== null; const updateCommand = versionAdvisory?.updateCommand ?? null; + const compatibilityUpdateCommand = getProviderCompatibilityUpdateCommand(liveProvider); const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); @@ -677,101 +687,97 @@ export function ProviderInstanceCard({
{titleHeadNode} - {versionCodeNode} - {versionAdvisory ? ( - - - - - } - /> - -
-
-

- Update available -

-

- {versionAdvisory.detail} -

-
- {onRunUpdate ? ( - - ) : null} - {onRunUpdate && updateCommand ? ( -
- - or, update manually using - -
- ) : null} - {updateCommand ? ( -
- - - {updateCommand} - - - - - copyToClipboard(updateCommand, { - providerName: displayName, - }) - } - aria-label="Copy update command" - > - - - } - /> - Copy command - -
- ) : null} -
-
-
+ {versionCodeNode || hasProviderAdvisoryIcons ? ( + + {versionCodeNode} + {compatibilityAdvisory ? ( + + + + + } + title={compatibilityAdvisory.title} + detail={ + + {compatibilityAdvisory.detail} + + } + updateCommand={compatibilityUpdateCommand} + canRunUpdate={onRunCompatibilityUpdate !== undefined} + isUpdating={isCompatibilityUpdating} + onRunUpdate={onRunCompatibilityUpdate} + copyLabel="Copy compatibility update command" + onCopyCommand={(command) => + copyToClipboard(command, { + providerName: displayName, + }) + } + /> + + ) : null} + {versionAdvisory ? ( + + + + + } + title="Update available" + detail={ + + {versionAdvisory.detail} + + } + updateCommand={updateCommand} + canRunUpdate={onRunUpdate !== undefined} + isUpdating={isUpdating} + onRunUpdate={onRunUpdate} + onCopyCommand={(command) => + copyToClipboard(command, { + providerName: displayName, + }) + } + /> + + ) : null} + ) : null} {titleTailNode}
diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 34f24006edc..faea25567fc 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -239,7 +239,7 @@ function createBaseServerConfig(): ServerConfig { function createOutdatedProvider( driver: string, - updateCommand = "npm install -g openai/codex@latest", + updateCommand = "npm install -g @openai/codex@latest", ): ServerProvider { return { instanceId: ProviderInstanceId.make(driver), @@ -1177,6 +1177,64 @@ describe("GeneralSettingsPanel observability", () => { }); }); + it("runs targeted compatibility updates from the provider card", async () => { + const updateProvider = vi.fn().mockResolvedValue({ + providers: [], + }); + window.nativeApi = { + persistence: { + getClientSettings: vi.fn().mockResolvedValue(null), + setClientSettings: vi.fn().mockResolvedValue(undefined), + }, + server: { + updateProvider, + }, + } as unknown as LocalApi; + const incompatibleProvider: ServerProvider = { + ...createOutdatedProvider("codex"), + version: "0.128.0", + status: "error", + message: + "This provider harness version 0.128.0 is known to be incompatible with this T3 Code release. Use 0.129.0.", + compatibilityAdvisory: { + status: "broken", + severity: "error", + currentVersion: "0.128.0", + message: + "This provider harness version 0.128.0 is known to be incompatible with this T3 Code release. Use 0.129.0.", + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + canUpdate: true, + ranges: [{ status: "broken", range: "<0.129.0" }], + }, + }; + + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [incompatibleProvider], + }); + + mounted = await render( + + + , + ); + + await page + .getByRole("button", { name: "Incompatible provider version — view details" }) + .click(); + await expect + .element(page.getByText("npm install -g @openai/codex@0.129.0")) + .toBeInTheDocument(); + await page.getByRole("button", { name: "Update now" }).click(); + + expect(updateProvider).toHaveBeenCalledWith({ + provider: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), + targetVersion: "0.129.0", + }); + }); + it("keeps long provider update commands inside the fixed-width popover", async () => { const longUpdateCommand = "npm install -g @anthropic-ai/claude-code@latest --registry=https://registry.npmjs.org --cache=/tmp/t3code-provider-update-cache"; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e7f21da4809..9d322621306 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -10,8 +10,16 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, + type ServerProvider, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { + beginProviderCompatibilityUpdate, + createProviderCompatibilityUpdateTracker, + endProviderCompatibilityUpdate, + getProviderCompatibilityUpdateRequest, + isProviderCompatibilityUpdateRunning, + scopeThreadRef, +} from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; @@ -63,6 +71,10 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { + canRunProviderCompatibilityUpdate, + getProviderCompatibilityUpdateCommand, +} from "./providerStatus"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, @@ -923,6 +935,9 @@ export function ProviderSettingsPanel() { const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet >(() => new Set()); + const [compatibilityUpdateTracker, setCompatibilityUpdateTracker] = useState(() => + createProviderCompatibilityUpdateTracker(), + ); const [openInstanceDetails, setOpenInstanceDetails] = useState>({}); const refreshingRef = useRef(false); @@ -1010,6 +1025,42 @@ export function ProviderSettingsPanel() { } }, []); + const runProviderCompatibilityUpdate = useCallback(async (provider: ServerProvider) => { + const request = getProviderCompatibilityUpdateRequest(provider); + if (!request) { + return; + } + + let started = false; + setCompatibilityUpdateTracker((previous) => { + const result = beginProviderCompatibilityUpdate(previous, provider); + started = result.started; + return result.tracker; + }); + if (!started) { + return; + } + + try { + await ensureLocalApi().server.updateProvider(request); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not update ${PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver}`, + description: + error instanceof Error + ? error.message + : "The provider update command could not be started.", + }), + ); + } finally { + setCompatibilityUpdateTracker((previous) => + endProviderCompatibilityUpdate(previous, provider), + ); + } + }, []); + interface InstanceRow { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -1245,6 +1296,19 @@ export function ProviderSettingsPanel() { updateCandidate !== undefined && canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && !updatingProviderDrivers.has(updateCandidate.driver); + const compatibilityUpdateCommand = getProviderCompatibilityUpdateCommand(liveProvider); + const showInlineCompatibilityUpdateButton = + canRunProviderCompatibilityUpdate(liveProvider) && + compatibilityUpdateCommand !== null && + Boolean(liveProvider?.compatibilityAdvisory?.recommendedVersion); + const isCompatibilityUpdateRunning = + liveProvider !== undefined && + (isProviderCompatibilityUpdateRunning(compatibilityUpdateTracker, liveProvider) || + isProviderUpdateActive(liveProvider)); + const canRunInlineCompatibilityUpdate = + showInlineCompatibilityUpdateButton && + liveProvider !== undefined && + !isCompatibilityUpdateRunning; const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { hiddenModels: [], modelOrder: [], @@ -1318,6 +1382,19 @@ export function ProviderSettingsPanel() { : undefined } isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} + onRunCompatibilityUpdate={ + showInlineCompatibilityUpdateButton && liveProvider + ? () => { + if (!canRunInlineCompatibilityUpdate) { + return; + } + void runProviderCompatibilityUpdate(liveProvider); + } + : undefined + } + isCompatibilityUpdating={ + showInlineCompatibilityUpdateButton ? isCompatibilityUpdateRunning : undefined + } /> ); })} diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts new file mode 100644 index 00000000000..75af979c9e1 --- /dev/null +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + getProviderCompatibilityAdvisoryPresentation, + getProviderCompatibilityUpdateCommand, +} from "./providerStatus"; + +describe("getProviderCompatibilityAdvisoryPresentation", () => { + it("hides supported compatibility advisories", () => { + expect( + getProviderCompatibilityAdvisoryPresentation({ + status: "supported", + severity: "info", + currentVersion: "0.129.0", + message: null, + recommendedRange: ">=0.129.0", + ranges: [], + }), + ).toBeNull(); + }); + + it("presents broken compatibility advisories strongly", () => { + expect( + getProviderCompatibilityAdvisoryPresentation({ + status: "broken", + severity: "error", + currentVersion: "0.128.0", + message: "Known incompatible.", + recommendedRange: ">=0.129.0", + ranges: [], + }), + ).toEqual({ + title: "Incompatible provider version", + detail: "Known incompatible.", + updateCommand: null, + canUpdate: false, + emphasis: "strong", + }); + }); + + it("derives targeted compatibility update commands from package install commands", () => { + expect( + getProviderCompatibilityUpdateCommand({ + compatibilityAdvisory: { + status: "broken", + severity: "error", + currentVersion: "0.128.0", + message: "Known incompatible.", + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + ranges: [], + }, + versionAdvisory: { + status: "behind_latest", + currentVersion: "0.128.0", + latestVersion: "0.130.0", + checkedAt: "2026-05-13T00:00:00.000Z", + message: "Update available.", + updateCommand: "vp i -g @openai/codex", + canUpdate: true, + }, + }), + ).toBe("vp i -g @openai/codex@0.129.0"); + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 06622a761b7..8f9b4d53e07 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -1,4 +1,14 @@ -import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; +import type { ServerProvider } from "@t3tools/contracts"; + +export { + canRunProviderCompatibilityUpdate, + getProviderCompatibilityAdvisoryPresentation, + getProviderCompatibilityUpdateCommand, + getProviderCompatibilityUpdateRequest, + getProviderVersionAdvisoryPresentation, + getProviderVersionLabel, + stripProviderCompatibilityInstallHint, +} from "@t3tools/client-runtime"; /** * Visual treatment for each server-reported provider status. Centralized so @@ -79,39 +89,3 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "Installed and ready, but authentication could not be verified.", }; } - -/** - * Normalize a version string for display. Adds the `v` prefix when the - * driver reported a bare version (e.g. `1.2.3`) so cards render - * consistently regardless of driver. - */ -export function getProviderVersionLabel(version: string | null | undefined) { - if (!version) return null; - return version.startsWith("v") ? version : `v${version}`; -} - -export function getProviderVersionAdvisoryPresentation( - advisory: ServerProviderVersionAdvisory | undefined, -): { - readonly detail: string; - readonly updateCommand: string | null; - readonly emphasis: "normal" | "strong"; -} | null { - if (!advisory || advisory.status === "current" || advisory.status === "unknown") { - return null; - } - - const label = "Update available"; - const version = advisory.latestVersion; - const versionLabel = getProviderVersionLabel(version); - - return { - detail: - advisory.message ?? - (versionLabel - ? `${label}: install ${versionLabel}.` - : `${label}: install the latest provider version.`), - updateCommand: advisory.updateCommand, - emphasis: "normal" as const, - }; -} diff --git a/bun.lock b/bun.lock index ffc4a5922bd..74a344e1c10 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "apps/desktop": { "name": "@t3tools/desktop", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "@effect/platform-node": "catalog:", "@t3tools/contracts": "workspace:*", @@ -45,12 +45,13 @@ }, "devDependencies": { "@astrojs/check": "^0.9.7", + "@vercel/config": "^0.3.0", "typescript": "catalog:", }, }, "apps/server": { "name": "t3", - "version": "0.0.23", + "version": "0.0.24", "bin": { "t3": "./dist/bin.mjs", }, @@ -83,7 +84,7 @@ }, "apps/web": { "name": "@t3tools/web", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "@base-ui/react": "^1.4.1", "@dnd-kit/core": "^6.3.1", @@ -165,7 +166,7 @@ }, "packages/contracts": { "name": "@t3tools/contracts", - "version": "0.0.23", + "version": "0.0.24", "dependencies": { "effect": "catalog:", }, @@ -972,7 +973,7 @@ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -1408,7 +1409,7 @@ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -2112,7 +2113,7 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -2120,6 +2121,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@anthropic-ai/claude-agent-sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@astrojs/language-server/@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], "@astrojs/yaml2ts/yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], @@ -2154,6 +2157,10 @@ "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "@rolldown/plugin-babel/rolldown": ["rolldown@1.0.0-rc.9", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.9" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q=="], @@ -2184,26 +2191,24 @@ "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], - "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], - "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@vercel/config/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "@vercel/routing-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "@vercel/routing-utils/path-to-regexp": ["path-to-regexp@6.1.0", "", {}, "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw=="], "@vitest/browser/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ajv-draft-04/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.2", "", { "dependencies": { "@babel/types": "^8.0.0-rc.2" }, "bin": "./bin/babel-parser.js" }, "sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ=="], + "astro/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], @@ -2242,6 +2247,8 @@ "vscode-json-languageservice/jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "yaml-language-server/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], @@ -2254,6 +2261,8 @@ "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -2322,7 +2331,9 @@ "@tanstack/router-plugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "ajv-draft-04/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.2", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.2", "@babel/helper-validator-identifier": "^8.0.0-rc.2" } }, "sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw=="], @@ -2392,6 +2403,8 @@ "vite/rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="], + "yaml-language-server/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], diff --git a/docs/providers/compatibility.md b/docs/providers/compatibility.md new file mode 100644 index 00000000000..e60e685a7c4 --- /dev/null +++ b/docs/providers/compatibility.md @@ -0,0 +1,177 @@ +# Provider Compatibility Map + +This guide is for maintainers updating `provider-compatibility.v1.json`. + +T3 Code bundles this file at build time and also fetches a hosted copy at +runtime. The hosted copy is the maintainer-overridable source of truth: old +installs can receive newer compatibility policy without updating the app. The +fetch is best effort, so the bundled copy must remain conservative and useful +when the user is offline or hosted endpoints are unavailable. + +## When To Update It + +Update the map when a provider harness release changes compatibility with a T3 +Code release. + +Examples: + +- Codex ships an app-server breaking change. +- OpenCode changes its SDK or CLI behavior. +- Cursor changes ACP behavior. +- A new T3 Code release restores support for a provider version that older T3 + Code releases cannot support. + +## Policy Shape + +Each policy applies to one provider driver and one T3 Code version range. + +```json +{ + "t3CodeRange": ">=0.0.24 <0.0.25", + "driver": "codex", + "recommendedRange": ">=0.129.0 <0.131.0", + "recommendedVersion": "0.130.0", + "ranges": [ + { + "status": "supported", + "range": ">=0.129.0 <0.131.0", + "label": "Known working Codex app-server harness" + }, + { + "status": "broken", + "range": "<0.129.0", + "label": "Known incompatible Codex app-server harness" + }, + { + "status": "broken", + "range": ">=0.131.0", + "label": "Known incompatible Codex app-server harness" + } + ] +} +``` + +Fields: + +- `t3CodeRange`: T3 Code versions this policy applies to. +- `driver`: provider driver id. Current ids include `codex`, `claudeAgent`, + `opencode`, and `cursor`. +- `recommendedRange`: the harness range users should be on. +- `recommendedVersion`: a concrete version to suggest when possible. +- `ranges`: ordered harness-version classifications. + +Statuses: + +- `supported`: known working. +- `graceful`: still expected to work, but users should update. +- `unsupported`: outside the maintained range. +- `broken`: known incompatible; the app shows an error-level advisory. +- `unknown`: not tested or not enough signal yet. + +## Version Ranges + +The range syntax is intentionally small: + +- comparators: `=`, `<`, `<=`, `>`, `>=`, `^` +- spaces mean AND, for example `>=0.129.0 <0.131.0` +- `||` means OR +- two-segment versions are treated as patch zero, for example `>=24.10` + +Provider and T3 Code prerelease suffixes are ignored for compatibility +comparison. For example: + +- `2026.05.09-0afadcc` matches `>=2026.05.09` +- `0.0.24-nightly.20260513.1` matches `>=0.0.24` + +## Handling Provider Breakages + +If Codex `0.131.0` breaks T3 Code `0.0.24`, narrow the existing Codex policy for +that app release and mark the new Codex range as broken: + +```json +{ + "t3CodeRange": ">=0.0.24 <0.0.25", + "driver": "codex", + "recommendedRange": ">=0.129.0 <0.131.0", + "recommendedVersion": "0.130.0", + "ranges": [ + { "status": "supported", "range": ">=0.129.0 <0.131.0" }, + { "status": "broken", "range": "<0.129.0" }, + { "status": "broken", "range": ">=0.131.0" } + ] +} +``` + +If T3 Code `0.0.25` fixes that breakage, add a separate policy for newer app +versions instead of leaving a single broad `>=0.0.24` policy: + +```json +{ + "t3CodeRange": ">=0.0.25", + "driver": "codex", + "recommendedRange": ">=0.131.0", + "recommendedVersion": "0.131.0", + "ranges": [{ "status": "supported", "range": ">=0.131.0" }] +} +``` + +Keep T3 Code ranges as narrow as needed. A broad app range such as `>=0.0.24` +means future app releases inherit the same provider policy until the hosted map +is updated again. + +## Full Breakage Recovery Flow + +Use this sequence when a provider release breaks existing T3 Code installs. + +1. A provider ships a breaking harness release. +2. Update the hosted map on `main` so existing T3 Code installs classify that + provider version correctly. For example, mark Codex `>=0.131.0` as `broken` + for `t3CodeRange: ">=0.0.24 <0.0.25"`. +3. Existing installs fetch the hosted map best-effort and show the compatibility + advisory without requiring an app update. +4. Ship a T3 Code fix in a new app release. +5. Update the bundled `provider-compatibility.v1.json` in that release so fresh + installs and offline users have the fixed policy baked in. +6. Update the hosted map again with a separate policy for the fixed T3 Code + version. For example, add `t3CodeRange: ">=0.0.25"` that marks Codex + `>=0.131.0` as `supported`. + +The hosted map is the emergency override for already-released builds. The +bundled map is the conservative fallback for future downloads, fresh installs, +and offline sessions. + +## Hosting + +The default runtime URLs are tried in order: + +```text +https://t3.codes/provider-compatibility.v1.json +https://raw.githubusercontent.com/pingdotgg/t3code/main/provider-compatibility.v1.json +``` + +The marketing app build mirrors the repo-root `provider-compatibility.v1.json` +into `apps/marketing/public/provider-compatibility.v1.json`, so marketing +deployments publish the primary `https://t3.codes/...` copy as a static asset. +The GitHub raw URL remains a fallback mirror. + +## Updating The Hosted Map + +The primary hosted map is: + +```text +https://t3.codes/provider-compatibility.v1.json +``` + +To update compatibility policy for existing installs: + +1. Edit `provider-compatibility.v1.json` on `main`. +2. Keep the JSON schema version at `1`. +3. Prefer non-overlapping `t3CodeRange` values when app releases differ. +4. Include a concrete `recommendedVersion` when a one-click install target is + known. +5. Ensure the marketing app release/deploy runs so the primary static copy is + updated. +6. Validate with `bun fmt`, `bun lint`, and `bun typecheck`. + +Old installs cache the remote map briefly, so hosted changes are not always +visible immediately. diff --git a/docs/release.md b/docs/release.md index df9033218b9..c75f47e6bdd 100644 --- a/docs/release.md +++ b/docs/release.md @@ -24,6 +24,8 @@ This document covers the unified release workflow for stable and nightly desktop - Publishes the CLI package (`apps/server`, npm package `t3`) with OIDC trusted publishing from the same workflow file: - stable releases publish npm dist-tag `latest` - nightly releases publish npm dist-tag `nightly` +- Uses `provider-compatibility.v1.json` as the hosted provider harness compatibility map. See + `docs/providers/compatibility.md` before changing compatibility policy. - Deploys the hosted web app to Vercel only after a release is published: - stable releases are aliased to the `latest` hosted app channel - nightly releases are aliased to the `nightly` hosted app channel diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index cb7472dff51..50df626f1d1 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -1,4 +1,5 @@ export * from "./advertisedEndpoint.ts"; export * from "./knownEnvironment.ts"; +export * from "./providerAdvisory.ts"; export * from "./scoped.ts"; export * from "./sourceControlDiscoveryState.ts"; diff --git a/packages/client-runtime/src/providerAdvisory.test.ts b/packages/client-runtime/src/providerAdvisory.test.ts new file mode 100644 index 00000000000..a975e7a4d40 --- /dev/null +++ b/packages/client-runtime/src/providerAdvisory.test.ts @@ -0,0 +1,92 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { + beginProviderCompatibilityUpdate, + canRunProviderCompatibilityUpdate, + createProviderCompatibilityUpdateTracker, + endProviderCompatibilityUpdate, + getProviderCompatibilityUpdateCommand, + getProviderCompatibilityUpdateRequest, + isProviderCompatibilityUpdateRunning, +} from "./providerAdvisory.ts"; + +const codexProvider = { + driver: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), + compatibilityAdvisory: { + status: "broken", + severity: "error", + currentVersion: "0.128.0", + message: "Known incompatible.", + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + canUpdate: true, + updateCommand: "npm install -g @openai/codex@0.129.0", + ranges: [], + }, + versionAdvisory: { + status: "behind_latest", + currentVersion: "0.128.0", + latestVersion: "0.130.0", + checkedAt: "2026-05-13T00:00:00.000Z", + message: "Update available.", + updateCommand: "vp i -g @openai/codex", + canUpdate: true, + }, +} satisfies Pick< + ServerProvider, + "driver" | "instanceId" | "compatibilityAdvisory" | "versionAdvisory" +>; + +describe("provider advisory runtime", () => { + it("derives manual targeted compatibility update commands from package install commands", () => { + expect( + getProviderCompatibilityUpdateCommand({ + ...codexProvider, + compatibilityAdvisory: { + ...codexProvider.compatibilityAdvisory, + updateCommand: null, + }, + }), + ).toBe("vp i -g @openai/codex@0.129.0"); + }); + + it("uses server capability, not manual command derivation, for one-click compatibility updates", () => { + const provider = { + ...codexProvider, + compatibilityAdvisory: { + ...codexProvider.compatibilityAdvisory, + canUpdate: false, + updateCommand: null, + }, + }; + + expect(getProviderCompatibilityUpdateCommand(provider)).toBe("vp i -g @openai/codex@0.129.0"); + expect(canRunProviderCompatibilityUpdate(provider)).toBe(false); + expect(getProviderCompatibilityUpdateRequest(provider)).toBeNull(); + }); + + it("builds targeted update requests when the server reports a runnable compatibility update", () => { + expect(getProviderCompatibilityUpdateRequest(codexProvider)).toEqual({ + provider: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), + targetVersion: "0.129.0", + }); + }); + + it("tracks compatibility update state by provider instance", () => { + const tracker = createProviderCompatibilityUpdateTracker(); + const started = beginProviderCompatibilityUpdate(tracker, codexProvider); + + expect(started.started).toBe(true); + expect(isProviderCompatibilityUpdateRunning(started.tracker, codexProvider)).toBe(true); + expect(beginProviderCompatibilityUpdate(started.tracker, codexProvider).started).toBe(false); + expect( + isProviderCompatibilityUpdateRunning( + endProviderCompatibilityUpdate(started.tracker, codexProvider), + codexProvider, + ), + ).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/providerAdvisory.ts b/packages/client-runtime/src/providerAdvisory.ts new file mode 100644 index 00000000000..17e726977b7 --- /dev/null +++ b/packages/client-runtime/src/providerAdvisory.ts @@ -0,0 +1,205 @@ +import type { + ProviderDriverKind, + ProviderInstanceId, + ServerProvider, + ServerProviderCompatibilityAdvisory, + ServerProviderVersionAdvisory, +} from "@t3tools/contracts"; + +export interface ProviderVersionAdvisoryPresentation { + readonly detail: string; + readonly updateCommand: string | null; + readonly emphasis: "normal" | "strong"; +} + +export interface ProviderCompatibilityAdvisoryPresentation { + readonly title: string; + readonly detail: string; + readonly updateCommand: string | null; + readonly canUpdate: boolean; + readonly emphasis: "normal" | "strong"; +} + +export interface ProviderCompatibilityUpdateRequest { + readonly provider: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + readonly targetVersion: string; +} + +export interface ProviderCompatibilityUpdateTracker { + readonly updatingInstanceIds: ReadonlySet; +} + +/** + * Normalize a version string for display. Adds the `v` prefix when the driver + * reported a bare version (e.g. `1.2.3`) so clients render consistently. + */ +export function getProviderVersionLabel(version: string | null | undefined) { + if (!version) return null; + return version.startsWith("v") ? version : `v${version}`; +} + +export function getProviderVersionAdvisoryPresentation( + advisory: ServerProviderVersionAdvisory | undefined, +): ProviderVersionAdvisoryPresentation | null { + if (!advisory || advisory.status === "current" || advisory.status === "unknown") { + return null; + } + + const label = "Update available"; + const version = advisory.latestVersion; + const versionLabel = getProviderVersionLabel(version); + + return { + detail: + advisory.message ?? + (versionLabel + ? `${label}: install ${versionLabel}.` + : `${label}: install the latest provider version.`), + updateCommand: advisory.updateCommand, + emphasis: "normal", + }; +} + +function makeTargetedUpdateCommand(input: { + readonly updateCommand: string | null | undefined; + readonly recommendedVersion: string | null | undefined; +}): string | null { + if (!input.updateCommand || !input.recommendedVersion) { + return null; + } + if (!input.updateCommand.includes("@latest")) { + const packageNameMatch = input.updateCommand.match( + /(?:^|\s)(@[^\s]+\/[^\s@]+|[^\s@]+)(?=\s*$)/, + ); + if (!packageNameMatch?.[1]) { + return null; + } + return input.updateCommand.replace( + packageNameMatch[1], + `${packageNameMatch[1]}@${input.recommendedVersion}`, + ); + } + return input.updateCommand.replace("@latest", `@${input.recommendedVersion}`); +} + +export function getProviderCompatibilityUpdateCommand( + provider: Pick | null | undefined, +): string | null { + const compatibilityAdvisory = provider?.compatibilityAdvisory; + if (!compatibilityAdvisory || compatibilityAdvisory.status === "supported") { + return null; + } + return ( + compatibilityAdvisory.updateCommand ?? + makeTargetedUpdateCommand({ + updateCommand: provider.versionAdvisory?.updateCommand, + recommendedVersion: compatibilityAdvisory.recommendedVersion, + }) + ); +} + +export function canRunProviderCompatibilityUpdate( + provider: Pick | null | undefined, +): boolean { + const compatibilityAdvisory = provider?.compatibilityAdvisory; + return Boolean( + compatibilityAdvisory && + compatibilityAdvisory.status !== "supported" && + compatibilityAdvisory.canUpdate === true && + compatibilityAdvisory.recommendedVersion, + ); +} + +export function getProviderCompatibilityUpdateRequest( + provider: + | Pick + | null + | undefined, +): ProviderCompatibilityUpdateRequest | null { + const targetVersion = provider?.compatibilityAdvisory?.recommendedVersion ?? null; + if (!provider || !targetVersion || !canRunProviderCompatibilityUpdate(provider)) { + return null; + } + return { + provider: provider.driver, + instanceId: provider.instanceId, + targetVersion, + }; +} + +export function getProviderCompatibilityAdvisoryPresentation( + advisory: ServerProviderCompatibilityAdvisory | undefined, +): ProviderCompatibilityAdvisoryPresentation | null { + if (!advisory || advisory.status === "supported") { + return null; + } + + const recommendedTarget = advisory.recommendedVersion ?? advisory.recommendedRange; + const recommended = recommendedTarget ? ` Recommended: ${recommendedTarget}.` : ""; + const fallback = + advisory.status === "unknown" + ? `Compatibility unknown.${recommended}` + : `This provider harness is outside the supported range.${recommended}`; + + return { + title: + advisory.status === "broken" ? "Incompatible provider version" : "Provider version warning", + detail: advisory.message ?? fallback, + updateCommand: advisory.updateCommand ?? null, + canUpdate: advisory.canUpdate === true, + emphasis: advisory.severity === "error" ? "strong" : "normal", + }; +} + +export function stripProviderCompatibilityInstallHint( + message: string, + recommendedVersion: string | null, +) { + if (!recommendedVersion) { + return message; + } + const escapedVersion = recommendedVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return message.replace(new RegExp(`\\s*Use\\s+${escapedVersion}\\.?\\s*$`, "i"), "").trim(); +} + +export function createProviderCompatibilityUpdateTracker( + updatingInstanceIds: Iterable = [], +): ProviderCompatibilityUpdateTracker { + return { updatingInstanceIds: new Set(updatingInstanceIds) }; +} + +export function isProviderCompatibilityUpdateRunning( + tracker: ProviderCompatibilityUpdateTracker, + provider: Pick | null | undefined, +): boolean { + return Boolean(provider && tracker.updatingInstanceIds.has(provider.instanceId)); +} + +export function beginProviderCompatibilityUpdate( + tracker: ProviderCompatibilityUpdateTracker, + provider: Pick, +): { readonly tracker: ProviderCompatibilityUpdateTracker; readonly started: boolean } { + if (tracker.updatingInstanceIds.has(provider.instanceId)) { + return { tracker, started: false }; + } + return { + tracker: createProviderCompatibilityUpdateTracker([ + ...tracker.updatingInstanceIds, + provider.instanceId, + ]), + started: true, + }; +} + +export function endProviderCompatibilityUpdate( + tracker: ProviderCompatibilityUpdateTracker, + provider: Pick, +): ProviderCompatibilityUpdateTracker { + if (!tracker.updatingInstanceIds.has(provider.instanceId)) { + return tracker; + } + const next = new Set(tracker.updatingInstanceIds); + next.delete(provider.instanceId); + return createProviderCompatibilityUpdateTracker(next); +} diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index a2ad0cbb383..d9aa5fa5d6b 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -71,4 +71,39 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); + + it("decodes provider compatibility advisories", () => { + const parsed = decodeServerProvider({ + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + version: "0.128.0", + status: "error", + auth: { + status: "authenticated", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + compatibilityAdvisory: { + status: "broken", + severity: "error", + currentVersion: "0.128.0", + message: "Known incompatible.", + recommendedRange: ">=0.129.0", + recommendedVersion: "0.129.0", + ranges: [ + { + status: "supported", + range: ">=0.129.0", + label: "Known working", + }, + ], + }, + }); + + expect(parsed.compatibilityAdvisory?.status).toBe("broken"); + expect(parsed.compatibilityAdvisory?.recommendedVersion).toBe("0.129.0"); + expect(parsed.compatibilityAdvisory?.ranges).toHaveLength(1); + }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 85ff4a4b2cb..db9ccac53e2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -134,6 +134,42 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ }); export type ServerProviderVersionAdvisory = typeof ServerProviderVersionAdvisory.Type; +export const ServerProviderCompatibilityStatus = Schema.Literals([ + "unknown", + "supported", + "graceful", + "unsupported", + "broken", +]); +export type ServerProviderCompatibilityStatus = typeof ServerProviderCompatibilityStatus.Type; + +export const ServerProviderCompatibilitySeverity = Schema.Literals(["info", "warning", "error"]); +export type ServerProviderCompatibilitySeverity = typeof ServerProviderCompatibilitySeverity.Type; + +export const ServerProviderCompatibilityRange = Schema.Struct({ + status: ServerProviderCompatibilityStatus, + range: TrimmedNonEmptyString, + label: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderCompatibilityRange = typeof ServerProviderCompatibilityRange.Type; + +export const ServerProviderCompatibilityAdvisory = Schema.Struct({ + status: ServerProviderCompatibilityStatus, + severity: ServerProviderCompatibilitySeverity, + currentVersion: Schema.NullOr(TrimmedNonEmptyString), + message: Schema.NullOr(TrimmedNonEmptyString), + recommendedRange: Schema.NullOr(TrimmedNonEmptyString), + recommendedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + updateCommand: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), + canUpdate: Schema.optionalKey(Schema.Boolean), + ranges: Schema.Array(ServerProviderCompatibilityRange).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + preAdvisoryStatus: Schema.optionalKey(ServerProviderState), + preAdvisoryMessage: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type; + export const ServerProviderUpdateStatus = Schema.Literals([ "idle", "queued", @@ -187,6 +223,7 @@ export const ServerProvider = Schema.Struct({ ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), + compatibilityAdvisory: Schema.optionalKey(ServerProviderCompatibilityAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), }); export type ServerProvider = typeof ServerProvider.Type; @@ -542,6 +579,7 @@ export type ServerProviderUpdatedPayload = typeof ServerProviderUpdatedPayload.T export const ServerProviderUpdateInput = Schema.Struct({ provider: ProviderDriverKind, instanceId: Schema.optionalKey(ProviderInstanceId), + targetVersion: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerProviderUpdateInput = typeof ServerProviderUpdateInput.Type; diff --git a/provider-compatibility.v1.json b/provider-compatibility.v1.json new file mode 100644 index 00000000000..c415fdfbf76 --- /dev/null +++ b/provider-compatibility.v1.json @@ -0,0 +1,77 @@ +{ + "version": 1, + "policies": [ + { + "t3CodeRange": ">=0.0.24", + "driver": "codex", + "recommendedRange": ">=0.129.0", + "recommendedVersion": "0.129.0", + "ranges": [ + { + "status": "supported", + "range": ">=0.129.0", + "label": "Known working Codex app-server harness" + }, + { + "status": "broken", + "range": "<0.129.0", + "label": "Known incompatible Codex app-server harness" + } + ] + }, + { + "t3CodeRange": ">=0.0.24", + "driver": "claudeAgent", + "recommendedRange": ">=0.2.111", + "recommendedVersion": "0.2.111", + "ranges": [ + { + "status": "supported", + "range": ">=0.2.111", + "label": "Known working Claude harness" + } + ] + }, + { + "t3CodeRange": ">=0.0.24", + "driver": "opencode", + "recommendedRange": ">=1.14.19 <1.14.41", + "recommendedVersion": "1.14.40", + "ranges": [ + { + "status": "broken", + "range": ">1.14.40", + "label": "Known incompatible OpenCode harness" + }, + { + "status": "supported", + "range": ">=1.14.19 <=1.14.40", + "label": "Known working OpenCode harness" + }, + { + "status": "broken", + "range": "<1.14.19", + "label": "Known incompatible OpenCode harness" + } + ] + }, + { + "t3CodeRange": ">=0.0.24", + "driver": "cursor", + "recommendedRange": ">=2026.05.09", + "recommendedVersion": "2026.05.09", + "ranges": [ + { + "status": "supported", + "range": ">=2026.05.09", + "label": "Known working Cursor harness" + }, + { + "status": "unknown", + "range": "<2026.05.09", + "label": "Untested Cursor harness" + } + ] + } + ] +} diff --git a/scripts/mirror-provider-compatibility-map.ts b/scripts/mirror-provider-compatibility-map.ts new file mode 100644 index 00000000000..9d05a35f9ba --- /dev/null +++ b/scripts/mirror-provider-compatibility-map.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +const decodeJson = Schema.decodeEffect(Schema.UnknownFromJsonString); + +const program = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const repoRoot = path.resolve(import.meta.dirname, ".."); + const sourcePath = path.join(repoRoot, "provider-compatibility.v1.json"); + const destinationPath = path.join( + repoRoot, + "apps", + "marketing", + "public", + "provider-compatibility.v1.json", + ); + + yield* Effect.flatMap(fs.readFileString(sourcePath), decodeJson); + + yield* fs.makeDirectory(path.dirname(destinationPath), { recursive: true }); + yield* fs.copyFile(sourcePath, destinationPath); + + yield* Effect.logInfo( + `Mirrored ${path.relative(repoRoot, sourcePath)} to ${path.relative(repoRoot, destinationPath)}.`, + ); +}); + +if (import.meta.main) { + program.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain); +}