authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-02 12:46:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-02 19:46:12+00:00
logcc1e634bfa62edd56ff792eea666e436fdef788f
treea15c91f3d07ee631a65929d51390cc41fbc2d5a3
parent1e7c8e0f24c217a3229615fa1de1e6ed85d7e835
signature Signed by PGP key B5690EEEBB952194

fix(server): released builds keep the OpenCode policy for their own version (#14863)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 68 insertions(+), 95 deletions(-)

apps/server/src/provider/ModelManifest.test.ts-57
...@@ -11,7 +11,6 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";...@@ -11,7 +11,6 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
11import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
12import * as ServerSettings from "../serverSettings.ts";12import * as ServerSettings from "../serverSettings.ts";
13import * as ModelManifest from "./ModelManifest.ts";13import * as ModelManifest from "./ModelManifest.ts";
14import { resolveProviderCompatibility } from "./providerCompatibility.ts";
1514
16/**15/**
17 * Test policy: this file covers manifest machinery, not manifest contents.16 * Test policy: this file covers manifest machinery, not manifest contents.
...@@ -584,62 +583,6 @@ describe("ModelManifest service", () => {...@@ -584,62 +583,6 @@ describe("ModelManifest service", () => {
584 );583 );
585});584});
586585
587// TEMPORARY V2 preview stopgap; remove with `withPreviewCompatibility` (#2829).
588describe("compatibility policy by release channel", () => {
589 const OPENCODE = ProviderDriverKind.make("opencode");
590 // Main's OpenCode policy (#14198): stable and nightly have no OpenCode 2 runtime.
591 const remote: ModelManifest.ModelManifestData = {
592 ...REMOTE_MANIFEST,
593 compatibility: [
594 {
595 driver: OPENCODE,
596 t3CodeRange: ">=0.0.42",
597 recommendedRange: ">=1.14.19 <2.0.0",
598 recommendedVersion: "1.14.19",
599 ranges: [
600 { range: ">=2.0.0", status: "broken" },
601 { range: ">=1.14.19 <2.0.0", status: "supported" },
602 { range: "<1.14.19", status: "broken" },
603 ],
604 },
605 ],
606 };
607
608 for (const [channel, t3CodeVersion, expected, openCode2Status] of [
609 [
610 "preview builds keep the bundled policy",
611 "0.0.44-preview.20260929.1",
612 { ...remote, compatibility: ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility },
613 "supported",
614 ],
615 ["stable builds adopt the fetched policy", "0.0.44", remote, "broken"],
616 ["nightly builds adopt the fetched policy", "0.0.44-nightly.20260929.1", remote, "broken"],
617 ] as const) {
618 it.live(channel, () =>
619 Effect.gen(function* () {
620 const openCode2 = (manifest: ModelManifest.ModelManifestData) =>
621 resolveProviderCompatibility(manifest.compatibility, OPENCODE, "2.0.18", t3CodeVersion)
622 ?.status;
623 const refreshed = yield* (yield* ModelManifest.makeForVersion(t3CodeVersion)).refresh;
624 assert.deepStrictEqual(refreshed, expected);
625 assert.strictEqual(openCode2(refreshed), openCode2Status);
626
627 // A restart reads the fetched manifest back from the disk cache.
628 const rebooted = yield* (yield* ModelManifest.makeForVersion(t3CodeVersion)).current;
629 assert.deepStrictEqual(rebooted, expected);
630 }).pipe(
631 Effect.scoped,
632 Effect.provide(
633 serviceLayers({
634 prefix: "model-manifest-channel-compatibility-test",
635 response: () => Response.json(remote),
636 }),
637 ),
638 ),
639 );
640 }
641});
642
643it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => {586it.effect("caches valid compatibility policies and keeps them after a malformed refresh", () => {
644 const remote: ModelManifest.ModelManifestData = {587 const remote: ModelManifest.ModelManifestData = {
645 ...REMOTE_MANIFEST,588 ...REMOTE_MANIFEST,
apps/server/src/provider/ModelManifest.ts+3-27
...@@ -19,7 +19,6 @@ import {...@@ -19,7 +19,6 @@ import {
19 type ProviderDriverKind,19 type ProviderDriverKind,
20 type ServerProviderModel,20 type ServerProviderModel,
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease";
23import { codexModelFamily } from "@t3tools/shared/model";22import { codexModelFamily } from "@t3tools/shared/model";
24import * as Clock from "effect/Clock";23import * as Clock from "effect/Clock";
25import * as Context from "effect/Context";24import * as Context from "effect/Context";
...@@ -31,7 +30,6 @@ import * as Schema from "effect/Schema";...@@ -31,7 +30,6 @@ import * as Schema from "effect/Schema";
31import * as Semaphore from "effect/Semaphore";30import * as Semaphore from "effect/Semaphore";
32import { HttpClient, HttpClientResponse } from "effect/unstable/http";31import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3332
34import packageJson from "../../package.json" with { type: "json" };
35import { ServerConfig } from "../config.ts";33import { ServerConfig } from "../config.ts";
36import * as ServerSettings from "../serverSettings.ts";34import * as ServerSettings from "../serverSettings.ts";
37import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";35import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts";
...@@ -142,25 +140,6 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);...@@ -142,25 +140,6 @@ const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema);
142export const BUNDLED_MODEL_MANIFEST: ModelManifestData =140export const BUNDLED_MODEL_MANIFEST: ModelManifestData =
143 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);141 Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson);
144142
145/**
146 * TEMPORARY V2 preview stopgap. Revert before V2 merges into main; it is on
147 * #2829's "Revert before merging into main" checklist.
148 *
149 * The fetched manifest is `main`'s, and its compatibility policy is written for
150 * stable and nightly builds, which do not run OpenCode 2. A policy's
151 * `t3CodeRange` cannot single out preview builds because range matching drops
152 * prerelease tags. Preview builds therefore keep the compatibility policy they
153 * shipped with and take everything else from the fetched manifest.
154 */
155function withPreviewCompatibility(
156 manifest: ModelManifestData,
157 t3CodeVersion: string,
158): ModelManifestData {
159 return cliReleaseChannelOf(t3CodeVersion) === "preview"
160 ? { ...manifest, compatibility: BUNDLED_MODEL_MANIFEST.compatibility }
161 : manifest;
162}
163
164/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */143/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */
165function manifestUpdatedAtMs(manifest: ModelManifestData): number {144function manifestUpdatedAtMs(manifest: ModelManifestData): number {
166 if (manifest.updatedAt === undefined) return 0;145 if (manifest.updatedAt === undefined) return 0;
...@@ -357,8 +336,7 @@ const BundledOnlyModelManifest: ModelManifest["Service"] = {...@@ -357,8 +336,7 @@ const BundledOnlyModelManifest: ModelManifest["Service"] = {
357336
358export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest);337export const layerTest = Layer.succeed(ModelManifest, BundledOnlyModelManifest);
359338
360/** `make` for a given T3 Code version, so tests can exercise each release channel. */339export const make = Effect.gen(function* () {
361export const makeForVersion = Effect.fnUntraced(function* (t3CodeVersion: string) {
362 const fileSystem = yield* FileSystem.FileSystem;340 const fileSystem = yield* FileSystem.FileSystem;
363 const path = yield* Path.Path;341 const path = yield* Path.Path;
364 const config = yield* ServerConfig;342 const config = yield* ServerConfig;
...@@ -391,7 +369,7 @@ export const makeForVersion = Effect.fnUntraced(function* (t3CodeVersion: string...@@ -391,7 +369,7 @@ export const makeForVersion = Effect.fnUntraced(function* (t3CodeVersion: string
391 if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) {369 if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) {
392 return;370 return;
393 }371 }
394 manifest = withPreviewCompatibility(fromDisk.manifest, t3CodeVersion);372 manifest = fromDisk.manifest;
395 fetchedAtMs = fromDisk.fetchedAtMs;373 fetchedAtMs = fromDisk.fetchedAtMs;
396 }),374 }),
397 );375 );
...@@ -430,7 +408,7 @@ export const makeForVersion = Effect.fnUntraced(function* (t3CodeVersion: string...@@ -430,7 +408,7 @@ export const makeForVersion = Effect.fnUntraced(function* (t3CodeVersion: string
430 return manifest;408 return manifest;
431 }409 }
432410
433 manifest = withPreviewCompatibility(fetched, t3CodeVersion);411 manifest = fetched;
434 fetchedAtMs = now;412 fetchedAtMs = now;
435 yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(413 yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe(
436 Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)),414 Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)),
...@@ -449,6 +427,4 @@ export const makeForVersion = Effect.fnUntraced(function* (t3CodeVersion: string...@@ -449,6 +427,4 @@ export const makeForVersion = Effect.fnUntraced(function* (t3CodeVersion: string
449 });427 });
450});428});
451429
452export const make = makeForVersion(packageJson.version);
453
454export const layer = Layer.effect(ModelManifest, make);430export const layer = Layer.effect(ModelManifest, make);
apps/server/src/provider/model-manifest.json+14-3
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1{1{
2 "version": 1,2 "version": 1,
3 "updatedAt": "2026-09-30T22:00:00Z",3 "updatedAt": "2026-10-02T19:45:00Z",
4 "compatibility": [4 "compatibility": [
5 {5 {
6 "driver": "codex",6 "driver": "codex",
...@@ -52,7 +52,18 @@...@@ -52,7 +52,18 @@
52 },52 },
53 {53 {
54 "driver": "opencode",54 "driver": "opencode",
55 "t3CodeRange": ">=0.0.42",55 "t3CodeRange": ">=0.0.42 <0.0.46",
56 "recommendedRange": ">=1.14.19 <2.0.0",
57 "recommendedVersion": "1.14.19",
58 "ranges": [
59 { "range": ">=2.0.0", "status": "broken" },
60 { "range": ">=1.14.19 <2.0.0", "status": "supported" },
61 { "range": "<1.14.19", "status": "broken" }
62 ]
63 },
64 {
65 "driver": "opencode",
66 "t3CodeRange": ">=0.0.46",
56 "recommendedRange": ">=2.0.18",67 "recommendedRange": ">=2.0.18",
57 "ranges": [68 "ranges": [
58 { "range": ">=2.0.18", "status": "supported" },69 { "range": ">=2.0.18", "status": "supported" },
...@@ -69,7 +80,7 @@...@@ -69,7 +80,7 @@
69 },80 },
70 {81 {
71 "driver": "pi",82 "driver": "pi",
72 "t3CodeRange": ">=0.0.42",83 "t3CodeRange": ">=0.0.46",
73 "recommendedRange": ">=1.0.0",84 "recommendedRange": ">=1.0.0",
74 "ranges": [85 "ranges": [
75 { "range": ">=0.80.5", "status": "supported" },86 { "range": ">=0.80.5", "status": "supported" },
apps/server/src/provider/providerCompatibility.test.ts+51-8
...@@ -51,6 +51,9 @@ const provider: ServerProvider = {...@@ -51,6 +51,9 @@ const provider: ServerProvider = {
51 slashCommands: [],51 slashCommands: [],
52};52};
5353
54// The first release that runs the V2 orchestrator.
55const V2_RELEASE = "0.0.46";
56
54describe("provider compatibility", () => {57describe("provider compatibility", () => {
55 it("bundles a compatibility policy for every built-in harness", () => {58 it("bundles a compatibility policy for every built-in harness", () => {
56 for (const builtIn of BUILT_IN_DRIVERS) {59 for (const builtIn of BUILT_IN_DRIVERS) {
...@@ -61,12 +64,42 @@ describe("provider compatibility", () => {...@@ -61,12 +64,42 @@ describe("provider compatibility", () => {
61 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,64 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
62 builtIn.driverKind,65 builtIn.driverKind,
63 null,66 null,
67 V2_RELEASE,
64 ),68 ),
65 `Missing bundled compatibility policy for ${builtIn.driverKind}`,69 `Missing bundled compatibility policy for ${builtIn.driverKind}`,
66 );70 );
67 }71 }
68 });72 });
6973
74 it("keeps releases before V2 on the OpenCode 1 policy they shipped with", () => {
75 // Every installed build fetches main's manifest, and 0.0.45 and older run the
76 // V1 orchestrator, which cannot drive OpenCode 2.
77 const opencode = ProviderDriverKind.make("opencode");
78 for (const [version, expected] of [
79 ["1.14.19", "supported"],
80 ["2.0.18", "broken"],
81 ["1.14.18", "broken"],
82 ] as const) {
83 const advisory = resolveProviderCompatibility(
84 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
85 opencode,
86 version,
87 "0.0.45",
88 );
89 assert.strictEqual(advisory?.status, expected, `OpenCode ${version} on 0.0.45`);
90 assert.strictEqual(advisory?.recommendedVersion, "1.14.19");
91 }
92 // Pi has no driver before V2.
93 assert.isUndefined(
94 resolveProviderCompatibility(
95 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
96 ProviderDriverKind.make("pi"),
97 "1.0.0",
98 "0.0.45",
99 ),
100 );
101 });
102
70 it("supports OpenCode 2 and gives OpenCode 1.x limited support", () => {103 it("supports OpenCode 2 and gives OpenCode 1.x limited support", () => {
71 const opencode = ProviderDriverKind.make("opencode");104 const opencode = ProviderDriverKind.make("opencode");
72 for (const [version, expected] of [105 for (const [version, expected] of [
...@@ -79,13 +112,16 @@ describe("provider compatibility", () => {...@@ -79,13 +112,16 @@ describe("provider compatibility", () => {
79 ["1.14.19", "graceful"],112 ["1.14.19", "graceful"],
80 ["1.14.18", "broken"],113 ["1.14.18", "broken"],
81 ] as const) {114 ] as const) {
82 const advisory = resolveProviderCompatibility(115 for (const t3CodeVersion of [V2_RELEASE, "0.0.46-preview.20261002.2598"]) {
83 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,116 const advisory = resolveProviderCompatibility(
84 opencode,117 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
85 version,118 opencode,
86 );119 version,
87 assert.strictEqual(advisory?.status, expected, `OpenCode ${version}`);120 t3CodeVersion,
88 assert.strictEqual(advisory?.recommendedRange, ">=2.0.18");121 );
122 assert.strictEqual(advisory?.status, expected, `OpenCode ${version} on ${t3CodeVersion}`);
123 assert.strictEqual(advisory?.recommendedRange, ">=2.0.18");
124 }
89 }125 }
90 // The advisory rides beside the probe: a ready 1.x instance stays ready and selectable.126 // The advisory rides beside the probe: a ready 1.x instance stays ready and selectable.
91 const ready = applyProviderCompatibility(127 const ready = applyProviderCompatibility(
...@@ -94,7 +130,14 @@ describe("provider compatibility", () => {...@@ -94,7 +130,14 @@ describe("provider compatibility", () => {
94 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,130 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
95 );131 );
96 assert.strictEqual(ready.status, "ready");132 assert.strictEqual(ready.status, "ready");
97 assert.strictEqual(ready.compatibilityAdvisory?.status, "graceful");133 assert.strictEqual(
134 ready.compatibilityAdvisory?.status,
135 resolveProviderCompatibility(
136 ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
137 opencode,
138 "1.18.33",
139 )?.status,
140 );
98 });141 });
99142
100 it("compares Cursor build dates without treating semver prereleases as stable", () => {143 it("compares Cursor build dates without treating semver prereleases as stable", () => {