authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-28 19:00:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-28 19:00:28-07:00
log76fa23df2ef09c8d9624cc6ce1273d32452b3d1c
treedcb47db8c2c50ae2095525ca59c25aaa630d30ab
parent5da55957d48dbd79c1a05b72bdf2479ae0956b1c
signature Signed by PGP key B5690EEEBB952194

fix(provider): mark OpenCode v2 incompatible (#14198)


6 files changed, 92 insertions(+), 32 deletions(-)

apps/mobile/src/features/threads/ThreadComposer.tsx+5-1
...@@ -663,7 +663,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -663,7 +663,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
663 ? "assertive"663 ? "assertive"
664 : "polite"664 : "polite"
665 }665 }
666 className="px-3 py-2 text-xs text-foreground"666 className={
667 selectedProviderStatus.compatibilityAdvisory.status === "broken"
668 ? "bg-danger px-3 py-2 text-xs text-danger-foreground"
669 : "px-3 py-2 text-xs text-foreground"
670 }
667 >671 >
668 {selectedProviderStatus.compatibilityAdvisory.message}672 {selectedProviderStatus.compatibilityAdvisory.message}
669 </Text>673 </Text>
apps/server/src/provider/model-manifest.json+5-3
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1{1{
2 "version": 1,2 "version": 1,
3 "updatedAt": "2026-09-28T20:00:00Z",3 "updatedAt": "2026-09-29T01:03:44Z",
4 "compatibility": [4 "compatibility": [
5 {5 {
6 "driver": "codex",6 "driver": "codex",
...@@ -43,9 +43,11 @@...@@ -43,9 +43,11 @@
43 {43 {
44 "driver": "opencode",44 "driver": "opencode",
45 "t3CodeRange": ">=0.0.42",45 "t3CodeRange": ">=0.0.42",
46 "recommendedRange": ">=1.14.19",46 "recommendedRange": ">=1.14.19 <2.0.0",
47 "recommendedVersion": "1.14.19",
47 "ranges": [48 "ranges": [
48 { "range": ">=1.14.19", "status": "supported" },49 { "range": ">=2.0.0", "status": "broken" },
50 { "range": ">=1.14.19 <2.0.0", "status": "supported" },
49 { "range": "<1.14.19", "status": "broken" }51 { "range": "<1.14.19", "status": "broken" }
50 ]52 ]
51 },53 },
apps/server/src/provider/providerCompatibility.test.ts-18
...@@ -65,24 +65,6 @@ describe("provider compatibility", () => {...@@ -65,24 +65,6 @@ describe("provider compatibility", () => {
65 }65 }
66 });66 });
6767
68 it("supports Codex 0.156 and marks Codex without Thread.projectId broken", () => {
69 const bundled = ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility;
70 for (const [t3CodeVersion, codexVersion, expected] of [
71 ["0.0.42", "0.148.0", "broken"],
72 ["0.0.42", "0.149.0", "unsupported"],
73 ["0.0.42", "0.155.0", "unsupported"],
74 ["0.0.42", "0.156.0", "supported"],
75 ["0.0.43-nightly.20260924.2200", "0.153.3", "unsupported"],
76 ["0.0.43-nightly.20260924.2200", "0.156.1", "supported"],
77 ] as const) {
78 assert.strictEqual(
79 resolveProviderCompatibility(bundled, driver, codexVersion, t3CodeVersion)?.status,
80 expected,
81 `T3 Code ${t3CodeVersion} with Codex ${codexVersion}`,
82 );
83 }
84 });
85
86 it("compares Cursor build dates without treating semver prereleases as stable", () => {68 it("compares Cursor build dates without treating semver prereleases as stable", () => {
87 const cursor = ProviderDriverKind.make("cursor");69 const cursor = ProviderDriverKind.make("cursor");
88 const cursorPolicy: ProviderCompatibilityPolicy = {70 const cursorPolicy: ProviderCompatibilityPolicy = {
apps/server/src/provider/providerMaintenanceRunner.test.ts+5-1
...@@ -216,7 +216,11 @@ const makeTestRunner = (...@@ -216,7 +216,11 @@ const makeTestRunner = (
216 manifest: ModelManifest.ModelManifestData = {216 manifest: ModelManifest.ModelManifestData = {
217 version: 1,217 version: 1,
218 currentModels: {},218 currentModels: {},
219 compatibility: [{ driver: CODEX_DRIVER, t3CodeRange: ">=0.0.42", ranges: [] }],219 compatibility: [CODEX_DRIVER, CURSOR_DRIVER, OPENCODE_DRIVER].map((driver) => ({
220 driver,
221 t3CodeRange: ">=0.0.42",
222 ranges: [],
223 })),
220 },224 },
221) =>225) =>
222 Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe(226 Effect.service(ProviderMaintenanceRunner.ProviderMaintenanceRunner).pipe(
apps/web/src/components/chat/ProviderStatusBanner.test.ts+54
...@@ -56,6 +56,60 @@ describe("compatibility banners", () => {...@@ -56,6 +56,60 @@ describe("compatibility banners", () => {
56 ).toBeNull();56 ).toBeNull();
57 });57 });
5858
59 it("shows downgrade guidance instead of a broken OpenCode inventory timeout", () => {
60 const message = "This provider version is known to be incompatible. Use 1.14.19.";
61 const broken: ServerProvider = {
62 ...provider,
63 driver: ProviderDriverKind.make("opencode"),
64 version: "2.0.3",
65 status: "error",
66 auth: { status: "unknown" },
67 message: "Failed to load OpenCode provider inventory: Timed out waiting for server start.",
68 compatibilityAdvisory: {
69 status: "broken",
70 message,
71 recommendedVersion: "1.14.19",
72 recommendedRange: ">=1.14.19 <2.0.0",
73 },
74 };
75 expect(shouldShowProviderStatusBanner(broken, null)).toBe(true);
76 const timeoutOnly: ServerProvider = {
77 ...broken,
78 compatibilityAdvisory: {
79 ...broken.compatibilityAdvisory!,
80 status: "supported",
81 message: null,
82 },
83 };
84 expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(timeoutOnly))).toBe(
85 true,
86 );
87 expect(shouldShowProviderStatusBanner(broken, getProviderStatusBannerKey(broken))).toBe(false);
88 expect(
89 shouldShowProviderStatusBanner(
90 {
91 ...broken,
92 compatibilityAdvisory: { ...broken.compatibilityAdvisory!, message: "Use 1.14.20." },
93 },
94 getProviderStatusBannerKey(broken),
95 ),
96 ).toBe(true);
97 expect(getProviderStatusMessage(broken)).toBe(message);
98 expect(
99 getProviderStatusMessage({
100 ...broken,
101 compatibilityAdvisory: {
102 ...broken.compatibilityAdvisory!,
103 status: "supported",
104 message: null,
105 },
106 }),
107 ).toBe(broken.message);
108 expect(getProviderStatusMessage({ ...broken, auth: { status: "unauthenticated" } })).toBe(
109 broken.message,
110 );
111 });
112
59 it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => {113 it("keeps authentication failures ahead of compatibility warnings even without a probe message", () => {
60 const unauthenticated: ServerProvider = {114 const unauthenticated: ServerProvider = {
61 ...provider,115 ...provider,
apps/web/src/components/chat/ProviderStatusBanner.tsx+23-9
...@@ -9,19 +9,25 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -9,19 +9,25 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
9/** Unsupported and broken versions fail mid-turn, so they warn even when ready. */9/** Unsupported and broken versions fail mid-turn, so they warn even when ready. */
10function getIncompatibleVersion(status: ServerProvider) {10function getIncompatibleVersion(status: ServerProvider) {
11 const compatibility = status.compatibilityAdvisory;11 const compatibility = status.compatibilityAdvisory;
12 return compatibility?.status === "unsupported" || compatibility?.status === "broken"12 if (status.status === "error" && status.auth.status === "unauthenticated") return null;
13 return compatibility?.status === "broken" ||
14 (status.status === "ready" && compatibility?.status === "unsupported")
13 ? compatibility15 ? compatibility
14 : null;16 : null;
15}17}
1618
17export function getProviderStatusBannerKey(status: ServerProvider | null): string | null {19export function getProviderStatusBannerKey(status: ServerProvider | null): string | null {
18 if (!status || status.status === "disabled") return null;20 if (!status || status.status === "disabled") return null;
19 if (status.status === "ready") {21 const incompatible = getIncompatibleVersion(status);
20 const incompatible = getIncompatibleVersion(status);22 if (incompatible) {
21 return incompatible23 return [
22 ? [status.instanceId, incompatible.status, status.version ?? ""].join("\u0000")24 status.instanceId,
23 : null;25 incompatible.status,
26 status.version ?? "",
27 incompatible.message ?? "",
28 ].join("\u0000");
24 }29 }
30 if (status.status === "ready") return null;
25 // Antigravity checks saved credentials when a session starts. Its local31 // Antigravity checks saved credentials when a session starts. Its local
26 // health check leaves auth unknown after a restart, which is not a failure.32 // health check leaves auth unknown after a restart, which is not a failure.
27 if (33 if (
...@@ -53,8 +59,15 @@ export function hasProviderSetup(status: ServerProvider): boolean {...@@ -53,8 +59,15 @@ export function hasProviderSetup(status: ServerProvider): boolean {
53 );59 );
54}60}
5561
56/** Keep the environment's error intact in both the banner and model picker. */62/** Broken-version guidance takes precedence over startup failures it can cause. */
57export function getProviderStatusMessage(status: ServerProvider): string {63export function getProviderStatusMessage(status: ServerProvider): string {
64 if (
65 status.auth.status !== "unauthenticated" &&
66 status.compatibilityAdvisory?.status === "broken" &&
67 status.compatibilityAdvisory.message
68 ) {
69 return status.compatibilityAdvisory.message;
70 }
58 if (status.message) return status.message;71 if (status.message) return status.message;
59 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);72 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
60 if (!status.installed && hasProviderSetup(status)) {73 if (!status.installed && hasProviderSetup(status)) {
...@@ -90,14 +103,15 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({...@@ -90,14 +103,15 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
90103
91 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);104 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
92 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";105 const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
93 const incompatible = status.status === "ready" ? getIncompatibleVersion(status) : null;106 const incompatible = getIncompatibleVersion(status);
94 const title = isUnauthenticated107 const title = isUnauthenticated
95 ? `${providerName} is unauthenticated`108 ? `${providerName} is unauthenticated`
96 : incompatible109 : incompatible
97 ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}`110 ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}`
98 : `${providerName} provider status`;111 : `${providerName} provider status`;
99 const message = incompatible?.message ?? getProviderStatusMessage(status);112 const message = incompatible?.message ?? getProviderStatusMessage(status);
100 const isWarning = status.status === "warning" || incompatible !== null;113 const isWarning =
114 incompatible?.status !== "broken" && (status.status === "warning" || incompatible !== null);
101115
102 return (116 return (
103 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">117 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">