diff --git a/.gitignore b/.gitignore index e16286c073..000f46963a 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 1763a00102..6a3a82d7a8 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "astro dev", - "build": "astro build", + "build": "bun ../../scripts/mirror-provider-compatibility-map.ts && astro build", "preview": "astro preview", "typecheck": "astro check" }, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e3f15d865c..5f92e70a36 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 48bc19e561..33d6b8558a 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 b399f9aa94..a7fbecde4c 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 816e8b70f5..910779dd29 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 035c08437a..29479cc818 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 86f99c9732..02a52abd79 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 455cac1eb0..51fcb80cfb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -47,6 +47,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); @@ -70,6 +71,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, @@ -979,6 +983,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 @@ -1054,6 +1059,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` @@ -1157,6 +1163,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), @@ -1208,6 +1215,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 0000000000..aac01d5600 --- /dev/null +++ b/apps/server/src/provider/ProviderCompatibility.test.ts @@ -0,0 +1,420 @@ +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("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("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("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 0000000000..0673bbd7fc --- /dev/null +++ b/apps/server/src/provider/ProviderCompatibility.ts @@ -0,0 +1,413 @@ +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)), + ), +); + +export const resolveRemoteProviderCompatibilityDocument = Effect.fn( + "resolveRemoteProviderCompatibilityDocument", +)(function* () { + const compatibility = yield* ProviderCompatibilityService; + return yield* compatibility.resolveRemoteDocument; +}); + +function applyCompatibilityAdvisory( + snapshot: Snapshot, + compatibilityAdvisory: ServerProviderCompatibilityAdvisory | undefined, +): Snapshot { + const baseSnapshot = removeExistingCompatibilityAdvisory(snapshot); + if (!compatibilityAdvisory) { + return baseSnapshot; + } + + const compatibilityMessage = + compatibilityAdvisory.severity !== "info" + ? (compatibilityAdvisory.message ?? undefined) + : undefined; + const status = + snapshot.enabled && compatibilityAdvisory.severity === "error" + ? "error" + : snapshot.enabled && + compatibilityAdvisory.severity === "warning" && + baseSnapshot.status === "ready" + ? "warning" + : baseSnapshot.status; + + return { + ...baseSnapshot, + status, + ...(compatibilityMessage || baseSnapshot.message + ? { message: compatibilityMessage ?? baseSnapshot.message } + : {}), + compatibilityAdvisory, + } 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; + return { + ...snapshotWithoutCompatibilityMessage, + status: snapshot.enabled ? "ready" : "disabled", + } 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) ? 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 e032b87a4d..12151322c0 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 7f5e9d94dc..c850cc3631 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 5f5f975a4e..ca9f5a00d3 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 5f76afb34d..40be630051 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 449dca8fc5..7b3268d270 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("codex"), + presentation: { displayName: "Codex" }, + 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 c40903e1b4..bd0e588f1e 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 813cdae731..28e93d9bf6 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"; @@ -257,6 +258,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 6b84aa11ca..5c9231dda7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -42,7 +42,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 +104,14 @@ 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, + DownloadIcon, + LoaderIcon, + TriangleAlertIcon, + WifiOffIcon, +} from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -147,7 +154,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 { @@ -183,6 +189,11 @@ import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; import { Button } from "./ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { + canRunProviderCompatibilityUpdate, + getProviderCompatibilityUpdateCommand, +} from "./settings/providerStatus"; import { buildVersionMismatchDismissalKey, dismissVersionMismatch, @@ -197,6 +208,75 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; + +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(); +} + +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} + + } + /> + +
+
+

+ Install compatible {providerLabel} +

+

+ Install provider harness version {recommendedVersion}. +

+
+ {canRunUpdate ? ( + + ) : null} + {updateCommand ? ( +
+ + {updateCommand} + +
+ ) : null} +
+
+
+ ); +} + type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; @@ -1150,6 +1230,8 @@ export default function ChatView(props: ChatViewProps) { const [dismissedVersionMismatchKey, setDismissedVersionMismatchKey] = useState( null, ); + const [compatibilityUpdatingProviderId, setCompatibilityUpdatingProviderId] = + useState(null); const versionMismatchDismissed = versionMismatchDismissKey === dismissedVersionMismatchKey || isVersionMismatchDismissed(versionMismatchDismissKey); @@ -1178,8 +1260,112 @@ 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 targetVersion = provider?.compatibilityAdvisory?.recommendedVersion ?? null; + if (!provider || !targetVersion) { + return; + } + + setCompatibilityUpdatingProviderId(provider.instanceId); + try { + await ensureLocalApi().server.updateProvider({ + provider: provider.driver, + instanceId: provider.instanceId, + targetVersion, + }); + } 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 { + setCompatibilityUpdatingProviderId((current) => + current === provider.instanceId ? null : current, + ); + } + }, [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 = + compatibilityUpdatingProviderId === activeProviderStatus.instanceId; + 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 +1434,17 @@ export default function ChatView(props: ChatViewProps) { return items; }, [ activeEnvironmentUnavailableState, + activeProviderStatus, + compatibilityUpdatingProviderId, 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 +1816,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 +3705,6 @@ export default function ChatView(props: ChatViewProps) { /> - {/* Error banner */} - setThreadError(activeThread.id, null)} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2c4743de3c..b4feb7870a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -302,6 +302,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isSendBusy: boolean; isConnecting: boolean; isEnvironmentUnavailable: boolean; + isProviderUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; onPreviousPendingQuestion: () => void; @@ -323,6 +324,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} @@ -716,6 +718,11 @@ export const ChatComposer = memo( () => 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], @@ -1053,7 +1060,11 @@ export const ChatComposer = memo( [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; + phase === "running" || + isSendBusy || + isConnecting || + isSelectedProviderUnavailable || + !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1619,12 +1630,21 @@ export const ChatComposer = memo( 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) { @@ -2079,6 +2099,7 @@ export const ChatComposer = memo( isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} + isProviderUnavailable={isSelectedProviderUnavailable} isPreparingWorktree={false} hasSendableContent={false} preserveComposerFocusOnPointerDown @@ -2265,12 +2286,15 @@ export const ChatComposer = memo( ? "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) } @@ -2289,6 +2313,7 @@ export const ChatComposer = memo( isSendBusy={isSendBusy} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} + isProviderUnavailable={isSelectedProviderUnavailable} isPreparingWorktree={false} hasSendableContent={false} preserveComposerFocusOnPointerDown @@ -2405,6 +2430,7 @@ export const ChatComposer = memo( 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 fbeb9de30b..e6dbc74daa 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 a882942585..0000000000 --- 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 b48412453c..53bf4acca7 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 9a00ccb75a..2a12b39803 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,6 +1,7 @@ "use client"; import { + AlertTriangleIcon, ArrowUpCircleIcon, ChevronDownIcon, CopyIcon, @@ -38,8 +39,10 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { + getProviderCompatibilityUpdateCommand, getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, + getProviderCompatibilityAdvisoryPresentation, getProviderSummary, getProviderVersionLabel, type ProviderStatusKey, @@ -55,6 +58,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++}`; @@ -420,6 +425,8 @@ interface ProviderInstanceCardProps { readonly onModelOrderChange: (next: ReadonlyArray) => void; readonly onRunUpdate?: (() => void) | undefined; readonly isUpdating?: boolean | undefined; + readonly onRunCompatibilityUpdate?: (() => void) | undefined; + readonly isCompatibilityUpdating?: boolean | undefined; } /** @@ -464,6 +471,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 @@ -481,8 +490,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); @@ -676,95 +690,200 @@ 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 ? ( + + + + + + } + /> + +
+
+

+ {compatibilityAdvisory.title} +

+

+ {compatibilityAdvisory.detail} +

+
+ {onRunCompatibilityUpdate ? ( + + ) : null} + {onRunCompatibilityUpdate && compatibilityUpdateCommand ? ( +
+ + or, update manually using + +
+ ) : null} + {compatibilityUpdateCommand ? ( +
+ + {compatibilityUpdateCommand} + + + + copyToClipboard(compatibilityUpdateCommand, { + providerName: displayName, + }) + } + aria-label="Copy compatibility update command" + > + + + } + /> + Copy command + +
+ ) : null} +
+
+
+
+ ) : null} + {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} +
+
+
+
+ ) : null} +
) : null} {titleTailNode}
diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 4abefc5425..1b800efd64 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -255,7 +255,7 @@ function createOutdatedProvider(driver: string): ServerProvider { latestVersion: "1.1.0", message: "Update available.", checkedAt: "2026-05-04T10:00:00.000Z", - updateCommand: "npm install -g openai/codex@latest", + updateCommand: "npm install -g @openai/codex@latest", canUpdate: true, }, }; @@ -1155,6 +1155,63 @@ describe("GeneralSettingsPanel observability", () => { instanceId: ProviderInstanceId.make("codex"), }); }); + + 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", + 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", + }); + }); }); describe("SourceControlSettingsPanel discovery states", () => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 505be5c73f..2de02fe1ba 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -10,6 +10,7 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, + type ServerProvider, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -63,6 +64,10 @@ import { type ProviderUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; +import { + canRunProviderCompatibilityUpdate, + getProviderCompatibilityUpdateCommand, +} from "./providerStatus"; import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, @@ -1010,6 +1015,55 @@ export function ProviderSettingsPanel() { } }, []); + const runProviderCompatibilityUpdate = useCallback(async (provider: ServerProvider) => { + const targetVersion = provider.compatibilityAdvisory?.recommendedVersion ?? null; + if (!targetVersion) { + return; + } + + let started = false; + setUpdatingProviderDrivers((previous) => { + if (previous.has(provider.driver)) { + return previous; + } + started = true; + const next = new Set(previous); + next.add(provider.driver); + return next; + }); + if (!started) { + return; + } + + try { + await ensureLocalApi().server.updateProvider({ + provider: provider.driver, + instanceId: provider.instanceId, + targetVersion, + }); + } 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 { + setUpdatingProviderDrivers((previous) => { + if (!previous.has(provider.driver)) { + return previous; + } + const next = new Set(previous); + next.delete(provider.driver); + return next; + }); + } + }, []); + interface InstanceRow { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -1245,6 +1299,22 @@ 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 && + (updatingProviderDrivers.has(liveProvider.driver) || + serverProviders.some( + (provider) => + provider.driver === liveProvider.driver && isProviderUpdateActive(provider), + )); + const canRunInlineCompatibilityUpdate = + showInlineCompatibilityUpdateButton && + liveProvider !== undefined && + !updatingProviderDrivers.has(liveProvider.driver); const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { hiddenModels: [], modelOrder: [], @@ -1318,6 +1388,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 0000000000..75af979c9e --- /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 06622a761b..bef3d14e5b 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -1,4 +1,8 @@ -import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/contracts"; +import type { + ServerProvider, + ServerProviderCompatibilityAdvisory, + ServerProviderVersionAdvisory, +} from "@t3tools/contracts"; /** * Visual treatment for each server-reported provider status. Centralized so @@ -115,3 +119,85 @@ export function getProviderVersionAdvisoryPresentation( emphasis: "normal" as const, }; } + +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; + if (!compatibilityAdvisory || compatibilityAdvisory.status === "supported") { + return false; + } + return ( + compatibilityAdvisory.canUpdate === true || + (provider?.versionAdvisory?.canUpdate === true && + getProviderCompatibilityUpdateCommand(provider) !== null) + ); +} + +export function getProviderCompatibilityAdvisoryPresentation( + advisory: ServerProviderCompatibilityAdvisory | undefined, +): { + readonly title: string; + readonly detail: string; + readonly updateCommand: string | null; + readonly canUpdate: boolean; + readonly emphasis: "normal" | "strong"; +} | 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", + }; +} diff --git a/docs/providers/compatibility.md b/docs/providers/compatibility.md new file mode 100644 index 0000000000..e60e685a7c --- /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 44ad113510..87cd409594 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/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index a2ad0cbb38..d9aa5fa5d6 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 0081c00ac7..32fc4ec619 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -134,6 +134,40 @@ 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([])), + ), +}); +export type ServerProviderCompatibilityAdvisory = typeof ServerProviderCompatibilityAdvisory.Type; + export const ServerProviderUpdateStatus = Schema.Literals([ "idle", "queued", @@ -187,6 +221,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; @@ -490,6 +525,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 0000000000..883b8f74a0 --- /dev/null +++ b/provider-compatibility.v1.json @@ -0,0 +1,72 @@ +{ + "version": 1, + "policies": [ + { + "t3CodeRange": ">=0.0.0", + "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.0", + "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.0", + "driver": "opencode", + "recommendedRange": ">=1.14.19", + "recommendedVersion": "1.14.19", + "ranges": [ + { + "status": "supported", + "range": ">=1.14.19", + "label": "Known working OpenCode harness" + }, + { + "status": "broken", + "range": "<1.14.19", + "label": "Known incompatible OpenCode harness" + } + ] + }, + { + "t3CodeRange": ">=0.0.0", + "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 0000000000..42d2ec95c6 --- /dev/null +++ b/scripts/mirror-provider-compatibility-map.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Console from "effect/Console"; +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", + ); + + const source = yield* fs.readFileString(sourcePath); + yield* decodeJson(source); + + yield* fs.makeDirectory(path.dirname(destinationPath), { recursive: true }); + yield* fs.copyFile(sourcePath, destinationPath); + + yield* Console.log( + `Mirrored ${path.relative(repoRoot, sourcePath)} to ${path.relative(repoRoot, destinationPath)}.`, + ); +}); + +if (import.meta.main) { + program.pipe(Effect.provide(NodeServices.layer), NodeRuntime.runMain); +}