From 68a4c6e3b37c69993e03c5917dc080e2faddf1e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 28 Sep 2026 18:35:55 -0700 Subject: [PATCH 1/3] fix(provider): mark OpenCode v2 incompatible --- .../src/features/threads/ThreadComposer.tsx | 6 ++- apps/server/src/provider/model-manifest.json | 8 +-- .../provider/providerCompatibility.test.ts | 49 +++++++++++++++++++ .../providerMaintenanceRunner.test.ts | 6 ++- .../chat/ProviderStatusBanner.test.ts | 33 +++++++++++++ .../components/chat/ProviderStatusBanner.tsx | 18 +++++-- 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index d131dfaa51f1..d4a845c197a0 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -663,7 +663,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? "assertive" : "polite" } - className="px-3 py-2 text-xs text-foreground" + className={ + selectedProviderStatus.compatibilityAdvisory.status === "broken" + ? "bg-danger px-3 py-2 text-xs text-danger-foreground" + : "px-3 py-2 text-xs text-foreground" + } > {selectedProviderStatus.compatibilityAdvisory.message} diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 87efe519ea1c..97734ea49060 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-09-28T20:00:00Z", + "updatedAt": "2026-09-29T01:03:44Z", "compatibility": [ { "driver": "codex", @@ -43,9 +43,11 @@ { "driver": "opencode", "t3CodeRange": ">=0.0.42", - "recommendedRange": ">=1.14.19", + "recommendedRange": ">=1.14.19 <2.0.0", + "recommendedVersion": "1.14.19", "ranges": [ - { "range": ">=1.14.19", "status": "supported" }, + { "range": ">=2.0.0", "status": "broken" }, + { "range": ">=1.14.19 <2.0.0", "status": "supported" }, { "range": "<1.14.19", "status": "broken" } ] }, diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 4d438a77b54e..27c8df49e5a3 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -83,6 +83,55 @@ describe("provider compatibility", () => { } }); + it("marks OpenCode 2 broken and recommends a supported downgrade", () => { + const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility; + const opencode = ProviderDriverKind.make("opencode"); + for (const t3CodeVersion of ["0.0.42", "0.0.43-nightly.20260924.2200"]) { + for (const [version, expected] of [ + ["1.14.18", "broken"], + ["1.14.19", "supported"], + ["1.99.0", "supported"], + ["2.0.0", "broken"], + ["2.0.3", "broken"], + ["v2.0.3", "broken"], + ["3.0.0", "broken"], + ] as const) { + assert.strictEqual( + resolveProviderCompatibility(bundled, opencode, version, t3CodeVersion)?.status, + expected, + `T3 Code ${t3CodeVersion} with OpenCode ${version}`, + ); + } + } + const broken = resolveProviderCompatibility(bundled, opencode, "2.0.3"); + assert.strictEqual(broken?.recommendedRange, ">=1.14.19 <2.0.0"); + assert.strictEqual(broken?.recommendedVersion, "1.14.19"); + assert.include(broken?.message ?? "", "Use 1.14.19."); + assert.strictEqual( + resolveProviderCompatibility(bundled, opencode, broken?.recommendedVersion ?? null)?.status, + "supported", + ); + const snapshot = applyProviderCompatibility( + { + ...provider, + driver: opencode, + version: "1.14.19", + versionAdvisory: { + status: "behind_latest", + currentVersion: "1.14.19", + latestVersion: "2.0.3", + canUpdate: true, + updateCommand: "npm install -g opencode-ai@latest", + checkedAt: provider.checkedAt, + message: null, + }, + }, + bundled, + [], + ); + assert.strictEqual(snapshot.compatibilityAdvisory?.latestVersionStatus, "broken"); + }); + it("compares Cursor build dates without treating semver prereleases as stable", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorPolicy: ProviderCompatibilityPolicy = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 998c301cb1fb..1f9acfb052f3 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -216,7 +216,11 @@ const makeTestRunner = ( manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: {}, - compatibility: [{ driver: CODEX_DRIVER, t3CodeRange: ">=0.0.42", ranges: [] }], + compatibility: [CODEX_DRIVER, OPENCODE_DRIVER].map((driver) => ({ + driver, + t3CodeRange: ">=0.0.42", + ranges: [], + })), }, ) => Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe( diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.ts b/apps/web/src/components/chat/ProviderStatusBanner.test.ts index 0ebaf180b51a..b638ac85dcff 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.ts +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.ts @@ -56,6 +56,39 @@ describe("compatibility banners", () => { ).toBeNull(); }); + it("shows downgrade guidance instead of a broken OpenCode inventory timeout", () => { + const message = "This provider version is known to be incompatible. Use 1.14.19."; + const broken: ServerProvider = { + ...provider, + driver: ProviderDriverKind.make("opencode"), + version: "2.0.3", + status: "error", + auth: { status: "unknown" }, + message: "Failed to load OpenCode provider inventory: Timed out waiting for server start.", + compatibilityAdvisory: { + status: "broken", + message, + recommendedVersion: "1.14.19", + recommendedRange: ">=1.14.19 <2.0.0", + }, + }; + expect(shouldShowProviderStatusBanner(broken, null)).toBe(true); + expect(getProviderStatusMessage(broken)).toBe(message); + expect( + getProviderStatusMessage({ + ...broken, + compatibilityAdvisory: { + ...broken.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }), + ).toBe(broken.message); + expect(getProviderStatusMessage({ ...broken, auth: { status: "unauthenticated" } })).toBe( + broken.message, + ); + }); + it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => { const unauthenticated: ServerProvider = { ...provider, diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 58d723c9a92a..55a60773ec2a 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -53,8 +53,15 @@ export function hasProviderSetup(status: ServerProvider): boolean { ); } -/** Keep the environment's error intact in both the banner and model picker. */ +/** Broken-version guidance takes precedence over startup failures it can cause. */ export function getProviderStatusMessage(status: ServerProvider): string { + if ( + status.auth.status !== "unauthenticated" && + status.compatibilityAdvisory?.status === "broken" && + status.compatibilityAdvisory.message + ) { + return status.compatibilityAdvisory.message; + } if (status.message) return status.message; const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); if (!status.installed && hasProviderSetup(status)) { @@ -90,14 +97,19 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; - const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null; + const compatibility = getIncompatibleVersion(status); + const incompatible = + !isUnauthenticated && (status.status === "ready" || compatibility?.status === "broken") + ? compatibility + : null; const title = isUnauthenticated ? `${providerName} is unauthenticated` : incompatible ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}` : `${providerName} provider status`; const message = incompatible?.message ?? getProviderStatusMessage(status); - const isWarning = status.status === "warning" || incompatible !== null; + const isWarning = + incompatible?.status !== "broken" && (status.status === "warning" || incompatible !== null); return (
From 0229856f6f68902ac81a1859613c83b15ec7e7a4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 28 Sep 2026 18:38:46 -0700 Subject: [PATCH 2/3] fix(web): show compatibility changes after banner dismissal --- .../chat/ProviderStatusBanner.test.ts | 21 ++++++++++++++++ .../components/chat/ProviderStatusBanner.tsx | 24 ++++++++++--------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.ts b/apps/web/src/components/chat/ProviderStatusBanner.test.ts index b638ac85dcff..a500549e7f7d 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.ts +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.ts @@ -73,6 +73,27 @@ describe("compatibility banners", () => { }, }; expect(shouldShowProviderStatusBanner(broken, null)).toBe(true); + const timeoutOnly: ServerProvider = { + ...broken, + compatibilityAdvisory: { + ...broken.compatibilityAdvisory!, + status: "supported", + message: null, + }, + }; + expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(timeoutOnly))).toBe( + true, + ); + expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(broken))).toBe(false); + expect( + shouldShowProviderStatusBanner( + { + ...broken, + compatibilityAdvisory: { ...broken.compatibilityAdvisory!, message: "Use 1.14.20." }, + }, + getProviderStatusBannerKey(broken), + ), + ).toBe(true); expect(getProviderStatusMessage(broken)).toBe(message); expect( getProviderStatusMessage({ diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index 55a60773ec2a..c83aef7caf51 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -9,19 +9,25 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** Unsupported and broken versions fail mid-turn, so they warn even when ready. */ function getIncompatibleVersion(status: ServerProvider) { const compatibility = status.compatibilityAdvisory; - return compatibility?.status === "unsupported" || compatibility?.status === "broken" + if (status.status === "error" && status.auth.status === "unauthenticated") return null; + return compatibility?.status === "broken" || + (status.status === "ready" && compatibility?.status === "unsupported") ? compatibility : null; } export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { if (!status || status.status === "disabled") return null; - if (status.status === "ready") { - const incompatible = getIncompatibleVersion(status); - return incompatible - ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000") - : null; + const incompatible = getIncompatibleVersion(status); + if (incompatible) { + return [ + status.instanceId, + incompatible.status, + status.version ?? "", + incompatible.message ?? "", + ].join("\u0000"); } + if (status.status === "ready") return null; // Antigravity checks saved credentials when a session starts. Its local // health check leaves auth unknown after a restart, which is not a failure. if ( @@ -97,11 +103,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; - const compatibility = getIncompatibleVersion(status); - const incompatible = - !isUnauthenticated && (status.status === "ready" || compatibility?.status === "broken") - ? compatibility - : null; + const incompatible = getIncompatibleVersion(status); const title = isUnauthenticated ? `${providerName} is unauthenticated` : incompatible From 1642bcfc71f24b230a04aadbceb7924fb70153e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 28 Sep 2026 18:45:52 -0700 Subject: [PATCH 3/3] test(provider): decouple compatibility tests from manifest data --- .../provider/providerCompatibility.test.ts | 67 ------------------- .../providerMaintenanceRunner.test.ts | 2 +- 2 files changed, 1 insertion(+), 68 deletions(-) diff --git a/apps/server/src/provider/providerCompatibility.test.ts b/apps/server/src/provider/providerCompatibility.test.ts index 27c8df49e5a3..3a43356d2caf 100644 --- a/apps/server/src/provider/providerCompatibility.test.ts +++ b/apps/server/src/provider/providerCompatibility.test.ts @@ -65,73 +65,6 @@ describe("provider compatibility", () => { } }); - it("supports Codex 0.156 and marks Codex without Thread.projectId broken", () => { - const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility; - for (const [t3CodeVersion, codexVersion, expected] of [ - ["0.0.42", "0.148.0", "broken"], - ["0.0.42", "0.149.0", "unsupported"], - ["0.0.42", "0.155.0", "unsupported"], - ["0.0.42", "0.156.0", "supported"], - ["0.0.43-nightly.20260924.2200", "0.153.3", "unsupported"], - ["0.0.43-nightly.20260924.2200", "0.156.1", "supported"], - ] as const) { - assert.strictEqual( - resolveProviderCompatibility(bundled, driver, codexVersion, t3CodeVersion)?.status, - expected, - `T3 Code ${t3CodeVersion} with Codex ${codexVersion}`, - ); - } - }); - - it("marks OpenCode 2 broken and recommends a supported downgrade", () => { - const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility; - const opencode = ProviderDriverKind.make("opencode"); - for (const t3CodeVersion of ["0.0.42", "0.0.43-nightly.20260924.2200"]) { - for (const [version, expected] of [ - ["1.14.18", "broken"], - ["1.14.19", "supported"], - ["1.99.0", "supported"], - ["2.0.0", "broken"], - ["2.0.3", "broken"], - ["v2.0.3", "broken"], - ["3.0.0", "broken"], - ] as const) { - assert.strictEqual( - resolveProviderCompatibility(bundled, opencode, version, t3CodeVersion)?.status, - expected, - `T3 Code ${t3CodeVersion} with OpenCode ${version}`, - ); - } - } - const broken = resolveProviderCompatibility(bundled, opencode, "2.0.3"); - assert.strictEqual(broken?.recommendedRange, ">=1.14.19 <2.0.0"); - assert.strictEqual(broken?.recommendedVersion, "1.14.19"); - assert.include(broken?.message ?? "", "Use 1.14.19."); - assert.strictEqual( - resolveProviderCompatibility(bundled, opencode, broken?.recommendedVersion ?? null)?.status, - "supported", - ); - const snapshot = applyProviderCompatibility( - { - ...provider, - driver: opencode, - version: "1.14.19", - versionAdvisory: { - status: "behind_latest", - currentVersion: "1.14.19", - latestVersion: "2.0.3", - canUpdate: true, - updateCommand: "npm install -g opencode-ai@latest", - checkedAt: provider.checkedAt, - message: null, - }, - }, - bundled, - [], - ); - assert.strictEqual(snapshot.compatibilityAdvisory?.latestVersionStatus, "broken"); - }); - it("compares Cursor build dates without treating semver prereleases as stable", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorPolicy: ProviderCompatibilityPolicy = { diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 1f9acfb052f3..194dd4fb4ba1 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -216,7 +216,7 @@ const makeTestRunner = ( manifest: ModelManifest.ModelManifestData = { version: 1, currentModels: {}, - compatibility: [CODEX_DRIVER, OPENCODE_DRIVER].map((driver) => ({ + compatibility: [CODEX_DRIVER, CURSOR_DRIVER, OPENCODE_DRIVER].map((driver) => ({ driver, t3CodeRange: ">=0.0.42", ranges: [],