Skip to content

Commit fe4ebc7

Browse files
fix(opencode): verify updates using the installed CLI version (pingdotgg#18561)
Co-authored-by: Julius Marminge <[email protected]>
1 parent 404c6e7 commit fe4ebc7

8 files changed

Lines changed: 234 additions & 53 deletions

File tree

‎apps/server/src/provider/providerMaintenanceRunner.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import * as ModelManifest from "./ModelManifest.ts";
2828
import * as ProviderMaintenanceRunner from "./providerMaintenanceRunner.ts";
2929
import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
3030
import {
31+
enrichProviderSnapshotWithVersionAdvisory,
3132
makeProviderMaintenanceCapabilities,
3233
type ProviderMaintenanceCapabilities,
3334
} from "@t3tools/provider-core/server/maintenanceResolver";
@@ -1016,6 +1017,113 @@ describe("providerMaintenanceRunner", () => {
10161017
});
10171018
});
10181019

1020+
it.effect.each([
1021+
{
1022+
mode: "default",
1023+
version: "1.14.19",
1024+
installation: { installedVersion: "1.14.20" },
1025+
status: "succeeded",
1026+
},
1027+
{
1028+
mode: "targeted",
1029+
version: "1.14.19",
1030+
installation: { installedVersion: "1.14.20" },
1031+
status: "succeeded",
1032+
},
1033+
{
1034+
mode: "unknown",
1035+
version: "1.14.20",
1036+
installation: { installedVersion: null },
1037+
status: "unchanged",
1038+
},
1039+
{ mode: "legacy", version: "1.14.20", installation: {}, status: "succeeded" },
1040+
] as const)(
1041+
"verifies the $mode OpenCode update using the refreshed installation",
1042+
({ mode, version, installation, status }) => {
1043+
const calls: string[] = [];
1044+
const capabilities = lifecycleFor(OPENCODE_DRIVER);
1045+
const pinnedCapabilities = {
1046+
...capabilities,
1047+
update: { ...capabilities.update!, lockKey: "npm-global:/fixture" },
1048+
};
1049+
const manifest: ModelManifest.ModelManifestData = {
1050+
version: 1,
1051+
currentModels: {},
1052+
compatibility: [
1053+
{
1054+
driver: OPENCODE_DRIVER,
1055+
t3CodeRange: ">=0.0.42",
1056+
recommendedVersion: "1.14.20",
1057+
ranges: [{ range: "=1.14.20", status: "supported" }],
1058+
},
1059+
],
1060+
};
1061+
return Effect.gen(function* () {
1062+
const { registry, providersRef } = yield* makeRegistry({ ...baseOpenCodeProvider, version });
1063+
const httpClient = yield* HttpClient.HttpClient;
1064+
const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions;
1065+
const updater = yield* makeTestRunner(
1066+
{
1067+
...registry,
1068+
getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities),
1069+
refreshInstance: () =>
1070+
Effect.gen(function* () {
1071+
assert.strictEqual(calls.length, 1);
1072+
const refreshed = yield* enrichProviderSnapshotWithVersionAdvisory(
1073+
{ ...baseOpenCodeProvider, version, ...installation },
1074+
pinnedCapabilities,
1075+
).pipe(
1076+
Effect.provideService(HttpClient.HttpClient, httpClient),
1077+
Effect.provideService(
1078+
ProviderLatestVersions.ProviderLatestVersions,
1079+
latestVersions,
1080+
),
1081+
);
1082+
return yield* Ref.updateAndGet(providersRef, (providers) =>
1083+
providers.map((provider) => ({ ...provider, ...refreshed })),
1084+
);
1085+
}),
1086+
},
1087+
manifest,
1088+
);
1089+
1090+
const result = yield* updater.updateProvider({
1091+
provider: OPENCODE_DRIVER,
1092+
...(mode === "default" ? {} : { targetVersion: "1.14.20" }),
1093+
});
1094+
1095+
assert.deepStrictEqual(calls, [
1096+
`npm install -g opencode-ai@${mode === "default" ? "latest" : "1.14.20"}`,
1097+
]);
1098+
const provider = result.providers[0];
1099+
assert.ok(provider);
1100+
assert.strictEqual(provider.updateState?.status, status);
1101+
assert.strictEqual(provider.version, version);
1102+
assert.strictEqual("installedVersion" in provider, mode !== "legacy");
1103+
assert.strictEqual(
1104+
provider.versionAdvisory?.status,
1105+
mode === "unknown" ? "unknown" : "current",
1106+
);
1107+
assert.strictEqual(
1108+
provider.versionAdvisory?.currentVersion,
1109+
mode === "unknown" ? null : "1.14.20",
1110+
);
1111+
}).pipe(
1112+
Effect.provide(
1113+
Layer.mergeAll(
1114+
layerNonWindowsPlatform,
1115+
ProviderLatestVersions.layer,
1116+
layerLatestVersionHttpClient("1.14.20"),
1117+
layerMockSpawner((command, args) => {
1118+
calls.push([command, ...args].join(" "));
1119+
return { stdout: "installed" };
1120+
}),
1121+
),
1122+
),
1123+
);
1124+
},
1125+
);
1126+
10191127
it.effect("refuses incompatible latest versions and unapproved or unpinnable targets", () => {
10201128
const calls: string[] = [];
10211129
const manifest: ModelManifest.ModelManifestData = {

‎apps/server/src/provider/providerMaintenanceRunner.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -519,12 +519,17 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () {
519519
// fail transiently on a healthy binary.
520520
const couldNotVerify =
521521
verifiedProviders.length === 0 ||
522-
verifiedProviders.some(
523-
(verifiedProvider) =>
522+
verifiedProviders.some((verifiedProvider) => {
523+
const installedVersion =
524+
verifiedProvider.installedVersion !== undefined
525+
? verifiedProvider.installedVersion
526+
: verifiedProvider.version;
527+
return (
524528
!isStillInstalled(verifiedProvider) ||
525529
(targetVersion !== undefined &&
526-
verifiedProvider.version?.replace(/^v/, "") !== targetVersion),
527-
);
530+
installedVersion?.replace(/^v/, "") !== targetVersion)
531+
);
532+
});
528533
const stillOutdated =
529534
targetVersion === undefined &&
530535
verifiedProviders.some((verifiedProvider) => isOutdatedProvider(verifiedProvider));

‎packages/contracts/src/server.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ const baseProviderSnapshot = {
3232
};
3333

3434
describe("ServerProvider", () => {
35+
it("preserves installed-version presence through the provider wire codec", () => {
36+
const encode = Schema.encodeSync(ServerProvider);
37+
for (const installation of [{}, { installedVersion: null }, { installedVersion: "1.2.3" }]) {
38+
const decoded = decodeServerProvider({ ...baseProviderSnapshot, ...installation });
39+
const wire = JSON.parse(JSON.stringify(encode(decoded)));
40+
expect(wire).toMatchObject({ version: "1.0.0", ...installation });
41+
expect(Object.hasOwn(wire, "installedVersion")).toBe(
42+
Object.hasOwn(installation, "installedVersion"),
43+
);
44+
expect(decodeServerProvider(wire)).toEqual(decoded);
45+
}
46+
});
47+
3548
it.each([undefined, true, false])("decodes workspace command discovery pending=%s", (pending) => {
3649
const workspace = {
3750
cwd: "/workspace/project",

‎packages/contracts/src/server.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,9 @@ export const ServerProvider = Schema.Struct({
257257
enabled: Schema.Boolean,
258258
installed: Schema.Boolean,
259259
version: Schema.NullOr(TrimmedNonEmptyString),
260+
// Version reported by the installed CLI for a local provider with a separate
261+
// server. Null means unknown; absence keeps legacy snapshots on `version`.
262+
installedVersion: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)),
260263
status: ServerProviderState,
261264
auth: ServerProviderAuth,
262265
checkedAt: IsoDateTime,

‎packages/provider-core/src/server/maintenanceResolver.test.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,11 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
161161
);
162162

163163
it.effect("does not fetch latest provider versions when update checks are disabled", () =>
164-
enrichProviderSnapshotWithVersionAdvisory(installedPackageToolProvider, manualPackageTool, {
165-
enableProviderUpdateChecks: false,
166-
}).pipe(
164+
enrichProviderSnapshotWithVersionAdvisory(
165+
{ ...installedPackageToolProvider, installedVersion: "1.1.0" },
166+
manualPackageTool,
167+
{ enableProviderUpdateChecks: false },
168+
).pipe(
167169
Effect.provide(ProviderLatestVersions.layer),
168170
Effect.provideService(
169171
HttpClient.HttpClient,
@@ -174,7 +176,7 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
174176
Effect.map((provider) => {
175177
expect(provider.versionAdvisory).toMatchObject({
176178
status: "unknown",
177-
currentVersion: "1.0.0",
179+
currentVersion: "1.1.0",
178180
latestVersion: null,
179181
checkedAt: "2026-04-10T00:00:00.000Z",
180182
});

‎packages/provider-core/src/server/maintenanceResolver.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -899,17 +899,19 @@ export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn(
899899
) {
900900
const capabilities =
901901
maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver);
902+
const currentVersion =
903+
snapshot.installedVersion !== undefined ? snapshot.installedVersion : snapshot.version;
902904
const shouldResolveLatestVersion =
903905
options?.enableProviderUpdateChecks !== false &&
904906
snapshot.enabled &&
905907
snapshot.installed &&
906-
Boolean(snapshot.version);
908+
Boolean(currentVersion);
907909
if (!shouldResolveLatestVersion) {
908910
return {
909911
...snapshot,
910912
versionAdvisory: createProviderVersionAdvisory({
911913
driver: snapshot.driver,
912-
currentVersion: snapshot.version,
914+
currentVersion,
913915
checkedAt: snapshot.checkedAt,
914916
maintenanceCapabilities: capabilities,
915917
}),
@@ -921,7 +923,7 @@ export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn(
921923
...snapshot,
922924
versionAdvisory: createProviderVersionAdvisory({
923925
driver: snapshot.driver,
924-
currentVersion: snapshot.version,
926+
currentVersion,
925927
latestVersion,
926928
checkedAt: DateTime.formatIso(yield* DateTime.now),
927929
maintenanceCapabilities: capabilities,

‎packages/provider-opencode/src/server/status.test.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ const runtimeMock = {
166166
inventoryError: null as Error | null,
167167
connectionError: null as Error | null,
168168
inventoryCwd: null as string | null,
169+
startCalls: 0,
169170
closeCalls: 0,
170171
sdkClientInputs: [] as Array<{
171172
baseUrl: string;
@@ -186,6 +187,7 @@ const runtimeMock = {
186187
this.state.inventoryError = null;
187188
this.state.connectionError = null;
188189
this.state.inventoryCwd = null;
190+
this.state.startCalls = 0;
189191
this.state.closeCalls = 0;
190192
this.state.sdkClientInputs.length = 0;
191193
this.state.inventory = {
@@ -199,6 +201,7 @@ const runtimeMock = {
199201
const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = {
200202
startOpenCodeServerProcess: ({ serverPassword, environment }) =>
201203
Effect.gen(function* () {
204+
runtimeMock.state.startCalls += 1;
202205
yield* Effect.addFinalizer(() =>
203206
Effect.sync(() => {
204207
runtimeMock.state.closeCalls += 1;
@@ -551,6 +554,40 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
551554
}),
552555
);
553556

557+
it.effect("reports the upgraded CLI without restarting a borrowed OpenCode server", () =>
558+
Effect.scoped(
559+
Effect.gen(function* () {
560+
const settings = makeOpenCodeSettings();
561+
const owner = yield* OpenCodeServerOwner.make({
562+
binaryPath: settings.binaryPath,
563+
directory: process.cwd(),
564+
});
565+
yield* owner.withServer(() =>
566+
Effect.gen(function* () {
567+
runtimeMock.state.versionStdout = "opencode 1.14.20\n";
568+
const snapshot = yield* checkOpenCodeProviderStatus(
569+
settings,
570+
process.cwd(),
571+
probeOpenCodeRuntime(settings).pipe(
572+
Effect.provideService(
573+
HttpClient.HttpClient,
574+
replayOpenCodeServer(OPENCODE_1_RESPONSES, settings.serverPassword),
575+
),
576+
Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble),
577+
),
578+
Effect.succeed([]),
579+
).pipe(Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, owner));
580+
581+
NodeAssert.equal(snapshot.version, "1.14.19");
582+
NodeAssert.equal(snapshot.installedVersion, "1.14.20");
583+
NodeAssert.equal(runtimeMock.state.startCalls, 1);
584+
NodeAssert.equal(runtimeMock.state.closeCalls, 0);
585+
}),
586+
);
587+
}),
588+
),
589+
);
590+
554591
it.effect("lists a local OpenCode 2 binary's models in Full access only, never via 1.x", () =>
555592
Effect.gen(function* () {
556593
runtimeMock.state.versionStdout = "opencode v2.0.18\n";
@@ -572,6 +609,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
572609

573610
NodeAssert.equal(snapshot.status, "ready");
574611
NodeAssert.equal(snapshot.version, "2.0.18");
612+
NodeAssert.equal("installedVersion" in snapshot, false);
575613
NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
576614
"approval-required",
577615
"auto-accept-edits",
@@ -652,11 +690,14 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
652690

653691
it.effect("reports local model inventory failures without treating them as empty", () =>
654692
Effect.gen(function* () {
693+
runtimeMock.state.versionStdout = "opencode 1.14.20\n";
655694
runtimeMock.state.inventoryError = new Error("opencode models failed");
656695
const snapshot = yield* checkProvider(makeOpenCodeSettings());
657696

658697
NodeAssert.equal(snapshot.status, "error");
659698
NodeAssert.equal(snapshot.installed, true);
699+
NodeAssert.equal(snapshot.version, "1.14.19");
700+
NodeAssert.equal(snapshot.installedVersion, "1.14.20");
660701
NodeAssert.equal(snapshot.models.length, 0);
661702
NodeAssert.equal(
662703
snapshot.message,
@@ -669,13 +710,15 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
669710
it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (it) => {
670711
it.effect("does not send a local environment password to a configured server", () =>
671712
Effect.gen(function* () {
713+
runtimeMock.state.versionStdout = "opencode 1.14.20\n";
672714
const snapshot = yield* checkProvider(
673715
makeOpenCodeSettings({ serverUrl: "http://127.0.0.1:9999" }),
674716
process.cwd(),
675717
{ OPENCODE_SERVER_PASSWORD: "local-secret" },
676718
);
677719

678720
NodeAssert.equal(snapshot.version, "1.14.19");
721+
NodeAssert.equal("installedVersion" in snapshot, false);
679722
NodeAssert.deepEqual(runtimeMock.state.sdkClientInputs, [
680723
{
681724
baseUrl: "http://127.0.0.1:9999",
@@ -700,6 +743,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus with configured server URL", (i
700743

701744
NodeAssert.equal(snapshot.status, "warning");
702745
NodeAssert.equal(snapshot.version, "2.0.18");
746+
NodeAssert.equal("installedVersion" in snapshot, false);
703747
NodeAssert.deepEqual(snapshot.supportedRuntimeModes, [
704748
"approval-required",
705749
"auto-accept-edits",

0 commit comments

Comments
 (0)