Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions apps/server/src/provider/Layers/OpenCodeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
openCodeCommandsToServerProviderSlashCommands,
} from "./OpenCodeProvider.ts";
import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts";
import { probeOpenCodeRuntime } from "../opencodeVersionProbe.ts";
import {
OPENCODE_VERSION_PROBE_TIMEOUT_ENV,
probeOpenCodeRuntime,
} from "../opencodeVersionProbe.ts";
import {
OPENCODE_1_RESPONSES,
OPENCODE_2_RESPONSES,
Expand Down Expand Up @@ -170,6 +173,7 @@ const runtimeMock = {
state: {
runVersionError: null as Error | null,
runVersionPending: false,
runVersionDelayMs: null as number | null,
versionStdout: DEFAULT_VERSION_STDOUT,
inventoryError: null as Error | null,
connectionError: null as Error | null,
Expand All @@ -189,6 +193,7 @@ const runtimeMock = {
reset() {
this.state.runVersionError = null;
this.state.runVersionPending = false;
this.state.runVersionDelayMs = null;
this.state.versionStdout = DEFAULT_VERSION_STDOUT;
this.state.inventoryError = null;
this.state.connectionError = null;
Expand Down Expand Up @@ -261,7 +266,11 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = {
cause: runtimeMock.state.runVersionError,
}),
)
: Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
: runtimeMock.state.runVersionDelayMs === null
? Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 })
: Effect.sleep(runtimeMock.state.runVersionDelayMs).pipe(
Effect.as({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }),
),
createOpenCodeSdkClient: (input) => {
runtimeMock.state.sdkClientInputs.push(input);
return {} as unknown as ReturnType<
Expand Down Expand Up @@ -387,7 +396,9 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
it.effect("times out a hanging local CLI version probe", () =>
Effect.gen(function* () {
runtimeMock.state.runVersionPending = true;
const probeFiber = yield* checkProvider(makeOpenCodeSettings()).pipe(Effect.forkChild);
const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe(
Effect.forkChild,
);

yield* Effect.yieldNow;
yield* TestClock.adjust("4 seconds");
Expand All @@ -402,6 +413,82 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => {
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("keeps the 4 second default when a cold start needs 5 seconds", () =>
Effect.gen(function* () {
runtimeMock.state.runVersionDelayMs = 5_000;
const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {}).pipe(
Effect.forkChild,
);

yield* Effect.yieldNow;
yield* TestClock.adjust("4 seconds");
const snapshot = yield* Fiber.join(probeFiber);

NodeAssert.equal(snapshot.status, "error");
NodeAssert.equal(
snapshot.message,
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("waits for a slow version probe when the environment timeout allows it", () =>
Effect.gen(function* () {
runtimeMock.state.runVersionDelayMs = 5_000;
const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
[OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "8s",
}).pipe(Effect.forkChild);

yield* Effect.yieldNow;
yield* TestClock.adjust("5 seconds");
const snapshot = yield* Fiber.join(probeFiber);

NodeAssert.equal(snapshot.status, "warning");
NodeAssert.equal(snapshot.installed, true);
NodeAssert.equal(snapshot.version, "1.14.19");
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("clamps an oversized version probe timeout to 60 seconds", () =>
Effect.gen(function* () {
runtimeMock.state.runVersionPending = true;
const probeFiber = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
[OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "2 hours",
}).pipe(Effect.forkChild);

yield* Effect.yieldNow;
yield* TestClock.adjust("59 seconds");
yield* Effect.yieldNow;
NodeAssert.equal(probeFiber.pollUnsafe(), undefined);
yield* TestClock.adjust("1 second");
yield* Effect.yieldNow;
const snapshot = yield* Fiber.join(probeFiber);

NodeAssert.equal(snapshot.status, "error");
NodeAssert.equal(
snapshot.message,
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.",
);
}).pipe(Effect.provide(TestClock.layer())),
);

it.effect("still reports a missing binary when the version probe timeout is raised", () =>
Effect.gen(function* () {
runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT");
const snapshot = yield* checkProvider(makeOpenCodeSettings(), process.cwd(), {
[OPENCODE_VERSION_PROBE_TIMEOUT_ENV]: "60 seconds",
});

NodeAssert.equal(snapshot.status, "error");
NodeAssert.equal(snapshot.installed, false);
NodeAssert.equal(snapshot.version, null);
NodeAssert.equal(
snapshot.message,
"OpenCode CLI (`opencode`) is not installed or not on PATH.",
);
}),
);

it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () =>
Effect.gen(function* () {
runtimeMock.state.inventory = {
Expand Down
192 changes: 192 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,198 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test
);
});

it("keeps a ready OpenCode snapshot through a version-probe timeout", () => {
const previousProvider = {
instanceId: ProviderInstanceId.make("opencode"),
driver: ProviderDriverKind.make("opencode"),
status: "ready",
enabled: true,
installed: true,
auth: { status: "authenticated", type: "opencode" },
checkedAt: "2026-07-17T00:00:00.000Z",
version: "1.18.19",
models: [
{
slug: "github/gpt-5",
name: "GPT-5",
subProvider: "GitHub",
isCustom: false,
capabilities: null,
},
],
slashCommands: [{ name: "review", description: "Review changes" }],
skills: [
{
name: "typescript",
description: "TypeScript help",
path: "/skills/typescript/SKILL.md",
enabled: true,
},
],
} as const satisfies ServerProvider;
const timedOutProvider = {
...previousProvider,
status: "error",
auth: { status: "unknown" },
checkedAt: "2026-07-17T00:01:00.000Z",
version: null,
models: [],
slashCommands: [],
skills: [],
message:
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
} satisfies ServerProvider;

const merged = mergeProviderSnapshot(previousProvider, timedOutProvider);
assert.equal(merged.status, "ready");
assert.equal(merged.version, previousProvider.version);
assert.deepStrictEqual(merged.auth, previousProvider.auth);
assert.deepStrictEqual(merged.models, [...previousProvider.models]);
assert.deepStrictEqual(merged.slashCommands, previousProvider.slashCommands);
assert.deepStrictEqual(merged.skills, previousProvider.skills);
assert.equal(merged.message, timedOutProvider.message);
assert.equal(merged.checkedAt, timedOutProvider.checkedAt);

for (const message of [
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1 second.",
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 60 seconds.",
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1500 millis.",
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 1235 millis.",
]) {
const variant = mergeProviderSnapshot(previousProvider, { ...timedOutProvider, message });
assert.equal(variant.status, "ready", message);
assert.equal(variant.version, previousProvider.version, message);
assert.deepStrictEqual(variant.auth, previousProvider.auth, message);
}
});

it("does not treat a launch failure that only contains the timeout phrase as a version-probe timeout", () => {
const previousProvider = {
instanceId: ProviderInstanceId.make("opencode"),
driver: ProviderDriverKind.make("opencode"),
status: "ready",
enabled: true,
installed: true,
auth: { status: "authenticated", type: "opencode" },
checkedAt: "2026-07-17T00:00:00.000Z",
version: "1.18.19",
models: [
{
slug: "github/gpt-5",
name: "GPT-5",
isCustom: false,
capabilities: null,
},
],
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
const binaryPath = "/opt/version probe timed out/opencode";
const messages = [
`Failed to execute OpenCode CLI health check: Failed to execute '${binaryPath} --version': spawn ${binaryPath} ENOENT`,
"version probe timed out",
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds. See the binary path.",
"Note: Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds",
];

for (const message of messages) {
const failedProvider = {
...previousProvider,
status: "error",
auth: { status: "unknown" },
checkedAt: "2026-07-17T00:01:00.000Z",
version: null,
models: [],
message,
} satisfies ServerProvider;
const merged = mergeProviderSnapshot(previousProvider, failedProvider);
assert.equal(merged.status, "error", message);
assert.equal(merged.version, null, message);
assert.deepStrictEqual(merged.auth, { status: "unknown" }, message);
assert.equal(merged.message, message);
}
});

it("does not keep last-known-good OpenCode state without a ready versioned snapshot", () => {
const pendingProvider = {
instanceId: ProviderInstanceId.make("opencode"),
driver: ProviderDriverKind.make("opencode"),
status: "warning",
enabled: true,
installed: false,
auth: { status: "unknown" },
checkedAt: "2026-07-17T00:00:00.000Z",
version: null,
models: [],
slashCommands: [],
skills: [],
message: "OpenCode provider status has not been checked in this session yet.",
} as const satisfies ServerProvider;
const timedOutProvider = {
...pendingProvider,
status: "error",
installed: true,
checkedAt: "2026-07-17T00:01:00.000Z",
message:
"Failed to execute OpenCode CLI health check: OpenCode CLI version probe timed out after 4 seconds.",
} satisfies ServerProvider;

const merged = mergeProviderSnapshot(pendingProvider, timedOutProvider);
assert.equal(merged.status, "error");
assert.equal(merged.version, null);
assert.equal(merged.message, timedOutProvider.message);
});

it("does not keep a ready OpenCode snapshot for a missing binary or other probe error", () => {
const previousProvider = {
instanceId: ProviderInstanceId.make("opencode"),
driver: ProviderDriverKind.make("opencode"),
status: "ready",
enabled: true,
installed: true,
auth: { status: "authenticated", type: "opencode" },
checkedAt: "2026-07-17T00:00:00.000Z",
version: "1.18.19",
models: [
{
slug: "github/gpt-5",
name: "GPT-5",
isCustom: false,
capabilities: null,
},
],
slashCommands: [],
skills: [],
} as const satisfies ServerProvider;
const missingProvider = {
...previousProvider,
status: "error",
installed: false,
auth: { status: "unknown" },
checkedAt: "2026-07-17T00:01:00.000Z",
version: null,
models: [],
message: "OpenCode CLI (`opencode`) is not installed or not on PATH.",
} satisfies ServerProvider;
const otherError = {
...missingProvider,
installed: true,
message: "Failed to execute OpenCode CLI health check: spawn opencode ENOENT",
} satisfies ServerProvider;

const missing = mergeProviderSnapshot(previousProvider, missingProvider);
assert.equal(missing.status, "error");
assert.equal(missing.installed, false);
assert.equal(missing.version, null);
assert.deepStrictEqual(missing.auth, { status: "unknown" });

const failed = mergeProviderSnapshot(previousProvider, otherError);
assert.equal(failed.status, "error");
assert.equal(failed.version, null);
assert.deepStrictEqual(failed.auth, { status: "unknown" });
});

it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => {
const previousProvider = {
instanceId: ProviderInstanceId.make("opencode"),
Expand Down
Loading
Loading