| author | |
| committer | |
| log | 76fa23df2ef09c8d9624cc6ce1273d32452b3d1c |
| tree | dcb47db8c2c50ae2095525ca59c25aaa630d30ab |
| parent | 5da55957d48dbd79c1a05b72bdf2479ae0956b1c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 67 | 67 | ||
| 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 | }); |
| 58 | 58 | ||
| 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. */ |
| 10 | function getIncompatibleVersion(status: ServerProvider) { | 10 | function 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 | ? compatibility | 15 | ? compatibility |
| 14 | : null; | 16 | : null; |
| 15 | } | 17 | } |
| 16 | 18 | ||
| 17 | export function getProviderStatusBannerKey(status: ServerProvider | null): string | null { | 19 | export 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 incompatible | 23 | 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 local | 31 | // 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 | } |
| 55 | 61 | ||
| 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. */ |
| 57 | export function getProviderStatusMessage(status: ServerProvider): string { | 63 | export 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({ |
| 90 | 103 | ||
| 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 = isUnauthenticated | 107 | const title = isUnauthenticated |
| 95 | ? `${providerName} is unauthenticated` | 108 | ? `${providerName} is unauthenticated` |
| 96 | : incompatible | 109 | : 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); | ||
| 101 | 115 | ||
| 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"> |