From 20395d13a0c63af8090f366011bf9263f2ea1875 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:16:03 -0400 Subject: [PATCH 01/24] feat(opencode): add native OpenCode 2 support --- .agents/AGENTS.md | 1 + apps/mobile/src/components/ProviderIcon.tsx | 2 +- .../src/provider/Drivers/OpenCode2Driver.ts | 136 ++ .../provider/Layers/OpenCode2Adapter.test.ts | 495 +++++++ .../src/provider/Layers/OpenCode2Adapter.ts | 1311 +++++++++++++++++ .../provider/Layers/OpenCode2Provider.test.ts | 148 ++ .../src/provider/Layers/OpenCode2Provider.ts | 347 +++++ .../ProviderInstanceRegistryLive.test.ts | 38 +- .../provider/Layers/ProviderRegistry.test.ts | 35 +- .../src/provider/Layers/ProviderRegistry.ts | 9 +- apps/server/src/provider/builtInDrivers.ts | 3 + .../src/provider/openCode2Runtime.test.ts | 225 +++ apps/server/src/provider/openCode2Runtime.ts | 589 ++++++++ apps/server/src/server.ts | 13 +- .../OpenCode2TextGeneration.test.ts | 103 ++ .../textGeneration/OpenCode2TextGeneration.ts | 233 +++ .../src/textGeneration/TextGeneration.ts | 8 +- .../OpenCode2AutoEnableNotification.tsx | 111 ++ .../src/components/chat/providerIconUtils.ts | 1 + .../settings/DiagnosticsSettings.tsx | 2 +- .../settings/ProviderModelsSection.tsx | 1 + .../settings/ProviderSettingsForm.test.ts | 9 + .../components/settings/providerDriverMeta.ts | 8 + apps/web/src/components/ui/toast.tsx | 13 +- apps/web/src/composerDraftStore.ts | 12 +- apps/web/src/lib/contextWindow.ts | 2 + .../openCode2AutoEnableNotification.test.ts | 100 ++ .../src/openCode2AutoEnableNotification.ts | 101 ++ apps/web/src/routes/__root.tsx | 2 + apps/web/src/session-logic.ts | 6 + docs/internals/glossary.md | 2 +- docs/internals/overview.md | 4 +- docs/internals/providers.md | 23 +- docs/user/install.md | 23 +- packages/contracts/src/model.ts | 5 + .../contracts/src/providerRuntime.test.ts | 18 + packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.test.ts | 7 + packages/contracts/src/settings.ts | 35 + 39 files changed, 4127 insertions(+), 55 deletions(-) create mode 100644 .agents/AGENTS.md create mode 100644 apps/server/src/provider/Drivers/OpenCode2Driver.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Adapter.test.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Adapter.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Provider.test.ts create mode 100644 apps/server/src/provider/Layers/OpenCode2Provider.ts create mode 100644 apps/server/src/provider/openCode2Runtime.test.ts create mode 100644 apps/server/src/provider/openCode2Runtime.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/OpenCode2TextGeneration.ts create mode 100644 apps/web/src/components/OpenCode2AutoEnableNotification.tsx create mode 100644 apps/web/src/openCode2AutoEnableNotification.test.ts create mode 100644 apps/web/src/openCode2AutoEnableNotification.ts diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 000000000000..eaa562898d01 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1 @@ +Use `gh attach` when adding files to GitHub, and never commit PR-only assets. diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..12c3b8b590c1 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -50,7 +50,7 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "opencode") { + if (props.provider === "opencode" || props.provider === "opencode2") { return ( diff --git a/apps/server/src/provider/Drivers/OpenCode2Driver.ts b/apps/server/src/provider/Drivers/OpenCode2Driver.ts new file mode 100644 index 000000000000..ec19ad3047e1 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenCode2Driver.ts @@ -0,0 +1,136 @@ +import { OpenCode2Settings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeOpenCode2TextGeneration } from "../../textGeneration/OpenCode2TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeOpenCode2Adapter } from "../Layers/OpenCode2Adapter.ts"; +import { + buildInitialOpenCode2ProviderSnapshot, + checkOpenCode2ProviderStatus, +} from "../Layers/OpenCode2Provider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { OpenCode2Runtime } from "../openCode2Runtime.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeOpenCode2Settings = Schema.decodeSync(OpenCode2Settings); +const DRIVER_KIND = ProviderDriverKind.make("opencode2"); +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type OpenCode2DriverEnv = + | BackgroundPolicy.BackgroundPolicy + | OpenCode2Runtime + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const OpenCode2Driver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenCode 2", + supportsMultipleInstances: false, + }, + configSchema: OpenCode2Settings, + defaultConfig: (): OpenCode2Settings => decodeOpenCode2Settings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies OpenCode2Settings; + + const adapter = yield* makeOpenCode2Adapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }).pipe(Effect.provideService(OpenCode2Runtime, runtime)); + const textGeneration = yield* makeOpenCode2TextGeneration(effectiveConfig, processEnv).pipe( + Effect.provideService(OpenCode2Runtime, runtime), + ); + + const checkProvider = checkOpenCode2ProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCode2Runtime, runtime), + ); + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities: MAINTENANCE, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialOpenCode2ProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build OpenCode 2 snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts new file mode 100644 index 000000000000..e65d03d3867a --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts @@ -0,0 +1,495 @@ +import * as NodeAssert from "node:assert/strict"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { + ApprovalRequestId, + OpenCode2Settings, + ProviderInstanceId, + type ProviderRuntimeEvent, + ThreadId, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../../config.ts"; +import { + type OpenCode2Connection, + type OpenCode2Event, + type OpenCode2HttpMethod, + type OpenCode2RequestInput, + OpenCode2Runtime, + type OpenCode2RuntimeShape, +} from "../openCode2Runtime.ts"; +import { makeOpenCode2Adapter, type OpenCode2AdapterShape } from "./OpenCode2Adapter.ts"; + +interface RequestCall { + readonly method: OpenCode2HttpMethod; + readonly path: string; + readonly operation: string; + readonly body?: unknown; +} + +interface Harness { + readonly adapter: OpenCode2AdapterShape; + readonly calls: Array; + readonly attachCount: () => number; + readonly publish: (event: OpenCode2Event) => Effect.Effect; +} + +const settings = Schema.decodeSync(OpenCode2Settings)({ + enabled: true, + binaryPath: "fake-opencode2", + customModels: [], +}); + +const threadId = ThreadId.make("thread-opencode2-test"); + +function event(id: string, type: string, data: unknown): OpenCode2Event { + return { id, type, data }; +} + +function withHarness( + promptShape: "flat" | "nested", + run: (harness: Harness) => Effect.Effect, + enabled = true, +) { + return Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const calls: Array = []; + let attachCalls = 0; + const request = (( + method: OpenCode2HttpMethod, + path: string, + input: OpenCode2RequestInput, + ) => + Effect.sync(() => { + calls.push({ + method, + path, + operation: input.operation, + ...(input.body !== undefined ? { body: input.body } : {}), + }); + if (path === "/api/session" || (method === "GET" && path.startsWith("/api/session/"))) { + const id = path === "/api/session" ? "ses_test" : path.slice("/api/session/".length); + return { data: { id } }; + } + if (path.endsWith("/message")) return { data: [], cursor: {} }; + if (path.endsWith("/prompt")) return { data: { id: "msg_user" } }; + return undefined; + })) as OpenCode2Connection["request"]; + const connection: OpenCode2Connection = { + url: "http://127.0.0.1:4096", + protocol: { + promptShape, + eventNamespace: promptShape === "flat" ? "session.next" : "session", + }, + request, + globalEvents: Stream.make(event("connected", "server.connected", {})).pipe( + Stream.concat(Stream.fromQueue(events)), + ), + }; + const runtime: OpenCode2RuntimeShape = { + attach: () => + Effect.sync(() => { + attachCalls += 1; + return connection; + }), + }; + const adapter = yield* makeOpenCode2Adapter({ ...settings, enabled }).pipe( + Effect.provideService(OpenCode2Runtime, runtime), + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode2-adapter-" })), + ); + yield* Effect.yieldNow; + return yield* run({ + adapter, + calls, + attachCount: () => attachCalls, + publish: (next) => Queue.offer(events, next).pipe(Effect.asVoid), + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); +} + +function collectEvents( + adapter: OpenCode2AdapterShape, + target: Array, + onEvent?: (event: ProviderRuntimeEvent) => Effect.Effect, +) { + return adapter.streamEvents.pipe( + Stream.runForEach((next) => + Effect.sync(() => { + target.push(next); + }).pipe(Effect.andThen(onEvent?.(next) ?? Effect.void)), + ), + Effect.forkScoped, + ); +} + +it.effect("attaches once and maps native deltas, tools, and terminal events", () => + withHarness("flat", ({ adapter, attachCount, calls, publish }) => + Effect.gen(function* () { + const observed: Array = []; + const firstCompleted = yield* Deferred.make(); + const secondCompleted = yield* Deferred.make(); + const publicDelta = yield* Deferred.make(); + let completedCount = 0; + yield* collectEvents(adapter, observed, (next) => + Effect.gen(function* () { + if (next.type === "turn.completed") { + completedCount += 1; + yield* Deferred.succeed( + completedCount === 1 ? firstCompleted : secondCompleted, + undefined, + ).pipe(Effect.ignore); + } + if (next.type === "content.delta" && next.payload.delta === "public") { + yield* Deferred.succeed(publicDelta, undefined).pipe(Effect.ignore); + } + }), + ); + NodeAssert.equal(attachCount(), 0); + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + cwd: "/tmp/opencode2-project", + }); + NodeAssert.equal(attachCount(), 1); + const turn = yield* adapter.sendTurn({ threadId, input: "hello" }); + + const promptCall = calls.find((call) => call.operation === "session.prompt"); + NodeAssert.deepEqual(promptCall?.body, { text: "hello", delivery: "steer" }); + NodeAssert.equal(session.resumeCursor !== undefined, true); + + yield* publish( + event("foreign", "session.next.text.delta", { + sessionID: "ses_foreign", + textID: "txt_foreign", + delta: "ignore me", + }), + ); + yield* publish( + event("reasoning-started", "session.next.reasoning.started", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + }), + ); + yield* publish( + event("reasoning", "session.next.reasoning.delta", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + delta: "thinking", + }), + ); + yield* publish( + event("reasoning-ended", "session.next.reasoning.ended", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + text: "thinking", + }), + ); + yield* publish( + event("text-started", "session.next.text.started", { + sessionID: "ses_test", + textID: "text-1", + }), + ); + yield* publish( + event("text", "session.next.text.delta", { + sessionID: "ses_test", + textID: "text-1", + delta: "answer", + }), + ); + yield* publish( + event("tool-input-started", "session.next.tool.input.started", { + sessionID: "ses_test", + callID: "call-1", + name: "shell", + }), + ); + yield* publish( + event("tool-input-delta", "session.next.tool.input.delta", { + sessionID: "ses_test", + callID: "call-1", + delta: '{"command":', + }), + ); + yield* publish( + event("tool-input-ended", "session.next.tool.input.ended", { + sessionID: "ses_test", + callID: "call-1", + text: '{"command":"pwd"}', + }), + ); + yield* publish( + event("tool-called", "session.next.tool.called", { + sessionID: "ses_test", + callID: "call-1", + tool: "shell", + input: { command: "pwd" }, + }), + ); + yield* publish( + event("tool-progress", "session.next.tool.progress", { + sessionID: "ses_test", + callID: "call-1", + content: [{ type: "text", text: "/tmp" }], + }), + ); + yield* publish( + event("tool-success", "session.next.tool.success", { + sessionID: "ses_test", + callID: "call-1", + content: [{ type: "text", text: "done" }], + }), + ); + yield* publish( + event("settled", "session.next.execution.settled", { + sessionID: "ses_test", + outcome: "success", + }), + ); + yield* Deferred.await(firstCompleted); + + const deltas = observed.filter((next) => next.type === "content.delta"); + NodeAssert.deepEqual( + deltas.map((next) => next.payload), + [ + { streamKind: "reasoning_text", delta: "thinking" }, + { streamKind: "assistant_text", delta: "answer" }, + ], + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.started"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.updated"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.completed"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "turn.completed" && next.turnId === turn.turnId), + true, + ); + + yield* adapter.sendTurn({ threadId, input: "public dev alias" }); + NodeAssert.equal(attachCount(), 1); + yield* publish( + event("public-text", "session.text.delta", { + sessionID: "ses_test", + assistantMessageID: "msg_public", + ordinal: 0, + delta: "public", + }), + ); + yield* publish( + event("public-terminal", "session.execution.succeeded", { + sessionID: "ses_test", + }), + ); + yield* Deferred.await(publicDelta); + yield* Deferred.await(secondCompleted); + NodeAssert.equal( + observed.some((next) => next.type === "content.delta" && next.payload.delta === "public"), + true, + ); + NodeAssert.equal(observed.filter((next) => next.type === "turn.completed").length, 2); + }), + ), +); + +it.effect("replies to permissions and forms and interrupts through native routes", () => + withHarness("nested", ({ adapter, calls, publish }) => + Effect.gen(function* () { + const observed: Array = []; + const permissionOpened = yield* Deferred.make(); + const formOpened = yield* Deferred.make(); + const turnAborted = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => { + if (next.type === "request.opened") { + return Deferred.succeed(permissionOpened, undefined).pipe(Effect.ignore); + } + if (next.type === "user-input.requested") { + return Deferred.succeed(formOpened, undefined).pipe(Effect.ignore); + } + if (next.type === "turn.aborted") { + return Deferred.succeed(turnAborted, undefined).pipe(Effect.ignore); + } + return Effect.void; + }); + yield* adapter.startSession({ threadId, runtimeMode: "approval-required" }); + yield* adapter.sendTurn({ threadId, input: "inspect" }); + + const promptCall = calls.find((call) => call.operation === "session.prompt"); + NodeAssert.deepEqual(promptCall?.body, { + prompt: { text: "inspect" }, + delivery: "steer", + }); + + yield* publish( + event("permission", "permission.asked", { + id: "per_test", + sessionID: "ses_test", + action: "read", + resources: [".env"], + }), + ); + yield* publish( + event("form", "form.created", { + form: { + id: "frm_test", + sessionID: "ses_test", + title: "Choose", + fields: [ + { + key: "choice", + type: "string", + title: "Choice", + description: "Pick one", + options: [{ value: "one", label: "One" }], + }, + ], + }, + }), + ); + yield* Deferred.await(permissionOpened); + yield* Deferred.await(formOpened); + + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.make("per_test"), + "acceptForSession", + ); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("frm_test"), { + choice: "one", + }); + yield* adapter.interruptTurn(threadId); + yield* Deferred.await(turnAborted); + + NodeAssert.deepEqual( + calls.find((call) => call.operation === "permission.reply"), + { + method: "POST", + path: "/api/session/ses_test/permission/per_test/reply", + operation: "permission.reply", + body: { reply: "always" }, + }, + ); + NodeAssert.deepEqual( + calls.find((call) => call.operation === "form.reply"), + { + method: "POST", + path: "/api/session/ses_test/form/frm_test/reply", + operation: "form.reply", + body: { answer: { choice: "one" } }, + }, + ); + NodeAssert.equal( + calls.some( + (call) => + call.operation === "session.interrupt" && + call.path === "/api/session/ses_test/interrupt", + ), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "request.opened"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "user-input.requested"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "turn.aborted"), + true, + ); + }), + ), +); + +it.effect("resumes existing sessions and detaches without stop or delete requests", () => + withHarness("flat", ({ adapter, calls }) => + Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode2"), + "anthropic/claude-sonnet", + [{ id: "variant", value: "low" }], + ), + }); + NodeAssert.equal( + calls.some((call) => call.method === "GET" && call.path === "/api/session/ses_existing"), + true, + ); + yield* adapter.sendTurn({ + threadId, + input: "change effort", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode2"), + "anthropic/claude-sonnet", + [{ id: "variant", value: "high" }], + ), + }); + NodeAssert.deepEqual( + calls.findLast((call) => call.operation === "session.switchModel")?.body, + { + model: { + providerID: "anthropic", + id: "claude-sonnet", + variant: "high", + }, + }, + ); + yield* adapter.stopSession(threadId); + yield* adapter.startSession({ + threadId: ThreadId.make("thread-opencode2-second"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: ThreadId.make("thread-opencode2-second"), + input: "active", + }); + yield* adapter.stopAll(); + + NodeAssert.equal( + calls.some((call) => call.method === "DELETE"), + false, + ); + NodeAssert.equal( + calls.some((call) => call.path.includes("/service/stop")), + false, + ); + NodeAssert.equal(calls.filter((call) => call.operation === "session.interrupt").length, 2); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); + }), + ), +); + +it.effect("does not attach when OpenCode 2 is disabled", () => + withHarness( + "flat", + ({ adapter, attachCount }) => + Effect.gen(function* () { + const result = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit); + NodeAssert.equal(Exit.isFailure(result), true); + NodeAssert.equal(attachCount(), 0); + }), + false, + ), +); diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts new file mode 100644 index 000000000000..6d9e57cfb40f --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -0,0 +1,1311 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeURL from "node:url"; + +import { + EventId, + type OpenCode2Settings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + RuntimeRequestId, + ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import type { ProviderAdapterShape, ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import { + type OpenCode2Event, + type OpenCode2RequestInput, + OpenCode2Runtime, + OpenCode2RuntimeError, +} from "../openCode2Runtime.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("opencode2"); +const RESUME_VERSION = 1 as const; + +const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); +const UnknownRecordArraySchema = Schema.Array(UnknownRecordSchema); +const SessionResponseSchema = Schema.Struct({ + data: Schema.Struct({ + id: Schema.String, + }), +}); +const MessagesResponseSchema = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}); + +const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecordSchema); +const decodeUnknownRecordArray = Schema.decodeUnknownOption(UnknownRecordArraySchema); + +type OpenCode2AdapterError = + | ProviderAdapterProcessError + | ProviderAdapterRequestError + | ProviderAdapterSessionNotFoundError + | ProviderAdapterValidationError; + +export type OpenCode2AdapterShape = ProviderAdapterShape; + +interface OpenCode2TurnSnapshot { + readonly id: TurnId; + readonly items: Array; +} + +interface OpenCode2SessionContext { + session: ProviderSession; + readonly providerSessionId: string; + readonly pendingPermissions: Set; + readonly pendingForms: Set; + readonly toolNameByCallId: Map; + activeTurnId: TurnId | undefined; + currentModel: string | undefined; + currentVariant: string | undefined; + currentAgent: string | undefined; +} + +export interface OpenCode2AdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface ModelRef { + readonly providerID: string; + readonly id: string; + readonly variant?: string; +} + +interface EventBaseInput { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string; + readonly requestId?: string; + readonly raw?: OpenCode2Event; +} + +function recordFromUnknown(value: unknown): Readonly> | undefined { + return Option.getOrUndefined(decodeUnknownRecord(value)); +} + +function recordsFromUnknown(value: unknown): ReadonlyArray>> { + return Option.getOrElse(decodeUnknownRecordArray(value), () => []); +} + +function stringField( + record: Readonly> | undefined, + ...keys: ReadonlyArray +): string | undefined { + for (const key of keys) { + const value = record?.[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return undefined; +} + +function eventData(event: OpenCode2Event): Readonly> | undefined { + const outer = recordFromUnknown(event.data); + return recordFromUnknown(outer?.properties) ?? outer; +} + +function eventSessionId(event: OpenCode2Event): string | undefined { + const data = eventData(event); + const form = recordFromUnknown(data?.form) ?? recordFromUnknown(data?.info); + return ( + stringField(data, "sessionID", "sessionId") ?? + stringField(form, "sessionID", "sessionId") ?? + event.durable?.aggregateID + ); +} + +function normalizeEventType(type: string): string { + const withoutVersion = type.replace(/\.\d+$/u, ""); + return withoutVersion.startsWith("session.next.") + ? `session.${withoutVersion.slice("session.next.".length)}` + : withoutVersion; +} + +function parseResumeCursor(raw: unknown): string | undefined { + const record = recordFromUnknown(raw); + if (record?.schemaVersion !== RESUME_VERSION) return undefined; + return stringField(record, "sessionId"); +} + +function parseModelRef(model: string, variant?: string): ModelRef | undefined { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { + providerID: model.slice(0, separator), + id: model.slice(separator + 1), + ...(variant ? { variant } : {}), + }; +} + +function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { + const normalized = toolName.toLowerCase(); + if ( + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("command") + ) { + return "command_execution"; + } + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { + return "file_change"; + } + if (normalized.includes("web")) return "web_search"; + if (normalized.includes("mcp")) return "mcp_tool_call"; + if (normalized.includes("image")) return "image_view"; + if (normalized.includes("task") || normalized.includes("agent")) { + return "collab_agent_tool_call"; + } + return "dynamic_tool_call"; +} + +function permissionRequestType( + action: string, +): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (action) { + case "bash": + case "shell": + return "command_execution_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "write": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function permissionReply(decision: "accept" | "acceptForSession" | "decline" | "cancel") { + switch (decision) { + case "accept": + return "once" as const; + case "acceptForSession": + return "always" as const; + case "decline": + case "cancel": + return "reject" as const; + } +} + +function permissionDecision(reply: string): string { + switch (reply) { + case "once": + return "accept"; + case "always": + return "acceptForSession"; + default: + return "decline"; + } +} + +function formFromEvent(event: OpenCode2Event): Readonly> | undefined { + const data = eventData(event); + return recordFromUnknown(data?.form) ?? recordFromUnknown(data?.info) ?? data; +} + +function formQuestions(form: Readonly>): ReadonlyArray { + return recordsFromUnknown(form.fields).map((field) => { + const id = stringField(field, "key", "id") ?? "answer"; + const title = stringField(field, "title", "label") ?? id; + const description = stringField(field, "description") ?? title; + const type = stringField(field, "type"); + const options = recordsFromUnknown(field.options).map((option) => { + const label = stringField(option, "label", "value") ?? "Option"; + return { + label, + description: stringField(option, "description") ?? label, + }; + }); + if (type === "boolean" && options.length === 0) { + options.push({ label: "Yes", description: "Yes" }, { label: "No", description: "No" }); + } + return { + id, + header: title, + question: description, + options, + ...(type === "multiselect" ? { multiSelect: true } : {}), + }; + }); +} + +function contentItemId( + data: Readonly>, + kind: "text" | "reasoning", +): string { + const explicit = stringField(data, kind === "text" ? "textID" : "reasoningID", "id"); + if (explicit) return explicit; + const messageId = stringField(data, "assistantMessageID", "messageID") ?? "message"; + const ordinal = typeof data.ordinal === "number" ? data.ordinal : 0; + return `${messageId}-${kind}-${ordinal}`; +} + +function toolDetail(data: Readonly>): string | undefined { + const error = recordFromUnknown(data.error); + const errorMessage = stringField(error, "message", "detail"); + if (errorMessage) return errorMessage; + if (typeof data.error === "string" && data.error.trim().length > 0) return data.error; + const texts = recordsFromUnknown(data.content) + .map((item) => stringField(item, "text")) + .filter((value): value is string => value !== undefined); + if (texts.length > 0) return texts.join("\n"); + if (typeof data.result === "string" && data.result.trim().length > 0) return data.result; + return undefined; +} + +function toRequestError(method: string, cause: OpenCode2RuntimeError) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: cause.detail, + cause, + }); +} + +function toProcessError(threadId: ThreadId, cause: OpenCode2RuntimeError) { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: cause.detail, + cause, + }); +} + +function encodedPathSegment(value: string): string { + return encodeURIComponent(value); +} + +export function makeOpenCode2Adapter( + settings: OpenCode2Settings, + options?: OpenCode2AdapterLiveOptions, +) { + return Effect.gen(function* () { + const runtime = yield* OpenCode2Runtime; + const serverConfig = yield* ServerConfig; + const scope = yield* Effect.scope; + const instanceId = options?.instanceId ?? ProviderInstanceId.make("opencode2"); + const events = yield* Queue.unbounded(); + const sessions = new Map(); + const sessionByProviderId = new Map(); + const serverConnected = yield* Deferred.make(); + let eventCounter = 0; + + const buildEventBase = Effect.fn("OpenCode2Adapter.buildEventBase")(function* ( + input: EventBaseInput, + ) { + eventCounter += 1; + return { + eventId: EventId.make(`opencode2-event-${eventCounter}`), + provider: PROVIDER, + providerInstanceId: instanceId, + threadId: input.threadId, + createdAt: DateTime.formatIso(yield* DateTime.now), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw + ? { + raw: { + source: "opencode2.api.event" as const, + payload: input.raw, + }, + } + : {}), + }; + }); + + const emit = (event: ProviderRuntimeEvent) => Queue.offer(events, event).pipe(Effect.asVoid); + + const ensureSession = Effect.fn("OpenCode2Adapter.ensureSession")(function* ( + threadId: ThreadId, + ) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return context; + }); + + const updateSession = Effect.fn("OpenCode2Adapter.updateSession")(function* ( + context: OpenCode2SessionContext, + patch: Partial, + clear?: "activeTurnId" | "lastError", + ) { + const next = { + ...context.session, + ...patch, + updatedAt: DateTime.formatIso(yield* DateTime.now), + } as ProviderSession & Record; + if (clear) delete next[clear]; + context.session = next; + return next; + }); + + const completeTurn = Effect.fn("OpenCode2Adapter.completeTurn")(function* ( + context: OpenCode2SessionContext, + state: "completed" | "failed" | "interrupted", + raw: OpenCode2Event, + errorMessage?: string, + ) { + const turnId = context.activeTurnId; + if (!turnId) return; + context.activeTurnId = undefined; + yield* updateSession( + context, + { + status: state === "failed" ? "error" : "ready", + ...(errorMessage ? { lastError: errorMessage } : {}), + }, + "activeTurnId", + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, raw })), + type: "turn.completed", + payload: { + state, + ...(errorMessage ? { errorMessage } : {}), + }, + }); + }); + + const writeNativeEvent = ( + context: OpenCode2SessionContext, + event: OpenCode2Event, + observedAt: string, + ) => + options?.nativeEventLogger + ? options.nativeEventLogger + .write( + { + observedAt, + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.providerSessionId, + type: event.type, + payload: event, + }, + }, + context.session.threadId, + ) + .pipe(Effect.catchCause(() => Effect.void)) + : Effect.void; + + const handleEvent = Effect.fn("OpenCode2Adapter.handleEvent")(function* ( + event: OpenCode2Event, + ) { + if (normalizeEventType(event.type) === "server.connected") { + yield* Deferred.succeed(serverConnected, undefined).pipe(Effect.ignore); + return; + } + const providerSessionId = eventSessionId(event); + if (!providerSessionId) return; + const context = sessionByProviderId.get(providerSessionId); + if (!context) return; + const data = eventData(event); + if (!data) return; + const type = normalizeEventType(event.type); + const turnId = context.activeTurnId; + yield* writeNativeEvent(context, event, DateTime.formatIso(yield* DateTime.now)); + + switch (type) { + case "session.text.started": { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, "text"), + raw: event, + })), + type: "item.started", + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", + }, + }); + break; + } + + case "session.reasoning.started": { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, "reasoning"), + raw: event, + })), + type: "item.started", + payload: { + itemType: "reasoning", + status: "inProgress", + title: "Reasoning", + }, + }); + break; + } + + case "session.text.delta": + case "session.reasoning.delta": { + const delta = stringField(data, "delta"); + if (!delta) break; + const kind = type === "session.text.delta" ? "text" : "reasoning"; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, kind), + raw: event, + })), + type: "content.delta", + payload: { + streamKind: kind === "text" ? "assistant_text" : "reasoning_text", + delta, + }, + }); + break; + } + + case "session.reasoning.ended": { + const text = stringField(data, "text"); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, "reasoning"), + raw: event, + })), + type: "item.completed", + payload: { + itemType: "reasoning", + status: "completed", + title: "Reasoning", + ...(text ? { detail: text } : {}), + }, + }); + break; + } + + case "session.text.ended": { + const text = stringField(data, "text"); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: contentItemId(data, "text"), + raw: event, + })), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(text ? { detail: text } : {}), + }, + }); + break; + } + + case "session.tool.input.started": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = stringField(data, "tool", "name") ?? "tool"; + context.toolNameByCallId.set(callId, tool); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: callId, + raw: event, + })), + type: "item.started", + payload: { + itemType: toToolLifecycleItemType(tool), + status: "inProgress", + title: tool, + }, + }); + break; + } + + case "session.tool.input.delta": + case "session.tool.input.ended": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = context.toolNameByCallId.get(callId) ?? "tool"; + const detail = stringField(data, "delta", "text"); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: callId, + raw: event, + })), + type: "item.updated", + payload: { + itemType: toToolLifecycleItemType(tool), + status: "inProgress", + title: tool, + ...(detail ? { detail } : {}), + data, + }, + }); + break; + } + + case "session.tool.called": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = stringField(data, "tool", "name") ?? "tool"; + const inputStarted = context.toolNameByCallId.has(callId); + context.toolNameByCallId.set(callId, tool); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: callId, + raw: event, + })), + type: inputStarted ? "item.updated" : "item.started", + payload: { + itemType: toToolLifecycleItemType(tool), + status: "inProgress", + title: tool, + data: { + tool, + input: data.input, + }, + }, + }); + break; + } + + case "session.tool.progress": + case "session.tool.success": + case "session.tool.failed": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = context.toolNameByCallId.get(callId) ?? "tool"; + const terminal = type !== "session.tool.progress"; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: callId, + raw: event, + })), + type: terminal ? "item.completed" : "item.updated", + payload: { + itemType: toToolLifecycleItemType(tool), + status: + type === "session.tool.failed" ? "failed" : terminal ? "completed" : "inProgress", + title: tool, + ...(toolDetail(data) ? { detail: toolDetail(data) } : {}), + data, + }, + }); + if (terminal) context.toolNameByCallId.delete(callId); + break; + } + + case "permission.asked": { + const requestId = stringField(data, "id", "requestID"); + if (!requestId) break; + const action = stringField(data, "action", "permission") ?? "unknown"; + const resources = Array.isArray(data.resources) + ? data.resources.filter((value): value is string => typeof value === "string") + : Array.isArray(data.patterns) + ? data.patterns.filter((value): value is string => typeof value === "string") + : []; + context.pendingPermissions.add(requestId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId, + raw: event, + })), + type: "request.opened", + payload: { + requestType: permissionRequestType(action), + detail: resources.length > 0 ? resources.join("\n") : action, + args: data.metadata ?? data.source, + }, + }); + break; + } + + case "permission.replied": { + const requestId = stringField(data, "requestID", "id"); + if (!requestId) break; + context.pendingPermissions.delete(requestId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId, + raw: event, + })), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: permissionDecision(stringField(data, "reply") ?? "reject"), + }, + }); + break; + } + + case "form.created": { + const form = formFromEvent(event); + if (!form) break; + const formId = stringField(form, "id", "formID"); + if (!formId) break; + context.pendingForms.add(formId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: formId, + raw: event, + })), + type: "user-input.requested", + payload: { + questions: formQuestions(form), + }, + }); + break; + } + + case "form.replied": + case "form.cancelled": { + const form = formFromEvent(event); + const formId = form ? stringField(form, "id", "formID") : undefined; + if (!formId) break; + context.pendingForms.delete(formId); + const answer = recordFromUnknown(data.answer) ?? recordFromUnknown(data.answers) ?? {}; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: formId, + raw: event, + })), + type: "user-input.resolved", + payload: { + answers: type === "form.cancelled" ? {} : answer, + }, + }); + break; + } + + case "session.execution.settled": { + const outcome = stringField(data, "outcome", "state") ?? "success"; + const error = recordFromUnknown(data.error); + const message = stringField(error, "message", "detail") ?? stringField(data, "message"); + yield* completeTurn( + context, + outcome === "failure" || outcome === "failed" + ? "failed" + : outcome === "interrupted" || outcome === "cancelled" + ? "interrupted" + : "completed", + event, + message, + ); + break; + } + + case "session.execution.succeeded": + case "session.idle": + yield* completeTurn(context, "completed", event); + break; + + case "session.execution.interrupted": + yield* completeTurn(context, "interrupted", event); + break; + + case "session.execution.failed": + case "session.error": { + const error = recordFromUnknown(data.error); + const message = + stringField(error, "message", "detail") ?? + stringField(data, "message") ?? + "OpenCode 2 session failed."; + yield* completeTurn(context, "failed", event, message); + break; + } + + default: + break; + } + }); + + const reportConnectionFailure = (cause: OpenCode2RuntimeError) => + Effect.forEach( + sessions.values(), + (context) => + Effect.gen(function* () { + yield* updateSession(context, { + status: "error", + lastError: cause.detail, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "runtime.error", + payload: { + message: cause.detail, + class: "transport_error", + }, + }); + }), + { discard: true }, + ); + + const ensureConnection = yield* Effect.cached( + Effect.gen(function* () { + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath, + ...(options?.environment ? { environment: options.environment } : {}), + }); + yield* connection.globalEvents.pipe( + Stream.runForEach(handleEvent), + Effect.flatMap(() => + Effect.fail( + new OpenCode2RuntimeError({ + operation: "event.subscribe", + kind: "connection", + detail: "OpenCode 2 event stream ended before the adapter detached.", + }), + ), + ), + Effect.tapError((cause) => Deferred.fail(serverConnected, cause).pipe(Effect.ignore)), + Effect.catch(reportConnectionFailure), + Effect.forkIn(scope), + ); + yield* Deferred.await(serverConnected).pipe( + Effect.timeoutOption("10 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new OpenCode2RuntimeError({ + operation: "event.subscribe", + kind: "connection", + detail: "Timed out waiting for OpenCode 2 server.connected.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + return connection; + }), + ); + + const request = ( + method: "GET" | "POST", + path: string, + input: OpenCode2RequestInput, + ) => + ensureConnection.pipe( + Effect.flatMap((connection) => connection.request(method, path, input)), + ); + + const interruptContext = Effect.fn("OpenCode2Adapter.interruptContext")(function* ( + context: OpenCode2SessionContext, + turnId?: TurnId, + ) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/interrupt`, + { + operation: "session.interrupt", + schema: Schema.Void, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.interrupt", cause))); + const interruptedTurnId = turnId ?? context.activeTurnId; + context.activeTurnId = undefined; + yield* updateSession(context, { status: "ready" }, "activeTurnId"); + if (interruptedTurnId) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: interruptedTurnId, + })), + type: "turn.aborted", + payload: { reason: "Interrupted by user." }, + }); + } + }); + + const detachContext = Effect.fn("OpenCode2Adapter.detachContext")(function* ( + context: OpenCode2SessionContext, + ) { + if (context.activeTurnId) { + yield* interruptContext(context, context.activeTurnId); + } + sessions.delete(context.session.threadId); + sessionByProviderId.delete(context.providerSessionId); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "session.exited", + payload: { + reason: "Session detached.", + recoverable: true, + exitKind: "graceful", + }, + }); + }); + + const startSession: OpenCode2AdapterShape["startSession"] = Effect.fn( + "OpenCode2Adapter.startSession", + )(function* (input) { + if (!settings.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "OpenCode 2 is disabled in provider settings.", + }); + } + const directory = input.cwd ?? serverConfig.cwd; + const selectedModel = input.modelSelection?.model; + if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `OpenCode 2 model selection is bound to instance '${input.modelSelection.instanceId}', expected '${instanceId}'.`, + }); + } + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const modelRef = selectedModel ? parseModelRef(selectedModel, variant) : undefined; + if (selectedModel && !modelRef) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "OpenCode 2 model selection must use the 'provider/model' format.", + }); + } + const agent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const resumeSessionId = parseResumeCursor(input.resumeCursor); + const resolved = resumeSessionId + ? yield* request("GET", `/api/session/${encodedPathSegment(resumeSessionId)}`, { + operation: "session.get", + schema: SessionResponseSchema, + }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))) + : yield* request("POST", "/api/session", { + operation: "session.create", + schema: SessionResponseSchema, + body: { + ...(input.title ? { title: input.title } : {}), + location: { directory }, + ...(modelRef ? { model: modelRef } : {}), + ...(agent ? { agent } : {}), + }, + }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + const providerSessionId = resolved.data.id; + if (resumeSessionId && modelRef) { + yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/model`, { + operation: "session.switchModel", + schema: Schema.Void, + body: { model: modelRef }, + }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + } + if (resumeSessionId && agent) { + yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/agent`, { + operation: "session.switchAgent", + schema: Schema.Void, + body: { agent }, + }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + } + const createdAt = DateTime.formatIso(yield* DateTime.now); + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(selectedModel ? { model: selectedModel } : {}), + threadId: input.threadId, + resumeCursor: { + schemaVersion: RESUME_VERSION, + sessionId: providerSessionId, + }, + createdAt, + updatedAt: createdAt, + }; + const context: OpenCode2SessionContext = { + session, + providerSessionId, + pendingPermissions: new Set(), + pendingForms: new Set(), + toolNameByCallId: new Map(), + activeTurnId: undefined, + currentModel: selectedModel, + currentVariant: variant, + currentAgent: agent, + }; + const previous = sessions.get(input.threadId); + if (previous) { + yield* detachContext(previous); + } + sessions.set(input.threadId, context); + sessionByProviderId.set(providerSessionId, context); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.started", + payload: { + message: "OpenCode 2 session attached", + resume: session.resumeCursor, + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { + providerThreadId: providerSessionId, + }, + }); + return session; + }); + + const sendTurn: OpenCode2AdapterShape["sendTurn"] = Effect.fn("OpenCode2Adapter.sendTurn")( + function* (input) { + const context = yield* ensureSession(input.threadId); + const connection = yield* ensureConnection.pipe( + Effect.mapError((cause) => toRequestError("connection.attach", cause)), + ); + if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode 2 model selection is bound to instance '${input.modelSelection.instanceId}', expected '${instanceId}'.`, + }); + } + const text = input.input?.trim() ?? ""; + const attachments = input.attachments ?? []; + if (text.length === 0 && attachments.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 turns require text input or at least one attachment.", + }); + } + const files: Array<{ readonly uri: string; readonly name: string }> = []; + for (const attachment of attachments) { + const path = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!path) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode 2 attachment '${attachment.name}' is unavailable.`, + }); + } + files.push({ + uri: NodeURL.pathToFileURL(path).href, + name: attachment.name, + }); + } + + const selectedModel = input.modelSelection?.model ?? context.session.model; + const variant = input.modelSelection + ? getModelSelectionStringOptionValue(input.modelSelection, "variant") + : context.currentVariant; + const modelRef = selectedModel ? parseModelRef(selectedModel, variant) : undefined; + if (selectedModel && !modelRef) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 model selection must use the 'provider/model' format.", + }); + } + if ( + modelRef && + (selectedModel !== context.currentModel || variant !== context.currentVariant) + ) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/model`, + { + operation: "session.switchModel", + schema: Schema.Void, + body: { model: modelRef }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.switchModel", cause))); + context.currentModel = selectedModel; + context.currentVariant = variant; + } + + const selectedAgent = + getModelSelectionStringOptionValue(input.modelSelection, "agent") ?? + (input.interactionMode === "plan" ? "plan" : context.currentAgent); + if (selectedAgent && selectedAgent !== context.currentAgent) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/agent`, + { + operation: "session.switchAgent", + schema: Schema.Void, + body: { agent: selectedAgent }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.switchAgent", cause))); + context.currentAgent = selectedAgent; + } + + const steeringTurnId = context.activeTurnId; + eventCounter += 1; + const turnId = steeringTurnId ?? TurnId.make(`opencode2-turn-${eventCounter}`); + context.activeTurnId = turnId; + yield* updateSession( + context, + { + status: "running", + activeTurnId: turnId, + ...(selectedModel ? { model: selectedModel } : {}), + }, + "lastError", + ); + if (!steeringTurnId) { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: { + ...(selectedModel ? { model: selectedModel } : {}), + ...(variant ? { effort: variant } : {}), + }, + }); + } + + const prompt = { + text, + ...(files.length > 0 ? { files } : {}), + }; + const body = + connection.protocol.promptShape === "flat" + ? { ...prompt, delivery: "steer" as const } + : { prompt, delivery: "steer" as const }; + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/prompt`, + { + operation: "session.prompt", + schema: Schema.Unknown, + body, + }, + ).pipe( + Effect.mapError((cause) => toRequestError("session.prompt", cause)), + Effect.tapError((cause) => + steeringTurnId + ? Effect.void + : Effect.gen(function* () { + context.activeTurnId = undefined; + yield* updateSession( + context, + { status: "ready", lastError: cause.detail }, + "activeTurnId", + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.aborted", + payload: { reason: cause.detail }, + }); + }), + ), + ); + return { + threadId: input.threadId, + turnId, + resumeCursor: context.session.resumeCursor, + }; + }, + ); + + const interruptTurn: OpenCode2AdapterShape["interruptTurn"] = Effect.fn( + "OpenCode2Adapter.interruptTurn", + )(function* (threadId, turnId) { + const context = yield* ensureSession(threadId); + yield* interruptContext(context, turnId); + }); + + const respondToRequest: OpenCode2AdapterShape["respondToRequest"] = Effect.fn( + "OpenCode2Adapter.respondToRequest", + )(function* (threadId, requestId, decision) { + const context = yield* ensureSession(threadId); + if (!context.pendingPermissions.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "permission.reply", + detail: `Unknown pending permission request: ${requestId}`, + }); + } + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/permission/${encodedPathSegment(requestId)}/reply`, + { + operation: "permission.reply", + schema: Schema.Void, + body: { reply: permissionReply(decision) }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("permission.reply", cause))); + }); + + const respondToUserInput: OpenCode2AdapterShape["respondToUserInput"] = Effect.fn( + "OpenCode2Adapter.respondToUserInput", + )(function* (threadId, requestId, answers) { + const context = yield* ensureSession(threadId); + if (!context.pendingForms.has(requestId)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "form.reply", + detail: `Unknown pending form request: ${requestId}`, + }); + } + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/form/${encodedPathSegment(requestId)}/reply`, + { + operation: "form.reply", + schema: Schema.Void, + body: { answer: answers }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("form.reply", cause))); + }); + + const detachSession = Effect.fn("OpenCode2Adapter.detachSession")(function* ( + threadId: ThreadId, + ) { + const context = yield* ensureSession(threadId); + yield* detachContext(context); + }); + + const readThread: OpenCode2AdapterShape["readThread"] = Effect.fn( + "OpenCode2Adapter.readThread", + )(function* (threadId) { + const context = yield* ensureSession(threadId); + const response = yield* request( + "GET", + `/api/session/${encodedPathSegment(context.providerSessionId)}/message`, + { + operation: "message.list", + schema: MessagesResponseSchema, + query: { order: "asc" }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("message.list", cause))); + const turns: Array = []; + for (const message of response.data) { + const record = recordFromUnknown(message); + if (!record || stringField(record, "type", "role") !== "assistant") continue; + const id = stringField(record, "id"); + if (!id) continue; + turns.push({ + id: TurnId.make(id), + items: Array.isArray(record.content) ? [...record.content] : [record], + }); + } + return { threadId, turns } satisfies ProviderThreadSnapshot; + }); + + const rollbackThread: OpenCode2AdapterShape["rollbackThread"] = Effect.fn( + "OpenCode2Adapter.rollbackThread", + )(function* (threadId, numTurns) { + const context = yield* ensureSession(threadId); + const snapshot = yield* readThread(threadId); + if (numTurns <= 0) return snapshot; + const target = snapshot.turns[snapshot.turns.length - numTurns]; + if (!target) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: `Cannot roll back ${numTurns} OpenCode 2 turns from a thread with ${snapshot.turns.length} turns.`, + }); + } + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/revert/stage`, + { + operation: "session.revert.stage", + schema: Schema.Unknown, + body: { messageID: target.id }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.revert.stage", cause))); + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/revert/commit`, + { + operation: "session.revert.commit", + schema: Schema.Void, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.revert.commit", cause))); + return yield* readThread(threadId); + }); + + const stopAll: OpenCode2AdapterShape["stopAll"] = () => + Effect.forEach([...sessions.keys()], detachSession, { + concurrency: "unbounded", + discard: true, + }); + + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + sessions.clear(); + sessionByProviderId.clear(); + }), + ), + Effect.ensuring(Queue.shutdown(events)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession: detachSession, + listSessions: () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)), + hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromQueue(events); + }, + } satisfies OpenCode2AdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.test.ts b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts new file mode 100644 index 000000000000..fff26a5f8801 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts @@ -0,0 +1,148 @@ +import * as NodeAssert from "node:assert/strict"; + +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { + OpenCode2Runtime, + OpenCode2RuntimeError, + type OpenCode2Connection, +} from "../openCode2Runtime.ts"; +import { checkOpenCode2ProviderStatus } from "./OpenCode2Provider.ts"; + +const settings = { + enabled: true, + binaryPath: "opencode2", + customModels: [], +} as const; + +function runtimeLayer(input: { + readonly responses?: Readonly>; + readonly attachError?: OpenCode2RuntimeError; + readonly paths?: Array; +}) { + const connection: OpenCode2Connection = { + url: "http://127.0.0.1:49374/", + protocol: { promptShape: "flat", eventNamespace: "session" }, + request: ((method: string, path: string, _requestInput: { readonly schema: unknown }) => { + input.paths?.push(`${method} ${path}`); + return Effect.succeed(input.responses?.[path]); + }) as OpenCode2Connection["request"], + globalEvents: Stream.empty, + }; + return Layer.succeed( + OpenCode2Runtime, + OpenCode2Runtime.of({ + attach: () => + input.attachError ? Effect.fail(input.attachError) : Effect.succeed(connection), + }), + ); +} + +it.effect("builds model inventory from the attached OpenCode 2 service", () => { + const paths: Array = []; + return Effect.gen(function* () { + const snapshot = yield* checkOpenCode2ProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "0.0.0-beta-17823"); + NodeAssert.equal(snapshot.auth.status, "authenticated"); + NodeAssert.deepEqual(paths.toSorted(), [ + "GET /api/agent", + "GET /api/health", + "GET /api/model", + "GET /api/model/default", + ]); + NodeAssert.deepEqual( + snapshot.models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "anthropic/claude-opus-4-6", isDefault: undefined }, + { slug: "openai/gpt-5.6", isDefault: true }, + ], + ); + const selected = snapshot.models.find((model) => model.slug === "openai/gpt-5.6"); + NodeAssert.deepEqual( + selected?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id) ?? [], + ["variant", "agent"], + ); + }).pipe( + Effect.provide( + runtimeLayer({ + paths, + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823", pid: 42 }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + variants: { low: {}, high: {} }, + }, + { + id: "claude-opus-4-6", + providerID: "anthropic", + name: "Claude Opus 4.6", + variants: [], + }, + ], + }, + "/api/model/default": { + data: { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + variants: { low: {}, high: {} }, + }, + }, + "/api/agent": { + data: [ + { id: "build", name: "Build" }, + { id: "explore", name: "Explore", mode: "subagent" }, + ], + }, + }, + }), + ), + ); +}); + +it.effect("reports unsupported adjacent preview protocols without hanging", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCode2ProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.match(snapshot.message ?? "", /preview is not supported/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCode2RuntimeError({ + operation: "openapi.detect", + kind: "unsupported-preview", + detail: "Unsupported prompt response.", + }), + }), + ), + ), +); + +it.effect("finishes the provider check when attachment is wedged", () => { + const layer = Layer.succeed( + OpenCode2Runtime, + OpenCode2Runtime.of({ attach: () => Effect.never }), + ); + return Effect.gen(function* () { + const fiber = yield* checkOpenCode2ProviderStatus(settings).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("15 seconds"); + const snapshot = yield* Fiber.join(fiber); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.match(snapshot.message ?? "", /discovery timed out/i); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, TestClock.layer()))); +}); diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.ts b/apps/server/src/provider/Layers/OpenCode2Provider.ts new file mode 100644 index 000000000000..3ac693d93991 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenCode2Provider.ts @@ -0,0 +1,347 @@ +import type { ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { + buildServerProvider, + nonEmptyTrimmed, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { OpenCode2Runtime, OpenCode2RuntimeError } from "../openCode2Runtime.ts"; + +export interface OpenCode2ProviderSettings { + readonly enabled: boolean; + readonly binaryPath: string; + readonly customModels: ReadonlyArray; +} + +const OPENCODE2_PRESENTATION = { + displayName: "OpenCode 2", + showInteractionModeToggle: false, +} as const; +const OPENCODE2_PROVIDER_PROBE_TIMEOUT = "15 seconds"; + +const OpenCode2HealthSchema = Schema.Struct({ + healthy: Schema.optionalKey(Schema.Boolean), + version: Schema.optionalKey(Schema.String), + pid: Schema.optionalKey(Schema.Number), +}); + +const OpenCode2VariantSchema = Schema.Union([Schema.String, Schema.Struct({ id: Schema.String })]); + +const OpenCode2ModelSchema = Schema.Struct({ + id: Schema.String, + providerID: Schema.String, + name: Schema.String, + enabled: Schema.optionalKey(Schema.Boolean), + status: Schema.optionalKey(Schema.String), + variants: Schema.optionalKey( + Schema.Union([ + Schema.Array(OpenCode2VariantSchema), + Schema.Record(Schema.String, Schema.Unknown), + ]), + ), +}); + +const OpenCode2ModelListSchema = Schema.Union([ + Schema.Array(OpenCode2ModelSchema), + Schema.Struct({ data: Schema.Array(OpenCode2ModelSchema) }), +]); + +const OpenCode2DefaultModelSchema = Schema.Union([ + Schema.NullOr(OpenCode2ModelSchema), + Schema.Struct({ data: Schema.NullOr(OpenCode2ModelSchema) }), +]); + +const OpenCode2AgentSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + mode: Schema.optionalKey(Schema.String), + hidden: Schema.optionalKey(Schema.Boolean), + description: Schema.optionalKey(Schema.String), +}); + +const OpenCode2AgentListSchema = Schema.Union([ + Schema.Array(OpenCode2AgentSchema), + Schema.Struct({ data: Schema.Array(OpenCode2AgentSchema) }), +]); + +type OpenCode2Model = typeof OpenCode2ModelSchema.Type; +type OpenCode2Agent = typeof OpenCode2AgentSchema.Type; + +const DEFAULT_OPENCODE2_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +function responseList(value: ReadonlyArray | { readonly data: ReadonlyArray }) { + return "data" in value ? value.data : value; +} + +function responseValue(value: A | null | { readonly data: A | null }): A | null { + return value !== null && typeof value === "object" && "data" in value ? value.data : value; +} + +function titleCase(value: string): string { + return value + .split(/[-_/]+/u) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function variantIds(model: OpenCode2Model): ReadonlyArray { + if (!model.variants) return []; + if (Array.isArray(model.variants)) { + return model.variants.map((variant) => (typeof variant === "string" ? variant : variant.id)); + } + return Object.keys(model.variants); +} + +function modelCapabilities( + model: OpenCode2Model, + agents: ReadonlyArray, +): ModelCapabilities { + const variants = variantIds(model); + const defaultVariant = variants.includes("default") + ? "default" + : variants.includes("medium") + ? "medium" + : variants.length === 1 + ? variants[0] + : undefined; + const visibleAgents = agents.filter( + (agent) => agent.hidden !== true && agent.mode !== "subagent", + ); + const defaultAgent = + visibleAgents.find((agent) => agent.id === "build")?.id ?? visibleAgents[0]?.id; + + return createModelCapabilities({ + optionDescriptors: [ + ...(variants.length > 0 + ? [ + { + id: "variant", + label: "Variant", + type: "select" as const, + options: variants.map((variant) => ({ + id: variant, + label: titleCase(variant), + ...(variant === defaultVariant ? { isDefault: true as const } : {}), + })), + ...(defaultVariant ? { currentValue: defaultVariant } : {}), + }, + ] + : []), + ...(visibleAgents.length > 0 + ? [ + { + id: "agent", + label: "Agent", + type: "select" as const, + options: visibleAgents.map((agent) => ({ + id: agent.id, + label: nonEmptyTrimmed(agent.name) ?? titleCase(agent.id), + ...(agent.id === defaultAgent ? { isDefault: true as const } : {}), + })), + ...(defaultAgent ? { currentValue: defaultAgent } : {}), + }, + ] + : []), + ], + }); +} + +function providerModels(input: { + readonly models: ReadonlyArray; + readonly defaultModel: OpenCode2Model | null; + readonly agents: ReadonlyArray; +}): ReadonlyArray { + const defaultSlug = input.defaultModel + ? `${input.defaultModel.providerID}/${input.defaultModel.id}` + : null; + return input.models + .filter((model) => model.enabled !== false && model.status !== "deprecated") + .map((model) => { + const slug = `${model.providerID}/${model.id}`; + return { + slug, + name: nonEmptyTrimmed(model.name) ?? model.id, + subProvider: model.providerID, + isCustom: false, + ...(slug === defaultSlug ? { isDefault: true as const } : {}), + capabilities: modelCapabilities(model, input.agents), + }; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + +function errorText(cause: unknown): string { + if (cause instanceof OpenCode2RuntimeError) { + return `${cause.detail} ${errorText(cause.cause)}`.trim(); + } + if (cause instanceof Error) return cause.message; + return typeof cause === "string" ? cause : ""; +} + +function failureSnapshot(input: { + readonly settings: OpenCode2ProviderSettings; + readonly checkedAt: string; + readonly cause: unknown; + readonly version?: string | null; +}): ServerProviderDraft { + const unsupported = + input.cause instanceof OpenCode2RuntimeError && input.cause.kind === "unsupported-preview"; + const detail = errorText(input.cause); + const missing = /(?:enoent|not found|could not resolve|failed to execute)/iu.test(detail); + return buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: input.settings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + [], + input.settings.customModels, + DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + ), + probe: { + installed: !missing, + version: input.version ?? null, + status: "error", + auth: { status: "unknown" }, + message: unsupported + ? `This OpenCode 2 preview is not supported by T3 Code. ${input.cause.detail}` + : missing + ? "OpenCode 2 CLI (`opencode2`) is not installed or not on PATH." + : detail || "Failed to connect to the OpenCode 2 background service.", + }, + }); +} + +export const buildInitialOpenCode2ProviderSnapshot = ( + settings: OpenCode2ProviderSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + settings.customModels, + DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + ), + probe: settings.enabled + ? { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 provider status has not been checked in this session yet.", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 is disabled in T3 Code settings.", + }, + }); + }); + +export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderStatus")(function* ( + settings: OpenCode2ProviderSettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!settings.enabled) return yield* buildInitialOpenCode2ProviderSnapshot(settings); + + const runtime = yield* OpenCode2Runtime; + const attached = yield* Effect.exit( + Effect.gen(function* () { + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath || "opencode2", + ...(environment ? { environment } : {}), + }); + const [health, modelsResponse, defaultResponse, agentsResponse] = yield* Effect.all( + [ + connection.request("GET", "/api/health", { + operation: "health.get", + schema: OpenCode2HealthSchema, + }), + connection.request("GET", "/api/model", { + operation: "model.list", + schema: OpenCode2ModelListSchema, + }), + connection.request("GET", "/api/model/default", { + operation: "model.default", + schema: OpenCode2DefaultModelSchema, + }), + connection.request("GET", "/api/agent", { + operation: "agent.list", + schema: OpenCode2AgentListSchema, + }), + ], + { concurrency: "unbounded" }, + ); + return { + health, + models: responseList(modelsResponse), + defaultModel: responseValue(defaultResponse), + agents: responseList(agentsResponse), + }; + }).pipe(Effect.timeoutOption(OPENCODE2_PROVIDER_PROBE_TIMEOUT)), + ); + if (Exit.isFailure(attached)) { + return failureSnapshot({ + settings, + checkedAt, + cause: Cause.squash(attached.cause), + }); + } + + if (Option.isNone(attached.value)) { + return failureSnapshot({ + settings, + checkedAt, + cause: new OpenCode2RuntimeError({ + operation: "provider.probe", + kind: "connection", + detail: "OpenCode 2 provider discovery timed out.", + }), + }); + } + + const value = attached.value.value; + const models = providerModelsFromSettings( + providerModels(value), + settings.customModels, + DEFAULT_OPENCODE2_MODEL_CAPABILITIES, + ); + const providerCount = new Set( + value.models + .filter((model: OpenCode2Model) => model.enabled !== false) + .map((model: OpenCode2Model) => model.providerID), + ).size; + return buildServerProvider({ + presentation: OPENCODE2_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: value.health.version ?? null, + status: models.length > 0 ? "ready" : "warning", + auth: { status: "authenticated", type: "opencode" }, + message: + models.length > 0 + ? `${providerCount} upstream provider${providerCount === 1 ? "" : "s"} available through the OpenCode 2 background service.` + : "Connected to OpenCode 2, but it did not report any available models.", + }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index a429367bfeb0..41076f65edd7 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`, `opencode2`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real provider binaries * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -29,6 +29,7 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, + type OpenCode2Settings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -43,11 +44,9 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; -import { CursorDriver } from "../Drivers/CursorDriver.ts"; -import { GrokDriver } from "../Drivers/GrokDriver.ts"; -import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import { OpenCode2RuntimeLive } from "../openCode2Runtime.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -133,6 +132,13 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeOpenCode2Config = (overrides: Partial): OpenCode2Settings => ({ + enabled: false, + binaryPath: "opencode2", + customModels: [], + ...overrides, +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -303,7 +309,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs // surfaced; that merged layer then provides `ServerConfig.layerTest`'s // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); + const infraLayer = Layer.merge(OpenCodeRuntimeLive, OpenCode2RuntimeLive).pipe( + Layer.provideMerge(NodeServices.layer), + ); const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", }).pipe( @@ -321,12 +329,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); + const openCode2Id = ProviderInstanceId.make("opencode2_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); + const openCode2DriverKind = ProviderDriverKind.make("opencode2"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -362,10 +372,16 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, + [openCode2Id]: { + driver: openCode2DriverKind, + displayName: "OpenCode 2", + enabled: false, + config: makeOpenCode2Config({}), + }, }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: BUILT_IN_DRIVERS, configMap, }); @@ -375,9 +391,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId, openCode2Id].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -388,11 +404,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); + const openCode2 = yield* registry.getInstance(openCode2Id); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); + expect(openCode2?.driverKind).toBe(openCode2DriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..08bc3c0dd92e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as OpenCode2Runtime from "../openCode2Runtime.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -1377,6 +1378,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + opencode2: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; // the branded index signature rejects plain string literals @@ -1419,7 +1421,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.merge( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenCode2Runtime.OpenCode2RuntimeLive, + ), + ), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will @@ -1489,6 +1496,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, + opencode2: { enabled: false }, }, }), ), @@ -1512,7 +1520,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.merge( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenCode2Runtime.OpenCode2RuntimeLive, + ), + ), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1634,7 +1647,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.merge( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenCode2Runtime.OpenCode2RuntimeLive, + ), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); @@ -1672,6 +1690,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te grok: { enabled: false, }, + opencode2: { + enabled: false, + }, }, }), ), @@ -1696,7 +1717,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.merge( + OpenCodeRuntime.OpenCodeRuntimeLive, + OpenCode2Runtime.OpenCode2RuntimeLive, + ), + ), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { @@ -1742,6 +1768,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", + "opencode2", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..1365917d70fe 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -79,12 +79,15 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean (model.capabilities?.optionDescriptors?.length ?? 0) > 0; const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - if (provider.driver !== ProviderDriverKind.make("opencode")) { + if ( + provider.driver !== ProviderDriverKind.make("opencode") && + provider.driver !== ProviderDriverKind.make("opencode2") + ) { return true; } - // OpenCode's initial snapshot is deliberately non-authoritative while its - // first probe is still running. A probe error from an installed CLI/server + // OpenCode snapshots are deliberately non-authoritative while their first + // probe is still running. A probe error from an installed CLI/server // is likewise partial: it could not establish the current inventory. // Conversely, disabled and missing-CLI snapshots are authoritative removals, // as are successful ready/warning inventories (including an empty one after diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..d1de39336097 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { OpenCode2Driver, type OpenCode2DriverEnv } from "./Drivers/OpenCode2Driver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | OpenCode2DriverEnv | OpenCodeDriverEnv; /** @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray Response; +}) { + const commands: OpenCode2CommandInput[] = []; + const requests: HttpClientRequest.HttpClientRequest[] = []; + const runCommand = (command: OpenCode2CommandInput) => { + commands.push(command); + const result: OpenCode2CommandResult = + command.args.join(" ") === "service start" + ? { stdout: "http://127.0.0.1:49374\n", stderr: "", code: 0 } + : { stdout: "private-password\n", stderr: "", code: 0 }; + return Effect.succeed(result); + }; + const httpClient = HttpClient.make((request) => { + requests.push(request); + const pathname = new URL(request.url).pathname; + const response = + pathname === "/openapi.json" + ? Response.json(input?.document ?? openApi("text")) + : (input?.onRequest?.(request) ?? Response.json({ ok: true })); + return Effect.succeed(HttpClientResponse.fromWeb(request, response)); + }); + return { + commands, + requests, + runtime: makeOpenCode2Runtime({ runCommand, httpClient }), + }; +} + +it.effect("memoizes one service attachment per binary and environment", () => { + const fake = fakeRuntime(); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const first = yield* runtime.attach({ + binaryPath: "opencode2", + environment: { PATH: "/one" }, + }); + const second = yield* runtime.attach({ + binaryPath: "opencode2", + environment: { PATH: "/one" }, + }); + + NodeAssert.equal(first, second); + NodeAssert.deepEqual( + fake.commands.map((command) => command.args), + [ + ["service", "start"], + ["service", "get", "password"], + ], + ); + NodeAssert.equal( + fake.requests.filter((request) => request.url.endsWith("/openapi.json")).length, + 1, + ); + + yield* runtime.attach({ + binaryPath: "opencode2", + environment: { PATH: "/two" }, + }); + NodeAssert.equal(fake.commands.length, 4); + }); +}); + +it.effect("uses private Basic auth for typed JSON and global SSE requests", () => { + const fake = fakeRuntime({ + onRequest: (request) => { + const pathname = new URL(request.url).pathname; + if (pathname === "/api/event") { + return new Response( + 'data: {"id":"evt_1","type":"server.connected","data":{}}\n\n' + + 'data: {"id":"evt_2","type":"session.text.delta","data":{"delta":"hi"}}\n\n', + { headers: { "content-type": "text/event-stream" } }, + ); + } + return Response.json({ value: "accepted" }); + }, + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const connection = yield* runtime.attach({ binaryPath: "opencode2" }); + const response = yield* connection.request("POST", "/api/example", { + operation: "example.post", + schema: Schema.Struct({ value: Schema.String }), + query: { count: 2 }, + body: { text: "hello" }, + }); + const events = yield* connection.globalEvents.pipe(Stream.runCollect); + + NodeAssert.deepEqual(response, { value: "accepted" }); + NodeAssert.deepEqual( + Array.from(events).map((event) => event.type), + ["server.connected", "session.text.delta"], + ); + const authenticated = fake.requests.filter( + (request) => new URL(request.url).pathname !== "/openapi.json", + ); + NodeAssert.ok(authenticated.length >= 2); + for (const request of authenticated) { + NodeAssert.equal( + request.headers.authorization, + `Basic ${Buffer.from("opencode:private-password").toString("base64")}`, + ); + } + NodeAssert.equal(new URL(authenticated[0]!.url).searchParams.get("count"), "2"); + }); +}); + +it.effect("decodes empty 204 responses against the requested schema", () => { + const fake = fakeRuntime({ + onRequest: () => new Response(null, { status: 204 }), + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const connection = yield* runtime.attach({ binaryPath: "opencode2" }); + const response = yield* connection.request("POST", "/api/session/ses_1/interrupt", { + operation: "session.interrupt", + schema: Schema.Undefined, + }); + + NodeAssert.equal(response, undefined); + }); +}); + +it.effect("fails explicitly when the preview OpenAPI shape is unknown", () => { + const fake = fakeRuntime({ document: { openapi: "3.1.0", paths: {} } }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const error = yield* Effect.flip(runtime.attach({ binaryPath: "opencode2" })); + + NodeAssert.equal(error.kind, "unsupported-preview"); + NodeAssert.match(error.detail, /unsupported prompt or event protocol/i); + }); +}); + +it.effect("times out a wedged service command", () => { + const httpClient = HttpClient.make(() => Effect.die("HTTP should not be reached")); + return Effect.gen(function* () { + const runtime = yield* makeOpenCode2Runtime({ + runCommand: () => Effect.never, + httpClient, + }); + const fiber = yield* runtime.attach({ binaryPath: "opencode2" }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + + NodeAssert.equal(error.kind, "command"); + NodeAssert.match(error.detail, /service start.*timed out/i); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); +}); + +it("detects both adjacent preview prompt and event namespaces", () => { + NodeAssert.deepEqual(detectOpenCode2Protocol(openApi("text")), { + promptShape: "flat", + eventNamespace: "session", + }); + NodeAssert.deepEqual(detectOpenCode2Protocol(openApi("prompt", "session.next.text.delta")), { + promptShape: "nested", + eventNamespace: "session.next", + }); + NodeAssert.deepEqual( + detectOpenCode2Protocol(openApi("prompt", "session.next.text.delta", "session.prompt")), + { + promptShape: "nested", + eventNamespace: "session.next", + }, + ); + NodeAssert.deepEqual( + detectOpenCode2Protocol(openApi("prompt", "session.next.text.delta", undefined)), + { + promptShape: "nested", + eventNamespace: "session.next", + }, + ); +}); diff --git a/apps/server/src/provider/openCode2Runtime.ts b/apps/server/src/provider/openCode2Runtime.ts new file mode 100644 index 000000000000..c2ff48ca048b --- /dev/null +++ b/apps/server/src/provider/openCode2Runtime.ts @@ -0,0 +1,589 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { collectStreamAsString } from "./providerSnapshot.ts"; + +export type OpenCode2HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +export interface OpenCode2Protocol { + readonly promptShape: "flat" | "nested"; + readonly eventNamespace: "session" | "session.next"; +} + +export interface OpenCode2Event { + readonly id: string; + readonly type: string; + readonly data: unknown; + readonly durable?: { + readonly aggregateID: string; + readonly seq: number; + readonly version: number; + }; + readonly location?: { + readonly directory: string; + readonly workspaceID?: string; + }; + readonly metadata?: Readonly>; +} + +export interface OpenCode2RequestInput { + readonly operation: string; + readonly schema: S; + readonly body?: unknown; + readonly query?: Readonly>; +} + +export interface OpenCode2Connection { + readonly url: string; + readonly protocol: OpenCode2Protocol; + readonly request: ( + method: OpenCode2HttpMethod, + path: string, + input: OpenCode2RequestInput, + ) => Effect.Effect; + readonly globalEvents: Stream.Stream; +} + +export type OpenCode2RuntimeErrorKind = + | "command" + | "connection" + | "request" + | "unsupported-preview"; + +export class OpenCode2RuntimeError extends Data.TaggedError("OpenCode2RuntimeError")<{ + readonly operation: string; + readonly kind: OpenCode2RuntimeErrorKind; + readonly detail: string; + readonly cause?: unknown; +}> {} + +export interface OpenCode2RuntimeShape { + readonly attach: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; +} + +export class OpenCode2Runtime extends Context.Service()( + "t3/provider/openCode2Runtime", +) {} + +export interface OpenCode2CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly code: number; +} + +export interface OpenCode2CommandInput { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment: NodeJS.ProcessEnv; +} + +export type OpenCode2CommandRunner = ( + input: OpenCode2CommandInput, +) => Effect.Effect; + +const OpenCode2EventSchema = Schema.Struct({ + id: Schema.String, + type: Schema.String, + data: Schema.Unknown, + durable: Schema.optionalKey( + Schema.Struct({ + aggregateID: Schema.String, + seq: Schema.Number, + version: Schema.Number, + }), + ), + location: Schema.optionalKey( + Schema.Struct({ + directory: Schema.String, + workspaceID: Schema.optionalKey(Schema.String), + }), + ), + metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}); + +const decodeOpenCode2SseEvent = Schema.decodeUnknownEffect( + Schema.fromJsonString(OpenCode2EventSchema), +); + +function environmentKey(environment: NodeJS.ProcessEnv): string { + return JSON.stringify( + Object.entries(environment) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + +function attachKey(input: { + readonly binaryPath: string; + readonly environment: NodeJS.ProcessEnv; +}): string { + return `${input.binaryPath}\n${environmentKey(input.environment)}`; +} + +function parseServiceUrl(stdout: string): string | null { + for (const line of stdout.trim().split(/\r?\n/u).toReversed()) { + const candidate = line.trim(); + if (!URL.canParse(candidate)) continue; + const url = new URL(candidate); + if (url.protocol === "http:" || url.protocol === "https:") return url.toString(); + } + return null; +} + +function commandFailureDetail(input: { + readonly args: ReadonlyArray; + readonly result: OpenCode2CommandResult; +}): string { + const stderr = input.result.stderr.trim(); + if (stderr.length > 0) return stderr; + return `OpenCode 2 command \`${input.args.join(" ")}\` exited with code ${input.result.code}.`; +} + +function isRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function localRef(document: unknown, ref: string): unknown { + if (!ref.startsWith("#/")) return undefined; + let value = document; + for (const rawSegment of ref.slice(2).split("/")) { + if (!isRecord(value)) return undefined; + const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~"); + value = value[segment]; + } + return value; +} + +function schemaPropertyNames( + document: unknown, + schema: unknown, + visited = new Set(), +): ReadonlySet { + if (!isRecord(schema) || visited.has(schema)) return new Set(); + visited.add(schema); + + const properties = isRecord(schema.properties) ? Object.keys(schema.properties) : []; + const nestedSchemas: Array = [schema.allOf, schema.anyOf, schema.oneOf] + .filter(Array.isArray) + .flat(); + if (typeof schema.$ref === "string") nestedSchemas.push(localRef(document, schema.$ref)); + + const result = new Set(properties); + for (const nested of nestedSchemas) { + for (const name of schemaPropertyNames(document, nested, visited)) result.add(name); + } + return result; +} + +function findOperation(value: unknown, operationId: string, visited = new Set()): unknown { + if (!isRecord(value) && !Array.isArray(value)) return undefined; + if (visited.has(value)) return undefined; + visited.add(value); + + if (isRecord(value) && value.operationId === operationId) return value; + for (const nested of Array.isArray(value) ? value : Object.values(value)) { + const found = findOperation(nested, operationId, visited); + if (found !== undefined) return found; + } + return undefined; +} + +function findPromptOperation(document: unknown): unknown { + for (const operationId of ["v2.session.prompt", "session.prompt"]) { + const operation = findOperation(document, operationId); + if (operation !== undefined) return operation; + } + + if (!isRecord(document)) return undefined; + const paths = document.paths; + if (!isRecord(paths)) return undefined; + for (const [path, methods] of Object.entries(paths)) { + if (!/^\/api\/session\/(?:\{[^}]+\}|:[^/]+)\/prompt$/u.test(path)) continue; + if (isRecord(methods) && isRecord(methods.post)) return methods.post; + } + return undefined; +} + +function containsString(value: unknown, expected: string, visited = new Set()): boolean { + if (value === expected) return true; + if (!isRecord(value) && !Array.isArray(value)) return false; + if (visited.has(value)) return false; + visited.add(value); + return (Array.isArray(value) ? value : Object.values(value)).some((nested) => + containsString(nested, expected, visited), + ); +} + +export function detectOpenCode2Protocol(document: unknown): OpenCode2Protocol | null { + const operation = findPromptOperation(document); + if (!isRecord(operation)) return null; + const requestBody = operation.requestBody; + const content = isRecord(requestBody) ? requestBody.content : undefined; + const jsonContent = isRecord(content) ? content["application/json"] : undefined; + const schema = isRecord(jsonContent) ? jsonContent.schema : undefined; + const properties = schemaPropertyNames(document, schema); + const promptShape = properties.has("text") ? "flat" : properties.has("prompt") ? "nested" : null; + if (promptShape === null) return null; + + const eventNamespace = containsString(document, "session.next.text.delta") + ? "session.next" + : containsString(document, "session.text.delta") + ? "session" + : null; + if (eventNamespace === null) return null; + return { promptShape, eventNamespace }; +} + +function requestUrl( + baseUrl: string, + path: string, + query: OpenCode2RequestInput["query"], +): string { + const url = new URL(path, baseUrl); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return url.toString(); +} + +function withAuthentication( + request: HttpClientRequest.HttpClientRequest, + password: string, +): HttpClientRequest.HttpClientRequest { + return request.pipe( + HttpClientRequest.basicAuth("opencode", password), + HttpClientRequest.acceptJson, + ); +} + +function makeRequest( + method: OpenCode2HttpMethod, + url: string, + body: unknown | undefined, +): HttpClientRequest.HttpClientRequest { + const request = HttpClientRequest.make(method)(url); + return body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body)); +} + +function runtimeError(input: { + readonly operation: string; + readonly kind: OpenCode2RuntimeErrorKind; + readonly detail: string; + readonly cause?: unknown; +}): OpenCode2RuntimeError { + return new OpenCode2RuntimeError(input); +} + +function makeConnection(input: { + readonly baseUrl: string; + readonly password: string; + readonly httpClient: HttpClient.HttpClient; + readonly protocol: OpenCode2Protocol; +}): OpenCode2Connection { + const request: OpenCode2Connection["request"] = (method, path, requestInput) => + input.httpClient + .execute( + withAuthentication( + makeRequest( + method, + requestUrl(input.baseUrl, path, requestInput.query), + requestInput.body, + ), + input.password, + ), + ) + .pipe( + Effect.mapError((cause) => + runtimeError({ + operation: requestInput.operation, + kind: "request", + detail: `OpenCode 2 ${requestInput.operation} request failed.`, + cause, + }), + ), + Effect.flatMap((response) => { + if (response.status < 200 || response.status >= 300) { + return Effect.fail( + runtimeError({ + operation: requestInput.operation, + kind: "request", + detail: `OpenCode 2 ${requestInput.operation} returned HTTP ${response.status}.`, + }), + ); + } + const decodeEmptyResponse = Schema.decodeUnknownEffect(requestInput.schema); + const decodeResponse = + response.status === 204 + ? decodeEmptyResponse(undefined) + : HttpClientResponse.schemaBodyJson(requestInput.schema)(response); + return decodeResponse.pipe( + Effect.mapError((cause) => + runtimeError({ + operation: requestInput.operation, + kind: "unsupported-preview", + detail: `This OpenCode 2 preview returned an unsupported ${requestInput.operation} response.`, + cause, + }), + ), + ); + }), + ); + + const globalEvents = Stream.unwrap( + input.httpClient + .execute( + withAuthentication( + HttpClientRequest.get(requestUrl(input.baseUrl, "/api/event", undefined)).pipe( + HttpClientRequest.setHeader("accept", "text/event-stream"), + ), + input.password, + ), + ) + .pipe( + Effect.mapError((cause) => + runtimeError({ + operation: "event.subscribe", + kind: "request", + detail: "OpenCode 2 event subscription failed.", + cause, + }), + ), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? Effect.succeed(response.stream) + : Effect.fail( + runtimeError({ + operation: "event.subscribe", + kind: "request", + detail: `OpenCode 2 event subscription returned HTTP ${response.status}.`, + }), + ), + ), + ), + ).pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filterMap((line) => { + if (!line.startsWith("data:")) return Result.failVoid; + return Result.succeed(line.slice("data:".length).trimStart()); + }), + Stream.mapEffect((line) => + decodeOpenCode2SseEvent(line).pipe( + Effect.mapError((cause) => + runtimeError({ + operation: "event.decode", + kind: "unsupported-preview", + detail: "This OpenCode 2 preview returned an unsupported event payload.", + cause, + }), + ), + ), + ), + Stream.mapError((cause) => + cause instanceof OpenCode2RuntimeError + ? cause + : runtimeError({ + operation: "event.subscribe", + kind: "request", + detail: "OpenCode 2 event stream failed.", + cause, + }), + ), + ); + + return { + url: input.baseUrl, + protocol: input.protocol, + request, + globalEvents, + }; +} + +export const makeOpenCode2Runtime = Effect.fn("makeOpenCode2Runtime")(function* (input: { + readonly runCommand: OpenCode2CommandRunner; + readonly httpClient: HttpClient.HttpClient; +}) { + const connections = new Map(); + const attachLock = yield* Semaphore.make(1); + const runCommand = (command: OpenCode2CommandInput) => + input.runCommand(command).pipe( + Effect.timeoutOption("10 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + runtimeError({ + operation: command.args.join("."), + kind: "command", + detail: `OpenCode 2 command \`${command.args.join(" ")}\` timed out.`, + }), + ), + onSome: (result) => Effect.succeed(result), + }), + ), + ); + + const attach: OpenCode2RuntimeShape["attach"] = (attachInput) => { + const environment = attachInput.environment ?? process.env; + const key = attachKey({ binaryPath: attachInput.binaryPath, environment }); + return attachLock.withPermit( + Effect.gen(function* () { + const existing = connections.get(key); + if (existing) return existing; + + const start = yield* runCommand({ + binaryPath: attachInput.binaryPath, + args: ["service", "start"], + environment, + }); + if (start.code !== 0) { + return yield* runtimeError({ + operation: "service.start", + kind: "command", + detail: commandFailureDetail({ args: ["service", "start"], result: start }), + }); + } + const baseUrl = parseServiceUrl(start.stdout); + if (baseUrl === null) { + return yield* runtimeError({ + operation: "service.start", + kind: "unsupported-preview", + detail: "OpenCode 2 `service start` did not return a supported HTTP URL.", + }); + } + + const passwordResult = yield* runCommand({ + binaryPath: attachInput.binaryPath, + args: ["service", "get", "password"], + environment, + }); + if (passwordResult.code !== 0) { + return yield* runtimeError({ + operation: "service.get.password", + kind: "command", + detail: commandFailureDetail({ + args: ["service", "get", "password"], + result: passwordResult, + }), + }); + } + const password = passwordResult.stdout.trim(); + if (password.length === 0) { + return yield* runtimeError({ + operation: "service.get.password", + kind: "unsupported-preview", + detail: "OpenCode 2 `service get password` returned an empty credential.", + }); + } + + const provisional = makeConnection({ + baseUrl, + password, + httpClient: input.httpClient, + protocol: { promptShape: "flat", eventNamespace: "session" }, + }); + const document = yield* provisional.request("GET", "/openapi.json", { + operation: "openapi.get", + schema: Schema.Unknown, + }); + const protocol = detectOpenCode2Protocol(document); + if (protocol === null) { + return yield* runtimeError({ + operation: "openapi.detect", + kind: "unsupported-preview", + detail: + "This OpenCode 2 preview exposes an unsupported prompt or event protocol in `/openapi.json`.", + }); + } + + const connection = makeConnection({ + baseUrl, + password, + httpClient: input.httpClient, + protocol, + }); + connections.set(key, connection); + return connection; + }), + ); + }; + + return OpenCode2Runtime.of({ attach }); +}); + +const makeLiveCommandRunner = Effect.fn("makeOpenCode2CommandRunner")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runCommand: OpenCode2CommandRunner = (input) => + Effect.gen(function* () { + const spawn = yield* resolveSpawnCommand(input.binaryPath, input.args, { + env: input.environment, + }).pipe( + Effect.mapError((cause) => + runtimeError({ + operation: "command.resolve", + kind: "command", + detail: "Failed to resolve the OpenCode 2 command.", + cause, + }), + ), + ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawn.command, spawn.args, { + env: input.environment, + shell: spawn.shell, + }), + ) + .pipe( + Effect.mapError((cause) => + runtimeError({ + operation: "command.spawn", + kind: "command", + detail: `Failed to execute the OpenCode 2 CLI at \`${input.binaryPath}\`.`, + cause, + }), + ), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => + runtimeError({ + operation: "command.collect", + kind: "command", + detail: "Failed to collect OpenCode 2 command output.", + cause, + }), + ), + ); + return { stdout, stderr, code }; + }).pipe(Effect.scoped); + return runCommand; +}); + +export const OpenCode2RuntimeLive = Layer.effect( + OpenCode2Runtime, + Effect.gen(function* () { + const runCommand = yield* makeLiveCommandRunner(); + const httpClient = yield* HttpClient.HttpClient; + return yield* makeOpenCode2Runtime({ runCommand, httpClient }); + }), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..60fc3c203a23 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,6 +35,7 @@ import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; +import * as OpenCode2Runtime from "./provider/openCode2Runtime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -392,12 +393,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Provided once at the runtime level so every consumer sees the same // logger instances. Layer.provideMerge(ProviderEventLoggers.layer), - // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old - // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but - // the rewritten registry reads snapshots off the instance registry and - // no longer transitively provides it. Exposing it at the runtime level - // keeps a single Live for all opencode consumers. - Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), + // OpenCode drivers capture their runtime services while building instances. + // Providing both services here keeps one stable runtime per protocol for all + // consumers, including OpenCode 2's singleton background-service attachment. + Layer.provideMerge( + Layer.merge(OpenCodeRuntime.OpenCodeRuntimeLive, OpenCode2Runtime.OpenCode2RuntimeLive), + ), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts new file mode 100644 index 000000000000..dcb694112d61 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -0,0 +1,103 @@ +import * as NodeAssert from "node:assert/strict"; + +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; + +import { OpenCode2Runtime, type OpenCode2Connection } from "../provider/openCode2Runtime.ts"; +import { makeOpenCode2TextGeneration } from "./OpenCode2TextGeneration.ts"; + +interface CapturedRequest { + readonly method: string; + readonly path: string; + readonly query: unknown; + readonly body: unknown; +} + +function runtimeLayer(response: unknown, requests: Array) { + const connection: OpenCode2Connection = { + url: "http://127.0.0.1:49374/", + protocol: { promptShape: "flat", eventNamespace: "session" }, + request: (( + method: string, + path: string, + input: { readonly query?: unknown; readonly body?: unknown }, + ) => { + requests.push({ method, path, query: input.query, body: input.body }); + return Effect.succeed(response); + }) as OpenCode2Connection["request"], + globalEvents: Stream.empty, + }; + return Layer.succeed( + OpenCode2Runtime, + OpenCode2Runtime.of({ attach: () => Effect.succeed(connection) }), + ); +} + +it.effect("generates structured text through the attached OpenCode 2 service", () => { + const requests: Array = []; + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCode2TextGeneration({ binaryPath: "opencode2" }); + const generated = yield* textGeneration.generateCommitMessage({ + cwd: "/work/project", + branch: "feat/opencode2", + stagedSummary: "M apps/server/src/provider/openCode2Runtime.ts", + stagedPatch: "diff --git a/runtime.ts b/runtime.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode2"), "openai/gpt-5.6", [ + { id: "variant", value: "high" }, + ]), + }); + + NodeAssert.deepEqual(generated, { + subject: "Add OpenCode 2 runtime", + body: "Attach to the shared service.", + }); + NodeAssert.equal(requests.length, 1); + const request = requests[0]; + NodeAssert.ok(request); + NodeAssert.equal(request.method, "POST"); + NodeAssert.equal(request.path, "/api/generate"); + NodeAssert.deepEqual(request.query, { + "location[directory]": "/work/project", + }); + NodeAssert.deepEqual(request.body, { + prompt: (request.body as { readonly prompt: string }).prompt, + model: { providerID: "openai", id: "gpt-5.6", variant: "high" }, + }); + NodeAssert.match((request.body as { readonly prompt: string }).prompt, /commit message/i); + }).pipe( + Effect.provide( + runtimeLayer( + { + text: JSON.stringify({ + subject: "Add OpenCode 2 runtime", + body: "Attach to the shared service.", + }), + }, + requests, + ), + ), + ); +}); + +it.effect("rejects model selections that do not identify an upstream provider", () => { + const requests: Array = []; + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCode2TextGeneration({ binaryPath: "opencode2" }); + const error = yield* Effect.flip( + textGeneration.generateBranchName({ + cwd: "/work/project", + message: "add opencode 2", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode2"), "gpt-5.6"), + }), + ); + + NodeAssert.equal(error._tag, "TextGenerationError"); + NodeAssert.match(error.detail, /provider\/model/); + NodeAssert.equal(requests.length, 0); + }).pipe(Effect.provide(runtimeLayer({ text: JSON.stringify({ branch: "unused" }) }, requests))); +}); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts new file mode 100644 index 000000000000..a32c044a0a96 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts @@ -0,0 +1,233 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { TextGenerationError, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { OpenCode2Runtime, type OpenCode2RuntimeError } from "../provider/openCode2Runtime.ts"; +import type { OpenCode2ProviderSettings } from "../provider/Layers/OpenCode2Provider.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const OPENCODE2_GENERATE_TIMEOUT = "3 minutes"; +const isTextGenerationError = Schema.is(TextGenerationError); + +const OpenCode2GenerateResponseSchema = Schema.Union([ + Schema.Struct({ text: Schema.String }), + Schema.Struct({ data: Schema.Struct({ text: Schema.String }) }), +]); + +type OpenCode2TextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +function parseModelSelection(modelSelection: ModelSelection): { + readonly providerID: string; + readonly id: string; + readonly variant?: string; +} | null { + const separator = modelSelection.model.indexOf("/"); + if (separator <= 0 || separator === modelSelection.model.length - 1) return null; + const providerID = modelSelection.model.slice(0, separator).trim(); + const id = modelSelection.model.slice(separator + 1).trim(); + if (!providerID || !id) return null; + const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + return { + providerID, + id, + ...(variant ? { variant } : {}), + }; +} + +function generatedText(response: typeof OpenCode2GenerateResponseSchema.Type): string { + return "data" in response ? response.data.text : response.text; +} + +function runtimeFailureDetail(cause: OpenCode2RuntimeError): string { + return cause.kind === "unsupported-preview" + ? `This OpenCode 2 preview is not supported for text generation. ${cause.detail}` + : cause.detail; +} + +export const makeOpenCode2TextGeneration = Effect.fn("makeOpenCode2TextGeneration")(function* ( + settings: Pick, + environment?: NodeJS.ProcessEnv, +) { + const runtime = yield* OpenCode2Runtime; + + const runOpenCode2Json = Effect.fn("runOpenCode2Json")(function* (input: { + readonly operation: OpenCode2TextGenerationOperation; + readonly cwd: string; + readonly prompt: string; + readonly outputSchema: S; + readonly modelSelection: ModelSelection; + }): Effect.fn.Return { + const model = parseModelSelection(input.modelSelection); + if (model === null) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 model selection must use the `provider/model` format.", + }); + } + + const response = yield* Effect.gen(function* () { + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath || "opencode2", + ...(environment ? { environment } : {}), + }); + return yield* connection.request("POST", "/api/generate", { + operation: "generate.text", + schema: OpenCode2GenerateResponseSchema, + query: { "location[directory]": input.cwd }, + body: { prompt: input.prompt, model }, + }); + }).pipe( + Effect.timeoutOption(OPENCODE2_GENERATE_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 text generation timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation: input.operation, + detail: runtimeFailureDetail(cause), + cause, + }), + ), + ); + + const text = generatedText(response).trim(); + if (!text) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned empty text generation output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); + return yield* decodeOutput(extractJsonObject(text)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned invalid structured text generation output.", + cause, + }), + ), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OpenCode2TextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runOpenCode2Json({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OpenCode2TextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runOpenCode2Json({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OpenCode2TextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCode2Json({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OpenCode2TextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runOpenCode2Json({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + return { title: sanitizeThreadTitle(generated.title) }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..ff58a1e8e95e 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "opencode" + | "opencode2"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/OpenCode2AutoEnableNotification.tsx b/apps/web/src/components/OpenCode2AutoEnableNotification.tsx new file mode 100644 index 000000000000..86da8fbb2988 --- /dev/null +++ b/apps/web/src/components/OpenCode2AutoEnableNotification.tsx @@ -0,0 +1,111 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + dismissOpenCode2AutoEnableNotification, + isOpenCode2AutoEnableNotificationPending, + openCode2AutoEnableNotificationKey, + readOpenCode2AutoEnableNotificationState, + recordOpenCode2AutoEnableDetection, + resolveOpenCode2AutoEnableDetectionKey, + writeOpenCode2AutoEnableNotificationState, +} from "../openCode2AutoEnableNotification"; +import { primaryServerProvidersAtom } from "../state/server"; +import { usePrimaryEnvironment } from "../state/environments"; +import { OpenCodeIcon } from "./Icons"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +type OpenCode2ToastId = ReturnType; + +export function OpenCode2AutoEnableNotification() { + const navigate = useNavigate(); + const providers = useAtomValue(primaryServerProvidersAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const [notificationState, setNotificationState] = useState( + readOpenCode2AutoEnableNotificationState, + ); + const activeToastRef = useRef<{ key: string; toastId: OpenCode2ToastId } | null>(null); + + const environmentKey = useMemo( + () => + primaryEnvironment === null + ? null + : openCode2AutoEnableNotificationKey(primaryEnvironment.environmentId), + [primaryEnvironment], + ); + const detectionKey = useMemo( + () => + resolveOpenCode2AutoEnableDetectionKey(primaryEnvironment?.environmentId ?? null, providers), + [primaryEnvironment, providers], + ); + + useEffect(() => { + if (detectionKey === null) return; + setNotificationState((current) => { + const next = recordOpenCode2AutoEnableDetection(current, detectionKey); + if (next !== current) writeOpenCode2AutoEnableNotificationState(next); + return next; + }); + }, [detectionKey]); + + const pendingKey = + environmentKey !== null && + isOpenCode2AutoEnableNotificationPending(notificationState, environmentKey) + ? environmentKey + : null; + + useEffect(() => { + const activeToast = activeToastRef.current; + if (activeToast !== null && activeToast.key !== pendingKey) { + toastManager.close(activeToast.toastId); + activeToastRef.current = null; + } + + if (pendingKey === null || activeToastRef.current !== null) return; + + let toastId: OpenCode2ToastId; + const dismiss = () => { + if (activeToastRef.current?.toastId === toastId) activeToastRef.current = null; + setNotificationState((current) => { + const next = dismissOpenCode2AutoEnableNotification(current, pendingKey); + if (next !== current) writeOpenCode2AutoEnableNotificationState(next); + return next; + }); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: "info", + title: "Hey! We support OpenCode 2", + description: + "It has been automatically enabled. If you want to disable it, go to Settings.", + timeout: 0, + actionProps: { + children: "Go to settings", + onClick: () => void navigate({ to: "/settings/providers" }), + }, + actionVariant: "outline", + data: { + leadingIcon: , + hideCopyButton: true, + disableSwipe: true, + onClose: dismiss, + }, + }), + ); + activeToastRef.current = { key: pendingKey, toastId }; + }, [navigate, pendingKey]); + + useEffect( + () => () => { + if (activeToastRef.current !== null) { + toastManager.close(activeToastRef.current.toastId); + activeToastRef.current = null; + } + }, + [], + ); + + return null; +} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..597cb7fa1c10 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -6,6 +6,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, + [ProviderDriverKind.make("opencode2")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, }; diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 9c36d32ff51a..b3144d32d2fa 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -299,7 +299,7 @@ function formatProcessName(command: string): string { function formatProcessType(process: ServerProcessDiagnosticsEntry): string { if (process.depth > 0) return "Subprocess"; - if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; + if (/\b(codex|claude|opencode2?|cursor)\b/i.test(process.command)) return "Agent"; return "Process"; } diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..5e211d9b8685 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial { }); }); + it("exposes the OpenCode 2 provider definition", () => { + const opencode2 = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode2")]; + + expect(opencode2).toMatchObject({ label: "OpenCode 2", badgeLabel: "New" }); + expect(deriveProviderSettingsFields(opencode2!).map((field) => field.key)).toEqual([ + "binaryPath", + ]); + }); + it("preserves unknown config keys while omitting empty configurable fields", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..15e229323ddc 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,6 +3,7 @@ import { CodexSettings, CursorSettings, GrokSettings, + OpenCode2Settings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; @@ -67,6 +68,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("opencode2"), + label: "OpenCode 2", + icon: OpenCodeIcon, + badgeLabel: "New", + settingsSchema: OpenCode2Settings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 69fd0ebf3664..2b03fc87dd77 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -46,6 +46,7 @@ export type ThreadToastData = { onClose?: (() => void) | undefined; dismissAfterVisibleMs?: number; hideCopyButton?: boolean; + disableSwipe?: boolean; additionalActions?: ReadonlyArray<{ id: string; props: ComponentPropsWithoutRef<"button">; @@ -648,11 +649,13 @@ function Toasts({ position }: { position: ToastPosition }) { } as CSSProperties } swipeDirection={ - position.includes("center") - ? [isTop ? "up" : "down"] - : position.includes("left") - ? ["left", isTop ? "up" : "down"] - : ["right", isTop ? "up" : "down"] + toast.data?.disableSwipe + ? [] + : position.includes("center") + ? [isTop ? "up" : "down"] + : position.includes("left") + ? ["left", isTop ? "up" : "down"] + : ["right", isTop ? "up" : "down"] } toast={toast} > diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3fe6681e09ed..ac4895985dac 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -812,7 +812,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode", "opencode2"] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -971,7 +971,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of ["codex", "claudeAgent", "cursor", "opencode", "opencode2"] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2773,7 +2773,13 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "opencode", + "opencode2", + ] as const) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..9aeb9f1bb0a2 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -38,6 +38,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Cursor"; case "opencode": return "OpenCode"; + case "opencode2": + return "OpenCode 2"; default: { // Title-case unknown driver kinds so they read reasonably. const trimmed = provider.replace(/Agent$/i, "").trim(); diff --git a/apps/web/src/openCode2AutoEnableNotification.test.ts b/apps/web/src/openCode2AutoEnableNotification.test.ts new file mode 100644 index 000000000000..e42bbb957e91 --- /dev/null +++ b/apps/web/src/openCode2AutoEnableNotification.test.ts @@ -0,0 +1,100 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { removeLocalStorageItem } from "./hooks/useLocalStorage"; +import { + dismissOpenCode2AutoEnableNotification, + EMPTY_OPENCODE2_AUTO_ENABLE_NOTIFICATION_STATE, + isOpenCode2AutoEnableNotificationPending, + openCode2AutoEnableNotificationKey, + OPENCODE2_AUTO_ENABLE_NOTIFICATION_STORAGE_KEY, + readOpenCode2AutoEnableNotificationState, + recordOpenCode2AutoEnableDetection, + resolveOpenCode2AutoEnableDetectionKey, + writeOpenCode2AutoEnableNotificationState, +} from "./openCode2AutoEnableNotification"; + +const environmentId = EnvironmentId.make("local"); + +function provider( + input: Partial> = {}, +): ServerProvider { + return { + instanceId: input.instanceId ?? ProviderInstanceId.make("opencode2"), + driver: input.driver ?? ProviderDriverKind.make("opencode2"), + enabled: input.enabled ?? true, + installed: input.installed ?? true, + version: "2.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-08-21T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; +} + +describe("OpenCode 2 auto-enable notification", () => { + beforeEach(() => { + removeLocalStorageItem(OPENCODE2_AUTO_ENABLE_NOTIFICATION_STORAGE_KEY); + }); + + it("detects only the enabled, installed default OpenCode 2 instance", () => { + const expectedKey = openCode2AutoEnableNotificationKey(environmentId); + expect(resolveOpenCode2AutoEnableDetectionKey(environmentId, [provider()])).toBe(expectedKey); + expect( + resolveOpenCode2AutoEnableDetectionKey(environmentId, [provider({ enabled: false })]), + ).toBeNull(); + expect( + resolveOpenCode2AutoEnableDetectionKey(environmentId, [provider({ installed: false })]), + ).toBeNull(); + expect( + resolveOpenCode2AutoEnableDetectionKey(environmentId, [ + provider({ driver: ProviderDriverKind.make("opencode") }), + ]), + ).toBeNull(); + expect( + resolveOpenCode2AutoEnableDetectionKey(environmentId, [ + provider({ instanceId: ProviderInstanceId.make("opencode2-custom") }), + ]), + ).toBeNull(); + }); + + it("stays pending across reloads until explicitly dismissed", () => { + const key = openCode2AutoEnableNotificationKey(environmentId); + const detected = recordOpenCode2AutoEnableDetection( + EMPTY_OPENCODE2_AUTO_ENABLE_NOTIFICATION_STATE, + key, + ); + writeOpenCode2AutoEnableNotificationState(detected); + + const reloaded = readOpenCode2AutoEnableNotificationState(); + expect(isOpenCode2AutoEnableNotificationPending(reloaded, key)).toBe(true); + + const dismissed = dismissOpenCode2AutoEnableNotification(reloaded, key); + writeOpenCode2AutoEnableNotificationState(dismissed); + const reloadedAfterDismissal = readOpenCode2AutoEnableNotificationState(); + expect(isOpenCode2AutoEnableNotificationPending(reloadedAfterDismissal, key)).toBe(false); + expect(recordOpenCode2AutoEnableDetection(reloadedAfterDismissal, key).dismissedKeys).toContain( + key, + ); + }); + + it("scopes dismissal state to each environment", () => { + const firstKey = openCode2AutoEnableNotificationKey(environmentId); + const secondKey = openCode2AutoEnableNotificationKey(EnvironmentId.make("remote")); + const detected = recordOpenCode2AutoEnableDetection( + recordOpenCode2AutoEnableDetection(EMPTY_OPENCODE2_AUTO_ENABLE_NOTIFICATION_STATE, firstKey), + secondKey, + ); + const dismissed = dismissOpenCode2AutoEnableNotification(detected, firstKey); + + expect(isOpenCode2AutoEnableNotificationPending(dismissed, firstKey)).toBe(false); + expect(isOpenCode2AutoEnableNotificationPending(dismissed, secondKey)).toBe(true); + }); +}); diff --git a/apps/web/src/openCode2AutoEnableNotification.ts b/apps/web/src/openCode2AutoEnableNotification.ts new file mode 100644 index 000000000000..eeef2f7cb54c --- /dev/null +++ b/apps/web/src/openCode2AutoEnableNotification.ts @@ -0,0 +1,101 @@ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + type EnvironmentId, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; + +export const OPENCODE2_AUTO_ENABLE_NOTIFICATION_STORAGE_KEY = + "t3code:opencode2-auto-enable-notification:v1"; + +export const OpenCode2AutoEnableNotificationStateSchema = Schema.Struct({ + detectedKeys: Schema.Array(Schema.String), + dismissedKeys: Schema.Array(Schema.String), +}); + +export type OpenCode2AutoEnableNotificationState = + typeof OpenCode2AutoEnableNotificationStateSchema.Type; + +export const EMPTY_OPENCODE2_AUTO_ENABLE_NOTIFICATION_STATE: OpenCode2AutoEnableNotificationState = + { + detectedKeys: [], + dismissedKeys: [], + }; + +const OPENCODE2_DRIVER = ProviderDriverKind.make("opencode2"); +const OPENCODE2_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE2_DRIVER); + +export function openCode2AutoEnableNotificationKey(environmentId: EnvironmentId): string { + return `${environmentId}:opencode2:auto-enabled:v1`; +} + +export function resolveOpenCode2AutoEnableDetectionKey( + environmentId: EnvironmentId | null, + providers: ReadonlyArray, +): string | null { + if (environmentId === null) return null; + + const wasAutoEnabled = providers.some( + (provider) => + provider.driver === OPENCODE2_DRIVER && + provider.instanceId === OPENCODE2_DEFAULT_INSTANCE_ID && + provider.enabled && + provider.installed, + ); + + return wasAutoEnabled ? openCode2AutoEnableNotificationKey(environmentId) : null; +} + +export function recordOpenCode2AutoEnableDetection( + state: OpenCode2AutoEnableNotificationState, + key: string, +): OpenCode2AutoEnableNotificationState { + if (state.detectedKeys.includes(key)) return state; + return { ...state, detectedKeys: [...state.detectedKeys, key] }; +} + +export function dismissOpenCode2AutoEnableNotification( + state: OpenCode2AutoEnableNotificationState, + key: string, +): OpenCode2AutoEnableNotificationState { + if (state.dismissedKeys.includes(key)) return state; + return { ...state, dismissedKeys: [...state.dismissedKeys, key] }; +} + +export function isOpenCode2AutoEnableNotificationPending( + state: OpenCode2AutoEnableNotificationState, + key: string, +): boolean { + return state.detectedKeys.includes(key) && !state.dismissedKeys.includes(key); +} + +export function readOpenCode2AutoEnableNotificationState(): OpenCode2AutoEnableNotificationState { + try { + return ( + getLocalStorageItem( + OPENCODE2_AUTO_ENABLE_NOTIFICATION_STORAGE_KEY, + OpenCode2AutoEnableNotificationStateSchema, + ) ?? EMPTY_OPENCODE2_AUTO_ENABLE_NOTIFICATION_STATE + ); + } catch (error) { + console.error("Could not read OpenCode 2 auto-enable notification state.", error); + return EMPTY_OPENCODE2_AUTO_ENABLE_NOTIFICATION_STATE; + } +} + +export function writeOpenCode2AutoEnableNotificationState( + state: OpenCode2AutoEnableNotificationState, +): void { + try { + setLocalStorageItem( + OPENCODE2_AUTO_ENABLE_NOTIFICATION_STORAGE_KEY, + state, + OpenCode2AutoEnableNotificationStateSchema, + ); + } catch (error) { + console.error("Could not persist OpenCode 2 auto-enable notification state.", error); + } +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 91381301418e..3c99ba5f0285 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { OpenCode2AutoEnableNotification } from "../components/OpenCode2AutoEnableNotification"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -142,6 +143,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..d1fa645b0b2e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -40,6 +40,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("opencode2"), + label: "OpenCode 2", + available: true, + pickerSidebarBadge: "new", + }, { value: ProviderDriverKind.make("cursor"), label: "Cursor", diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..45bda00218ce 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and OpenCode 2. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..f4fa8bf08b61 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, OpenCode, and OpenCode 2. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..8e90f40b4afc 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,15 +7,16 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: -| Driver kind | Driver source | -| ------------- | --------------------------------------- | -| `codex` | [`Drivers/CodexDriver.ts`][codex] | -| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | -| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | -| `grok` | [`Drivers/GrokDriver.ts`][grok] | -| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| Driver kind | Driver source | +| ------------- | ----------------------------------------- | +| `codex` | [`Drivers/CodexDriver.ts`][codex] | +| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | +| `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | +| `opencode2` | [`Drivers/OpenCode2Driver.ts`][opencode2] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -23,6 +24,11 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. +OpenCode 2 follows the CLI's desktop lifecycle instead of owning a daemon. Its runtime runs the +idempotent `opencode2 service start`, reads the service credential with +`opencode2 service get password`, and attaches to that singleton over the native `/api/*` HTTP and +SSE protocol. T3 Code never starts `opencode2 serve` and never stops or deletes the shared service. + ## Registry and routing Two registries separate configuration from live processes: @@ -81,6 +87,7 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts +[opencode2]: ../../apps/server/src/provider/Drivers/OpenCode2Driver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..513c94c136dd 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,16 +54,19 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | - -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn -them on in **Settings** → the provider's card when you want to use them. +| Provider | CLI | Default binary | Log in with | +| ------------------ | ----------------------------------------------------- | -------------- | ---------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| OpenCode 2 Preview | [OpenCode 2](https://opencode.ai/v2) | `opencode2` | `opencode2 auth login` | + +Codex and Claude are on by default. OpenCode 2 is enabled automatically when T3 Code detects the +preview CLI, and a one-time notice links to its Settings card. Cursor, Grok Build, and stable +OpenCode are off by default; turn them on in **Settings** → the provider's card when you want to +use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..cebd2e24b415 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const OPENCODE2_DRIVER_KIND = ProviderDriverKind.make("opencode2"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [OPENCODE2_DRIVER_KIND]: "OpenCode 2", }; diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 7563ac155681..1b97dc78db06 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -23,6 +23,24 @@ describe("ProviderRuntimeEvent", () => { expect(parsed.providerInstanceId).toBe("ollama_local"); }); + it("accepts raw OpenCode 2 SDK events", () => { + const parsed = decodeRuntimeEvent({ + type: "session.started", + eventId: "event-opencode2-session", + provider: "opencode2", + providerInstanceId: "opencode2", + createdAt: "2026-08-21T00:00:00.000Z", + threadId: "thread-1", + payload: { message: "started" }, + raw: { + source: "opencode2.api.event", + payload: { type: "server.connected" }, + }, + }); + + expect(parsed.raw?.source).toBe("opencode2.api.event"); + }); + it("decodes turn.plan.updated for plan rendering", () => { const parsed = decodeRuntimeEvent({ type: "turn.plan.updated", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..2424961c107f 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -26,6 +26,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), + Schema.Literal("opencode2.api.event"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f59da5ece14..66f014cb2170 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -187,12 +187,15 @@ describe("provider enabled defaults", () => { expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); + expect(decoded.providers.opencode2.enabled).toBe(true); + expect(decoded.providers.opencode2.binaryPath).toBe("opencode2"); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("opencode2"))).toBe(true); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); @@ -303,6 +306,9 @@ describe("ServerSettingsPatch string normalization", () => { homePath: " ~/.codex ", launchArgs: " --strict-config --enable foo ", }, + opencode2: { + binaryPath: " /opt/homebrew/bin/opencode2 ", + }, }, providerInstances: { codex_personal: { @@ -319,6 +325,7 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); + expect(patch.providers?.opencode2?.binaryPath).toBe("/opt/homebrew/bin/opencode2"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d249..8492e72cf21a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -519,6 +519,33 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const OpenCode2Settings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("opencode2").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the OpenCode 2 binary.", + providerSettingsForm: { + placeholder: "opencode2", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type OpenCode2Settings = typeof OpenCode2Settings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -661,6 +688,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + opencode2: OpenCode2Settings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -808,6 +836,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const OpenCode2SettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -849,6 +883,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + opencode2: Schema.optionalKey(OpenCode2SettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From dd1a1fbcba8db6c61edd0858cce8a7833d7c3597 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:23:55 -0400 Subject: [PATCH 02/24] fix(opencode): detect installed OpenCode 2 previews --- .../provider/Layers/OpenCode2Adapter.test.ts | 5 +-- .../provider/Layers/OpenCode2Provider.test.ts | 2 +- .../src/provider/openCode2Runtime.test.ts | 34 ++++++------------- apps/server/src/provider/openCode2Runtime.ts | 24 ++----------- .../OpenCode2TextGeneration.test.ts | 2 +- 5 files changed, 17 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts index e65d03d3867a..5e2984a9a9e5 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts @@ -86,10 +86,7 @@ function withHarness( })) as OpenCode2Connection["request"]; const connection: OpenCode2Connection = { url: "http://127.0.0.1:4096", - protocol: { - promptShape, - eventNamespace: promptShape === "flat" ? "session.next" : "session", - }, + protocol: { promptShape }, request, globalEvents: Stream.make(event("connected", "server.connected", {})).pipe( Stream.concat(Stream.fromQueue(events)), diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.test.ts b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts index fff26a5f8801..119dc0ed1a12 100644 --- a/apps/server/src/provider/Layers/OpenCode2Provider.test.ts +++ b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts @@ -27,7 +27,7 @@ function runtimeLayer(input: { }) { const connection: OpenCode2Connection = { url: "http://127.0.0.1:49374/", - protocol: { promptShape: "flat", eventNamespace: "session" }, + protocol: { promptShape: "flat" }, request: ((method: string, path: string, _requestInput: { readonly schema: unknown }) => { input.paths?.push(`${method} ${path}`); return Effect.succeed(input.responses?.[path]); diff --git a/apps/server/src/provider/openCode2Runtime.test.ts b/apps/server/src/provider/openCode2Runtime.test.ts index 768bdd351114..3c5b672c8253 100644 --- a/apps/server/src/provider/openCode2Runtime.test.ts +++ b/apps/server/src/provider/openCode2Runtime.test.ts @@ -17,14 +17,13 @@ import { function openApi( promptProperty: "text" | "prompt", - eventType = "session.text.delta", - operationId: string | undefined = "v2.session.prompt", + operationId: string | null = "v2.session.prompt", ) { return { paths: { "/api/session/{sessionID}/prompt": { post: { - ...(operationId ? { operationId } : {}), + ...(operationId === null ? {} : { operationId }), requestBody: { content: { "application/json": { @@ -41,7 +40,6 @@ function openApi( type: "object", properties: { [promptProperty]: { type: "string" } }, }, - EventType: { enum: [eventType] }, }, }, }; @@ -178,7 +176,7 @@ it.effect("fails explicitly when the preview OpenAPI shape is unknown", () => { const error = yield* Effect.flip(runtime.attach({ binaryPath: "opencode2" })); NodeAssert.equal(error.kind, "unsupported-preview"); - NodeAssert.match(error.detail, /unsupported prompt or event protocol/i); + NodeAssert.match(error.detail, /unsupported prompt protocol/i); }); }); @@ -199,27 +197,17 @@ it.effect("times out a wedged service command", () => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); }); -it("detects both adjacent preview prompt and event namespaces", () => { +it("detects both adjacent preview prompt shapes", () => { NodeAssert.deepEqual(detectOpenCode2Protocol(openApi("text")), { promptShape: "flat", - eventNamespace: "session", }); - NodeAssert.deepEqual(detectOpenCode2Protocol(openApi("prompt", "session.next.text.delta")), { + NodeAssert.deepEqual(detectOpenCode2Protocol(openApi("prompt")), { + promptShape: "nested", + }); + NodeAssert.deepEqual(detectOpenCode2Protocol(openApi("prompt", "session.prompt")), { + promptShape: "nested", + }); + NodeAssert.deepEqual(detectOpenCode2Protocol(openApi("prompt", null)), { promptShape: "nested", - eventNamespace: "session.next", }); - NodeAssert.deepEqual( - detectOpenCode2Protocol(openApi("prompt", "session.next.text.delta", "session.prompt")), - { - promptShape: "nested", - eventNamespace: "session.next", - }, - ); - NodeAssert.deepEqual( - detectOpenCode2Protocol(openApi("prompt", "session.next.text.delta", undefined)), - { - promptShape: "nested", - eventNamespace: "session.next", - }, - ); }); diff --git a/apps/server/src/provider/openCode2Runtime.ts b/apps/server/src/provider/openCode2Runtime.ts index c2ff48ca048b..04cb48facce3 100644 --- a/apps/server/src/provider/openCode2Runtime.ts +++ b/apps/server/src/provider/openCode2Runtime.ts @@ -17,7 +17,6 @@ export type OpenCode2HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; export interface OpenCode2Protocol { readonly promptShape: "flat" | "nested"; - readonly eventNamespace: "session" | "session.next"; } export interface OpenCode2Event { @@ -217,16 +216,6 @@ function findPromptOperation(document: unknown): unknown { return undefined; } -function containsString(value: unknown, expected: string, visited = new Set()): boolean { - if (value === expected) return true; - if (!isRecord(value) && !Array.isArray(value)) return false; - if (visited.has(value)) return false; - visited.add(value); - return (Array.isArray(value) ? value : Object.values(value)).some((nested) => - containsString(nested, expected, visited), - ); -} - export function detectOpenCode2Protocol(document: unknown): OpenCode2Protocol | null { const operation = findPromptOperation(document); if (!isRecord(operation)) return null; @@ -237,14 +226,7 @@ export function detectOpenCode2Protocol(document: unknown): OpenCode2Protocol | const properties = schemaPropertyNames(document, schema); const promptShape = properties.has("text") ? "flat" : properties.has("prompt") ? "nested" : null; if (promptShape === null) return null; - - const eventNamespace = containsString(document, "session.next.text.delta") - ? "session.next" - : containsString(document, "session.text.delta") - ? "session" - : null; - if (eventNamespace === null) return null; - return { promptShape, eventNamespace }; + return { promptShape }; } function requestUrl( @@ -493,7 +475,7 @@ export const makeOpenCode2Runtime = Effect.fn("makeOpenCode2Runtime")(function* baseUrl, password, httpClient: input.httpClient, - protocol: { promptShape: "flat", eventNamespace: "session" }, + protocol: { promptShape: "flat" }, }); const document = yield* provisional.request("GET", "/openapi.json", { operation: "openapi.get", @@ -505,7 +487,7 @@ export const makeOpenCode2Runtime = Effect.fn("makeOpenCode2Runtime")(function* operation: "openapi.detect", kind: "unsupported-preview", detail: - "This OpenCode 2 preview exposes an unsupported prompt or event protocol in `/openapi.json`.", + "This OpenCode 2 preview exposes an unsupported prompt protocol in `/openapi.json`.", }); } diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts index dcb694112d61..d20569be50b8 100644 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts @@ -21,7 +21,7 @@ interface CapturedRequest { function runtimeLayer(response: unknown, requests: Array) { const connection: OpenCode2Connection = { url: "http://127.0.0.1:49374/", - protocol: { promptShape: "flat", eventNamespace: "session" }, + protocol: { promptShape: "flat" }, request: (( method: string, path: string, From a7570007f18ca4ff4ba116a4e6edfbc94ab64069 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:48:54 -0400 Subject: [PATCH 03/24] fix(opencode): recover native service connections --- .agents/AGENTS.md | 2 +- .../provider/Layers/OpenCode2Adapter.test.ts | 122 +++++++++++++++++- .../src/provider/Layers/OpenCode2Adapter.ts | 114 +++++++++++----- .../provider/Layers/OpenCode2Provider.test.ts | 82 ++++++++++++ .../src/provider/Layers/OpenCode2Provider.ts | 25 ++-- .../src/provider/openCode2Runtime.test.ts | 32 ++++- apps/server/src/provider/openCode2Runtime.ts | 37 ++---- 7 files changed, 345 insertions(+), 69 deletions(-) diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index eaa562898d01..d4931d0bb680 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -1 +1 @@ -Use `gh attach` when adding files to GitHub, and never commit PR-only assets. +Install the `sudosubin/gh-attach` extension with `gh extension install sudosubin/gh-attach`, then use `gh attach` when adding files to GitHub. Never commit PR-only assets. diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts index 5e2984a9a9e5..637a1227b45a 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.test.ts @@ -25,6 +25,7 @@ import { type OpenCode2HttpMethod, type OpenCode2RequestInput, OpenCode2Runtime, + OpenCode2RuntimeError, type OpenCode2RuntimeShape, } from "../openCode2Runtime.ts"; import { makeOpenCode2Adapter, type OpenCode2AdapterShape } from "./OpenCode2Adapter.ts"; @@ -41,6 +42,7 @@ interface Harness { readonly calls: Array; readonly attachCount: () => number; readonly publish: (event: OpenCode2Event) => Effect.Effect; + readonly failEvents: (error: OpenCode2RuntimeError) => Effect.Effect; } const settings = Schema.decodeSync(OpenCode2Settings)({ @@ -59,9 +61,10 @@ function withHarness( promptShape: "flat" | "nested", run: (harness: Harness) => Effect.Effect, enabled = true, + failFirstHandshake = false, ) { return Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const calls: Array = []; let attachCalls = 0; const request = (( @@ -89,15 +92,36 @@ function withHarness( protocol: { promptShape }, request, globalEvents: Stream.make(event("connected", "server.connected", {})).pipe( - Stream.concat(Stream.fromQueue(events)), + Stream.concat( + Stream.fromQueue(events).pipe( + Stream.mapEffect((next) => + next instanceof OpenCode2RuntimeError ? Effect.fail(next) : Effect.succeed(next), + ), + ), + ), ), }; const runtime: OpenCode2RuntimeShape = { attach: () => Effect.sync(() => { attachCalls += 1; - return connection; - }), + return attachCalls; + }).pipe( + Effect.map((attempt) => + failFirstHandshake && attempt === 1 + ? { + ...connection, + globalEvents: Stream.fail( + new OpenCode2RuntimeError({ + operation: "event.subscribe", + kind: "connection", + detail: "The initial event handshake failed.", + }), + ), + } + : connection, + ), + ), }; const adapter = yield* makeOpenCode2Adapter({ ...settings, enabled }).pipe( Effect.provideService(OpenCode2Runtime, runtime), @@ -109,6 +133,7 @@ function withHarness( calls, attachCount: () => attachCalls, publish: (next) => Queue.offer(events, next).pipe(Effect.asVoid), + failEvents: (error) => Queue.offer(events, error).pipe(Effect.asVoid), }); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); } @@ -490,3 +515,92 @@ it.effect("does not attach when OpenCode 2 is disabled", () => false, ), ); + +it.effect("retries after the initial event handshake fails", () => + withHarness( + "flat", + ({ adapter, attachCount }) => + Effect.gen(function* () { + const first = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit); + NodeAssert.equal(Exit.isFailure(first), true); + + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + NodeAssert.equal(attachCount(), 2); + }), + true, + true, + ), +); + +it.effect("reconnects after the native event stream fails", () => + withHarness("flat", ({ adapter, attachCount, failEvents }) => + Effect.gen(function* () { + const runtimeError = yield* Deferred.make(); + yield* collectEvents(adapter, [], (next) => + next.type === "runtime.error" + ? Deferred.succeed(runtimeError, undefined).pipe(Effect.ignore) + : Effect.void, + ); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* failEvents( + new OpenCode2RuntimeError({ + operation: "event.subscribe", + kind: "connection", + detail: "The shared event stream disconnected.", + }), + ); + yield* Deferred.await(runtimeError); + + yield* adapter.sendTurn({ threadId, input: "reconnect" }); + NodeAssert.equal(attachCount(), 2); + }), + ), +); + +it.effect("restores the regular agent after a plan turn", () => + withHarness("flat", ({ adapter, calls }) => + Effect.gen(function* () { + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ + threadId, + input: "plan this", + interactionMode: "plan", + }); + yield* adapter.sendTurn({ threadId, input: "build this" }); + + NodeAssert.deepEqual( + calls.filter((call) => call.operation === "session.switchAgent").map((call) => call.body), + [{ agent: "plan" }, { agent: "build" }], + ); + }), + ), +); + +it.effect("moves a resumed provider session to its latest T3 thread", () => + withHarness("flat", ({ adapter }) => + Effect.gen(function* () { + const firstThread = ThreadId.make("thread-opencode2-first"); + const secondThread = ThreadId.make("thread-opencode2-second"); + const resumeCursor = { schemaVersion: 1, sessionId: "ses_shared" } as const; + yield* adapter.startSession({ + threadId: firstThread, + runtimeMode: "full-access", + resumeCursor, + }); + yield* adapter.startSession({ + threadId: secondThread, + runtimeMode: "full-access", + resumeCursor, + }); + + NodeAssert.deepEqual( + (yield* adapter.listSessions()).map((session) => session.threadId), + [secondThread], + ); + yield* adapter.stopSession(secondThread); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); + }), + ), +); diff --git a/apps/server/src/provider/Layers/OpenCode2Adapter.ts b/apps/server/src/provider/Layers/OpenCode2Adapter.ts index 6d9e57cfb40f..33b9d768365a 100644 --- a/apps/server/src/provider/Layers/OpenCode2Adapter.ts +++ b/apps/server/src/provider/Layers/OpenCode2Adapter.ts @@ -19,9 +19,12 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -34,6 +37,7 @@ import { } from "../Errors.ts"; import type { ProviderAdapterShape, ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; import { + type OpenCode2Connection, type OpenCode2Event, type OpenCode2RequestInput, OpenCode2Runtime, @@ -81,6 +85,7 @@ interface OpenCode2SessionContext { currentModel: string | undefined; currentVariant: string | undefined; currentAgent: string | undefined; + regularAgent: string; } export interface OpenCode2AdapterLiveOptions { @@ -103,6 +108,11 @@ interface EventBaseInput { readonly raw?: OpenCode2Event; } +interface OpenCode2ActiveConnection { + readonly connection: OpenCode2Connection; + readonly interrupt: Effect.Effect; +} + function recordFromUnknown(value: unknown): Readonly> | undefined { return Option.getOrUndefined(decodeUnknownRecord(value)); } @@ -310,7 +320,8 @@ export function makeOpenCode2Adapter( const events = yield* Queue.unbounded(); const sessions = new Map(); const sessionByProviderId = new Map(); - const serverConnected = yield* Deferred.make(); + const connectionLock = yield* Semaphore.make(1); + let activeConnection: OpenCode2ActiveConnection | undefined; let eventCounter = 0; const buildEventBase = Effect.fn("OpenCode2Adapter.buildEventBase")(function* ( @@ -421,7 +432,6 @@ export function makeOpenCode2Adapter( event: OpenCode2Event, ) { if (normalizeEventType(event.type) === "server.connected") { - yield* Deferred.succeed(serverConnected, undefined).pipe(Effect.ignore); return; } const providerSessionId = eventSessionId(event); @@ -787,14 +797,29 @@ export function makeOpenCode2Adapter( { discard: true }, ); - const ensureConnection = yield* Effect.cached( + const invalidateConnection = (entry: OpenCode2ActiveConnection) => + Effect.gen(function* () { + if (activeConnection !== entry) return; + activeConnection = undefined; + yield* entry.interrupt; + }); + + const ensureConnection = connectionLock.withPermit( Effect.gen(function* () { + if (activeConnection) return activeConnection; + const connection = yield* runtime.attach({ binaryPath: settings.binaryPath, ...(options?.environment ? { environment: options.environment } : {}), }); - yield* connection.globalEvents.pipe( - Stream.runForEach(handleEvent), + const connected = yield* Deferred.make(); + const streamFailure = yield* Ref.make>(Option.none()); + const eventFiber = yield* connection.globalEvents.pipe( + Stream.runForEach((event) => + normalizeEventType(event.type) === "server.connected" + ? Deferred.succeed(connected, undefined).pipe(Effect.ignore) + : handleEvent(event), + ), Effect.flatMap(() => Effect.fail( new OpenCode2RuntimeError({ @@ -804,27 +829,41 @@ export function makeOpenCode2Adapter( }), ), ), - Effect.tapError((cause) => Deferred.fail(serverConnected, cause).pipe(Effect.ignore)), - Effect.catch(reportConnectionFailure), - Effect.forkIn(scope), - ); - yield* Deferred.await(serverConnected).pipe( - Effect.timeoutOption("10 seconds"), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new OpenCode2RuntimeError({ - operation: "event.subscribe", - kind: "connection", - detail: "Timed out waiting for OpenCode 2 server.connected.", - }), - ), - onSome: Effect.succeed, + Effect.catchTag("OpenCode2RuntimeError", (cause) => + Effect.gen(function* () { + yield* Ref.set(streamFailure, Option.some(cause)); + yield* Deferred.fail(connected, cause).pipe(Effect.ignore); + if (activeConnection?.connection === connection) activeConnection = undefined; + yield* reportConnectionFailure(cause); }), ), + Effect.forkIn(scope), ); - return connection; + const interrupt = Fiber.interrupt(eventFiber).pipe(Effect.asVoid); + const handshake = yield* Deferred.await(connected).pipe(Effect.timeoutOption("10 seconds")); + if (Option.isNone(handshake)) { + yield* interrupt; + return yield* new OpenCode2RuntimeError({ + operation: "event.subscribe", + kind: "connection", + detail: "Timed out waiting for OpenCode 2 server.connected.", + }); + } + + const entry = { connection, interrupt } satisfies OpenCode2ActiveConnection; + const failedBeforeActivation = yield* Ref.get(streamFailure); + if (Option.isSome(failedBeforeActivation)) { + yield* interrupt; + return yield* failedBeforeActivation.value; + } + activeConnection = entry; + const failedDuringActivation = yield* Ref.get(streamFailure); + if (Option.isSome(failedDuringActivation)) { + activeConnection = undefined; + yield* interrupt; + return yield* failedDuringActivation.value; + } + return entry; }), ); @@ -834,7 +873,11 @@ export function makeOpenCode2Adapter( input: OpenCode2RequestInput, ) => ensureConnection.pipe( - Effect.flatMap((connection) => connection.request(method, path, input)), + Effect.flatMap((entry) => + entry.connection + .request(method, path, input) + .pipe(Effect.tapError(() => invalidateConnection(entry))), + ), ); const interruptContext = Effect.fn("OpenCode2Adapter.interruptContext")(function* ( @@ -929,6 +972,14 @@ export function makeOpenCode2Adapter( }, }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); const providerSessionId = resolved.data.id; + const staleContexts = new Set(); + const previousForThread = sessions.get(input.threadId); + const previousForProvider = sessionByProviderId.get(providerSessionId); + if (previousForThread) staleContexts.add(previousForThread); + if (previousForProvider) staleContexts.add(previousForProvider); + for (const stale of staleContexts) { + yield* detachContext(stale); + } if (resumeSessionId && modelRef) { yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/model`, { operation: "session.switchModel", @@ -968,12 +1019,9 @@ export function makeOpenCode2Adapter( activeTurnId: undefined, currentModel: selectedModel, currentVariant: variant, - currentAgent: agent, + currentAgent: agent ?? "build", + regularAgent: agent ?? "build", }; - const previous = sessions.get(input.threadId); - if (previous) { - yield* detachContext(previous); - } sessions.set(input.threadId, context); sessionByProviderId.set(providerSessionId, context); yield* emit({ @@ -998,6 +1046,7 @@ export function makeOpenCode2Adapter( function* (input) { const context = yield* ensureSession(input.threadId); const connection = yield* ensureConnection.pipe( + Effect.map((entry) => entry.connection), Effect.mapError((cause) => toRequestError("connection.attach", cause)), ); if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { @@ -1064,9 +1113,12 @@ export function makeOpenCode2Adapter( context.currentVariant = variant; } + const explicitAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); const selectedAgent = - getModelSelectionStringOptionValue(input.modelSelection, "agent") ?? - (input.interactionMode === "plan" ? "plan" : context.currentAgent); + input.interactionMode === "plan" ? "plan" : (explicitAgent ?? context.regularAgent); + if (input.interactionMode !== "plan" && explicitAgent) { + context.regularAgent = explicitAgent; + } if (selectedAgent && selectedAgent !== context.currentAgent) { yield* request( "POST", diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.test.ts b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts index 119dc0ed1a12..fc5bbad08318 100644 --- a/apps/server/src/provider/Layers/OpenCode2Provider.test.ts +++ b/apps/server/src/provider/Layers/OpenCode2Provider.test.ts @@ -131,6 +131,88 @@ it.effect("reports unsupported adjacent preview protocols without hanging", () = ), ); +it.effect("does not report an HTTP 404 as a missing CLI", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCode2ProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.doesNotMatch(snapshot.message ?? "", /not installed/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCode2RuntimeError({ + operation: "model.list", + kind: "request", + detail: "OpenCode 2 model.list returned HTTP 404 Not Found.", + }), + }), + ), + ), +); + +it.effect("warns when the attached OpenCode 2 service reports unhealthy", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCode2ProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.match(snapshot.message ?? "", /reported that it is unhealthy/i); + }).pipe( + Effect.provide( + runtimeLayer({ + responses: { + "/api/health": { healthy: false, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + }, + ], + }, + "/api/model/default": { data: null }, + "/api/agent": { data: [] }, + }, + }), + ), + ), +); + +it.effect("describes custom models without counting deprecated upstream providers", () => { + const customSettings = { + ...settings, + customModels: ["custom/local-model"], + }; + return Effect.gen(function* () { + const snapshot = yield* checkOpenCode2ProviderStatus(customSettings); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.match(snapshot.message ?? "", /custom models configured/i); + NodeAssert.doesNotMatch(snapshot.message ?? "", /0 upstream providers/i); + }).pipe( + Effect.provide( + runtimeLayer({ + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "old-model", + providerID: "legacy", + name: "Old Model", + status: "deprecated", + }, + ], + }, + "/api/model/default": { data: null }, + "/api/agent": { data: [] }, + }, + }), + ), + ); +}); + it.effect("finishes the provider check when attachment is wedged", () => { const layer = Layer.succeed( OpenCode2Runtime, diff --git a/apps/server/src/provider/Layers/OpenCode2Provider.ts b/apps/server/src/provider/Layers/OpenCode2Provider.ts index 3ac693d93991..be056ead2641 100644 --- a/apps/server/src/provider/Layers/OpenCode2Provider.ts +++ b/apps/server/src/provider/Layers/OpenCode2Provider.ts @@ -198,7 +198,11 @@ function failureSnapshot(input: { const unsupported = input.cause instanceof OpenCode2RuntimeError && input.cause.kind === "unsupported-preview"; const detail = errorText(input.cause); - const missing = /(?:enoent|not found|could not resolve|failed to execute)/iu.test(detail); + const missing = + input.cause instanceof OpenCode2RuntimeError && + input.cause.kind === "command" && + (input.cause.operation === "command.resolve" || input.cause.operation === "command.spawn") && + /(?:enoent|not found|could not resolve|failed to execute)/iu.test(detail); return buildServerProvider({ presentation: OPENCODE2_PRESENTATION, enabled: input.settings.enabled, @@ -318,16 +322,16 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta } const value = attached.value.value; + const discoveredModels = providerModels(value); const models = providerModelsFromSettings( - providerModels(value), + discoveredModels, settings.customModels, DEFAULT_OPENCODE2_MODEL_CAPABILITIES, ); const providerCount = new Set( - value.models - .filter((model: OpenCode2Model) => model.enabled !== false) - .map((model: OpenCode2Model) => model.providerID), + discoveredModels.map((model) => model.subProvider).filter((provider) => provider !== undefined), ).size; + const healthy = value.health.healthy !== false; return buildServerProvider({ presentation: OPENCODE2_PRESENTATION, enabled: true, @@ -336,12 +340,15 @@ export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderSta probe: { installed: true, version: value.health.version ?? null, - status: models.length > 0 ? "ready" : "warning", + status: healthy && models.length > 0 ? "ready" : "warning", auth: { status: "authenticated", type: "opencode" }, - message: - models.length > 0 + message: !healthy + ? "The OpenCode 2 background service reported that it is unhealthy." + : providerCount > 0 ? `${providerCount} upstream provider${providerCount === 1 ? "" : "s"} available through the OpenCode 2 background service.` - : "Connected to OpenCode 2, but it did not report any available models.", + : models.length > 0 + ? "Connected to OpenCode 2 using the custom models configured in T3 Code." + : "Connected to OpenCode 2, but it did not report any available models.", }, }); }); diff --git a/apps/server/src/provider/openCode2Runtime.test.ts b/apps/server/src/provider/openCode2Runtime.test.ts index 3c5b672c8253..38e393df3085 100644 --- a/apps/server/src/provider/openCode2Runtime.test.ts +++ b/apps/server/src/provider/openCode2Runtime.test.ts @@ -109,13 +109,43 @@ it.effect("memoizes one service attachment per binary and environment", () => { }); }); +it.effect("refreshes a cached attachment when the shared service is no longer healthy", () => { + let healthFails = false; + const fake = fakeRuntime({ + onRequest: (request) => { + if (new URL(request.url).pathname === "/api/health" && healthFails) { + return Response.json({ healthy: false }, { status: 503 }); + } + return Response.json({ healthy: true }); + }, + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + const first = yield* runtime.attach({ binaryPath: "opencode2" }); + healthFails = true; + const second = yield* runtime.attach({ binaryPath: "opencode2" }); + + NodeAssert.notEqual(first, second); + NodeAssert.deepEqual( + fake.commands.map((command) => command.args), + [ + ["service", "start"], + ["service", "get", "password"], + ["service", "start"], + ["service", "get", "password"], + ], + ); + }); +}); + it.effect("uses private Basic auth for typed JSON and global SSE requests", () => { const fake = fakeRuntime({ onRequest: (request) => { const pathname = new URL(request.url).pathname; if (pathname === "/api/event") { return new Response( - 'data: {"id":"evt_1","type":"server.connected","data":{}}\n\n' + + 'data: {"id":"evt_1",\n' + + 'data: "type":"server.connected","data":{}}\n\n' + 'data: {"id":"evt_2","type":"session.text.delta","data":{"delta":"hi"}}\n\n', { headers: { "content-type": "text/event-stream" } }, ); diff --git a/apps/server/src/provider/openCode2Runtime.ts b/apps/server/src/provider/openCode2Runtime.ts index 04cb48facce3..5d43d1c7eb6e 100644 --- a/apps/server/src/provider/openCode2Runtime.ts +++ b/apps/server/src/provider/openCode2Runtime.ts @@ -1,14 +1,15 @@ import * as Context from "effect/Context"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as Sse from "effect/unstable/encoding/Sse"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { collectStreamAsString } from "./providerSnapshot.ts"; @@ -113,10 +114,6 @@ const OpenCode2EventSchema = Schema.Struct({ metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), }); -const decodeOpenCode2SseEvent = Schema.decodeUnknownEffect( - Schema.fromJsonString(OpenCode2EventSchema), -); - function environmentKey(environment: NodeJS.ProcessEnv): string { return JSON.stringify( Object.entries(environment) @@ -357,23 +354,8 @@ function makeConnection(input: { ), ).pipe( Stream.decodeText(), - Stream.splitLines, - Stream.filterMap((line) => { - if (!line.startsWith("data:")) return Result.failVoid; - return Result.succeed(line.slice("data:".length).trimStart()); - }), - Stream.mapEffect((line) => - decodeOpenCode2SseEvent(line).pipe( - Effect.mapError((cause) => - runtimeError({ - operation: "event.decode", - kind: "unsupported-preview", - detail: "This OpenCode 2 preview returned an unsupported event payload.", - cause, - }), - ), - ), - ), + Stream.pipeThroughChannel(Sse.decodeDataSchema(OpenCode2EventSchema)), + Stream.map((event) => event.data), Stream.mapError((cause) => cause instanceof OpenCode2RuntimeError ? cause @@ -424,7 +406,16 @@ export const makeOpenCode2Runtime = Effect.fn("makeOpenCode2Runtime")(function* return attachLock.withPermit( Effect.gen(function* () { const existing = connections.get(key); - if (existing) return existing; + if (existing) { + const health = yield* Effect.exit( + existing.request("GET", "/api/health", { + operation: "health.get", + schema: Schema.Unknown, + }), + ); + if (Exit.isSuccess(health)) return existing; + connections.delete(key); + } const start = yield* runCommand({ binaryPath: attachInput.binaryPath, From 6782686630956f226584cdc8d9a1b27b59d38407 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:50:14 -0400 Subject: [PATCH 04/24] fix(web): align OpenCode 2 notice icon --- apps/web/src/components/OpenCode2AutoEnableNotification.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/OpenCode2AutoEnableNotification.tsx b/apps/web/src/components/OpenCode2AutoEnableNotification.tsx index 86da8fbb2988..ac00f6749f4c 100644 --- a/apps/web/src/components/OpenCode2AutoEnableNotification.tsx +++ b/apps/web/src/components/OpenCode2AutoEnableNotification.tsx @@ -87,7 +87,7 @@ export function OpenCode2AutoEnableNotification() { }, actionVariant: "outline", data: { - leadingIcon: , + leadingIcon: (value: ReadonlyArray | { readonly data: ReadonlyArray }) { - return "data" in value ? value.data : value; -} - -function responseValue(value: A | null | { readonly data: A | null }): A | null { - return value !== null && typeof value === "object" && "data" in value ? value.data : value; -} - -function titleCase(value: string): string { - return value - .split(/[-_/]+/u) - .filter((segment) => segment.length > 0) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(" "); -} - -function variantIds(model: OpenCode2Model): ReadonlyArray { - if (!model.variants) return []; - if (Array.isArray(model.variants)) { - return model.variants.map((variant) => (typeof variant === "string" ? variant : variant.id)); - } - return Object.keys(model.variants); -} - -function modelCapabilities( - model: OpenCode2Model, - agents: ReadonlyArray, -): ModelCapabilities { - const variants = variantIds(model); - const defaultVariant = variants.includes("default") - ? "default" - : variants.includes("medium") - ? "medium" - : variants.length === 1 - ? variants[0] - : undefined; - const visibleAgents = agents.filter( - (agent) => agent.hidden !== true && agent.mode !== "subagent", - ); - const defaultAgent = - visibleAgents.find((agent) => agent.id === "build")?.id ?? visibleAgents[0]?.id; - - return createModelCapabilities({ - optionDescriptors: [ - ...(variants.length > 0 - ? [ - { - id: "variant", - label: "Variant", - type: "select" as const, - options: variants.map((variant) => ({ - id: variant, - label: titleCase(variant), - ...(variant === defaultVariant ? { isDefault: true as const } : {}), - })), - ...(defaultVariant ? { currentValue: defaultVariant } : {}), - }, - ] - : []), - ...(visibleAgents.length > 0 - ? [ - { - id: "agent", - label: "Agent", - type: "select" as const, - options: visibleAgents.map((agent) => ({ - id: agent.id, - label: nonEmptyTrimmed(agent.name) ?? titleCase(agent.id), - ...(agent.id === defaultAgent ? { isDefault: true as const } : {}), - })), - ...(defaultAgent ? { currentValue: defaultAgent } : {}), - }, - ] - : []), - ], - }); -} - -function providerModels(input: { - readonly models: ReadonlyArray; - readonly defaultModel: OpenCode2Model | null; - readonly agents: ReadonlyArray; -}): ReadonlyArray { - const defaultSlug = input.defaultModel - ? `${input.defaultModel.providerID}/${input.defaultModel.id}` - : null; - return input.models - .filter((model) => model.enabled !== false && model.status !== "deprecated") - .map((model) => { - const slug = `${model.providerID}/${model.id}`; - return { - slug, - name: nonEmptyTrimmed(model.name) ?? model.id, - subProvider: model.providerID, - isCustom: false, - ...(slug === defaultSlug ? { isDefault: true as const } : {}), - capabilities: modelCapabilities(model, input.agents), - }; - }) - .toSorted((left, right) => left.name.localeCompare(right.name)); -} - -function failureSnapshot(input: { - readonly settings: OpenCode2ProviderSettings; - readonly checkedAt: string; - readonly cause: unknown; - readonly version?: string | null; -}): ServerProviderDraft { - const unsupported = OpenCode2Runtime.isOpenCode2UnsupportedPreviewError(input.cause); - const missing = OpenCode2Runtime.isOpenCode2CommandNotFoundError(input.cause); - const timedOut = OpenCode2Runtime.isOpenCode2TimeoutError(input.cause); - return buildServerProvider({ - presentation: OPENCODE2_PRESENTATION, - enabled: input.settings.enabled, - checkedAt: input.checkedAt, - models: providerModelsFromSettings( - [], - input.settings.customModels, - DEFAULT_OPENCODE2_MODEL_CAPABILITIES, - ), - probe: { - installed: !missing, - version: input.version ?? null, - status: "error", - auth: { status: "unknown" }, - message: unsupported - ? "This OpenCode 2 preview is not supported by T3 Code." - : missing - ? "OpenCode 2 CLI (`opencode2`) is not installed or not on PATH." - : timedOut - ? "OpenCode 2 provider discovery timed out." - : input.cause instanceof Error - ? input.cause.message - : "Failed to connect to the OpenCode 2 background service.", - }, - }); -} - -export const buildInitialOpenCode2ProviderSnapshot = ( - settings: OpenCode2ProviderSettings, -): Effect.Effect => - Effect.gen(function* () { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - return buildServerProvider({ - presentation: OPENCODE2_PRESENTATION, - enabled: settings.enabled, - checkedAt, - models: providerModelsFromSettings( - [], - settings.customModels, - DEFAULT_OPENCODE2_MODEL_CAPABILITIES, - ), - probe: settings.enabled - ? { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenCode 2 provider status has not been checked in this session yet.", - } - : { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenCode 2 is disabled in T3 Code settings.", - }, - }); - }); - -export const checkOpenCode2ProviderStatus = Effect.fn("checkOpenCode2ProviderStatus")(function* ( - settings: OpenCode2ProviderSettings, - environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - if (!settings.enabled) return yield* buildInitialOpenCode2ProviderSnapshot(settings); - - const runtime = yield* OpenCode2Runtime.OpenCode2Runtime; - const attached = yield* Effect.exit( - Effect.gen(function* () { - const connection = yield* runtime.attach({ - binaryPath: settings.binaryPath || "opencode2", - ...(environment ? { environment } : {}), - }); - const [health, modelsResponse, defaultResponse, agentsResponse] = yield* Effect.all( - [ - connection.request("GET", "/api/health", { - operation: "health.get", - schema: OpenCode2HealthSchema, - }), - connection.request("GET", "/api/model", { - operation: "model.list", - schema: OpenCode2ModelListSchema, - }), - connection.request("GET", "/api/model/default", { - operation: "model.default", - schema: OpenCode2DefaultModelSchema, - }), - connection.request("GET", "/api/agent", { - operation: "agent.list", - schema: OpenCode2AgentListSchema, - }), - ], - { concurrency: "unbounded" }, - ); - return { - health, - models: responseList(modelsResponse), - defaultModel: responseValue(defaultResponse), - agents: responseList(agentsResponse), - }; - }).pipe(Effect.timeoutOption(OPENCODE2_PROVIDER_PROBE_TIMEOUT)), - ); - if (Exit.isFailure(attached)) { - return failureSnapshot({ - settings, - checkedAt, - cause: Cause.squash(attached.cause), - }); - } - - if (Option.isNone(attached.value)) { - return failureSnapshot({ - settings, - checkedAt, - cause: new OpenCode2Runtime.OpenCode2TimeoutError({ - operation: "provider.probe", - }), - }); - } - - const value = attached.value.value; - const discoveredModels = providerModels(value); - const models = providerModelsFromSettings( - discoveredModels, - settings.customModels, - DEFAULT_OPENCODE2_MODEL_CAPABILITIES, - ); - const providerCount = new Set( - discoveredModels.map((model) => model.subProvider).filter((provider) => provider !== undefined), - ).size; - const healthy = value.health.healthy !== false; - return buildServerProvider({ - presentation: OPENCODE2_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: value.health.version ?? null, - status: healthy && models.length > 0 ? "ready" : "warning", - auth: { status: "authenticated", type: "opencode" }, - message: !healthy - ? "The OpenCode 2 background service reported that it is unhealthy." - : providerCount > 0 - ? `${providerCount} upstream provider${providerCount === 1 ? "" : "s"} available through the OpenCode 2 background service.` - : models.length > 0 - ? "Connected to OpenCode 2 using the custom models configured in T3 Code." - : "Connected to OpenCode 2, but it did not report any available models.", - }, - }); -}); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..8e8f4798d00a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1,1444 +1,694 @@ import * as NodeAssert from "node:assert/strict"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; -import { beforeEach } from "vite-plus/test"; - import { + ApprovalRequestId, OpenCodeSettings, - ProviderDriverKind, ProviderInstanceId, + type ProviderRuntimeEvent, ThreadId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; -import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; -import { - appendOpenCodeAssistantTextDelta, - isOpenCodeNotFound, - isSameOpenCodeDirectory, - makeOpenCodeAdapter, - mergeOpenCodeAssistantText, -} from "./OpenCodeAdapter.ts"; - -// Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. -class OpenCodeAdapter extends Context.Service()( - "t3/provider/Layers/OpenCodeAdapter.test/OpenCodeAdapter", -) {} - -const asThreadId = (value: string): ThreadId => ThreadId.make(value); +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; -type MessageEntry = { - info: { - id: string; - role: "user" | "assistant"; - }; - parts: Array; -}; +import { ServerConfig } from "../../config.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import { makeOpenCodeAdapter, type OpenCodeAdapterShape } from "./OpenCodeAdapter.ts"; + +interface RequestCall { + readonly method: OpenCodeRuntime.OpenCodeHttpMethod; + readonly path: string; + readonly operation: string; + readonly body?: unknown; +} + +interface Harness { + readonly adapter: OpenCodeAdapterShape; + readonly calls: Array; + readonly attachCount: () => number; + readonly failNextRequest: (operation: string) => void; + readonly missNextRequest: (operation: string) => void; + readonly publish: (event: OpenCodeRuntime.OpenCodeEvent) => Effect.Effect; + readonly failEvents: (error: OpenCodeRuntime.OpenCodeRuntimeFailure) => Effect.Effect; +} + +const settings = Schema.decodeSync(OpenCodeSettings)({ + enabled: true, + binaryPath: "fake-opencode2", + customModels: [], +}); -const runtimeMock = { - state: { - startCalls: [] as string[], - sessionCreateUrls: [] as string[], - sessionCreateInputs: [] as Array>, - authHeaders: [] as Array, - abortCalls: [] as string[], - closeCalls: [] as string[], - revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, - promptCalls: [] as Array, - promptAsyncError: null as Error | null, - closeError: null as Error | null, - messages: [] as MessageEntry[], - subscribedEvents: [] as unknown[], - sessionGetIds: [] as string[], - missingSessionIds: new Set(), - transientErrorSessionIds: new Set(), - sessionDirectoryById: new Map(), - sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, - forkCalls: [] as Array<{ sessionID: string; directory?: string }>, - }, - reset() { - this.state.startCalls.length = 0; - this.state.sessionCreateUrls.length = 0; - this.state.sessionCreateInputs.length = 0; - this.state.authHeaders.length = 0; - this.state.abortCalls.length = 0; - this.state.closeCalls.length = 0; - this.state.revertCalls.length = 0; - this.state.promptCalls.length = 0; - this.state.promptAsyncError = null; - this.state.closeError = null; - this.state.messages = []; - this.state.subscribedEvents = []; - this.state.sessionGetIds.length = 0; - this.state.missingSessionIds.clear(); - this.state.transientErrorSessionIds.clear(); - this.state.sessionDirectoryById.clear(); - this.state.sessionUpdateCalls.length = 0; - this.state.forkCalls.length = 0; - }, -}; +const threadId = ThreadId.make("thread-opencode2-test"); + +function event(id: string, type: string, data: unknown): OpenCodeRuntime.OpenCodeEvent { + return { id, type, data }; +} + +function withHarness( + promptShape: "flat" | "nested", + run: (harness: Harness) => Effect.Effect, + enabled = true, + failFirstHandshake = false, +) { + return Effect.gen(function* () { + const events = yield* Queue.unbounded< + OpenCodeRuntime.OpenCodeEvent | OpenCodeRuntime.OpenCodeRuntimeFailure + >(); + const calls: Array = []; + const failedOperations = new Set(); + const missingOperations = new Set(); + let attachCalls = 0; + const request = (( + method: OpenCodeRuntime.OpenCodeHttpMethod, + path: string, + input: OpenCodeRuntime.OpenCodeRequestInput, + ) => + Effect.gen(function* () { + calls.push({ + method, + path, + operation: input.operation, + ...(input.body !== undefined ? { body: input.body } : {}), + }); + if (failedOperations.delete(input.operation)) { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: input.operation, + reason: "transport", + }); + } + if (missingOperations.delete(input.operation)) { + return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: input.operation, + reason: "http-status", + status: 404, + }); + } + if (path === "/api/session" || (method === "GET" && path.startsWith("/api/session/"))) { + const id = path === "/api/session" ? "ses_test" : path.slice("/api/session/".length); + return { data: { id } }; + } + if (path.endsWith("/message")) return { data: [], cursor: {} }; + if (path.endsWith("/prompt")) return { data: { id: "msg_user" } }; + return undefined; + })) as OpenCodeRuntime.OpenCodeConnection["request"]; + const connection: OpenCodeRuntime.OpenCodeConnection = { + url: "http://127.0.0.1:4096", + protocol: { promptShape }, + request, + globalEvents: Stream.make(event("connected", "server.connected", {})).pipe( + Stream.concat( + Stream.fromQueue(events).pipe( + Stream.mapEffect((next) => + OpenCodeRuntime.isOpenCodeRuntimeError(next) || + OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(next) || + OpenCodeRuntime.isOpenCodeCommandNotFoundError(next) || + OpenCodeRuntime.isOpenCodeTimeoutError(next) + ? Effect.fail(next) + : Effect.succeed(next), + ), + ), + ), + ), + }; + const runtime: OpenCodeRuntime.OpenCodeRuntime["Service"] = { + attach: () => + Effect.sync(() => { + attachCalls += 1; + return attachCalls; + }).pipe( + Effect.map((attempt) => + failFirstHandshake && attempt === 1 + ? { + ...connection, + globalEvents: Stream.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "connection-ended", + }), + ), + } + : connection, + ), + ), + }; + const adapter = yield* makeOpenCodeAdapter({ ...settings, enabled }).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode2-adapter-" })), + ); + yield* Effect.yieldNow; + return yield* run({ + adapter, + calls, + attachCount: () => attachCalls, + failNextRequest: (operation) => failedOperations.add(operation), + missNextRequest: (operation) => missingOperations.add(operation), + publish: (next) => Queue.offer(events, next).pipe(Effect.asVoid), + failEvents: (error) => Queue.offer(events, error).pipe(Effect.asVoid), + }); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); +} + +function collectEvents( + adapter: OpenCodeAdapterShape, + target: Array, + onEvent?: (event: ProviderRuntimeEvent) => Effect.Effect, +) { + return adapter.streamEvents.pipe( + Stream.runForEach((next) => + Effect.sync(() => { + target.push(next); + }).pipe(Effect.andThen(onEvent?.(next) ?? Effect.void)), + ), + Effect.forkScoped, + ); +} -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => +it.effect("attaches once and maps native deltas, tools, and terminal events", () => + withHarness("flat", ({ adapter, attachCount, calls, publish }) => Effect.gen(function* () { - runtimeMock.state.startCalls.push(binaryPath); - const url = "http://127.0.0.1:4301"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - if (runtimeMock.state.closeError) { - throw runtimeMock.state.closeError; + const observed: Array = []; + const firstCompleted = yield* Deferred.make(); + const secondCompleted = yield* Deferred.make(); + const publicDelta = yield* Deferred.make(); + let completedCount = 0; + yield* collectEvents(adapter, observed, (next) => + Effect.gen(function* () { + if (next.type === "turn.completed") { + completedCount += 1; + yield* Deferred.succeed( + completedCount === 1 ? firstCompleted : secondCompleted, + undefined, + ).pipe(Effect.ignore); } - }), - ); - return { - url, - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.gen(function* () { - const url = serverUrl ?? "http://127.0.0.1:4301"; - // Always register a finalizer so the closeCalls/closeError probes fire; - // production attaches none for external servers. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - if (runtimeMock.state.closeError) { - throw runtimeMock.state.closeError; + if (next.type === "content.delta" && next.payload.delta === "public") { + yield* Deferred.succeed(publicDelta, undefined).pipe(Effect.ignore); } }), ); - return { - url, - exitCode: null, - external: Boolean(serverUrl), - }; - }), - runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ - session: { - create: async (input: Record) => { - runtimeMock.state.sessionCreateUrls.push(baseUrl); - runtimeMock.state.sessionCreateInputs.push(input); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - return { data: { id: `${baseUrl}/session` } }; - }, - get: async ({ sessionID }: { sessionID: string }) => { - runtimeMock.state.sessionGetIds.push(sessionID); - // The real client is `throwOnError: true`: non-2xx rejects rather - // than resolving, so missing → 404 throw, transient → 500 throw. - if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) { - throw new Error("opencode server error", { cause: { status: 500 } }); - } - if (runtimeMock.state.missingSessionIds.has(sessionID)) { - throw new Error(`Session not found: ${sessionID}`, { - cause: { status: 404, body: { name: "NotFoundError" } }, - }); - } - const directory = runtimeMock.state.sessionDirectoryById.get(sessionID); - return { data: { id: sessionID, ...(directory ? { directory } : {}) } }; - }, - update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { - runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); - return { data: { id: sessionID } }; - }, - fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { - // Fork clones history into a new session bound to the directory. - const forkedId = `${sessionID}_fork`; - runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); - if (directory) { - runtimeMock.state.sessionDirectoryById.set(forkedId, directory); - } - return { data: { id: forkedId, ...(directory ? { directory } : {}) } }; - }, - abort: async ({ sessionID }: { sessionID: string }) => { - runtimeMock.state.abortCalls.push(sessionID); - }, - promptAsync: async (input: unknown) => { - runtimeMock.state.promptCalls.push(input); - if (runtimeMock.state.promptAsyncError) { - throw runtimeMock.state.promptAsyncError; - } - }, - messages: async () => ({ data: runtimeMock.state.messages }), - revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { - runtimeMock.state.revertCalls.push({ - sessionID, - ...(messageID ? { messageID } : {}), - }); - if (!messageID) { - runtimeMock.state.messages = []; - return; - } + NodeAssert.equal(attachCount(), 0); + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + cwd: "/tmp/opencode2-project", + }); + NodeAssert.equal(attachCount(), 1); + const turn = yield* adapter.sendTurn({ threadId, input: "hello" }); - const targetIndex = runtimeMock.state.messages.findIndex( - (entry) => entry.info.id === messageID, - ); - runtimeMock.state.messages = - targetIndex >= 0 - ? runtimeMock.state.messages.slice(0, targetIndex + 1) - : runtimeMock.state.messages; - }, - }, - event: { - subscribe: async () => ({ - stream: (async function* () { - for (const event of runtimeMock.state.subscribedEvents) { - yield event; - } - })(), - }), - }, - }) as unknown as ReturnType, - loadOpenCodeInventory: () => - Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", - cause: null, - }), - ), - loadInventoryFromCli: () => - Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", - cause: null, - }), - ), -}; + const promptCall = calls.find((call) => call.operation === "session.prompt"); + NodeAssert.deepEqual(promptCall?.body, { text: "hello", delivery: "steer" }); + NodeAssert.equal(session.resumeCursor !== undefined, true); -const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { - upsert: () => Effect.void, - getProvider: () => - Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), - listThreadIds: () => Effect.succeed([]), - listBindings: () => Effect.succeed([]), -}); + yield* publish( + event("foreign", "session.next.text.delta", { + sessionID: "ses_foreign", + textID: "txt_foreign", + delta: "ignore me", + }), + ); + yield* publish( + event("reasoning-started", "session.next.reasoning.started", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + }), + ); + yield* publish( + event("reasoning", "session.next.reasoning.delta", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + delta: "thinking", + }), + ); + yield* publish( + event("reasoning-ended", "session.next.reasoning.ended", { + sessionID: "ses_test", + reasoningID: "reasoning-1", + text: "thinking", + }), + ); + yield* publish( + event("text-started", "session.next.text.started", { + sessionID: "ses_test", + textID: "text-1", + }), + ); + yield* publish( + event("text", "session.next.text.delta", { + sessionID: "ses_test", + textID: "text-1", + delta: "answer", + }), + ); + yield* publish( + event("tool-input-started", "session.next.tool.input.started", { + sessionID: "ses_test", + callID: "call-1", + name: "shell", + }), + ); + yield* publish( + event("tool-input-delta", "session.next.tool.input.delta", { + sessionID: "ses_test", + callID: "call-1", + delta: '{"command":', + }), + ); + yield* publish( + event("tool-input-ended", "session.next.tool.input.ended", { + sessionID: "ses_test", + callID: "call-1", + text: '{"command":"pwd"}', + }), + ); + yield* publish( + event("tool-called", "session.next.tool.called", { + sessionID: "ses_test", + callID: "call-1", + tool: "shell", + input: { command: "pwd" }, + }), + ); + yield* publish( + event("tool-progress", "session.next.tool.progress", { + sessionID: "ses_test", + callID: "call-1", + content: [{ type: "text", text: "/tmp" }], + }), + ); + yield* publish( + event("tool-success", "session.next.tool.success", { + sessionID: "ses_test", + callID: "call-1", + content: [{ type: "text", text: "done" }], + }), + ); + yield* publish( + event("settled", "session.next.execution.settled", { + sessionID: "ses_test", + outcome: "success", + }), + ); + yield* Deferred.await(firstCompleted); -// The adapter now receives its settings as a plain argument (the old design -// read from `ServerSettingsService` internally). The test-only -// `ServerSettingsService` below is still kept because other dependencies in -// the layer graph reach for it — but the routing values the assertions -// probe (serverUrl, serverPassword) must be threaded directly through the -// decoded `OpenCodeSettings`. -const openCodeAdapterTestSettings = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", -}); + const deltas = observed.filter((next) => next.type === "content.delta"); + NodeAssert.deepEqual( + deltas.map((next) => next.payload), + [ + { streamKind: "reasoning_text", delta: "thinking" }, + { streamKind: "assistant_text", delta: "answer" }, + ], + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.started"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.updated"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "item.completed"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "turn.completed" && next.turnId === turn.turnId), + true, + ); -const OpenCodeAdapterTestLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings), -).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge( - ServerSettingsService.layerTest({ - providers: { - opencode: { - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }, - }, + yield* adapter.sendTurn({ threadId, input: "public dev alias" }); + NodeAssert.equal(attachCount(), 1); + yield* publish( + event("public-text", "session.text.delta", { + sessionID: "ses_test", + assistantMessageID: "msg_public", + ordinal: 0, + delta: "public", + }), + ); + yield* publish( + event("public-terminal", "session.execution.succeeded", { + sessionID: "ses_test", + }), + ); + yield* Deferred.await(publicDelta); + yield* Deferred.await(secondCompleted); + NodeAssert.equal( + observed.some((next) => next.type === "content.delta" && next.payload.delta === "public"), + true, + ); + NodeAssert.equal(observed.filter((next) => next.type === "turn.completed").length, 2); }), ), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), ); -beforeEach(() => { - runtimeMock.reset(); -}); - -const advanceTestClock = (ms: number) => - TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); - -it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { - it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-opencode"), - runtimeMode: "full-access", - }); - - NodeAssert.equal(session.provider, "opencode"); - NodeAssert.equal(session.threadId, "thread-opencode"); - NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - NodeAssert.deepEqual(runtimeMock.state.authHeaders, [ - `Basic ${btoa("opencode:secret-password")}`, - ]); - }), - ); - - it.effect("returns a durable resume cursor for a freshly created session", () => +it.effect("replies to permissions and forms and interrupts through native routes", () => + withHarness("nested", ({ adapter, calls, publish }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-cursor"); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", + const observed: Array = []; + const permissionOpened = yield* Deferred.make(); + const formOpened = yield* Deferred.make(); + const turnAborted = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => { + if (next.type === "request.opened") { + return Deferred.succeed(permissionOpened, undefined).pipe(Effect.ignore); + } + if (next.type === "user-input.requested") { + return Deferred.succeed(formOpened, undefined).pipe(Effect.ignore); + } + if (next.type === "turn.aborted") { + return Deferred.succeed(turnAborted, undefined).pipe(Effect.ignore); + } + return Effect.void; }); + yield* adapter.startSession({ threadId, runtimeMode: "approval-required" }); + yield* adapter.sendTurn({ threadId, input: "inspect" }); - // Without a persisted cursor, a session is created and its id is - // surfaced as a resume cursor so the upper layer can persist it. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "http://127.0.0.1:9999/session", + const promptCall = calls.find((call) => call.operation === "session.prompt"); + NodeAssert.deepEqual(promptCall?.body, { + prompt: { text: "inspect" }, + delivery: "steer", }); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("resumes the persisted OpenCode session instead of creating a new one", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-resume"); + yield* publish( + event("permission", "permission.asked", { + id: "per_test", + sessionID: "ses_test", + action: "read", + resources: [".env"], + }), + ); + yield* publish( + event("form", "form.created", { + form: { + id: "frm_test", + sessionID: "ses_test", + title: "Choose", + fields: [ + { + key: "choice", + type: "string", + title: "Choice", + description: "Pick one", + options: [{ value: "one", label: "One" }], + }, + ], + }, + }), + ); + yield* Deferred.await(permissionOpened); + yield* Deferred.await(formOpened); - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), + yield* adapter.respondToRequest( threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, - }); - - // The adapter validates the persisted id with session.get and re-adopts - // it — no new session is minted (issue #3604). - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_persisted"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_persisted", + ApprovalRequestId.make("per_test"), + "acceptForSession", + ); + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("frm_test"), { + choice: "one", }); - // Resume re-asserts the permission ruleset for the current runtimeMode. - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_persisted"); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); + yield* adapter.interruptTurn(threadId); + yield* Deferred.await(turnAborted); - yield* adapter.stopSession(threadId); + NodeAssert.deepEqual( + calls.find((call) => call.operation === "permission.reply"), + { + method: "POST", + path: "/api/session/ses_test/permission/per_test/reply", + operation: "permission.reply", + body: { reply: "always" }, + }, + ); + NodeAssert.deepEqual( + calls.find((call) => call.operation === "form.reply"), + { + method: "POST", + path: "/api/session/ses_test/form/frm_test/reply", + operation: "form.reply", + body: { answer: { choice: "one" } }, + }, + ); + NodeAssert.equal( + calls.some( + (call) => + call.operation === "session.interrupt" && + call.path === "/api/session/ses_test/interrupt", + ), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "request.opened"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "user-input.requested"), + true, + ); + NodeAssert.equal( + observed.some((next) => next.type === "turn.aborted"), + true, + ); }), - ); + ), +); - it.effect("sends follow-up turns to the resumed session id", () => +it.effect("resumes existing sessions and detaches without stop or delete requests", () => + withHarness("flat", ({ adapter, calls }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-resume-turn"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), threadId, runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_persisted" }, + resumeCursor: { schemaVersion: 1, sessionId: "ses_existing" }, + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "anthropic/claude-sonnet", + [{ id: "variant", value: "low" }], + ), }); - - const result = yield* adapter.sendTurn({ + NodeAssert.equal( + calls.some((call) => call.method === "GET" && call.path === "/api/session/ses_existing"), + true, + ); + yield* adapter.sendTurn({ threadId, - input: "continue where we left off", + input: "change effort", modelSelection: createModelSelection( ProviderInstanceId.make("opencode"), - "anthropic/sonnet", + "anthropic/claude-sonnet", + [{ id: "variant", value: "high" }], ), }); - - // The prompt targets the resumed id, and the turn re-surfaces the cursor. NodeAssert.deepEqual( - (runtimeMock.state.promptCalls[0] as { sessionID: string }).sessionID, - "ses_persisted", + calls.findLast((call) => call.operation === "session.switchModel")?.body, + { + model: { + providerID: "anthropic", + id: "claude-sonnet", + variant: "high", + }, + }, ); - NodeAssert.deepEqual(result.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_persisted", - }); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("falls back to a fresh session when the persisted session is gone", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-stale"); - runtimeMock.state.missingSessionIds.add("ses_stale"); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, + yield* adapter.startSession({ + threadId: ThreadId.make("thread-opencode2-second"), runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_stale" }, }); - - // get probed the stale id, found nothing, then created a new session and - // emitted a fresh cursor rather than wedging the thread. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_stale"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "http://127.0.0.1:9999/session", + yield* adapter.sendTurn({ + threadId: ThreadId.make("thread-opencode2-second"), + input: "active", }); + yield* adapter.stopAll(); - yield* adapter.stopSession(threadId); + NodeAssert.equal( + calls.some((call) => call.method === "DELETE"), + false, + ); + NodeAssert.equal( + calls.some((call) => call.path.includes("/service/stop")), + false, + ); + NodeAssert.equal(calls.filter((call) => call.operation === "session.interrupt").length, 2); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); }), - ); + ), +); - it.effect("ignores a malformed or wrong-version resume cursor", () => +it.effect("starts a fresh native session when a stored resume cursor no longer exists", () => + withHarness("flat", ({ adapter, calls, missNextRequest }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-badcursor"); - + missNextRequest("session.get"); const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), threadId, runtimeMode: "full-access", - resumeCursor: { schemaVersion: 99, sessionId: "ses_persisted" }, - }); - - // A foreign/stale-shaped cursor is treated as "no resume": never probed, - // a fresh session is created. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, []); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, ["http://127.0.0.1:9999"]); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "http://127.0.0.1:9999/session", + resumeCursor: { schemaVersion: 1, sessionId: "ses_from_v1" }, }); - yield* adapter.stopSession(threadId); + NodeAssert.deepEqual( + calls + .filter((call) => call.operation === "session.get" || call.operation === "session.create") + .map((call) => [call.method, call.path]), + [ + ["GET", "/api/session/ses_from_v1"], + ["POST", "/api/session"], + ], + ); + NodeAssert.deepEqual(session.resumeCursor, { schemaVersion: 1, sessionId: "ses_test" }); }), - ); + ), +); - it.effect("surfaces a non-not-found resume probe error instead of silently starting fresh", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-transient"); - // session.get returns a 500 (not a 404) for this id. - runtimeMock.state.transientErrorSessionIds.add("ses_transient"); +it.effect("does not attach when OpenCode 2 is disabled", () => + withHarness( + "flat", + ({ adapter, attachCount }) => + Effect.gen(function* () { + const result = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit); + NodeAssert.equal(Exit.isFailure(result), true); + NodeAssert.equal(attachCount(), 0); + }), + false, + ), +); - const exit = yield* Effect.exit( - adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_transient" }, +it.effect("retries after the initial event handshake fails", () => + withHarness( + "flat", + ({ adapter, attachCount }) => + Effect.gen(function* () { + const first = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.exit); + NodeAssert.equal(Exit.isFailure(first), true); + + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + NodeAssert.equal(attachCount(), 2); + }), + true, + true, + ), +); + +it.effect("reconnects after the native event stream fails", () => + withHarness("flat", ({ adapter, attachCount, failEvents }) => + Effect.gen(function* () { + const runtimeError = yield* Deferred.make(); + const secondTurnStarted = yield* Deferred.make(); + const observed: Array = []; + let startedTurns = 0; + yield* collectEvents(adapter, observed, (next) => + Effect.gen(function* () { + if (next.type === "runtime.error") { + yield* Deferred.succeed(runtimeError, undefined).pipe(Effect.ignore); + } + if (next.type === "turn.started") { + startedTurns += 1; + if (startedTurns === 2) { + yield* Deferred.succeed(secondTurnStarted, undefined).pipe(Effect.ignore); + } + } + }), + ); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "before disconnect" }); + yield* failEvents( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "connection-ended", }), ); + yield* Deferred.await(runtimeError); + NodeAssert.equal((yield* adapter.listSessions())[0]?.activeTurnId, undefined); - // A transient/transport/auth failure must propagate — NOT be masked as a - // brand-new empty session (the #3604 class of silent context loss). - NodeAssert.equal(Exit.isFailure(exit), true); - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_transient"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + yield* adapter.sendTurn({ threadId, input: "reconnect" }); + yield* Deferred.await(secondTurnStarted); + NodeAssert.equal(attachCount(), 2); + NodeAssert.equal(observed.filter((event) => event.type === "turn.started").length, 2); }), - ); + ), +); - it.effect("re-applies the current runtimeMode permissions when resuming", () => +it.effect("restores the regular agent after a plan turn", () => + withHarness("flat", ({ adapter, calls }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-perms"); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - // A different runtimeMode than the original create — resume must not - // leave the upstream session on stale permissions. - runtimeMode: "approval-required", + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, - resumeCursor: { schemaVersion: 1, sessionId: "ses_perms" }, + input: "plan this", + interactionMode: "plan", }); + yield* adapter.sendTurn({ threadId, input: "build this" }); - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_perms"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_perms"); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.permission != null, true); - - yield* adapter.stopSession(threadId); + NodeAssert.deepEqual( + calls.filter((call) => call.operation === "session.switchAgent").map((call) => call.body), + [{ agent: "plan" }, { agent: "build" }], + ); }), - ); - - it.effect( - "forks the resumed session into the requested directory instead of losing context", - () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-cwd"); - // The persisted session still exists but was created in another working dir - // (e.g. the thread moved from the project root into a git worktree). - runtimeMock.state.sessionDirectoryById.set("ses_otherdir", "/some/other/worktree"); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_otherdir" }, - }); - - // A cwd change must not mint an empty session: the adapter forks the - // persisted session into the requested cwd, carrying history forward. - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_otherdir"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.equal(runtimeMock.state.forkCalls.length, 1); - NodeAssert.equal(runtimeMock.state.forkCalls[0]?.sessionID, "ses_otherdir"); - NodeAssert.equal(typeof runtimeMock.state.forkCalls[0]?.directory, "string"); - // Permission ruleset re-asserted on the fork for the current runtimeMode. - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls.length, 1); - NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_otherdir_fork"); - // Durable cursor now points at the history-complete fork in the new directory. - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_otherdir_fork", - }); + ), +); - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("reuses the resumed session when the stored directory differs only lexically", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-samedir"); - // Same working tree, different spelling (trailing slash) — must reuse, - // not fork. - runtimeMock.state.sessionDirectoryById.set("ses_samedir", `${process.cwd()}/`); - - const session = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - resumeCursor: { schemaVersion: 1, sessionId: "ses_samedir" }, - }); - - NodeAssert.deepEqual(runtimeMock.state.sessionGetIds, ["ses_samedir"]); - NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); - NodeAssert.deepEqual(runtimeMock.state.forkCalls, []); - NodeAssert.deepEqual(session.resumeCursor, { - schemaVersion: 1, - sessionId: "ses_samedir", - }); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("fails sendTurn for missing sessions through the typed error channel", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const result = yield* adapter - .sendTurn({ - threadId: asThreadId("thread-opencode-missing-send"), - input: "hello", - attachments: [], - }) - .pipe(Effect.result); - - NodeAssert.equal(result._tag, "Failure"); - NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - NodeAssert.equal(result.failure.provider, "opencode"); - NodeAssert.equal(result.failure.threadId, "thread-opencode-missing-send"); - }), - ); - - it.effect("fails stopSession for missing sessions through the typed error channel", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const result = yield* adapter - .stopSession(asThreadId("thread-opencode-missing-stop")) - .pipe(Effect.result); - - NodeAssert.equal(result._tag, "Failure"); - NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); - NodeAssert.equal(result.failure.provider, "opencode"); - NodeAssert.equal(result.failure.threadId, "thread-opencode-missing-stop"); - }), - ); - - it.effect("stops a configured-server session without trying to own server lifecycle", () => +it.effect("moves a resumed provider session to its latest T3 thread", () => + withHarness("flat", ({ adapter }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; + const firstThread = ThreadId.make("thread-opencode2-first"); + const secondThread = ThreadId.make("thread-opencode2-second"); + const resumeCursor = { schemaVersion: 1, sessionId: "ses_shared" } as const; yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-opencode"), + threadId: firstThread, runtimeMode: "full-access", + resumeCursor, }); - - yield* adapter.stopSession(asThreadId("thread-opencode")); - - NodeAssert.deepEqual(runtimeMock.state.startCalls, []); - NodeAssert.deepEqual( - runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), - true, - ); - }), - ); - - it.effect("emits one session.exited event when stopping a session", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-stop-event"); - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(3), - Stream.runCollect, - Effect.forkChild, - ); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, + threadId: secondThread, runtimeMode: "full-access", + resumeCursor, }); - yield* adapter.stopSession(threadId); - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); NodeAssert.deepEqual( - events.map((event) => event.type), - ["session.started", "thread.started", "session.exited"], - ); - }), - ); - - it.effect("clears session state even when cleanup finalizers throw", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-stop-all-a"), - runtimeMode: "full-access", - }); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-stop-all-b"), - runtimeMode: "full-access", - }); - - runtimeMock.state.closeError = new Error("close failed"); - // `stopAll` relies on `stopOpenCodeContext`, which is typed as - // never-failing. A throwing finalizer surfaces as a defect — `Effect.exit` - // captures it so the assertions can still run. The key invariant we're - // validating is "the sessions map and close-call probes reflect cleanup - // attempts regardless of finalizer outcome". - yield* Effect.exit(adapter.stopAll()); - const sessions = yield* adapter.listSessions(); - - NodeAssert.deepEqual(runtimeMock.state.closeCalls, [ - "http://127.0.0.1:9999", - "http://127.0.0.1:9999", - ]); - NodeAssert.deepEqual(sessions, []); - }), - ); - - it.effect("completes streamEvents when the adapter scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make("sequential"); - let scopeClosed = false; - - try { - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - const context = yield* Layer.buildWithScope(adapterLayer, scope); - const adapter = yield* Effect.service(OpenCodeAdapter).pipe(Effect.provide(context)); - const eventsFiber = yield* adapter.streamEvents.pipe(Stream.runCollect, Effect.forkChild); - - yield* Scope.close(scope, Exit.void); - scopeClosed = true; - - const exit = yield* Fiber.await(eventsFiber).pipe(Effect.timeout("1 second")); - NodeAssert.equal(Exit.hasInterrupts(exit), true); - } finally { - if (!scopeClosed) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - } - }), - ); - - it.effect("rolls back session state when sendTurn fails before OpenCode accepts the prompt", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-send-turn-failure"), - runtimeMode: "full-access", - }); - - runtimeMock.state.promptAsyncError = new Error("prompt failed"); - const error = yield* adapter - .sendTurn({ - threadId: asThreadId("thread-send-turn-failure"), - input: "Fix it", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }) - .pipe(Effect.flip); - const sessions = yield* adapter.listSessions(); - - NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); - if (error._tag !== "ProviderAdapterRequestError") { - throw new Error("Unexpected error type"); - } - NodeAssert.equal(error.detail, "prompt failed"); - NodeAssert.equal( - error.message, - "Provider adapter request failed (opencode) for session.promptAsync: prompt failed", + (yield* adapter.listSessions()).map((session) => session.threadId), + [secondThread], ); - NodeAssert.equal(sessions.length, 1); - NodeAssert.equal(sessions[0]?.status, "ready"); - NodeAssert.equal(sessions[0]?.activeTurnId, undefined); - NodeAssert.equal(sessions[0]?.lastError, "prompt failed"); - }), - ); - - it.effect("steers a running turn instead of opening a new one on mid-turn sendTurn", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-steer"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "run 5 commands", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }); - - // Steer: OpenCode queues the prompt into the busy session, so the - // active turn id is reused instead of opening a new turn. - const steeredTurn = yield* adapter.sendTurn({ - threadId, - input: "actually run 15", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }); - NodeAssert.equal(String(steeredTurn.turnId), String(turn.turnId)); - - const sessions = yield* adapter.listSessions(); - const session = sessions.find((entry) => entry.threadId === threadId); - NodeAssert.equal(session?.status, "running"); - NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); - NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + yield* adapter.stopSession(secondThread); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); }), - ); + ), +); - it.effect("keeps the running turn when a steer prompt fails", () => +it.effect("keeps the existing session when resumed setup fails", () => + withHarness("flat", ({ adapter, failNextRequest }) => Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-steer-failure"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "run 5 commands", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }); - - runtimeMock.state.promptAsyncError = new Error("steer failed"); - const error = yield* adapter - .sendTurn({ - threadId, - input: "actually run 15", - modelSelection: { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", - }, - }) - .pipe(Effect.flip); - - // The original turn keeps running — only the steer prompt failed. - NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); - const sessions = yield* adapter.listSessions(); - const session = sessions.find((entry) => entry.threadId === threadId); - NodeAssert.equal(session?.status, "running"); - NodeAssert.equal(String(session?.activeTurnId), String(turn.turnId)); - }), - ); - - it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-custom-instance"), - runtimeMode: "full-access", - }); - - yield* adapter.sendTurn({ - threadId: asThreadId("thread-custom-instance"), - input: "Fix it", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", - [ - { id: "agent", value: "github-copilot" }, - { id: "variant", value: "high" }, - ], - ), - }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - }, - agent: "github-copilot", - variant: "high", - parts: [{ type: "text", text: "Fix it" }], - }); - }).pipe(Effect.provide(adapterLayer)); - }); - - it.effect("uses the bound custom instance id for fallback sendTurn model selection", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-custom-instance-fallback-model"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - modelSelection: createModelSelection( - ProviderInstanceId.make("opencode_zen"), - "anthropic/claude-sonnet-4-5", - ), - }); - - yield* adapter.sendTurn({ - threadId, - input: "Fix it", - }); - - NodeAssert.deepEqual(runtimeMock.state.promptCalls.at(-1), { - sessionID: "http://127.0.0.1:9999/session", - model: { - providerID: "anthropic", - modelID: "claude-sonnet-4-5", - }, - parts: [{ type: "text", text: "Fix it" }], - }); - }).pipe(Effect.provide(adapterLayer)); - }); - - it.effect("rejects sendTurn model selections for another instance id", () => { - const instanceId = ProviderInstanceId.make("opencode_zen"); - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-custom-instance-wrong-selection"); + const firstThread = ThreadId.make("thread-opencode2-first"); + const secondThread = ThreadId.make("thread-opencode2-second"); + const resumeCursor = { schemaVersion: 1, sessionId: "ses_shared" } as const; yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, + threadId: firstThread, runtimeMode: "full-access", + resumeCursor, }); - - const error = yield* adapter - .sendTurn({ - threadId, - input: "Fix it", + failNextRequest("session.switchModel"); + const result = yield* adapter + .startSession({ + threadId: secondThread, + runtimeMode: "full-access", + resumeCursor, modelSelection: createModelSelection( ProviderInstanceId.make("opencode"), - "anthropic/claude-sonnet-4-5", + "anthropic/claude-sonnet", ), }) - .pipe(Effect.flip); - - NodeAssert.equal(error._tag, "ProviderAdapterValidationError"); - if (error._tag !== "ProviderAdapterValidationError") { - throw new Error("Unexpected error type"); - } - NodeAssert.equal( - error.issue, - "OpenCode model selection is bound to instance 'opencode', expected 'opencode_zen'.", - ); - NodeAssert.deepEqual(runtimeMock.state.promptCalls, []); - }).pipe(Effect.provide(adapterLayer)); - }); - - it.effect("reverts the full thread when rollback removes every assistant turn", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-rollback-all"); - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - runtimeMock.state.messages = [ - { - info: { id: "assistant-1", role: "assistant" }, - parts: [], - }, - { - info: { id: "assistant-2", role: "assistant" }, - parts: [], - }, - ]; + .pipe(Effect.exit); - const snapshot = yield* adapter.rollbackThread(threadId, 2); - - NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ - { sessionID: "http://127.0.0.1:9999/session" }, - ]); - NodeAssert.deepEqual(snapshot.turns, []); - }), - ); - - it.effect("classifies a confirmed not-found across the shapes the SDK/runtime can produce", () => - Effect.sync(() => { - // The real production shape: runOpenCodeSdk wraps the thrown Error - // (cause = { body, status }) under OpenCodeRuntimeError. - const wrappedError = new Error("Session not found: ses_x", { - cause: { body: { name: "NotFoundError" }, status: 404 }, - }); - NodeAssert.equal( - isOpenCodeNotFound({ - _tag: "OpenCodeRuntimeError", - operation: "session.get", - detail: "Session not found: ses_x", - cause: wrappedError, - }), - true, - ); - - // 404 expressed only via response.status (the bot's flagged shape). - NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 404 } } }), true); - // 404 via a bare numeric status / statusCode. - NodeAssert.equal(isOpenCodeNotFound(new Error("x", { cause: { status: 404 } })), true); - NodeAssert.equal(isOpenCodeNotFound({ statusCode: 404 }), true); - // OpenCode NotFoundError body name with no status. - NodeAssert.equal(isOpenCodeNotFound({ body: { name: "NotFoundError" } }), true); - - // NOT a miss: only structured signals count, never free text. A non-404 - // error whose message/detail merely contains "not found" must propagate, - // not be misread as a missing session and silently start fresh. - NodeAssert.equal( - isOpenCodeNotFound(new Error("upstream provider not found", { cause: { status: 500 } })), - false, - ); - NodeAssert.equal(isOpenCodeNotFound({ detail: "status=500 body={...not found...}" }), false); - // An explicit non-404 status seals its subtree: a 500 whose serialized - // body echoes a NotFoundError name — or that is itself named - // *NotFound* — is a real failure, never a miss. - NodeAssert.equal(isOpenCodeNotFound({ status: 500, body: { name: "NotFoundError" } }), false); - NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError", status: 500 }), false); - // A "NotFound"-flavored name that isn't OpenCode's exact `NotFoundError` - // is not a confirmed miss even without a sealing status. - NodeAssert.equal(isOpenCodeNotFound({ name: "UpstreamNotFoundError" }), false); - NodeAssert.equal(isOpenCodeNotFound({ cause: { name: "ProviderNotFoundError" } }), false); - NodeAssert.equal( - isOpenCodeNotFound( - new Error("x", { cause: { status: 502, body: { name: "NotFoundError" } } }), - ), - false, - ); - // Other transient/auth/network failures must propagate too. - NodeAssert.equal(isOpenCodeNotFound(new Error("boom", { cause: { status: 500 } })), false); - NodeAssert.equal(isOpenCodeNotFound({ cause: { response: { status: 401 } } }), false); - NodeAssert.equal(isOpenCodeNotFound(new Error("network error (no response)")), false); - NodeAssert.equal(isOpenCodeNotFound(undefined), false); - }), - ); - - it.effect("treats lexically or physically identical directories as the same", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); - - // Lexical-only differences (trailing slash, dot segments) short-circuit - // without touching the filesystem — the paths need not exist. - NodeAssert.equal(yield* sameDirectory("/repo/project/", "/repo/project"), true); - NodeAssert.equal(yield* sameDirectory("/repo/nested/../project", "/repo/project"), true); - // Nonexistent paths degrade to the lexical comparison instead of failing. - NodeAssert.equal(yield* sameDirectory("/repo/project", "/repo/other"), false); - - // A symlinked cwd (the macOS `/tmp` → `/private/tmp` shape) resolves to - // the directory it points at, so the two spellings compare equal. - const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode-dir-" }); - const real = path.join(base, "real"); - const link = path.join(base, "link"); - yield* fileSystem.makeDirectory(real); - yield* fileSystem.symlink(real, link); - NodeAssert.equal(yield* sameDirectory(link, real), true); - NodeAssert.equal(yield* sameDirectory(link, path.join(base, "other")), false); - }).pipe(Effect.scoped), - ); - - it.effect("appends raw assistant text deltas and reconciles part update snapshots", () => - Effect.sync(() => { - const firstUpdate = mergeOpenCodeAssistantText(undefined, "Hello"); - const overlapDelta = appendOpenCodeAssistantTextDelta(firstUpdate.latestText, "lo world"); - const secondUpdate = mergeOpenCodeAssistantText(overlapDelta.nextText, "Hellolo world"); - - NodeAssert.deepEqual( - [firstUpdate.deltaToEmit, overlapDelta.deltaToEmit, secondUpdate.deltaToEmit], - ["Hello", "lo world", ""], - ); - NodeAssert.equal(secondUpdate.latestText, "Hellolo world"); - }), - ); - - it.effect("does not strip coincidental prefix overlap from OpenCode part deltas", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-raw-delta"); - const part = { - id: "part-raw-delta", - sessionID: "http://127.0.0.1:9999/session", - messageID: "msg-raw-delta", - type: "text", - text: "A B", - time: { start: 1 }, - }; - runtimeMock.state.subscribedEvents = [ - { - type: "message.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - info: { - id: "msg-raw-delta", - role: "assistant", - }, - }, - }, - { - type: "message.part.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - part, - time: 1, - }, - }, - { - type: "message.part.delta", - properties: { - sessionID: "http://127.0.0.1:9999/session", - messageID: "msg-raw-delta", - partID: "part-raw-delta", - field: "text", - delta: "Bonus", - }, - }, - { - type: "message.part.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - part: { - ...part, - text: "A BBonus", - time: { start: 1, end: 2 }, - }, - time: 2, - }, - }, - ]; - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(5), - Stream.runCollect, - Effect.forkChild, - ); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - const deltas = events.filter((event) => event.type === "content.delta"); + NodeAssert.equal(Exit.isFailure(result), true); NodeAssert.deepEqual( - deltas.map((event) => (event.type === "content.delta" ? event.payload.delta : "")), - ["A B", "Bonus"], - ); - NodeAssert.equal(events.at(-1)?.type, "item.completed"); - const completed = events.at(-1); - if (completed?.type === "item.completed") { - NodeAssert.equal(completed.payload.detail, "A BBonus"); - } - }), - ); - - it.effect("lets OpenCode own session title generation and emits title metadata updates", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-title-sync"); - runtimeMock.state.subscribedEvents = [ - { - type: "session.updated", - properties: { - info: { - id: "http://127.0.0.1:9999/session", - title: "Investigate OpenCode title sync", - }, - }, - }, - ]; - - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(3), - Stream.runCollect, - Effect.forkChild, - ); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); - NodeAssert.equal("title" in (runtimeMock.state.sessionCreateInputs[0] ?? {}), false); - - const metadataUpdated = events.find((event) => event.type === "thread.metadata.updated"); - NodeAssert.ok(metadataUpdated); - if (metadataUpdated.type === "thread.metadata.updated") { - NodeAssert.equal(metadataUpdated.payload.name, "Investigate OpenCode title sync"); - } - }), - ); - - it.effect("passes the thread title to session.create when provided", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-title-provided"); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - title: "Investigate reconnect failures", - }); - - NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); - NodeAssert.equal( - runtimeMock.state.sessionCreateInputs[0]?.title, - "Investigate reconnect failures", + (yield* adapter.listSessions()).map((session) => session.threadId), + [firstThread], ); }), - ); - - it.effect("does not mirror OpenCode's default placeholder session titles", () => - Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const threadId = asThreadId("thread-opencode-placeholder-title"); - runtimeMock.state.subscribedEvents = [ - { - type: "session.updated", - properties: { - info: { - id: "http://127.0.0.1:9999/session", - title: "New session - 2026-08-09T10:20:30.456Z", - }, - }, - }, - { - type: "session.updated", - properties: { - info: { - id: "http://127.0.0.1:9999/session", - title: "Investigate reconnect failures", - }, - }, - }, - ]; - - const eventsFiber = yield* adapter.streamEvents.pipe( - Stream.filter((event) => event.threadId === threadId), - Stream.take(3), - Stream.runCollect, - Effect.forkChild, - ); - - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId, - runtimeMode: "full-access", - }); - - const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); - const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated"); - NodeAssert.equal(metadataUpdated.length, 1); - if (metadataUpdated[0]?.type === "thread.metadata.updated") { - NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures"); - } - }), - ); - - it.effect("writes provider-native observability records using the session thread id", () => - Effect.gen(function* () { - const nativeEvents: Array<{ - readonly event?: { - readonly provider?: string; - readonly threadId?: string; - readonly providerThreadId?: string; - readonly type?: string; - }; - }> = []; - const nativeThreadIds: Array = []; - runtimeMock.state.subscribedEvents = [ - { - type: "message.updated", - properties: { - info: { - id: "msg-missing-session", - role: "assistant", - }, - }, - }, - { - type: "message.updated", - properties: { - sessionID: "http://127.0.0.1:9999/other-session", - info: { - id: "msg-other-session", - role: "assistant", - }, - }, - }, - { - type: "message.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - info: { - id: "msg-native-log", - role: "assistant", - }, - }, - }, - ]; - - const nativeEventLogger = { - filePath: "memory://opencode-native-events", - write: (event: unknown, threadId: ThreadId | null) => { - nativeEvents.push(event as (typeof nativeEvents)[number]); - nativeThreadIds.push(threadId ?? null); - return Effect.void; - }, - close: () => Effect.void, - }; - - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { - nativeEventLogger, - }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge( - ServerSettingsService.layerTest({ - providers: { - opencode: { - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }, - }, - }), - ), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - const session = yield* Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - const started = yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-native-log"), - runtimeMode: "full-access", - }); - yield* advanceTestClock(10); - return started; - }).pipe(Effect.provide(adapterLayer)); - - NodeAssert.equal(session.threadId, "thread-native-log"); - NodeAssert.equal(nativeEvents.length, 1); - NodeAssert.equal( - nativeEvents.some((record) => record.event?.provider === "opencode"), - true, - ); - NodeAssert.equal( - nativeEvents.some( - (record) => record.event?.providerThreadId === "http://127.0.0.1:9999/session", - ), - true, - ); - NodeAssert.equal( - nativeEvents.some((record) => record.event?.threadId === "thread-native-log"), - true, - ); - NodeAssert.equal( - nativeEvents.some((record) => record.event?.type === "message.updated"), - true, - ); - NodeAssert.equal( - nativeThreadIds.every((threadId) => threadId === "thread-native-log"), - true, - ); - }), - ); - - it.effect("keeps the event pump alive when native event logging fails", () => - Effect.gen(function* () { - runtimeMock.state.subscribedEvents = [ - { - type: "message.updated", - properties: { - sessionID: "http://127.0.0.1:9999/session", - info: { - id: "msg-native-log-failure", - role: "assistant", - }, - }, - }, - ]; - - const nativeEventLogger = { - filePath: "memory://opencode-native-events", - write: () => Effect.die(new Error("native log write failed")), - close: () => Effect.void, - }; - - const adapterLayer = Layer.effect( - OpenCodeAdapter, - makeOpenCodeAdapter(openCodeAdapterTestSettings, { - nativeEventLogger, - }), - ).pipe( - Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge( - ServerSettingsService.layerTest({ - providers: { - opencode: { - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }, - }, - }), - ), - Layer.provideMerge(providerSessionDirectoryTestLayer), - Layer.provideMerge(NodeServices.layer), - ); - - // Capture closeCalls *inside* the provided layer scope: the adapter's - // layer finalizer now tears down any live sessions when the layer - // closes (which is exactly what we want for leak prevention), so - // inspecting closeCalls after `Effect.provide` completes would observe - // the teardown — not the behavior under test. We care that the event - // pump kept the session alive while logging was failing. - const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () { - const adapter = yield* OpenCodeAdapter; - yield* adapter.startSession({ - provider: ProviderDriverKind.make("opencode"), - threadId: asThreadId("thread-native-log-failure"), - runtimeMode: "full-access", - }); - yield* advanceTestClock(10); - return { - sessions: yield* adapter.listSessions(), - closeCallsDuringRun: [...runtimeMock.state.closeCalls], - }; - }).pipe(Effect.provide(adapterLayer)); - - NodeAssert.equal(sessions.length, 1); - NodeAssert.equal(sessions[0]?.threadId, "thread-native-log-failure"); - NodeAssert.deepEqual(closeCallsDuringRun, []); - }), - ); -}); + ), +); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..5cc9309c5901 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeURL from "node:url"; + import { EventId, type OpenCodeSettings, @@ -12,1574 +15,1223 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Crypto from "effect/Crypto"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; -import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; -import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; -import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; -import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, - ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { - buildOpenCodePermissionRules, - OpenCodeRuntime, - OpenCodeRuntimeError, - openCodeQuestionId, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - runOpenCodeSdk, - toOpenCodeFileParts, - toOpenCodePermissionReply, - toOpenCodeQuestionAnswers, - type OpenCodeServerConnection, -} from "../opencodeRuntime.ts"; -import * as Option from "effect/Option"; +import type { ProviderAdapterShape, ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; +import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("opencode"); +const RESUME_VERSION = 1 as const; + +const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); +const UnknownRecordArraySchema = Schema.Array(UnknownRecordSchema); +const SessionResponseSchema = Schema.Struct({ + data: Schema.Struct({ + id: Schema.String, + }), +}); +const MessagesResponseSchema = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}); -/** - * Version tag stamped into the OpenCode resume cursor. Bump if the cursor - * shape changes so stale-shaped cursors written by older builds are ignored - * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). - */ -const OPENCODE_RESUME_VERSION = 1 as const; - -/** - * Decode a persisted resume cursor into the upstream `ses_…` id. Anything - * that isn't a current-version cursor with a non-empty id means "no resume" - * rather than an error. Re-adopting the session id IS the resume mechanism — - * OpenCode scopes a conversation's history by session id. - */ -function parseOpenCodeResume(raw: unknown): { readonly sessionId: string } | undefined { - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { - return undefined; - } - const record = raw as Record; - if (record.schemaVersion !== OPENCODE_RESUME_VERSION) { - return undefined; - } - if (typeof record.sessionId !== "string" || record.sessionId.trim().length === 0) { - return undefined; - } - return { sessionId: record.sessionId.trim() }; -} - -/** - * Whether an error definitively reports a missing session. Only a confirmed - * miss may silently start a fresh session; any other failure (the SDK client - * is `throwOnError: true`, so `session.get` rejects on every non-2xx) must - * propagate, or a transient blip resets a live thread to an empty one — the - * #3604 silent context loss. Decides on structured signals only, never free - * text: a numeric 404 or the exact `NotFoundError` name, found via a bounded walk - * over `cause`/`body`/`error`/`data`. An explicit non-404 status seals its - * subtree so a wrapped "NotFound" name can't reclassify a real failure. - * Exported for unit testing. - */ -export function isOpenCodeNotFound(cause: unknown): boolean { - const seen = new Set(); - const queue: Array = [cause]; - for (let steps = 0; queue.length > 0 && steps < 32; steps += 1) { - const node = queue.shift(); - if (node === null || typeof node !== "object" || seen.has(node)) { - continue; - } - seen.add(node); - const record = node as Record; - - const response = record.response; - const statuses = [ - record.status, - record.statusCode, - response !== null && typeof response === "object" - ? (response as { readonly status?: unknown }).status - : undefined, - ].filter((status): status is number => typeof status === "number"); - if (statuses.includes(404)) { - return true; - } - if (statuses.length > 0) { - continue; - } - - const name = record.name; - if (typeof name === "string" && name.toLowerCase() === "notfounderror") { - return true; - } +const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecordSchema); +const decodeUnknownRecordArray = Schema.decodeUnknownOption(UnknownRecordArraySchema); - for (const key of ["cause", "body", "error", "data"] as const) { - if (record[key] !== undefined) { - queue.push(record[key]); - } - } - } - return false; -} +type OpenCodeAdapterError = + | ProviderAdapterProcessError + | ProviderAdapterRequestError + | ProviderAdapterSessionNotFoundError + | ProviderAdapterValidationError; -/** - * Whether two directory spellings name the same location. Raw string - * equality misreads a trailing slash, `.`/`..` segment, or symlinked cwd - * (macOS `/tmp` → `/private/tmp`) as a cwd change, needlessly forking the - * session on every resume. Lexically equal paths short-circuit; otherwise - * both sides go through `realPath`, each falling back to its lexical form - * on failure (deleted directory, external-server path) — so the probe can - * only widen matches, never split them. Takes the services as arguments so - * adapter methods stay service-free. Exported for unit testing. - */ -export function isSameOpenCodeDirectory( - fileSystem: FileSystem.FileSystem, - path: Path.Path, - left: string, - right: string, -): Effect.Effect { - const lexicalLeft = path.resolve(left); - const lexicalRight = path.resolve(right); - if (lexicalLeft === lexicalRight) { - return Effect.succeed(true); - } - const canonicalize = (lexical: string) => - fileSystem.realPath(lexical).pipe(Effect.orElseSucceed(() => lexical)); - return Effect.zipWith( - canonicalize(lexicalLeft), - canonicalize(lexicalRight), - (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight, - ); -} +export type OpenCodeAdapterShape = ProviderAdapterShape; interface OpenCodeTurnSnapshot { readonly id: TurnId; readonly items: Array; } -type OpenCodeSubscribedEvent = - Awaited> extends { - readonly stream: AsyncIterable; - } - ? TEvent - : never; - -function trimText(value: string | undefined | null): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; +interface OpenCodeSessionContext { + session: ProviderSession; + readonly providerSessionId: string; + readonly pendingPermissions: Set; + readonly pendingForms: Set; + readonly toolNameByCallId: Map; + activeTurnId: TurnId | undefined; + currentModel: string | undefined; + currentVariant: string | undefined; + currentAgent: string | undefined; + regularAgent: string; } -function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined { - const properties = "properties" in event ? event.properties : undefined; - if (!properties || typeof properties !== "object") { - return undefined; - } - - const sessionID = (properties as { readonly sessionID?: unknown }).sessionID; - const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined; - if (sessionIDFromProperties) { - return sessionIDFromProperties; - } - - const info = (properties as { readonly info?: { readonly id?: unknown } }).info; - return info && typeof info.id === "string" ? info.id : undefined; +export interface OpenCodeAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogger?: EventNdjsonLogger; } -function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined { - if (event.type !== "session.updated") { - return undefined; - } +interface ModelRef { + readonly providerID: string; + readonly id: string; + readonly variant?: string; +} - const title = trimText(event.properties.info.title); - // OpenCode mints a placeholder title at session.create when no title was - // provided, and re-emits it on every `session.updated`. Mirroring it would - // overwrite the thread's real title (openCodeEventSessionTitle feeds the - // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated - // placeholders so the thread isn't locked onto them. - if (!title || isOpenCodeDefaultTitle(title)) { - return undefined; - } +interface EventBaseInput { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: string; + readonly requestId?: string; + readonly raw?: OpenCodeRuntime.OpenCodeEvent; +} - return title; +interface OpenCodeActiveConnection { + readonly connection: OpenCodeRuntime.OpenCodeConnection; + readonly interrupt: Effect.Effect; } -const OPENCODE_DEFAULT_TITLE_PATTERN = - /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +function recordFromUnknown(value: unknown): Readonly> | undefined { + return Option.getOrUndefined(decodeUnknownRecord(value)); +} -function isOpenCodeDefaultTitle(title: string): boolean { - return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); +function recordsFromUnknown(value: unknown): ReadonlyArray>> { + return Option.getOrElse(decodeUnknownRecordArray(value), () => []); } -interface OpenCodeSessionContext { - session: ProviderSession; - readonly client: OpencodeClient; - readonly server: OpenCodeServerConnection; - readonly directory: string; - readonly openCodeSessionId: string; - readonly pendingPermissions: Map; - readonly pendingQuestions: Map; - readonly messageRoleById: Map; - readonly partById: Map; - readonly emittedTextByPartId: Map; - readonly completedAssistantPartIds: Set; - readonly turns: Array; - activeTurnId: TurnId | undefined; - activeAgent: string | undefined; - activeVariant: string | undefined; - /** - * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. - * The session lifecycle is owned by `sessionScope`; this Ref exists only - * so concurrent callers can race the transition safely via `getAndSet`. - */ - readonly stopped: Ref.Ref; - /** - * Sole lifecycle handle for the session. Closing this scope: - * - aborts the `AbortController` registered as a finalizer - * (cancels the in-flight `event.subscribe` fetch), - * - interrupts the event-pump and server-exit fibers forked - * via `Effect.forkIn(sessionScope)`, - * - tears down the OpenCode server process for scope-owned servers. - */ - readonly sessionScope: Scope.Closeable; +function stringField( + record: Readonly> | undefined, + ...keys: ReadonlyArray +): string | undefined { + for (const key of keys) { + const value = record?.[key]; + if (typeof value === "string" && value.trim().length > 0) return value; + } + return undefined; } -export interface OpenCodeAdapterLiveOptions { - readonly instanceId?: ProviderInstanceId; - readonly environment?: NodeJS.ProcessEnv; - readonly nativeEventLogPath?: string; - readonly nativeEventLogger?: EventNdjsonLogger; +function eventData( + event: OpenCodeRuntime.OpenCodeEvent, +): Readonly> | undefined { + const outer = recordFromUnknown(event.data); + return recordFromUnknown(outer?.properties) ?? outer; } -const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +function eventSessionId(event: OpenCodeRuntime.OpenCodeEvent): string | undefined { + const data = eventData(event); + const form = recordFromUnknown(data?.form) ?? recordFromUnknown(data?.info); + return ( + stringField(data, "sessionID", "sessionId") ?? + stringField(form, "sessionID", "sessionId") ?? + event.durable?.aggregateID + ); +} -/** - * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into - * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call - * sites pipe through this in `Effect.mapError` so they never build the error - * shape by hand. - */ -const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: cause.operation, - detail: cause.detail, - cause: cause.cause, - }); +function normalizeEventType(type: string): string { + const withoutVersion = type.replace(/\.\d+$/u, ""); + return withoutVersion.startsWith("session.next.") + ? `session.${withoutVersion.slice("session.next.".length)}` + : withoutVersion; +} -/** - * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The - * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}), - * in which case we preserve its `detail`; otherwise we fall back to - * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). - */ -const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), - cause, - }); +function parseResumeCursor(raw: unknown): string | undefined { + const record = recordFromUnknown(raw); + if (record?.schemaVersion !== RESUME_VERSION) return undefined; + return stringField(record, "sessionId"); +} -type EventBaseInput = { - readonly threadId: ThreadId; - readonly turnId?: TurnId | undefined; - readonly itemId?: string | undefined; - readonly requestId?: string | undefined; - readonly createdAt?: string | undefined; - readonly raw?: unknown; -}; +function parseModelRef(model: string, variant?: string): ModelRef | undefined { + const separator = model.indexOf("/"); + if (separator <= 0 || separator === model.length - 1) return undefined; + return { + providerID: model.slice(0, separator), + id: model.slice(separator + 1), + ...(variant ? { variant } : {}), + }; +} function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { const normalized = toolName.toLowerCase(); - if (normalized.includes("bash") || normalized.includes("command")) { - return "command_execution"; - } if ( - normalized.includes("edit") || - normalized.includes("write") || - normalized.includes("patch") || - normalized.includes("multiedit") + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("command") ) { - return "file_change"; - } - if (normalized.includes("web")) { - return "web_search"; - } - if (normalized.includes("mcp")) { - return "mcp_tool_call"; + return "command_execution"; } - if (normalized.includes("image")) { - return "image_view"; + if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("patch")) { + return "file_change"; } - if ( - normalized.includes("task") || - normalized.includes("agent") || - normalized.includes("subtask") - ) { + if (normalized.includes("web")) return "web_search"; + if (normalized.includes("mcp")) return "mcp_tool_call"; + if (normalized.includes("image")) return "image_view"; + if (normalized.includes("task") || normalized.includes("agent")) { return "collab_agent_tool_call"; } return "dynamic_tool_call"; } -function mapPermissionToRequestType( - permission: string, +function permissionRequestType( + action: string, ): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { - switch (permission) { + switch (action) { case "bash": + case "shell": return "command_execution_approval"; case "read": return "file_read_approval"; case "edit": + case "write": return "file_change_approval"; default: return "unknown"; } } -function mapPermissionDecision(reply: "once" | "always" | "reject"): string { +function permissionReply(decision: "accept" | "acceptForSession" | "decline" | "cancel") { + switch (decision) { + case "accept": + return "once" as const; + case "acceptForSession": + return "always" as const; + case "decline": + case "cancel": + return "reject" as const; + } +} + +function permissionDecision(reply: string): string { switch (reply) { case "once": return "accept"; case "always": return "acceptForSession"; - case "reject": default: return "decline"; } } -function resolveTurnSnapshot( - context: OpenCodeSessionContext, - turnId: TurnId, -): OpenCodeTurnSnapshot { - const existing = context.turns.find((turn) => turn.id === turnId); - if (existing) { - return existing; - } - - const created: OpenCodeTurnSnapshot = { id: turnId, items: [] }; - context.turns.push(created); - return created; -} - -function appendTurnItem( - context: OpenCodeSessionContext, - turnId: TurnId | undefined, - item: unknown, -): void { - if (!turnId) { - return; - } - resolveTurnSnapshot(context, turnId).items.push(item); +function formFromEvent( + event: OpenCodeRuntime.OpenCodeEvent, +): Readonly> | undefined { + const data = eventData(event); + return recordFromUnknown(data?.form) ?? recordFromUnknown(data?.info) ?? data; } -const ensureSessionContext = Effect.fn("ensureSessionContext")(function* ( - sessions: ReadonlyMap, - threadId: ThreadId, -) { - const session = sessions.get(threadId); - if (!session) { - return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); - } - if (yield* Ref.get(session.stopped)) { - return yield* new ProviderAdapterSessionClosedError({ - provider: PROVIDER, - threadId, +function formQuestions(form: Readonly>): ReadonlyArray { + return recordsFromUnknown(form.fields).map((field) => { + const id = stringField(field, "key", "id") ?? "answer"; + const title = stringField(field, "title", "label") ?? id; + const description = stringField(field, "description") ?? title; + const type = stringField(field, "type"); + const options = recordsFromUnknown(field.options).map((option) => { + const label = stringField(option, "label", "value") ?? "Option"; + return { + label, + description: stringField(option, "description") ?? label, + }; }); - } - return session; -}); - -function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray { - return request.questions.map((question, index) => ({ - id: openCodeQuestionId(index, question), - header: question.header, - question: question.question, - options: question.options.map((option) => ({ - label: option.label, - description: option.description, - })), - ...(question.multiple ? { multiSelect: true } : {}), - })); -} - -function resolveTextStreamKind(part: Part | undefined): "assistant_text" | "reasoning_text" { - return part?.type === "reasoning" ? "reasoning_text" : "assistant_text"; + if (type === "boolean" && options.length === 0) { + options.push({ label: "Yes", description: "Yes" }, { label: "No", description: "No" }); + } + return { + id, + header: title, + question: description, + options, + ...(type === "multiselect" ? { multiSelect: true } : {}), + }; + }); } -function textFromPart(part: Part): string | undefined { - switch (part.type) { - case "text": - case "reasoning": - return part.text; - default: - return undefined; - } +function contentItemId( + data: Readonly>, + kind: "text" | "reasoning", +): string { + const explicit = stringField(data, kind === "text" ? "textID" : "reasoningID", "id"); + if (explicit) return explicit; + const messageId = stringField(data, "assistantMessageID", "messageID") ?? "message"; + const ordinal = typeof data.ordinal === "number" ? data.ordinal : 0; + return `${messageId}-${kind}-${ordinal}`; } -function commonPrefixLength(left: string, right: string): number { - let index = 0; - while (index < left.length && index < right.length && left[index] === right[index]) { - index += 1; - } - return index; +function toolDetail(data: Readonly>): string | undefined { + const error = recordFromUnknown(data.error); + const errorMessage = stringField(error, "message", "detail"); + if (errorMessage) return errorMessage; + if (typeof data.error === "string" && data.error.trim().length > 0) return data.error; + const texts = recordsFromUnknown(data.content) + .map((item) => stringField(item, "text")) + .filter((value): value is string => value !== undefined); + if (texts.length > 0) return texts.join("\n"); + if (typeof data.result === "string" && data.result.trim().length > 0) return data.result; + return undefined; } -function resolveLatestAssistantText(previousText: string | undefined, nextText: string): string { - if (previousText && previousText.length > nextText.length && previousText.startsWith(nextText)) { - return previousText; - } - return nextText; +function toRequestError(method: string, cause: OpenCodeRuntime.OpenCodeRuntimeFailure) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `OpenCode 2 request failed during '${method}'.`, + cause, + }); } -export function mergeOpenCodeAssistantText( - previousText: string | undefined, - nextText: string, -): { - readonly latestText: string; - readonly deltaToEmit: string; -} { - const latestText = resolveLatestAssistantText(previousText, nextText); - return { - latestText, - deltaToEmit: latestText.slice(commonPrefixLength(previousText ?? "", latestText)), - }; +function toProcessError(threadId: ThreadId, cause: OpenCodeRuntime.OpenCodeRuntimeFailure) { + return new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: "OpenCode 2 could not attach the requested session.", + cause, + }); } -export function appendOpenCodeAssistantTextDelta( - previousText: string, - delta: string, -): { - readonly nextText: string; - readonly deltaToEmit: string; -} { - return { - nextText: previousText + delta, - deltaToEmit: delta, - }; +function encodedPathSegment(value: string): string { + return encodeURIComponent(value); } -const isoFromEpochMs = (value: number) => - DateTime.make(value).pipe( - Option.match({ - onNone: () => undefined, - onSome: DateTime.formatIso, - }), +function isMissingOpenCodeSession(cause: OpenCodeRuntime.OpenCodeRuntimeFailure): boolean { + return ( + OpenCodeRuntime.isOpenCodeRuntimeError(cause) && + cause.reason === "http-status" && + cause.status === 404 ); - -function messageRoleForPart( - context: OpenCodeSessionContext, - part: Pick, -): "assistant" | "user" | undefined { - const known = context.messageRoleById.get(part.messageID); - if (known) { - return known; - } - return part.type === "tool" ? "assistant" : undefined; -} - -function detailFromToolPart(part: Extract): string | undefined { - switch (part.state.status) { - case "completed": - return part.state.output; - case "error": - return part.state.error; - case "running": - return part.state.title; - default: - return undefined; - } -} - -function toolStateCreatedAt(part: Extract): string | undefined { - switch (part.state.status) { - case "running": - return isoFromEpochMs(part.state.time.start); - case "completed": - case "error": - return isoFromEpochMs(part.state.time.end); - default: - return undefined; - } -} - -function sessionErrorMessage(error: unknown): string { - if (!error || typeof error !== "object") { - return "OpenCode session failed."; - } - const data = "data" in error && error.data && typeof error.data === "object" ? error.data : null; - const message = data && "message" in data ? data.message : null; - return typeof message === "string" && message.trim().length > 0 - ? message - : "OpenCode session failed."; } -function updateProviderSession( - context: OpenCodeSessionContext, - patch: Partial, - options?: { - readonly clearActiveTurnId?: boolean; - readonly clearLastError?: boolean; - }, -): Effect.Effect { - return Effect.gen(function* () { - const updatedAt = yield* nowIso; - const nextSession = { - ...context.session, - ...patch, - updatedAt, - } as ProviderSession & Record; - const mutableSession = nextSession as Record; - if (options?.clearActiveTurnId) { - delete mutableSession.activeTurnId; - } - if (options?.clearLastError) { - delete mutableSession.lastError; - } - context.session = nextSession; - return nextSession; - }); -} - -const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( - context: OpenCodeSessionContext, -) { - // Race-safe one-shot: first caller flips the flag, everyone else no-ops. - if (yield* Ref.getAndSet(context.stopped, true)) { - return false; - } - - // Best-effort remote abort. The scope close below tears down the local - // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), - // but we still want to tell OpenCode that this session is done. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); - - // Closing the session scope interrupts every fiber forked into it and - // runs each finalizer we registered — the `AbortController.abort()` call, - // the child-process termination, etc. - yield* Scope.close(context.sessionScope, Exit.void); - return true; -}); - export function makeOpenCodeAdapter( - openCodeSettings: OpenCodeSettings, + settings: OpenCodeSettings, options?: OpenCodeAdapterLiveOptions, ) { return Effect.gen(function* () { - const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sameDirectory = (left: string, right: string) => - isSameOpenCodeDirectory(fileSystem, path, left, right); - const nativeEventLogger = - options?.nativeEventLogger ?? - (options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { - stream: "native", - }) - : undefined); - // Only close loggers we created. If the caller passed one in via - // `options.nativeEventLogger`, they own its lifecycle. - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; - const runtimeEvents = yield* Queue.unbounded(); + const scope = yield* Effect.scope; + const instanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); + const events = yield* Queue.unbounded(); const sessions = new Map(); - const randomUUIDv4 = crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "crypto/randomUUIDv4", - detail: "Failed to generate OpenCode runtime identifier.", - cause, - }), - ), - ); - const buildEventBase = (input: EventBaseInput) => - Effect.all({ - eventId: randomUUIDv4.pipe(Effect.map(EventId.make)), - createdAt: input.createdAt === undefined ? nowIso : Effect.succeed(input.createdAt), - }).pipe( - Effect.map(({ eventId, createdAt }) => ({ - eventId, - provider: PROVIDER, - threadId: input.threadId, - createdAt, - ...(input.turnId ? { turnId: input.turnId } : {}), - ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), - ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), - ...(input.raw !== undefined - ? { - raw: { - source: "opencode.sdk.event" as const, - payload: input.raw, - }, - } - : {}), - })), - ); + const sessionByProviderId = new Map(); + const connectionLock = yield* Semaphore.make(1); + let activeConnection: OpenCodeActiveConnection | undefined; + let eventCounter = 0; - // Layer-level finalizer: when the adapter layer shuts down, stop every - // session. Each session's `Scope.close` tears down its spawned OpenCode - // server (via the `ChildProcessSpawner` finalizer installed in - // `startOpenCodeServerProcess`) and interrupts the forked event/exit - // fibers. Consumers that can't reason about Effect scopes therefore - // cannot leak OpenCode child processes by forgetting to call `stopAll`. - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const contexts = [...sessions.values()]; - sessions.clear(); - // `ignoreCause` swallows both typed failures (none here) and defects - // from throwing scope finalizers so a sibling's death can't interrupt - // the remaining cleanups. - yield* Effect.forEach( - contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), - { concurrency: "unbounded", discard: true }, - ); - // Close the logger AFTER session teardown so any final lifecycle - // events emitted during shutdown still get written. `close` flushes - // the `Logger.batched` window and closes each per-thread - // `RotatingFileSink` handle owned by the logger's internal scope. - if (managedNativeEventLogger !== undefined) { - yield* managedNativeEventLogger.close(); - } - }).pipe(Effect.ensuring(Queue.shutdown(runtimeEvents))), - ); + const buildEventBase = Effect.fn("OpenCodeAdapter.buildEventBase")(function* ( + input: EventBaseInput, + ) { + eventCounter += 1; + return { + eventId: EventId.make(`opencode-event-${eventCounter}`), + provider: PROVIDER, + providerInstanceId: instanceId, + threadId: input.threadId, + createdAt: DateTime.formatIso(yield* DateTime.now), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + ...(input.raw + ? { + raw: { + source: "opencode.api.event" as const, + payload: input.raw, + }, + } + : {}), + }; + }); - const emit = (event: ProviderRuntimeEvent) => - Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); - const writeNativeEvent = ( - threadId: ThreadId, - event: { - readonly observedAt: string; - readonly event: Record; - }, - ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); - const writeNativeEventBestEffort = ( - threadId: ThreadId, - event: { - readonly observedAt: string; - readonly event: Record; - }, - ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + const emit = (event: ProviderRuntimeEvent) => Queue.offer(events, event).pipe(Effect.asVoid); - const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( - context: OpenCodeSessionContext, - message: string, + const ensureSession = Effect.fn("OpenCodeAdapter.ensureSession")(function* ( + threadId: ThreadId, ) { - // Atomic one-shot: two fibers can race here (the event-pump on stream - // failure and the server-exit watcher). `getAndSet` flips the flag in - // a single step so the loser observes `true` and returns; a plain - // `Ref.get` would let both racers slip past and emit duplicates. - if (yield* Ref.getAndSet(context.stopped, true)) { - return; + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); } - const turnId = context.activeTurnId; - sessions.delete(context.session.threadId); - // Emit lifecycle events BEFORE tearing down the scope. Both call sites - // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; - // closing that scope triggers the fiber-interrupt finalizer, so any - // subsequent yield point would unwind and silently drop these emits. - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - })), - type: "runtime.error", - payload: { - message, - class: "transport_error", - }, - }).pipe(Effect.ignore); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - })), - type: "session.exited", - payload: { - reason: message, - recoverable: false, - exitKind: "error", - }, - }).pipe(Effect.ignore); - // Inline the teardown that `stopOpenCodeContext` would do; we can't - // delegate to it because our `getAndSet` above already flipped the - // one-shot guard, so the call would no-op. - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.ignore({ log: true })); - yield* Scope.close(context.sessionScope, Exit.void); + return context; }); - /** Emit content.delta and item.completed events for an assistant text part. */ - const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( + const updateSession = Effect.fn("OpenCodeAdapter.updateSession")(function* ( context: OpenCodeSessionContext, - part: Part, - turnId: TurnId | undefined, - raw: unknown, + patch: Partial, + clear?: "activeTurnId" | "lastError", ) { - const text = textFromPart(part); - if (text === undefined) { - return; - } - const previousText = context.emittedTextByPartId.get(part.id); - const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); - context.emittedTextByPartId.set(part.id, latestText); - if (latestText !== text) { - context.partById.set( - part.id, - (part.type === "text" || part.type === "reasoning" - ? { ...part, text: latestText } - : part) satisfies Part, - ); - } - if (deltaToEmit.length > 0) { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: part.id, - createdAt: - (part.type === "text" || part.type === "reasoning") && part.time !== undefined - ? isoFromEpochMs(part.time.start) - : undefined, - raw, - })), - type: "content.delta", - payload: { - streamKind: resolveTextStreamKind(part), - delta: deltaToEmit, - }, - }); - } - - if ( - part.type === "text" && - part.time?.end !== undefined && - !context.completedAssistantPartIds.has(part.id) - ) { - context.completedAssistantPartIds.add(part.id); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: part.id, - createdAt: isoFromEpochMs(part.time.end), - raw, - })), - type: "item.completed", - payload: { - itemType: "assistant_message", - status: "completed", - title: "Assistant message", - ...(latestText.length > 0 ? { detail: latestText } : {}), - }, - }); - } + const next = { + ...context.session, + ...patch, + updatedAt: DateTime.formatIso(yield* DateTime.now), + } as ProviderSession & Record; + if (clear) delete next[clear]; + context.session = next; + return next; }); - const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + const completeTurn = Effect.fn("OpenCodeAdapter.completeTurn")(function* ( context: OpenCodeSessionContext, - event: OpenCodeSubscribedEvent, + state: "completed" | "failed" | "interrupted", + raw: OpenCodeRuntime.OpenCodeEvent, + errorMessage?: string, ) { - const payloadSessionId = openCodeEventSessionId(event); - if (payloadSessionId !== context.openCodeSessionId) { - return; - } - const turnId = context.activeTurnId; - yield* writeNativeEventBestEffort(context.session.threadId, { - observedAt: yield* nowIso, - event: { - provider: PROVIDER, - threadId: context.session.threadId, - providerThreadId: context.openCodeSessionId, - type: event.type, - ...(turnId ? { turnId } : {}), - payload: event, + if (!turnId) return; + context.activeTurnId = undefined; + yield* updateSession( + context, + { + status: state === "failed" ? "error" : "ready", + ...(errorMessage ? { lastError: errorMessage } : {}), + }, + "activeTurnId", + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, raw })), + type: "turn.completed", + payload: { + state, + ...(errorMessage ? { errorMessage } : {}), }, }); + }); - switch (event.type) { - case "session.updated": { - const title = openCodeEventSessionTitle(event); - if (title) { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - raw: event, - })), - type: "thread.metadata.updated", - payload: { - name: title, - metadata: { - sessionID: context.openCodeSessionId, + const writeNativeEvent = ( + context: OpenCodeSessionContext, + event: OpenCodeRuntime.OpenCodeEvent, + observedAt: string, + ) => + options?.nativeEventLogger + ? options.nativeEventLogger + .write( + { + observedAt, + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.providerSessionId, + type: event.type, + payload: event, }, }, - }); - } - break; - } - - case "message.updated": { - context.messageRoleById.set(event.properties.info.id, event.properties.info.role); - if (event.properties.info.role === "assistant") { - for (const part of context.partById.values()) { - if (part.messageID !== event.properties.info.id) { - continue; - } - yield* emitAssistantTextDelta(context, part, turnId, event); - } - } - break; - } + context.session.threadId, + ) + .pipe(Effect.catchCause(() => Effect.void)) + : Effect.void; - case "message.removed": { - context.messageRoleById.delete(event.properties.messageID); - break; - } + const handleEvent = Effect.fn("OpenCodeAdapter.handleEvent")(function* ( + event: OpenCodeRuntime.OpenCodeEvent, + ) { + if (normalizeEventType(event.type) === "server.connected") { + return; + } + const providerSessionId = eventSessionId(event); + if (!providerSessionId) return; + const context = sessionByProviderId.get(providerSessionId); + if (!context) return; + const data = eventData(event); + if (!data) return; + const type = normalizeEventType(event.type); + const turnId = context.activeTurnId; + yield* writeNativeEvent(context, event, DateTime.formatIso(yield* DateTime.now)); - case "message.part.delta": { - const existingPart = context.partById.get(event.properties.partID); - if (!existingPart) { - break; - } - const role = messageRoleForPart(context, existingPart); - if (role !== "assistant") { - break; - } - const streamKind = resolveTextStreamKind(existingPart); - const delta = event.properties.delta; - if (delta.length === 0) { - break; - } - const previousText = - context.emittedTextByPartId.get(event.properties.partID) ?? - textFromPart(existingPart) ?? - ""; - const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); - if (deltaToEmit.length === 0) { - break; - } - context.emittedTextByPartId.set(event.properties.partID, nextText); - if (existingPart.type === "text" || existingPart.type === "reasoning") { - context.partById.set(event.properties.partID, { - ...existingPart, - text: nextText, - }); - } + switch (type) { + case "session.text.started": { yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - itemId: event.properties.partID, + itemId: contentItemId(data, "text"), raw: event, })), - type: "content.delta", + type: "item.started", payload: { - streamKind, - delta: deltaToEmit, + itemType: "assistant_message", + status: "inProgress", + title: "Assistant message", }, }); break; } - case "message.part.updated": { - const part = event.properties.part; - context.partById.set(part.id, part); - const messageRole = messageRoleForPart(context, part); - - if (messageRole === "assistant") { - yield* emitAssistantTextDelta(context, part, turnId, event); - } - - if (part.type === "tool") { - const itemType = toToolLifecycleItemType(part.tool); - const title = - part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; - const detail = detailFromToolPart(part); - const payload = { - itemType, - ...(part.state.status === "error" - ? { status: "failed" as const } - : part.state.status === "completed" - ? { status: "completed" as const } - : { status: "inProgress" as const }), - ...(title ? { title } : {}), - ...(detail ? { detail } : {}), - data: { - tool: part.tool, - state: part.state, - }, - }; - const runtimeEvent: ProviderRuntimeEvent = { - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: part.callID, - createdAt: toolStateCreatedAt(part), - raw: event, - })), - type: - part.state.status === "pending" - ? "item.started" - : part.state.status === "completed" || part.state.status === "error" - ? "item.completed" - : "item.updated", - payload, - }; - appendTurnItem(context, turnId, part); - yield* emit(runtimeEvent); - } - break; - } - - case "permission.asked": { - context.pendingPermissions.set(event.properties.id, event.properties); + case "session.reasoning.started": { yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.id, + itemId: contentItemId(data, "reasoning"), raw: event, })), - type: "request.opened", + type: "item.started", payload: { - requestType: mapPermissionToRequestType(event.properties.permission), - detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, + itemType: "reasoning", + status: "inProgress", + title: "Reasoning", }, }); break; } - case "permission.replied": { - context.pendingPermissions.delete(event.properties.requestID); + case "session.text.delta": + case "session.reasoning.delta": { + const delta = stringField(data, "delta"); + if (!delta) break; + const kind = type === "session.text.delta" ? "text" : "reasoning"; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.requestID, + itemId: contentItemId(data, kind), raw: event, })), - type: "request.resolved", + type: "content.delta", payload: { - requestType: "unknown", - decision: mapPermissionDecision(event.properties.reply), + streamKind: kind === "text" ? "assistant_text" : "reasoning_text", + delta, }, }); break; } - case "question.asked": { - context.pendingQuestions.set(event.properties.id, event.properties); + case "session.reasoning.ended": { + const text = stringField(data, "text"); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.id, + itemId: contentItemId(data, "reasoning"), raw: event, })), - type: "user-input.requested", + type: "item.completed", payload: { - questions: normalizeQuestionRequest(event.properties), + itemType: "reasoning", + status: "completed", + title: "Reasoning", + ...(text ? { detail: text } : {}), }, }); break; } - case "question.replied": { - const request = context.pendingQuestions.get(event.properties.requestID); - context.pendingQuestions.delete(event.properties.requestID); - const answers = Object.fromEntries( - (request?.questions ?? []).map((question, index) => [ - openCodeQuestionId(index, question), - event.properties.answers[index]?.join(", ") ?? "", - ]), - ); + case "session.text.ended": { + const text = stringField(data, "text"); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.requestID, + itemId: contentItemId(data, "text"), raw: event, })), - type: "user-input.resolved", - payload: { answers }, + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + title: "Assistant message", + ...(text ? { detail: text } : {}), + }, }); break; } - case "question.rejected": { - context.pendingQuestions.delete(event.properties.requestID); + case "session.tool.input.started": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = stringField(data, "tool", "name") ?? "tool"; + context.toolNameByCallId.set(callId, tool); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, - requestId: event.properties.requestID, + itemId: callId, raw: event, })), - type: "user-input.resolved", - payload: { answers: {} }, + type: "item.started", + payload: { + itemType: toToolLifecycleItemType(tool), + status: "inProgress", + title: tool, + }, }); break; } - case "session.status": { - if (event.properties.status.type === "busy") { - yield* updateProviderSession(context, { - status: "running", - activeTurnId: turnId, - }); - } + case "session.tool.input.delta": + case "session.tool.input.ended": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = context.toolNameByCallId.get(callId) ?? "tool"; + const detail = stringField(data, "delta", "text"); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: callId, + raw: event, + })), + type: "item.updated", + payload: { + itemType: toToolLifecycleItemType(tool), + status: "inProgress", + title: tool, + ...(detail ? { detail } : {}), + data, + }, + }); + break; + } - if (event.properties.status.type === "retry") { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - raw: event, - })), - type: "runtime.warning", - payload: { - message: event.properties.status.message, - detail: event.properties.status, + case "session.tool.called": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = stringField(data, "tool", "name") ?? "tool"; + const inputStarted = context.toolNameByCallId.has(callId); + context.toolNameByCallId.set(callId, tool); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: callId, + raw: event, + })), + type: inputStarted ? "item.updated" : "item.started", + payload: { + itemType: toToolLifecycleItemType(tool), + status: "inProgress", + title: tool, + data: { + tool, + input: data.input, }, - }); - break; - } + }, + }); + break; + } - if (event.properties.status.type === "idle" && turnId) { - context.activeTurnId = undefined; - yield* updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId, - raw: event, - })), - type: "turn.completed", - payload: { - state: "completed", - }, - }); - } + case "session.tool.progress": + case "session.tool.success": + case "session.tool.failed": { + const callId = stringField(data, "callID", "id"); + if (!callId) break; + const tool = context.toolNameByCallId.get(callId) ?? "tool"; + const terminal = type !== "session.tool.progress"; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: callId, + raw: event, + })), + type: terminal ? "item.completed" : "item.updated", + payload: { + itemType: toToolLifecycleItemType(tool), + status: + type === "session.tool.failed" ? "failed" : terminal ? "completed" : "inProgress", + title: tool, + ...(toolDetail(data) ? { detail: toolDetail(data) } : {}), + data, + }, + }); + if (terminal) context.toolNameByCallId.delete(callId); break; } - case "session.error": { - const message = sessionErrorMessage(event.properties.error); - const activeTurnId = context.activeTurnId; - context.activeTurnId = undefined; - yield* updateProviderSession( - context, - { - status: "error", - lastError: message, + case "permission.asked": { + const requestId = stringField(data, "id", "requestID"); + if (!requestId) break; + const action = stringField(data, "action", "permission") ?? "unknown"; + const resources = Array.isArray(data.resources) + ? data.resources.filter((value): value is string => typeof value === "string") + : Array.isArray(data.patterns) + ? data.patterns.filter((value): value is string => typeof value === "string") + : []; + context.pendingPermissions.add(requestId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId, + raw: event, + })), + type: "request.opened", + payload: { + requestType: permissionRequestType(action), + detail: resources.length > 0 ? resources.join("\n") : action, + args: data.metadata ?? data.source, }, - { clearActiveTurnId: true }, - ); - if (activeTurnId) { - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: activeTurnId, - raw: event, - })), - type: "turn.completed", - payload: { - state: "failed", - errorMessage: message, - }, - }); - } + }); + break; + } + + case "permission.replied": { + const requestId = stringField(data, "requestID", "id"); + if (!requestId) break; + context.pendingPermissions.delete(requestId); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, + turnId, + requestId, raw: event, })), - type: "runtime.error", + type: "request.resolved", payload: { - message, - class: "provider_error", - detail: event.properties.error, + requestType: "unknown", + decision: permissionDecision(stringField(data, "reply") ?? "reject"), }, }); break; } + case "form.created": { + const form = formFromEvent(event); + if (!form) break; + const formId = stringField(form, "id", "formID"); + if (!formId) break; + context.pendingForms.add(formId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: formId, + raw: event, + })), + type: "user-input.requested", + payload: { + questions: formQuestions(form), + }, + }); + break; + } + + case "form.replied": + case "form.cancelled": { + const form = formFromEvent(event); + const formId = form ? stringField(form, "id", "formID") : undefined; + if (!formId) break; + context.pendingForms.delete(formId); + const answer = recordFromUnknown(data.answer) ?? recordFromUnknown(data.answers) ?? {}; + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: formId, + raw: event, + })), + type: "user-input.resolved", + payload: { + answers: type === "form.cancelled" ? {} : answer, + }, + }); + break; + } + + case "session.execution.settled": { + const outcome = stringField(data, "outcome", "state") ?? "success"; + const error = recordFromUnknown(data.error); + const message = stringField(error, "message", "detail") ?? stringField(data, "message"); + yield* completeTurn( + context, + outcome === "failure" || outcome === "failed" + ? "failed" + : outcome === "interrupted" || outcome === "cancelled" + ? "interrupted" + : "completed", + event, + message, + ); + break; + } + + case "session.execution.succeeded": + case "session.idle": + yield* completeTurn(context, "completed", event); + break; + + case "session.execution.interrupted": + yield* completeTurn(context, "interrupted", event); + break; + + case "session.execution.failed": + case "session.error": { + const error = recordFromUnknown(data.error); + const message = + stringField(error, "message", "detail") ?? + stringField(data, "message") ?? + "OpenCode 2 session failed."; + yield* completeTurn(context, "failed", event, message); + break; + } + default: break; } }); - const startEventPump = Effect.fn("startEventPump")(function* (context: OpenCodeSessionContext) { - // One AbortController per session scope. The finalizer fires when - // the scope closes (explicit stop, unexpected exit, or layer - // shutdown) and cancels the in-flight `event.subscribe` fetch so - // the async iterable unwinds cleanly. - const eventsAbortController = new AbortController(); - yield* Scope.addFinalizer( - context.sessionScope, - Effect.sync(() => eventsAbortController.abort()), - ); - - // Fibers forked into `context.sessionScope` are interrupted - // automatically when the scope closes — no bookkeeping required. - yield* Effect.flatMap( - runOpenCodeSdk("event.subscribe", () => - context.client.event.subscribe(undefined, { - signal: eventsAbortController.signal, - }), - ), - (subscription) => - Stream.fromAsyncIterable( - subscription.stream, - (cause) => - new OpenCodeRuntimeError({ - operation: "event.subscribe", - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), - ).pipe( - Effect.exit, - Effect.flatMap((exit) => + const reportConnectionFailure = (cause: OpenCodeRuntime.OpenCodeRuntimeFailure) => + Effect.forEach( + sessions.values(), + (context) => Effect.gen(function* () { - // Expected paths: caller aborted the fetch or the session - // has already been marked stopped. Treat as a clean exit. - if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { - return; - } - if (Exit.isFailure(exit)) { - yield* emitUnexpectedExit( - context, - openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), - ); - } + context.activeTurnId = undefined; + yield* updateSession( + context, + { + status: "error", + lastError: cause.message, + }, + "activeTurnId", + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "runtime.error", + payload: { + message: cause.message, + class: "transport_error", + }, + }); }), - ), - Effect.forkIn(context.sessionScope), + { discard: true }, ); - if (!context.server.external && context.server.exitCode !== null) { - yield* context.server.exitCode.pipe( - Effect.flatMap((code) => - Effect.gen(function* () { - if (yield* Ref.get(context.stopped)) { - return; - } - yield* emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`); - }), - ), - Effect.forkIn(context.sessionScope), - ); - } - }); - - const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( - function* (input) { - const binaryPath = openCodeSettings.binaryPath; - const serverUrl = openCodeSettings.serverUrl; - const serverPassword = openCodeSettings.serverPassword; - const directory = input.cwd ?? serverConfig.cwd; - const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; - const existing = sessions.get(input.threadId); - if (existing) { - yield* stopOpenCodeContext(existing); - sessions.delete(input.threadId); - } + const invalidateConnection = (entry: OpenCodeActiveConnection) => + Effect.gen(function* () { + if (activeConnection !== entry) return; + activeConnection = undefined; + yield* entry.interrupt; + }); - const started = yield* Effect.gen(function* () { - const sessionScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - Effect.gen(function* () { - // The runtime binds the server's lifetime to the Scope.Scope - // we provide below — closing `sessionScope` kills the child - // process automatically. No manual `server.close()` needed. - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath, - serverUrl, - ...(options?.environment ? { environment: options.environment } : {}), - }); - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory, - ...(server.external && serverPassword ? { serverPassword } : {}), - }); - const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); - if (mcpSession && !server.external) { - yield* runOpenCodeSdk("mcp.add", () => - client.mcp.add({ - name: "t3-code", - config: { - type: "remote", - url: mcpSession.endpoint, - headers: { - Authorization: mcpSession.authorizationHeader, - }, - oauth: false, - }, - }), - ); - } - // Resume: re-adopt the session named by the durable cursor — - // OpenCode scopes history by session id. The probe recovers only - // a confirmed not-found (start fresh); transport/auth/server - // errors propagate instead of masking as a new empty session. - const resolved = yield* Effect.gen(function* () { - const adopted = resumeSessionId - ? yield* runOpenCodeSdk("session.get", () => - client.session.get({ sessionID: resumeSessionId }), - ).pipe( - Effect.map((response) => response.data), - Effect.catchIf( - (cause) => isOpenCodeNotFound(cause), - () => Effect.void, - ), - ) - : undefined; - - // Reuse in place only when the session still matches the - // requested cwd; on a cwd change it is forked below instead. - const reusable = - adopted && - (!adopted.directory || (yield* sameDirectory(adopted.directory, directory))) - ? adopted - : undefined; - - if (reusable) { - // Resume skips `session.create`, so re-assert the ruleset — - // a runtime-mode change would otherwise leave the session on - // its original permissions. - yield* runOpenCodeSdk("session.update", () => - client.session.update({ - sessionID: reusable.id, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - return { openCodeSession: reusable, created: false }; - } - - // The session lives under a different cwd (e.g. the thread - // moved into a git worktree). Fork it into the requested - // directory instead of minting an empty one — the fork carries - // the full history, so the follow-up keeps its context (#3604). - if (adopted) { - yield* Effect.logInfo( - `OpenCode session '${adopted.id}' was created under a different working directory; forking into '${directory}' to preserve conversation history.`, - ); - const forkedSession = yield* runOpenCodeSdk("session.fork", () => - client.session.fork({ sessionID: adopted.id, directory }), - ); - const forked = forkedSession.data; - if (!forked) { - return yield* new OpenCodeRuntimeError({ - operation: "session.fork", - detail: "OpenCode session.fork returned no session payload.", - }); - } - yield* runOpenCodeSdk("session.update", () => - client.session.update({ - sessionID: forked.id, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - return { openCodeSession: forked, created: true }; - } + const ensureConnection = connectionLock.withPermit( + Effect.gen(function* () { + if (activeConnection) return activeConnection; - if (resumeSessionId) { - yield* Effect.logWarning( - `OpenCode session '${resumeSessionId}' no longer exists; starting a fresh session.`, - ); - } - const createdSession = yield* runOpenCodeSdk("session.create", () => - client.session.create({ - ...(input.title ? { title: input.title } : {}), - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); - if (!createdSession.data) { - return yield* new OpenCodeRuntimeError({ - operation: "session.create", - detail: "OpenCode session.create returned no session payload.", - }); - } - return { openCodeSession: createdSession.data, created: true }; - }); - - return { - sessionScope, - server, - client, - openCodeSession: resolved.openCodeSession, - created: resolved.created, - }; - }).pipe(Effect.provideService(Scope.Scope, sessionScope)), - ); - if (Exit.isFailure(startedExit)) { - yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); - return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); - } - return startedExit.value; + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath, + ...(options?.environment ? { environment: options.environment } : {}), }); - - // Guard against a concurrent startSession call that may have raced - // and already inserted a session while we were awaiting async work. - const raceWinner = sessions.get(input.threadId); - if (raceWinner) { - // Another call won the race — clean up. Only abort the remote - // session if we created it here; a resumed one is shared upstream - // state the winner is now using. - if (started.created) { - yield* runOpenCodeSdk("session.abort", () => - started.client.session.abort({ - sessionID: started.openCodeSession.id, + const connected = yield* Deferred.make(); + const streamFailure = yield* Ref.make< + Option.Option + >(Option.none()); + const handleStreamFailure = (cause: OpenCodeRuntime.OpenCodeRuntimeFailure) => + Effect.gen(function* () { + yield* Ref.set(streamFailure, Option.some(cause)); + yield* Deferred.fail(connected, cause).pipe(Effect.ignore); + if (activeConnection?.connection === connection) activeConnection = undefined; + yield* reportConnectionFailure(cause); + }); + const eventFiber = yield* connection.globalEvents.pipe( + Stream.runForEach((event) => + normalizeEventType(event.type) === "server.connected" + ? Deferred.succeed(connected, undefined).pipe(Effect.ignore) + : handleEvent(event), + ), + Effect.flatMap(() => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "connection-ended", }), - ).pipe(Effect.ignore); - } - yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); - return raceWinner.session; + ), + ), + Effect.catchTags({ + OpenCodeRuntimeError: handleStreamFailure, + OpenCodeUnsupportedPreviewError: handleStreamFailure, + OpenCodeCommandNotFoundError: handleStreamFailure, + OpenCodeTimeoutError: handleStreamFailure, + }), + Effect.forkIn(scope), + ); + const interrupt = Fiber.interrupt(eventFiber).pipe(Effect.asVoid); + const handshake = yield* Deferred.await(connected).pipe(Effect.timeoutOption("10 seconds")); + if (Option.isNone(handshake)) { + yield* interrupt; + return yield* new OpenCodeRuntime.OpenCodeTimeoutError({ + operation: "event.subscribe", + }); } - const createdAt = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - providerInstanceId: boundInstanceId, - status: "ready", - runtimeMode: input.runtimeMode, - cwd: directory, - ...(input.modelSelection ? { model: input.modelSelection.model } : {}), - threadId: input.threadId, - // ProviderService persists this cursor and feeds it back into - // `startSession` after the in-memory session is lost (reaper / - // restart), so follow-ups continue the same conversation (#3604). - resumeCursor: { - schemaVersion: OPENCODE_RESUME_VERSION, - sessionId: started.openCodeSession.id, - }, - createdAt, - updatedAt: createdAt, - }; + const entry = { connection, interrupt } satisfies OpenCodeActiveConnection; + const failedBeforeActivation = yield* Ref.get(streamFailure); + if (Option.isSome(failedBeforeActivation)) { + yield* interrupt; + return yield* failedBeforeActivation.value; + } + activeConnection = entry; + const failedDuringActivation = yield* Ref.get(streamFailure); + if (Option.isSome(failedDuringActivation)) { + activeConnection = undefined; + yield* interrupt; + return yield* failedDuringActivation.value; + } + return entry; + }), + ); - const context: OpenCodeSessionContext = { - session, - client: started.client, - server: started.server, - directory, - openCodeSessionId: started.openCodeSession.id, - pendingPermissions: new Map(), - pendingQuestions: new Map(), - partById: new Map(), - emittedTextByPartId: new Map(), - messageRoleById: new Map(), - completedAssistantPartIds: new Set(), - turns: [], - activeTurnId: undefined, - activeAgent: undefined, - activeVariant: undefined, - stopped: yield* Ref.make(false), - sessionScope: started.sessionScope, - }; - sessions.set(input.threadId, context); - yield* startEventPump(context); + const request = ( + method: "GET" | "POST", + path: string, + input: OpenCodeRuntime.OpenCodeRequestInput, + ) => + ensureConnection.pipe( + Effect.flatMap((entry) => + entry.connection + .request(method, path, input) + .pipe(Effect.tapError(() => invalidateConnection(entry))), + ), + ); + const interruptContext = Effect.fn("OpenCodeAdapter.interruptContext")(function* ( + context: OpenCodeSessionContext, + turnId?: TurnId, + ) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/interrupt`, + { + operation: "session.interrupt", + schema: Schema.Void, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.interrupt", cause))); + const interruptedTurnId = turnId ?? context.activeTurnId; + context.activeTurnId = undefined; + yield* updateSession(context, { status: "ready" }, "activeTurnId"); + if (interruptedTurnId) { yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId })), - type: "session.started", - payload: { - message: "OpenCode session started", - }, - }); - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId })), - type: "thread.started", - payload: { - providerThreadId: started.openCodeSession.id, - }, + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId: interruptedTurnId, + })), + type: "turn.aborted", + payload: { reason: "Interrupted by user." }, }); + } + }); - return session; - }, - ); + const detachContext = Effect.fn("OpenCodeAdapter.detachContext")(function* ( + context: OpenCodeSessionContext, + ) { + if (context.activeTurnId) { + yield* interruptContext(context, context.activeTurnId); + } + sessions.delete(context.session.threadId); + sessionByProviderId.delete(context.providerSessionId); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.session.threadId })), + type: "session.exited", + payload: { + reason: "Session detached.", + recoverable: true, + exitKind: "graceful", + }, + }); + }); - const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { - const context = yield* ensureSessionContext(sessions, input.threadId); - // A sendTurn while a turn is active is a steer: OpenCode queues the - // prompt into the busy session and the work continues as one turn, so - // the active turn id is reused instead of opening a new turn. - const steeringTurnId = context.activeTurnId; - const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${yield* randomUUIDv4}`); - const modelSelection = - input.modelSelection ?? - (context.session.model - ? { instanceId: boundInstanceId, model: context.session.model } - : undefined); - if (modelSelection !== undefined && modelSelection.instanceId !== boundInstanceId) { + const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn( + "OpenCodeAdapter.startSession", + )(function* (input) { + if (!settings.enabled) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, - operation: "sendTurn", - issue: `OpenCode model selection is bound to instance '${modelSelection?.instanceId}', expected '${boundInstanceId}'.`, + operation: "startSession", + issue: "OpenCode 2 is disabled in provider settings.", }); } - const parsedModel = parseOpenCodeModelSlug(modelSelection?.model); - if (!parsedModel) { + const directory = input.cwd ?? serverConfig.cwd; + const selectedModel = input.modelSelection?.model; + if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, - operation: "sendTurn", - issue: "OpenCode model selection must use the 'provider/model' format.", + operation: "startSession", + issue: `OpenCode 2 model selection is bound to instance '${input.modelSelection.instanceId}', expected '${instanceId}'.`, }); } - - const text = input.input?.trim(); - const fileParts = toOpenCodeFileParts({ - attachments: input.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }), - }); - if ((!text || text.length === 0) && fileParts.length === 0) { + const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const modelRef = selectedModel ? parseModelRef(selectedModel, variant) : undefined; + if (selectedModel && !modelRef) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, - operation: "sendTurn", - issue: "OpenCode turns require text input or at least one attachment.", + operation: "startSession", + issue: "OpenCode 2 model selection must use the 'provider/model' format.", }); } - - const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); - const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); - - context.activeTurnId = turnId; - context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); - context.activeVariant = variant; - yield* updateProviderSession( - context, - { - status: "running", - activeTurnId: turnId, - model: modelSelection?.model ?? context.session.model, + const agent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const resumeSessionId = parseResumeCursor(input.resumeCursor); + const createSession = request("POST", "/api/session", { + operation: "session.create", + schema: SessionResponseSchema, + body: { + ...(input.title ? { title: input.title } : {}), + location: { directory }, + ...(modelRef ? { model: modelRef } : {}), + ...(agent ? { agent } : {}), }, - { clearLastError: true }, - ); - - if (steeringTurnId === undefined) { - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId, turnId })), - type: "turn.started", - payload: { - model: modelSelection?.model ?? context.session.model, - ...(variant ? { effort: variant } : {}), - }, - }); + }).pipe(Effect.map((resolved) => ({ resolved, resumedExistingSession: false as const }))); + const resolution = yield* ( + resumeSessionId + ? request("GET", `/api/session/${encodedPathSegment(resumeSessionId)}`, { + operation: "session.get", + schema: SessionResponseSchema, + }).pipe( + Effect.map((resolved) => ({ resolved, resumedExistingSession: true as const })), + Effect.catchIf(isMissingOpenCodeSession, () => createSession), + ) + : createSession + ).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + const providerSessionId = resolution.resolved.data.id; + const staleContexts = new Set(); + const previousForThread = sessions.get(input.threadId); + const previousForProvider = sessionByProviderId.get(providerSessionId); + if (previousForThread) staleContexts.add(previousForThread); + if (previousForProvider) staleContexts.add(previousForProvider); + if (resolution.resumedExistingSession && modelRef) { + yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/model`, { + operation: "session.switchModel", + schema: Schema.Void, + body: { model: modelRef }, + }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); } - - yield* runOpenCodeSdk("session.promptAsync", () => - context.client.session.promptAsync({ - sessionID: context.openCodeSessionId, - model: parsedModel, - ...(context.activeAgent ? { agent: context.activeAgent } : {}), - ...(context.activeVariant ? { variant: context.activeVariant } : {}), - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], - }), - ).pipe( - Effect.mapError(toRequestError), - // On failure of a fresh turn: clear active-turn state, flip the - // session back to ready with lastError set, emit turn.aborted, then - // let the typed error propagate. We don't need to rebuild the error - // here — `toRequestError` already produced the right shape. A failed - // steer leaves the still-running original turn untouched. - Effect.tapError((requestError) => - steeringTurnId !== undefined - ? Effect.void - : Effect.gen(function* () { - context.activeTurnId = undefined; - context.activeAgent = undefined; - context.activeVariant = undefined; - yield* updateProviderSession( - context, - { - status: "ready", - model: modelSelection?.model ?? context.session.model, - lastError: requestError.detail, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...(yield* buildEventBase({ - threadId: input.threadId, - turnId, - })), - type: "turn.aborted", - payload: { - reason: requestError.detail, - }, - }); - }), - ), - ); - - return { + if (resolution.resumedExistingSession && agent) { + yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/agent`, { + operation: "session.switchAgent", + schema: Schema.Void, + body: { agent }, + }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + } + for (const stale of staleContexts) { + yield* detachContext(stale); + } + const createdAt = DateTime.formatIso(yield* DateTime.now); + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd: directory, + ...(selectedModel ? { model: selectedModel } : {}), threadId: input.threadId, - turnId, - // Re-surface the durable cursor on every turn so the persisted binding - // is refreshed alongside last-seen/runtime state (mirrors Grok/Codex). - ...(context.session.resumeCursor !== undefined - ? { resumeCursor: context.session.resumeCursor } - : {}), + resumeCursor: { + schemaVersion: RESUME_VERSION, + sessionId: providerSessionId, + }, + createdAt, + updatedAt: createdAt, + }; + const context: OpenCodeSessionContext = { + session, + providerSessionId, + pendingPermissions: new Set(), + pendingForms: new Set(), + toolNameByCallId: new Map(), + activeTurnId: undefined, + currentModel: selectedModel, + currentVariant: variant, + currentAgent: agent ?? "build", + regularAgent: agent ?? "build", }; + sessions.set(input.threadId, context); + sessionByProviderId.set(providerSessionId, context); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.started", + payload: { + message: "OpenCode 2 session attached", + resume: session.resumeCursor, + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { + providerThreadId: providerSessionId, + }, + }); + return session; }); - const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( - function* (threadId, turnId) { - const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("OpenCodeAdapter.sendTurn")( + function* (input) { + const context = yield* ensureSession(input.threadId); + const connection = yield* ensureConnection.pipe( + Effect.map((entry) => entry.connection), + Effect.mapError((cause) => toRequestError("connection.attach", cause)), + ); + if (input.modelSelection && input.modelSelection.instanceId !== instanceId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode 2 model selection is bound to instance '${input.modelSelection.instanceId}', expected '${instanceId}'.`, + }); + } + const text = input.input?.trim() ?? ""; + const attachments = input.attachments ?? []; + if (text.length === 0 && attachments.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 turns require text input or at least one attachment.", + }); + } + const files: Array<{ readonly uri: string; readonly name: string }> = []; + for (const attachment of attachments) { + const path = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!path) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `OpenCode 2 attachment '${attachment.name}' is unavailable.`, + }); + } + files.push({ + uri: NodeURL.pathToFileURL(path).href, + name: attachment.name, + }); + } + + const selectedModel = input.modelSelection?.model ?? context.session.model; + const variant = input.modelSelection + ? getModelSelectionStringOptionValue(input.modelSelection, "variant") + : context.currentVariant; + const modelRef = selectedModel ? parseModelRef(selectedModel, variant) : undefined; + if (selectedModel && !modelRef) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "OpenCode 2 model selection must use the 'provider/model' format.", + }); + } + if ( + modelRef && + (selectedModel !== context.currentModel || variant !== context.currentVariant) + ) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/model`, + { + operation: "session.switchModel", + schema: Schema.Void, + body: { model: modelRef }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.switchModel", cause))); + context.currentModel = selectedModel; + context.currentVariant = variant; + } + + const explicitAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedAgent = + input.interactionMode === "plan" ? "plan" : (explicitAgent ?? context.regularAgent); + if (input.interactionMode !== "plan" && explicitAgent) { + context.regularAgent = explicitAgent; + } + if (selectedAgent && selectedAgent !== context.currentAgent) { + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/agent`, + { + operation: "session.switchAgent", + schema: Schema.Void, + body: { agent: selectedAgent }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.switchAgent", cause))); + context.currentAgent = selectedAgent; + } + + const steeringTurnId = context.activeTurnId; + eventCounter += 1; + const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${eventCounter}`); + context.activeTurnId = turnId; + yield* updateSession( + context, + { + status: "running", + activeTurnId: turnId, + ...(selectedModel ? { model: selectedModel } : {}), + }, + "lastError", + ); + if (!steeringTurnId) { yield* emit({ - ...(yield* buildEventBase({ - threadId, - turnId: turnId ?? context.activeTurnId, - })), - type: "turn.aborted", + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", payload: { - reason: "Interrupted by user.", + ...(selectedModel ? { model: selectedModel } : {}), + ...(variant ? { effort: variant } : {}), }, }); } + + const prompt = { + text, + ...(files.length > 0 ? { files } : {}), + }; + const body = + connection.protocol.promptShape === "flat" + ? { ...prompt, delivery: "steer" as const } + : { prompt, delivery: "steer" as const }; + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/prompt`, + { + operation: "session.prompt", + schema: Schema.Unknown, + body, + }, + ).pipe( + Effect.mapError((cause) => toRequestError("session.prompt", cause)), + Effect.tapError((cause) => + steeringTurnId + ? Effect.void + : Effect.gen(function* () { + context.activeTurnId = undefined; + yield* updateSession( + context, + { status: "ready", lastError: cause.detail }, + "activeTurnId", + ); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.aborted", + payload: { reason: cause.detail }, + }); + }), + ), + ); + return { + threadId: input.threadId, + turnId, + resumeCursor: context.session.resumeCursor, + }; }, ); + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn( + "OpenCodeAdapter.interruptTurn", + )(function* (threadId, turnId) { + const context = yield* ensureSession(threadId); + yield* interruptContext(context, turnId); + }); + const respondToRequest: OpenCodeAdapterShape["respondToRequest"] = Effect.fn( - "respondToRequest", + "OpenCodeAdapter.respondToRequest", )(function* (threadId, requestId, decision) { - const context = yield* ensureSessionContext(sessions, threadId); + const context = yield* ensureSession(threadId); if (!context.pendingPermissions.has(requestId)) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, @@ -1587,134 +1239,126 @@ export function makeOpenCodeAdapter( detail: `Unknown pending permission request: ${requestId}`, }); } - - yield* runOpenCodeSdk("permission.reply", () => - context.client.permission.reply({ - requestID: requestId, - reply: toOpenCodePermissionReply(decision), - }), - ).pipe(Effect.mapError(toRequestError)); + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/permission/${encodedPathSegment(requestId)}/reply`, + { + operation: "permission.reply", + schema: Schema.Void, + body: { reply: permissionReply(decision) }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("permission.reply", cause))); }); const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( - "respondToUserInput", + "OpenCodeAdapter.respondToUserInput", )(function* (threadId, requestId, answers) { - const context = yield* ensureSessionContext(sessions, threadId); - const request = context.pendingQuestions.get(requestId); - if (!request) { + const context = yield* ensureSession(threadId); + if (!context.pendingForms.has(requestId)) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, - method: "question.reply", - detail: `Unknown pending user-input request: ${requestId}`, + method: "form.reply", + detail: `Unknown pending form request: ${requestId}`, }); } + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/form/${encodedPathSegment(requestId)}/reply`, + { + operation: "form.reply", + schema: Schema.Void, + body: { answer: answers }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("form.reply", cause))); + }); - yield* runOpenCodeSdk("question.reply", () => - context.client.question.reply({ - requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), - }), - ).pipe(Effect.mapError(toRequestError)); + const detachSession = Effect.fn("OpenCodeAdapter.detachSession")(function* ( + threadId: ThreadId, + ) { + const context = yield* ensureSession(threadId); + yield* detachContext(context); }); - const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( + const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("OpenCodeAdapter.readThread")( function* (threadId) { - const context = sessions.get(threadId); - if (!context) { - return yield* new ProviderAdapterSessionNotFoundError({ - provider: PROVIDER, - threadId, - }); - } - const stopped = yield* stopOpenCodeContext(context); - sessions.delete(threadId); - if (!stopped) { - return; - } - yield* emit({ - ...(yield* buildEventBase({ threadId })), - type: "session.exited", - payload: { - reason: "Session stopped.", - recoverable: false, - exitKind: "graceful", + const context = yield* ensureSession(threadId); + const response = yield* request( + "GET", + `/api/session/${encodedPathSegment(context.providerSessionId)}/message`, + { + operation: "message.list", + schema: MessagesResponseSchema, + query: { order: "asc" }, }, - }); - }, - ); - - const listSessions: OpenCodeAdapterShape["listSessions"] = () => - Effect.sync(() => [...sessions.values()].map((context) => context.session)); - - const hasSession: OpenCodeAdapterShape["hasSession"] = (threadId) => - Effect.sync(() => sessions.has(threadId)); - - const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( - function* (threadId) { - const context = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toRequestError)); - + ).pipe(Effect.mapError((cause) => toRequestError("message.list", cause))); const turns: Array = []; - for (const entry of messages.data ?? []) { - if (entry.info.role === "assistant") { - turns.push({ - id: TurnId.make(entry.info.id), - items: [entry.info, ...entry.parts], - }); - } + for (const message of response.data) { + const record = recordFromUnknown(message); + if (!record || stringField(record, "type", "role") !== "assistant") continue; + const id = stringField(record, "id"); + if (!id) continue; + turns.push({ + id: TurnId.make(id), + items: Array.isArray(record.content) ? [...record.content] : [record], + }); } - - return { - threadId, - turns, - }; + return { threadId, turns } satisfies ProviderThreadSnapshot; }, ); - const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( - function* (threadId, numTurns) { - const context = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toRequestError)); - - const assistantMessages = (messages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); - const targetIndex = assistantMessages.length - numTurns - 1; - const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - ...(target ? { messageID: target.info.id } : {}), - }), - ).pipe(Effect.mapError(toRequestError)); - - return yield* readThread(threadId); - }, - ); + const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn( + "OpenCodeAdapter.rollbackThread", + )(function* (threadId, numTurns) { + const context = yield* ensureSession(threadId); + const snapshot = yield* readThread(threadId); + if (numTurns <= 0) return snapshot; + const target = snapshot.turns[snapshot.turns.length - numTurns]; + if (!target) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: `Cannot roll back ${numTurns} OpenCode 2 turns from a thread with ${snapshot.turns.length} turns.`, + }); + } + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/revert/stage`, + { + operation: "session.revert.stage", + schema: Schema.Unknown, + body: { messageID: target.id }, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.revert.stage", cause))); + yield* request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/revert/commit`, + { + operation: "session.revert.commit", + schema: Schema.Void, + }, + ).pipe(Effect.mapError((cause) => toRequestError("session.revert.commit", cause))); + return yield* readThread(threadId); + }); const stopAll: OpenCodeAdapterShape["stopAll"] = () => - Effect.gen(function* () { - const contexts = [...sessions.values()]; - sessions.clear(); - // `stopOpenCodeContext` is typed as never-failing — SDK aborts are - // already `Effect.ignore`'d inside it. `ignoreCause` here also - // swallows defects from throwing finalizers so one bad close can't - // interrupt the sibling fibers. Same pattern as the layer finalizer. - yield* Effect.forEach( - contexts, - (context) => Effect.ignoreCause(stopOpenCodeContext(context)), - { concurrency: "unbounded", discard: true }, - ); + Effect.forEach([...sessions.keys()], detachSession, { + concurrency: "unbounded", + discard: true, }); + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.ignoreCause, + Effect.ensuring( + Effect.sync(() => { + sessions.clear(); + sessionByProviderId.clear(); + }), + ), + Effect.ensuring(Queue.shutdown(events)), + ), + ); + return { provider: PROVIDER, capabilities: { @@ -1725,14 +1369,15 @@ export function makeOpenCodeAdapter( interruptTurn, respondToRequest, respondToUserInput, - stopSession, - listSessions, - hasSession, + stopSession: detachSession, + listSessions: () => + Effect.sync(() => [...sessions.values()].map((context) => context.session)), + hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), readThread, rollbackThread, stopAll, get streamEvents() { - return Stream.fromQueue(runtimeEvents); + return Stream.fromQueue(events); }, } satisfies OpenCodeAdapterShape; }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..d2009b3060b4 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -1,353 +1,264 @@ import * as NodeAssert from "node:assert/strict"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { beforeEach } from "vite-plus/test"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; -import { OpenCodeSettings } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; -import type { OpenCodeInventory } from "../opencodeRuntime.ts"; -const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); -const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; +const settings = { + enabled: true, + binaryPath: "opencode2", + customModels: [], +} as const; -/** - * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag - * are deleted. The snapshot-producing logic they wrapped now lives in the - * standalone `checkOpenCodeProviderStatus(settings, cwd)` Effect, which - * drivers call directly when building their per-instance snapshot - * `ServerProviderShape`. Tests mirror that shape: build a settings payload, - * invoke the check, assert on the returned snapshot. - */ - -const runtimeMock = { - state: { - runVersionError: null as Error | null, - versionStdout: DEFAULT_VERSION_STDOUT, - inventoryError: null as Error | null, - inventoryCwd: null as string | null, - closeCalls: 0, - inventory: { - providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - } as unknown, - }, - reset() { - this.state.runVersionError = null; - this.state.versionStdout = DEFAULT_VERSION_STDOUT; - this.state.inventoryError = null; - this.state.inventoryCwd = null; - this.state.closeCalls = 0; - this.state.inventory = { - providerList: { connected: [], all: [] as unknown[], default: {} }, - agents: [] as unknown[], - skills: [] as unknown[], - }; - }, -}; - -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: () => - Effect.succeed({ - url: "http://127.0.0.1:4301", - exitCode: Effect.never, +function runtimeLayer(input: { + readonly responses?: Readonly>; + readonly attachError?: OpenCodeRuntime.OpenCodeRuntimeFailure; + readonly paths?: Array; +}) { + const connection: OpenCodeRuntime.OpenCodeConnection = { + url: "http://127.0.0.1:49374/", + protocol: { promptShape: "flat" }, + request: ((method: string, path: string, _requestInput: { readonly schema: unknown }) => { + input.paths?.push(`${method} ${path}`); + return Effect.succeed(input.responses?.[path]); + }) as OpenCodeRuntime.OpenCodeConnection["request"], + globalEvents: Stream.empty, + }; + return Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime.of({ + attach: () => + input.attachError ? Effect.fail(input.attachError) : Effect.succeed(connection), }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.gen(function* () { - if (!serverUrl) { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls += 1; - }), - ); - } - return { - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }; - }), - runOpenCodeCommand: () => - runtimeMock.state.runVersionError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: runtimeMock.state.runVersionError.message, - cause: runtimeMock.state.runVersionError, - }), - ) - : Effect.succeed({ stdout: runtimeMock.state.versionStdout, stderr: "", code: 0 }), - createOpenCodeSdkClient: () => - ({}) as unknown as ReturnType, - loadOpenCodeInventory: () => - runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), - loadInventoryFromCli: ({ cwd }) => { - runtimeMock.state.inventoryCwd = cwd; - return runtimeMock.state.inventoryError - ? Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: runtimeMock.state.inventoryError.message, - cause: runtimeMock.state.inventoryError, - }), - ) - : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); - }, -}; + ); +} -beforeEach(() => { - runtimeMock.reset(); +it.effect("builds model inventory from the attached OpenCode 2 service", () => { + const paths: Array = []; + return Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.equal(snapshot.version, "0.0.0-beta-17823"); + NodeAssert.equal(snapshot.auth.status, "authenticated"); + NodeAssert.deepEqual(paths.toSorted(), [ + "GET /api/agent", + "GET /api/health", + "GET /api/model", + "GET /api/model/default", + "GET /api/skill", + ]); + NodeAssert.deepEqual( + snapshot.models.map((model) => ({ slug: model.slug, isDefault: model.isDefault })), + [ + { slug: "anthropic/claude-opus-4-6", isDefault: undefined }, + { slug: "openai/gpt-5.6", isDefault: true }, + ], + ); + const selected = snapshot.models.find((model) => model.slug === "openai/gpt-5.6"); + NodeAssert.deepEqual( + selected?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id) ?? [], + ["variant", "agent"], + ); + NodeAssert.deepEqual(snapshot.skills, [ + { + name: "ship", + path: "/work/.agents/skills/ship/SKILL.md", + enabled: true, + description: "Ship a change.", + shortDescription: "Ship a change.", + }, + ]); + }).pipe( + Effect.provide( + runtimeLayer({ + paths, + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823", pid: 42 }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + variants: { low: {}, high: {} }, + }, + { + id: "claude-opus-4-6", + providerID: "anthropic", + name: "Claude Opus 4.6", + variants: [], + }, + ], + }, + "/api/model/default": { + data: { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + variants: { low: {}, high: {} }, + }, + }, + "/api/agent": { + data: [ + { id: "build", name: "Build" }, + { id: "explore", name: "Explore", mode: "subagent" }, + ], + }, + "/api/skill": { + data: [ + { + name: "ship", + description: "Ship a change.", + location: "/work/.agents/skills/ship/SKILL.md", + }, + ], + }, + }, + }), + ), + ); }); -const testLayer = Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(NodeServices.layer), +it.effect("reports unsupported adjacent preview protocols without hanging", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.match(snapshot.message ?? "", /preview is not supported/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCodeRuntime.OpenCodeUnsupportedPreviewError({ + operation: "openapi.detect", + }), + }), + ), + ), ); -const makeOpenCodeSettings = (overrides?: Partial): OpenCodeSettings => - decodeOpenCodeSettings({ - enabled: true, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - ...overrides, - }); +it.effect("does not report an HTTP 404 as a missing CLI", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); -it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { - it.effect("shows a codex-style missing binary message", () => - Effect.gen(function* () { - runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.doesNotMatch(snapshot.message ?? "", /not installed/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "model.list", + reason: "http-status", + status: 404, + }), + }), + ), + ), +); - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, false); - NodeAssert.equal( - snapshot.message, - "OpenCode CLI (`opencode`) is not installed or not on PATH.", - ); - }), - ); +it.effect("reports a structurally missing CLI", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); - it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => - Effect.gen(function* () { - runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.equal(snapshot.installed, false); + NodeAssert.match(snapshot.message ?? "", /not installed or not on PATH/i); + }).pipe( + Effect.provide( + runtimeLayer({ + attachError: new OpenCodeRuntime.OpenCodeCommandNotFoundError({ + operation: "service.start", + cause: new Error("Command was unavailable."), + }), + }), + ), + ), +); - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); - }), - ); +it.effect("warns when the attached OpenCode 2 service reports unhealthy", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); - it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: { - none: {}, - low: {}, - medium: {}, - high: {}, - xhigh: {}, - }, - }, + NodeAssert.equal(snapshot.status, "warning"); + NodeAssert.match(snapshot.message ?? "", /reported that it is unhealthy/i); + }).pipe( + Effect.provide( + runtimeLayer({ + responses: { + "/api/health": { healthy: false, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", }, - }, - ], - default: {}, + ], + }, + "/api/model/default": { data: null }, + "/api/agent": { data: [] }, + "/api/skill": { data: [] }, }, - agents: [ - { name: "build", hidden: false, mode: "primary" }, - { name: "plan", hidden: false, mode: "primary" }, - ], - }; - - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); + }), + ), + ), +); - NodeAssert.ok(model); - const variantDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "variant" && descriptor.type === "select", - ); - NodeAssert.ok(variantDescriptor && variantDescriptor.type === "select"); - NodeAssert.equal( - variantDescriptor.options.find((option) => option.isDefault === true)?.id, - "medium", - ); - const agentDescriptor = model.capabilities?.optionDescriptors?.find( - (descriptor) => descriptor.id === "agent" && descriptor.type === "select", - ); - NodeAssert.ok(agentDescriptor && agentDescriptor.type === "select"); - NodeAssert.equal( - agentDescriptor.options.find((option) => option.isDefault === true)?.id, - "build", - ); - }), - ); +it.effect("describes custom models without counting deprecated upstream providers", () => { + const customSettings = { + ...settings, + customModels: ["custom/local-model"], + }; + return Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(customSettings); - it.effect("includes OpenCode skills in the provider snapshot", () => - Effect.gen(function* () { - runtimeMock.state.inventory = { - providerList: { - connected: ["openai"], - all: [ - { - id: "openai", - name: "OpenAI", - models: { - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", - variants: {}, - }, + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.match(snapshot.message ?? "", /custom models configured/i); + NodeAssert.doesNotMatch(snapshot.message ?? "", /0 upstream providers/i); + }).pipe( + Effect.provide( + runtimeLayer({ + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "old-model", + providerID: "legacy", + name: "Old Model", + status: "deprecated", }, - }, - ], - default: {}, - }, - agents: [], - skills: [ - { - name: "openclaw-review", - description: "Review OpenClaw workflow changes.", - location: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - content: "---\nname: openclaw-review\n---\n", - }, - { - name: "openclaw-triage", - description: "Triage OpenClaw routing issues.", - location: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - content: "---\nname: openclaw-triage\n---\n", - }, - { - name: "missing-location", - description: "This incomplete SDK row should be skipped.", - location: "", - content: "---\nname: missing-location\n---\n", + ], }, - ], - }; - - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - - NodeAssert.deepEqual( - snapshot.skills.map((skill) => ({ - name: skill.name, - path: skill.path, - enabled: skill.enabled, - shortDescription: skill.shortDescription, - })), - [ - { - name: "openclaw-review", - path: "/Users/test/.agents/skills/openclaw-review/SKILL.md", - enabled: true, - shortDescription: "Review OpenClaw workflow changes.", - }, - { - name: "openclaw-triage", - path: "/Users/test/.agents/skills/openclaw-triage/SKILL.md", - enabled: true, - shortDescription: "Triage OpenClaw routing issues.", - }, - ], - ); - }), - ); - - it.effect("does not spawn a local server for health check (uses CLI instead)", () => - Effect.gen(function* () { - yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - - NodeAssert.equal(runtimeMock.state.closeCalls, 0); - NodeAssert.equal(runtimeMock.state.inventoryCwd, process.cwd()); - }), - ); - - it.effect("reports local model inventory failures without treating them as empty", () => - Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error("opencode models failed"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal(snapshot.models.length, 0); - NodeAssert.equal( - snapshot.message, - "Failed to execute OpenCode CLI health check: opencode models failed", - ); - }), + "/api/model/default": { data: null }, + "/api/agent": { data: [] }, + "/api/skill": { data: [] }, + }, + }), + ), ); }); -it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { - it.effect("surfaces a friendly auth error for configured servers", () => - Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error("401 Unauthorized"); - const snapshot = yield* checkOpenCodeProviderStatus( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }), - process.cwd(), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "OpenCode server rejected authentication. Check the server URL and password.", - ); - }), +it.effect("finishes the provider check when attachment is wedged", () => { + const layer = Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime.of({ attach: () => Effect.never }), ); + return Effect.gen(function* () { + const fiber = yield* checkOpenCodeProviderStatus(settings).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + yield* TestClock.adjust("15 seconds"); + const snapshot = yield* Fiber.join(fiber); - it.effect("surfaces a friendly connection error for configured servers", () => - Effect.gen(function* () { - runtimeMock.state.inventoryError = new Error( - "fetch failed: connect ECONNREFUSED 127.0.0.1:9999", - ); - const snapshot = yield* checkOpenCodeProviderStatus( - makeOpenCodeSettings({ - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", - }), - process.cwd(), - ); - - NodeAssert.equal(snapshot.status, "error"); - NodeAssert.equal(snapshot.installed, true); - NodeAssert.equal( - snapshot.message, - "Couldn't reach the configured OpenCode server at http://127.0.0.1:9999. Check that the server is running and the URL is correct.", - ); - }), - ); + NodeAssert.equal(snapshot.status, "error"); + NodeAssert.match(snapshot.message ?? "", /discovery timed out/i); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, TestClock.layer()))); }); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..9e12b0efde28 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,215 +1,170 @@ -import { - type ModelCapabilities, - type OpenCodeSettings, - type ServerProviderModel, - type ServerProviderSkill, +import type { + ModelCapabilities, + ServerProviderModel, + ServerProviderSkill, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { createModelCapabilities } from "@t3tools/shared/model"; -import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, nonEmptyTrimmed, - parseGenericCliVersion, providerModelsFromSettings, type ServerProviderDraft, } from "../providerSnapshot.ts"; -import { - OpenCodeRuntime, - openCodeRuntimeErrorDetail, - type OpenCodeInventory, -} from "../opencodeRuntime.ts"; -import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; + +export interface OpenCodeProviderSettings { + readonly enabled: boolean; + readonly binaryPath: string; + readonly customModels: ReadonlyArray; +} const OPENCODE_PRESENTATION = { displayName: "OpenCode", showInteractionModeToggle: false, } as const; -const MINIMUM_OPENCODE_VERSION = "1.14.19"; - -class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ - readonly cause: unknown; - readonly detail: string; -}> {} - -function normalizeProbeMessage(message: string): string | undefined { - const trimmed = message.trim(); - if (trimmed.length === 0) { - return undefined; - } - if ( - trimmed === "An error occurred in Effect.tryPromise" || - trimmed === "An error occurred in Effect.try" - ) { - return undefined; - } - return trimmed; -} - -function normalizedErrorMessage(cause: unknown): string | undefined { - if (cause instanceof OpenCodeProbeError) { - return normalizeProbeMessage(cause.detail); - } +const OPENCODE_PROVIDER_PROBE_TIMEOUT = "15 seconds"; - if (!(cause instanceof Error)) { - return undefined; - } - - return normalizeProbeMessage(cause.message); -} +const OpenCodeHealthSchema = Schema.Struct({ + healthy: Schema.optionalKey(Schema.Boolean), + version: Schema.optionalKey(Schema.String), + pid: Schema.optionalKey(Schema.Number), +}); -function formatOpenCodeProbeError(input: { - readonly cause: unknown; - readonly isExternalServer: boolean; - readonly serverUrl: string; -}): { readonly installed: boolean; readonly message: string } { - const detail = normalizedErrorMessage(input.cause); - const lower = detail?.toLowerCase() ?? ""; +const OpenCodeVariantSchema = Schema.Union([Schema.String, Schema.Struct({ id: Schema.String })]); + +const OpenCodeModelSchema = Schema.Struct({ + id: Schema.String, + providerID: Schema.String, + name: Schema.String, + enabled: Schema.optionalKey(Schema.Boolean), + status: Schema.optionalKey(Schema.String), + variants: Schema.optionalKey( + Schema.Union([ + Schema.Array(OpenCodeVariantSchema), + Schema.Record(Schema.String, Schema.Unknown), + ]), + ), +}); - if (input.isExternalServer) { - if ( - lower.includes("401") || - lower.includes("403") || - lower.includes("unauthorized") || - lower.includes("forbidden") - ) { - return { - installed: true, - message: "OpenCode server rejected authentication. Check the server URL and password.", - }; - } +const OpenCodeModelListSchema = Schema.Union([ + Schema.Array(OpenCodeModelSchema), + Schema.Struct({ data: Schema.Array(OpenCodeModelSchema) }), +]); + +const OpenCodeDefaultModelSchema = Schema.Union([ + Schema.NullOr(OpenCodeModelSchema), + Schema.Struct({ data: Schema.NullOr(OpenCodeModelSchema) }), +]); + +const OpenCodeAgentSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + mode: Schema.optionalKey(Schema.String), + hidden: Schema.optionalKey(Schema.Boolean), + description: Schema.optionalKey(Schema.String), +}); - if ( - lower.includes("econnrefused") || - lower.includes("enotfound") || - lower.includes("fetch failed") || - lower.includes("networkerror") || - lower.includes("timed out") || - lower.includes("timeout") || - lower.includes("socket hang up") - ) { - return { - installed: true, - message: `Couldn't reach the configured OpenCode server at ${input.serverUrl}. Check that the server is running and the URL is correct.`, - }; - } +const OpenCodeAgentListSchema = Schema.Union([ + Schema.Array(OpenCodeAgentSchema), + Schema.Struct({ data: Schema.Array(OpenCodeAgentSchema) }), +]); - return { - installed: true, - message: detail ?? "Failed to connect to the configured OpenCode server.", - }; - } +const OpenCodeSkillSchema = Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), + location: Schema.String, +}); - if (lower.includes("enoent") || lower.includes("notfound")) { - return { - installed: false, - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - }; - } +const OpenCodeSkillListSchema = Schema.Union([ + Schema.Array(OpenCodeSkillSchema), + Schema.Struct({ data: Schema.Array(OpenCodeSkillSchema) }), +]); - if (lower.includes("quarantine")) { - return { - installed: true, - message: - "macOS is blocking the OpenCode binary (quarantine). Run `xattr -d com.apple.quarantine $(which opencode)` to fix this.", - }; - } +type OpenCodeModel = typeof OpenCodeModelSchema.Type; +type OpenCodeAgent = typeof OpenCodeAgentSchema.Type; +type OpenCodeSkill = typeof OpenCodeSkillSchema.Type; - if (lower.includes("invalid code signature") || lower.includes("corrupted")) { - return { - installed: true, - message: - "macOS killed the OpenCode process due to an invalid code signature. The binary may be corrupted — try reinstalling OpenCode.", - }; - } +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); - return { - installed: true, - message: detail - ? `Failed to execute OpenCode CLI health check: ${detail}` - : "Failed to execute OpenCode CLI health check.", - }; +function responseList(value: ReadonlyArray | { readonly data: ReadonlyArray }) { + return "data" in value ? value.data : value; } -function titleCaseSlug(value: string): string { - const segments: Array = []; - for (const segment of value.split(/[-_/]+/)) { - if (segment.length > 0) { - segments.push(segment.charAt(0).toUpperCase() + segment.slice(1)); - } - } - return segments.join(" "); +function responseValue(value: A | null | { readonly data: A | null }): A | null { + return value !== null && typeof value === "object" && "data" in value ? value.data : value; } -function inferDefaultVariant( - providerID: string, - variants: ReadonlyArray, -): string | undefined { - if (variants.length === 1) { - return variants[0]; - } - if (providerID === "anthropic" || providerID.startsWith("google")) { - return variants.includes("high") ? "high" : undefined; - } - if (providerID === "openai" || providerID === "opencode") { - return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; - } - return undefined; +function titleCase(value: string): string { + return value + .split(/[-_/]+/u) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); } -function inferDefaultAgent(agents: ReadonlyArray): string | undefined { - return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; +function variantIds(model: OpenCodeModel): ReadonlyArray { + if (!model.variants) return []; + if (Array.isArray(model.variants)) { + return model.variants.map((variant) => (typeof variant === "string" ? variant : variant.id)); + } + return Object.keys(model.variants); } -const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -function openCodeCapabilitiesForModel(input: { - readonly providerID: string; - readonly model: ProviderListResponse["all"][number]["models"][string]; - readonly agents: ReadonlyArray; -}): ModelCapabilities { - const variantValues = Object.keys(input.model.variants ?? {}); - const defaultVariant = inferDefaultVariant(input.providerID, variantValues); - const variantOptions = variantValues.map((value) => - defaultVariant === value - ? { id: value, label: titleCaseSlug(value), isDefault: true as const } - : { id: value, label: titleCaseSlug(value) }, - ); - const primaryAgents = input.agents.filter( - (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), - ); - const defaultAgent = inferDefaultAgent(primaryAgents); - const agentOptions = primaryAgents.map((agent) => - defaultAgent === agent.name - ? { id: agent.name, label: titleCaseSlug(agent.name), isDefault: true as const } - : { id: agent.name, label: titleCaseSlug(agent.name) }, +function modelCapabilities( + model: OpenCodeModel, + agents: ReadonlyArray, +): ModelCapabilities { + const variants = variantIds(model); + const defaultVariant = variants.includes("default") + ? "default" + : variants.includes("medium") + ? "medium" + : variants.length === 1 + ? variants[0] + : undefined; + const visibleAgents = agents.filter( + (agent) => agent.hidden !== true && agent.mode !== "subagent", ); + const defaultAgent = + visibleAgents.find((agent) => agent.id === "build")?.id ?? visibleAgents[0]?.id; + return createModelCapabilities({ optionDescriptors: [ - ...(variantOptions.length > 0 + ...(variants.length > 0 ? [ { id: "variant", label: "Variant", type: "select" as const, - options: variantOptions, + options: variants.map((variant) => ({ + id: variant, + label: titleCase(variant), + ...(variant === defaultVariant ? { isDefault: true as const } : {}), + })), ...(defaultVariant ? { currentValue: defaultVariant } : {}), }, ] : []), - ...(agentOptions.length > 0 + ...(visibleAgents.length > 0 ? [ { id: "agent", label: "Agent", type: "select" as const, - options: agentOptions, + options: visibleAgents.map((agent) => ({ + id: agent.id, + label: nonEmptyTrimmed(agent.name) ?? titleCase(agent.id), + ...(agent.id === defaultAgent ? { isDefault: true as const } : {}), + })), ...(defaultAgent ? { currentValue: defaultAgent } : {}), }, ] @@ -218,247 +173,196 @@ function openCodeCapabilitiesForModel(input: { }); } -function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { - const connected = new Set(input.providerList.connected); - const models: Array = []; - - for (const provider of input.providerList.all) { - if (!connected.has(provider.id)) { - continue; - } - - for (const model of Object.values(provider.models)) { - const name = nonEmptyTrimmed(model.name); - if (!name) { - continue; - } - - const subProvider = nonEmptyTrimmed(provider.name); - models.push({ - slug: `${provider.id}/${model.id}`, - name, - ...(subProvider ? { subProvider } : {}), +function providerModels(input: { + readonly models: ReadonlyArray; + readonly defaultModel: OpenCodeModel | null; + readonly agents: ReadonlyArray; +}): ReadonlyArray { + const defaultSlug = input.defaultModel + ? `${input.defaultModel.providerID}/${input.defaultModel.id}` + : null; + return input.models + .filter((model) => model.enabled !== false && model.status !== "deprecated") + .map((model) => { + const slug = `${model.providerID}/${model.id}`; + return { + slug, + name: nonEmptyTrimmed(model.name) ?? model.id, + subProvider: model.providerID, isCustom: false, - capabilities: openCodeCapabilitiesForModel({ - providerID: provider.id, - model, - agents: input.agents, - }), - }); - } - } - - return models.toSorted((left, right) => left.name.localeCompare(right.name)); + ...(slug === defaultSlug ? { isDefault: true as const } : {}), + capabilities: modelCapabilities(model, input.agents), + }; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); } -function trimOptional(value: string | null | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; +function providerSkills(skills: ReadonlyArray): ReadonlyArray { + return skills + .flatMap((skill) => { + const name = nonEmptyTrimmed(skill.name); + const path = nonEmptyTrimmed(skill.location); + if (!name || !path) return []; + const description = nonEmptyTrimmed(skill.description ?? ""); + return [ + { + name, + path, + enabled: true, + ...(description ? { description, shortDescription: description } : {}), + }, + ]; + }) + .toSorted((left, right) => left.name.localeCompare(right.name)); } -function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray { - const skills: ServerProviderSkill[] = []; - for (const skill of input.skills ?? []) { - const name = trimOptional(skill.name); - const path = trimOptional(skill.location); - if (!name || !path) { - continue; - } - - const description = trimOptional(skill.description); - skills.push({ - name, - path, - enabled: true, - ...(description ? { description, shortDescription: description } : {}), - }); - } - - return skills.toSorted((left, right) => left.name.localeCompare(right.name)); +function failureSnapshot(input: { + readonly settings: OpenCodeProviderSettings; + readonly checkedAt: string; + readonly cause: unknown; + readonly version?: string | null; +}): ServerProviderDraft { + const unsupported = OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(input.cause); + const missing = OpenCodeRuntime.isOpenCodeCommandNotFoundError(input.cause); + const timedOut = OpenCodeRuntime.isOpenCodeTimeoutError(input.cause); + return buildServerProvider({ + presentation: OPENCODE_PRESENTATION, + enabled: input.settings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + [], + input.settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: !missing, + version: input.version ?? null, + status: "error", + auth: { status: "unknown" }, + message: unsupported + ? "This OpenCode 2 preview is not supported by T3 Code." + : missing + ? "OpenCode 2 CLI (`opencode2`) is not installed or not on PATH." + : timedOut + ? "OpenCode 2 provider discovery timed out." + : input.cause instanceof Error + ? input.cause.message + : "Failed to connect to the OpenCode 2 background service.", + }, + }); } -export const makePendingOpenCodeProvider = ( - openCodeSettings: OpenCodeSettings, +export const buildInitialOpenCodeProviderSnapshot = ( + settings: OpenCodeProviderSettings, ): Effect.Effect => Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = providerModelsFromSettings( - [], - openCodeSettings.customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: - openCodeSettings.serverUrl.trim().length > 0 - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } - + const checkedAt = DateTime.formatIso(yield* DateTime.now); return buildServerProvider({ presentation: OPENCODE_PRESENTATION, - enabled: true, + enabled: settings.enabled, checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "OpenCode provider status has not been checked in this session yet.", - }, + models: providerModelsFromSettings( + [], + settings.customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: settings.enabled + ? { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 provider status has not been checked in this session yet.", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenCode 2 is disabled in T3 Code settings.", + }, }); }); export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( - openCodeSettings: OpenCodeSettings, - cwd: string, + settings: OpenCodeProviderSettings, environment?: NodeJS.ProcessEnv, -): Effect.fn.Return { - const openCodeRuntime = yield* OpenCodeRuntime; - const resolvedEnvironment = environment ?? process.env; +): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const customModels = openCodeSettings.customModels; - const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; - - const fallback = (cause: unknown, version: string | null = null) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - serverUrl: openCodeSettings.serverUrl, - }); - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, + if (!settings.enabled) return yield* buildInitialOpenCodeProviderSnapshot(settings); + + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const attached = yield* Effect.exit( + Effect.gen(function* () { + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath || "opencode2", + ...(environment ? { environment } : {}), + }); + const [health, modelsResponse, defaultResponse, agentsResponse, skillsResponse] = + yield* Effect.all( + [ + connection.request("GET", "/api/health", { + operation: "health.get", + schema: OpenCodeHealthSchema, + }), + connection.request("GET", "/api/model", { + operation: "model.list", + schema: OpenCodeModelListSchema, + }), + connection.request("GET", "/api/model/default", { + operation: "model.default", + schema: OpenCodeDefaultModelSchema, + }), + connection.request("GET", "/api/agent", { + operation: "agent.list", + schema: OpenCodeAgentListSchema, + }), + connection.request("GET", "/api/skill", { + operation: "skill.list", + schema: OpenCodeSkillListSchema, + }), + ], + { concurrency: "unbounded" }, + ); + return { + health, + models: responseList(modelsResponse), + defaultModel: responseValue(defaultResponse), + agents: responseList(agentsResponse), + skills: responseList(skillsResponse), + }; + }).pipe(Effect.timeoutOption(OPENCODE_PROVIDER_PROBE_TIMEOUT)), + ); + if (Exit.isFailure(attached)) { + return failureSnapshot({ + settings, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, + cause: Cause.squash(attached.cause), }); - }; + } - if (!openCodeSettings.enabled) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: false, + if (Option.isNone(attached.value)) { + return failureSnapshot({ + settings, checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: isExternalServer - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, + cause: new OpenCodeRuntime.OpenCodeTimeoutError({ + operation: "provider.probe", + }), }); } - let version: string | null = null; - if (!isExternalServer) { - const versionExit = yield* Effect.exit( - openCodeRuntime - .runOpenCodeCommand({ - binaryPath: openCodeSettings.binaryPath, - args: ["--version"], - environment: resolvedEnvironment, - }) - .pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), - ); - if (versionExit._tag === "Failure") { - return fallback(Cause.squash(versionExit.cause)); - } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; - - if (!version) { - return fallback( - new Error( - `Unable to determine OpenCode version from \`opencode --version\` output. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`, - ), - null, - ); - } - if (compareSemverVersions(version, MINIMUM_OPENCODE_VERSION) < 0) { - return buildServerProvider({ - presentation: OPENCODE_PRESENTATION, - enabled: openCodeSettings.enabled, - checkedAt, - models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: `OpenCode v${version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`, - }, - }); - } - } - - const inventoryExit = yield* Effect.exit( - (isExternalServer - ? Effect.scoped( - Effect.gen(function* () { - const server = yield* openCodeRuntime.connectToOpenCodeServer({ - binaryPath: openCodeSettings.binaryPath, - serverUrl: openCodeSettings.serverUrl, - environment: resolvedEnvironment, - }); - return yield* openCodeRuntime.loadOpenCodeInventory( - openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: cwd, - ...(openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }), - ); - }), - ) - : openCodeRuntime.loadInventoryFromCli({ - binaryPath: openCodeSettings.binaryPath, - cwd, - environment: resolvedEnvironment, - }) - ).pipe( - Effect.mapError( - (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version); - } - + const value = attached.value.value; + const discoveredModels = providerModels(value); + const skills = providerSkills(value.skills); const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), - customModels, + discoveredModels, + settings.customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value); - const connectedCount = inventoryExit.value.providerList.connected.length; + const providerCount = new Set( + discoveredModels.map((model) => model.subProvider).filter((provider) => provider !== undefined), + ).size; + const healthy = value.health.healthy !== false; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, @@ -467,18 +371,16 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu skills, probe: { installed: true, - version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", - }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", + version: value.health.version ?? null, + status: healthy && models.length > 0 ? "ready" : "warning", + auth: { status: "authenticated", type: "opencode" }, + message: !healthy + ? "The OpenCode 2 background service reported that it is unhealthy." + : providerCount > 0 + ? `${providerCount} upstream provider${providerCount === 1 ? "" : "s"} available through the OpenCode 2 background service.` + : models.length > 0 + ? "Connected to OpenCode 2 using the custom models configured in T3 Code." + : "Connected to OpenCode 2, but it did not report any available models.", }, }); }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index f0c928736994..97f49cc8ca42 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`, `opencode2`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -29,7 +29,6 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, - type OpenCode2Settings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -46,8 +45,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; -import * as OpenCode2Runtime from "../openCode2Runtime.ts"; -import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -124,15 +122,6 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ }); const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ - enabled: false, - binaryPath: "opencode", - serverUrl: "", - serverPassword: "", - customModels: [], - ...overrides, -}); - -const makeOpenCode2Config = (overrides: Partial): OpenCode2Settings => ({ enabled: false, binaryPath: "opencode2", customModels: [], @@ -299,19 +288,8 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { describe("ProviderInstanceRegistryLive — all drivers slice", () => { // All drivers need `NodeServices` (ChildProcessSpawner + FileSystem + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` - // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. - // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via - // `Layer.provide`, so the only external requirement it still exposes is - // `ChildProcessSpawner` — resolved here by piping it through - // `provideMerge(NodeServices.layer)`. - // - // The nested `provideMerge`s read bottom-up: `NodeServices.layer` - // provides `OpenCodeRuntimeLive`'s deps while keeping its own outputs - // surfaced; that merged layer then provides `ServerConfig.layerTest`'s - // `FileSystem` dep while keeping everything else surfaced to the test. - const infraLayer = Layer.merge(OpenCodeRuntimeLive, OpenCode2Runtime.layer).pipe( - Layer.provideMerge(NodeServices.layer), - ); + // at construction time, so we wire the shared native runtime into the stack. + const infraLayer = OpenCodeRuntime.layer.pipe(Layer.provideMerge(NodeServices.layer)); const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-all-drivers-test", }).pipe( @@ -329,14 +307,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); - const openCode2Id = ProviderInstanceId.make("opencode2_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); - const openCode2DriverKind = ProviderDriverKind.make("opencode2"); const configMap: ProviderInstanceConfigMap = { [codexId]: { @@ -372,12 +348,6 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeOpenCodeConfig({}), }, - [openCode2Id]: { - driver: openCode2DriverKind, - displayName: "OpenCode 2", - enabled: false, - config: makeOpenCode2Config({}), - }, }; const { registry } = yield* makeProviderInstanceRegistry({ @@ -391,9 +361,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(6); + expect(instances).toHaveLength(5); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId, openCode2Id].toSorted(), + [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -404,13 +374,11 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); const openCode = yield* registry.getInstance(openCodeId); - const openCode2 = yield* registry.getInstance(openCode2Id); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); - expect(openCode2?.driverKind).toBe(openCode2DriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fd77ef890d28..5ff8929eaa70 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,7 +34,6 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as OpenCode2Runtime from "../openCode2Runtime.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -1378,7 +1377,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, - opencode2: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; // the branded index signature rejects plain string literals @@ -1421,9 +1419,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge( - Layer.merge(OpenCodeRuntime.OpenCodeRuntimeLive, OpenCode2Runtime.layer), - ), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will @@ -1493,7 +1489,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te cursor: { enabled: false }, grok: { enabled: false }, opencode: { enabled: false }, - opencode2: { enabled: false }, }, }), ), @@ -1517,9 +1512,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge( - Layer.merge(OpenCodeRuntime.OpenCodeRuntimeLive, OpenCode2Runtime.layer), - ), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { spawnedCommands.push((command as { readonly command: string }).command); @@ -1641,9 +1634,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge( - Layer.merge(OpenCodeRuntime.OpenCodeRuntimeLive, OpenCode2Runtime.layer), - ), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), ); @@ -1681,7 +1672,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te grok: { enabled: false, }, - opencode2: { + opencode: { enabled: false, }, }, @@ -1708,9 +1699,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ProviderEventLoggers.NoOpProviderEventLoggers, ), ), - Layer.provideMerge( - Layer.merge(OpenCodeRuntime.OpenCodeRuntimeLive, OpenCode2Runtime.layer), - ), + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { @@ -1756,7 +1745,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "cursor", "grok", "opencode", - "opencode2", ]); assert.strictEqual(cursorProvider?.enabled, false); assert.strictEqual(cursorProvider?.status, "disabled"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 1365917d70fe..9c3badf0cb1a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -79,10 +79,7 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean (model.capabilities?.optionDescriptors?.length ?? 0) > 0; const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - if ( - provider.driver !== ProviderDriverKind.make("opencode") && - provider.driver !== ProviderDriverKind.make("opencode2") - ) { + if (provider.driver !== ProviderDriverKind.make("opencode")) { return true; } diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index d1de39336097..791a96e1da3c 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,7 +24,6 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; -import { OpenCode2Driver, type OpenCode2DriverEnv } from "./Drivers/OpenCode2Driver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -38,7 +37,6 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCode2DriverEnv | OpenCodeDriverEnv; /** @@ -52,5 +50,4 @@ export const BUILT_IN_DRIVERS: ReadonlyArray>; -} - -export interface OpenCode2RequestInput { - readonly operation: string; - readonly schema: S; - readonly body?: unknown; - readonly query?: Readonly>; -} - -export interface OpenCode2Connection { - readonly url: string; - readonly protocol: OpenCode2Protocol; - readonly request: ( - method: OpenCode2HttpMethod, - path: string, - input: OpenCode2RequestInput, - ) => Effect.Effect; - readonly globalEvents: Stream.Stream; -} - -const OpenCode2RuntimeFailureReason = Schema.Literals([ - "command-exit", - "connection-ended", - "http-status", - "transport", -]); - -export class OpenCode2RuntimeError extends Schema.TaggedErrorClass()( - "OpenCode2RuntimeError", - { - operation: Schema.String, - reason: OpenCode2RuntimeFailureReason, - status: Schema.optionalKey(Schema.Number), - exitCode: Schema.optionalKey(Schema.Number), - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - const status = this.status === undefined ? "" : `, HTTP ${this.status}`; - const exitCode = this.exitCode === undefined ? "" : `, exit ${this.exitCode}`; - return `OpenCode 2 operation '${this.operation}' failed (${this.reason}${status}${exitCode}).`; - } -} - -export class OpenCode2UnsupportedPreviewError extends Schema.TaggedErrorClass()( - "OpenCode2UnsupportedPreviewError", - { - operation: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return `This OpenCode 2 preview is not supported during '${this.operation}'.`; - } -} - -export class OpenCode2CommandNotFoundError extends Schema.TaggedErrorClass()( - "OpenCode2CommandNotFoundError", - { - operation: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `OpenCode 2 command was not found during '${this.operation}'.`; - } -} - -export class OpenCode2TimeoutError extends Schema.TaggedErrorClass()( - "OpenCode2TimeoutError", - { operation: Schema.String }, -) { - override get message(): string { - return `OpenCode 2 operation '${this.operation}' timed out.`; - } -} - -export const isOpenCode2RuntimeError = Schema.is(OpenCode2RuntimeError); -export const isOpenCode2UnsupportedPreviewError = Schema.is(OpenCode2UnsupportedPreviewError); -export const isOpenCode2CommandNotFoundError = Schema.is(OpenCode2CommandNotFoundError); -export const isOpenCode2TimeoutError = Schema.is(OpenCode2TimeoutError); - -export type OpenCode2RuntimeFailure = - | OpenCode2RuntimeError - | OpenCode2UnsupportedPreviewError - | OpenCode2CommandNotFoundError - | OpenCode2TimeoutError; - -export class OpenCode2Runtime extends Context.Service< - OpenCode2Runtime, - { - readonly attach: (input: { - readonly binaryPath: string; - readonly environment?: NodeJS.ProcessEnv; - }) => Effect.Effect; - } ->()("t3/provider/openCode2Runtime") {} - -export interface OpenCode2CommandResult { - readonly stdout: string; - readonly stderr: string; - readonly code: number; -} - -interface OpenCode2CommandInput { - readonly operation: string; - readonly binaryPath: string; - readonly args: ReadonlyArray; - readonly environment: NodeJS.ProcessEnv; -} - -const OpenCode2EventSchema = Schema.Struct({ - id: Schema.String, - type: Schema.String, - data: Schema.Unknown, - durable: Schema.optionalKey( - Schema.Struct({ - aggregateID: Schema.String, - seq: Schema.Number, - version: Schema.Number, - }), - ), - location: Schema.optionalKey( - Schema.Struct({ - directory: Schema.String, - workspaceID: Schema.optionalKey(Schema.String), - }), - ), - metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), -}); - -const OPENCODE2_ATTACH_REQUEST_TIMEOUT = "10 seconds"; - -function environmentKey(environment: NodeJS.ProcessEnv): string { - return JSON.stringify( - Object.entries(environment) - .filter((entry): entry is [string, string] => entry[1] !== undefined) - .toSorted(([left], [right]) => left.localeCompare(right)), - ); -} - -function attachKey(input: { - readonly binaryPath: string; - readonly environment: NodeJS.ProcessEnv; -}): string { - return `${input.binaryPath}\n${environmentKey(input.environment)}`; -} - -function parseServiceUrl(stdout: string): string | null { - for (const line of stdout.trim().split(/\r?\n/u).toReversed()) { - const candidate = line.trim(); - if (!URL.canParse(candidate)) continue; - const url = new URL(candidate); - if (url.protocol === "http:" || url.protocol === "https:") return url.toString(); - } - return null; -} - -function isRecord(value: unknown): value is Readonly> { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function localRef(document: unknown, ref: string): unknown { - if (!ref.startsWith("#/")) return undefined; - let value = document; - for (const rawSegment of ref.slice(2).split("/")) { - if (!isRecord(value)) return undefined; - const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~"); - value = value[segment]; - } - return value; -} - -function schemaPropertyNames( - document: unknown, - schema: unknown, - visited = new Set(), -): ReadonlySet { - if (!isRecord(schema) || visited.has(schema)) return new Set(); - visited.add(schema); - - const properties = isRecord(schema.properties) ? Object.keys(schema.properties) : []; - const nestedSchemas: Array = [schema.allOf, schema.anyOf, schema.oneOf] - .filter(Array.isArray) - .flat(); - if (typeof schema.$ref === "string") nestedSchemas.push(localRef(document, schema.$ref)); - - const result = new Set(properties); - for (const nested of nestedSchemas) { - for (const name of schemaPropertyNames(document, nested, visited)) result.add(name); - } - return result; -} - -function findOperation(value: unknown, operationId: string, visited = new Set()): unknown { - if (!isRecord(value) && !Array.isArray(value)) return undefined; - if (visited.has(value)) return undefined; - visited.add(value); - - if (isRecord(value) && value.operationId === operationId) return value; - for (const nested of Array.isArray(value) ? value : Object.values(value)) { - const found = findOperation(nested, operationId, visited); - if (found !== undefined) return found; - } - return undefined; -} - -function findPromptOperation(document: unknown): unknown { - for (const operationId of ["v2.session.prompt", "session.prompt"]) { - const operation = findOperation(document, operationId); - if (operation !== undefined) return operation; - } - - if (!isRecord(document)) return undefined; - const paths = document.paths; - if (!isRecord(paths)) return undefined; - for (const [path, methods] of Object.entries(paths)) { - if (!/^\/api\/session\/(?:\{[^}]+\}|:[^/]+)\/prompt$/u.test(path)) continue; - if (isRecord(methods) && isRecord(methods.post)) return methods.post; - } - return undefined; -} - -export function detectOpenCode2Protocol(document: unknown): OpenCode2Protocol | null { - const operation = findPromptOperation(document); - if (!isRecord(operation)) return null; - const requestBody = operation.requestBody; - const content = isRecord(requestBody) ? requestBody.content : undefined; - const jsonContent = isRecord(content) ? content["application/json"] : undefined; - const schema = isRecord(jsonContent) ? jsonContent.schema : undefined; - const properties = schemaPropertyNames(document, schema); - const promptShape = properties.has("text") ? "flat" : properties.has("prompt") ? "nested" : null; - if (promptShape === null) return null; - return { promptShape }; -} - -function requestUrl( - baseUrl: string, - path: string, - query: OpenCode2RequestInput["query"], -): string { - const url = new URL(path, baseUrl); - for (const [key, value] of Object.entries(query ?? {})) { - if (value !== undefined) url.searchParams.set(key, String(value)); - } - return url.toString(); -} - -function withAuthentication( - request: HttpClientRequest.HttpClientRequest, - password: string, -): HttpClientRequest.HttpClientRequest { - return request.pipe( - HttpClientRequest.basicAuth("opencode", password), - HttpClientRequest.acceptJson, - ); -} - -function makeRequest( - method: OpenCode2HttpMethod, - url: string, - body: unknown | undefined, -): HttpClientRequest.HttpClientRequest { - const request = HttpClientRequest.make(method)(url); - return body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body)); -} - -function isCommandNotFoundCause(cause: unknown): boolean { - return ( - cause instanceof PlatformError.PlatformError && - cause.reason instanceof PlatformError.SystemError && - cause.reason._tag === "NotFound" - ); -} - -function makeConnection(input: { - readonly baseUrl: string; - readonly password: string; - readonly httpClient: HttpClient.HttpClient; - readonly protocol: OpenCode2Protocol; -}): OpenCode2Connection { - const request: OpenCode2Connection["request"] = (method, path, requestInput) => - Effect.gen(function* () { - const response = yield* input.httpClient - .execute( - withAuthentication( - makeRequest( - method, - requestUrl(input.baseUrl, path, requestInput.query), - requestInput.body, - ), - input.password, - ), - ) - .pipe( - Effect.mapError( - (cause) => - new OpenCode2RuntimeError({ - operation: requestInput.operation, - reason: "transport", - cause, - }), - ), - ); - if (response.status < 200 || response.status >= 300) { - return yield* new OpenCode2RuntimeError({ - operation: requestInput.operation, - reason: "http-status", - status: response.status, - }); - } - const decodeEmptyResponse = Schema.decodeUnknownEffect(requestInput.schema); - const decodeResponse = - response.status === 204 - ? decodeEmptyResponse(undefined) - : HttpClientResponse.schemaBodyJson(requestInput.schema)(response); - return yield* decodeResponse.pipe( - Effect.mapError( - (cause) => - new OpenCode2UnsupportedPreviewError({ - operation: requestInput.operation, - cause, - }), - ), - ); - }); - - const globalEvents = Stream.unwrap( - input.httpClient - .execute( - withAuthentication( - HttpClientRequest.get(requestUrl(input.baseUrl, "/api/event", undefined)).pipe( - HttpClientRequest.setHeader("accept", "text/event-stream"), - ), - input.password, - ), - ) - .pipe( - Effect.mapError( - (cause) => - new OpenCode2RuntimeError({ - operation: "event.subscribe", - reason: "transport", - cause, - }), - ), - Effect.flatMap((response) => - response.status >= 200 && response.status < 300 - ? Effect.succeed(response.stream) - : Effect.fail( - new OpenCode2RuntimeError({ - operation: "event.subscribe", - reason: "http-status", - status: response.status, - }), - ), - ), - ), - ).pipe( - Stream.decodeText(), - Stream.pipeThroughChannel(Sse.decodeDataSchema(OpenCode2EventSchema)), - Stream.map((event) => event.data), - Stream.mapError((cause) => - isOpenCode2RuntimeError(cause) || - isOpenCode2UnsupportedPreviewError(cause) || - isOpenCode2CommandNotFoundError(cause) || - isOpenCode2TimeoutError(cause) - ? cause - : new OpenCode2UnsupportedPreviewError({ - operation: "event.subscribe", - cause, - }), - ), - ); - - return { - url: input.baseUrl, - protocol: input.protocol, - request, - globalEvents, - }; -} - -export const make = Effect.fn("OpenCode2Runtime.make")(function* () { - const httpClient = yield* HttpClient.HttpClient; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const connections = new Map(); - const attachLock = yield* Semaphore.make(1); - - const runCommand = (command: OpenCode2CommandInput) => - Effect.gen(function* () { - const spawn = yield* resolveSpawnCommand(command.binaryPath, command.args, { - env: command.environment, - }); - const child = yield* spawner - .spawn( - ChildProcess.make(spawn.command, spawn.args, { - env: command.environment, - shell: spawn.shell, - }), - ) - .pipe( - Effect.mapError((cause) => - isCommandNotFoundCause(cause) - ? new OpenCode2CommandNotFoundError({ - operation: command.operation, - cause, - }) - : new OpenCode2RuntimeError({ - operation: command.operation, - reason: "transport", - cause, - }), - ), - ); - const [stdout, stderr, code] = yield* Effect.all( - [ - collectStreamAsString(child.stdout), - collectStreamAsString(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError( - (cause) => - new OpenCode2RuntimeError({ - operation: command.operation, - reason: "transport", - cause, - }), - ), - ); - return { stdout, stderr, code }; - }).pipe( - Effect.scoped, - Effect.timeoutOption("10 seconds"), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new OpenCode2TimeoutError({ - operation: command.operation, - }), - ), - onSome: Effect.succeed, - }), - ), - ); - - const attach: OpenCode2Runtime["Service"]["attach"] = (attachInput) => { - const environment = attachInput.environment ?? process.env; - const key = attachKey({ binaryPath: attachInput.binaryPath, environment }); - return attachLock.withPermit( - Effect.gen(function* () { - const existing = connections.get(key); - if (existing) { - const health = yield* Effect.exit( - existing - .request("GET", "/api/health", { - operation: "health.get", - schema: Schema.Unknown, - }) - .pipe( - Effect.timeoutOption(OPENCODE2_ATTACH_REQUEST_TIMEOUT), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail(new OpenCode2TimeoutError({ operation: "health.get" })), - onSome: Effect.succeed, - }), - ), - ), - ); - if (Exit.isSuccess(health)) return existing; - connections.delete(key); - } - - const start = yield* runCommand({ - operation: "service.start", - binaryPath: attachInput.binaryPath, - args: ["service", "start"], - environment, - }); - if (start.code !== 0) { - return yield* new OpenCode2RuntimeError({ - operation: "service.start", - reason: "command-exit", - exitCode: start.code, - }); - } - const baseUrl = parseServiceUrl(start.stdout); - if (baseUrl === null) { - return yield* new OpenCode2UnsupportedPreviewError({ - operation: "service.start", - }); - } - - const passwordResult = yield* runCommand({ - operation: "service.get.password", - binaryPath: attachInput.binaryPath, - args: ["service", "get", "password"], - environment, - }); - if (passwordResult.code !== 0) { - return yield* new OpenCode2RuntimeError({ - operation: "service.get.password", - reason: "command-exit", - exitCode: passwordResult.code, - }); - } - const password = passwordResult.stdout.trim(); - if (password.length === 0) { - return yield* new OpenCode2UnsupportedPreviewError({ - operation: "service.get.password", - }); - } - - const provisional = makeConnection({ - baseUrl, - password, - httpClient, - protocol: { promptShape: "flat" }, - }); - const document = yield* provisional - .request("GET", "/openapi.json", { - operation: "openapi.get", - schema: Schema.Unknown, - }) - .pipe( - Effect.timeoutOption(OPENCODE2_ATTACH_REQUEST_TIMEOUT), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new OpenCode2TimeoutError({ operation: "openapi.get" })), - onSome: Effect.succeed, - }), - ), - ); - const protocol = detectOpenCode2Protocol(document); - if (protocol === null) { - return yield* new OpenCode2UnsupportedPreviewError({ - operation: "openapi.detect", - }); - } - - const connection = makeConnection({ - baseUrl, - password, - httpClient, - protocol, - }); - connections.set(key, connection); - return connection; - }), - ); - }; - - return OpenCode2Runtime.of({ attach }); -}); - -export const layer = Layer.effect(OpenCode2Runtime, make()); diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts deleted file mode 100644 index 8d5ba353389d..000000000000 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import { describe, it } from "vite-plus/test"; - -import { - parseAgentListCliOutput, - parseModelsCliOutput, - parseSkillsCliOutput, -} from "./opencodeRuntime.ts"; - -describe("parseModelsCliOutput", () => { - it("parses a single model from a single provider", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - JSON.stringify({ - id: "claude-sonnet-4-5", - providerID: "anthropic", - name: "Claude Sonnet 4.5", - capabilities: { temperature: true, reasoning: true, toolcall: true }, - cost: { input: 3, output: 15 }, - limit: { context: 200000, output: 8192 }, - status: "active", - options: {}, - headers: {}, - release_date: "2025-01-01", - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.equal(result.connected.length, 1); - NodeAssert.equal(result.connected[0], "anthropic"); - - const provider = result.providers.get("anthropic")!; - NodeAssert.ok(provider); - NodeAssert.equal(provider.id, "anthropic"); - NodeAssert.equal(provider.name, "anthropic"); - NodeAssert.equal(Object.keys(provider.models).length, 1); - - const model = provider.models["claude-sonnet-4-5"]!; - NodeAssert.ok(model); - NodeAssert.equal(model.id, "claude-sonnet-4-5"); - NodeAssert.equal(model.providerID, "anthropic"); - NodeAssert.equal(model.name, "Claude Sonnet 4.5"); - }); - - it("parses multiple models from multiple providers", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet 4.5" }), - "anthropic/claude-haiku-4-5", - JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), - "openai/gpt-4o", - JSON.stringify({ id: "gpt-4o", providerID: "openai", name: "GPT-4o" }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 2); - NodeAssert.equal(result.connected.length, 2); - NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); - NodeAssert.equal(Object.keys(result.providers.get("anthropic")!.models).length, 2); - NodeAssert.equal(Object.keys(result.providers.get("openai")!.models).length, 1); - }); - - it("handles empty input", () => { - const result = parseModelsCliOutput(""); - NodeAssert.equal(result.providers.size, 0); - NodeAssert.equal(result.connected.length, 0); - }); - - it("skips unparseable JSON blocks", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - "this is not valid json {{{", - "anthropic/claude-haiku-4-5", - JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - const provider = result.providers.get("anthropic")!; - NodeAssert.equal(Object.keys(provider.models).length, 1); - NodeAssert.ok(provider.models["claude-haiku-4-5"]); - }); - - it("handles Windows-style CRLF line endings", () => { - const stdout = - "anthropic/claude-sonnet-4-5\r\n" + - JSON.stringify({ id: "claude-sonnet-4-5", providerID: "anthropic", name: "Sonnet" }) + - "\r\n"; - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); - }); - - it("handles model JSON with variants and nested fields", () => { - const stdout = [ - "opencode/gpt-5.4", - JSON.stringify({ - id: "gpt-5.4", - providerID: "opencode", - name: "GPT-5.4", - family: "gpt", - capabilities: { - temperature: true, - reasoning: true, - attachment: false, - toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, - output: { text: true, audio: false, image: false, video: false, pdf: false }, - interleaved: false, - }, - cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - limit: { context: 200000, input: 160000, output: 32000 }, - status: "active", - options: {}, - headers: {}, - release_date: "2025-01-01", - variants: { none: {}, low: {}, medium: {}, high: {} }, - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - const model = result.providers.get("opencode")!.models["gpt-5.4"]!; - NodeAssert.ok(model); - NodeAssert.ok(model.capabilities); - NodeAssert.equal(model.capabilities!.reasoning, true); - NodeAssert.ok(model.variants); - NodeAssert.equal(model.variants!["medium"] !== undefined, true); - }); - - it("keeps a model whose JSON body has a slash and no interior whitespace", () => { - // OpenRouter-style: the model id contains a `/` and no string value has a - // space, so the JSON body line itself matches the slug regex. It must still - // be treated as the body of the preceding slug, not a new slug. - const stdout = [ - "openrouter/qwen/qwen3-coder", - JSON.stringify({ - id: "qwen/qwen3-coder", - providerID: "openrouter", - name: "qwen3-coder", - status: "active", - }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - NodeAssert.deepEqual([...result.connected], ["openrouter"]); - const provider = result.providers.get("openrouter")!; - NodeAssert.ok(provider); - const model = provider.models["qwen/qwen3-coder"]!; - NodeAssert.ok(model); - NodeAssert.equal(model.id, "qwen/qwen3-coder"); - NodeAssert.equal(model.providerID, "openrouter"); - }); -}); - -describe("parseAgentListCliOutput", () => { - it("parses a single agent", () => { - const stdout = [ - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.name, "build"); - NodeAssert.equal(result[0]!.mode, "primary"); - NodeAssert.equal(result[0]!.permission.length, 1); - }); - - it("parses multiple agents", () => { - const stdout = [ - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - "explore (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - "plan (primary)", - " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.md" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 3); - NodeAssert.equal(result[0]!.name, "build"); - NodeAssert.equal(result[0]!.mode, "primary"); - NodeAssert.equal(result[1]!.name, "explore"); - NodeAssert.equal(result[1]!.mode, "subagent"); - NodeAssert.equal(result[2]!.name, "plan"); - NodeAssert.equal(result[2]!.mode, "primary"); - }); - - it("handles empty input", () => { - const result = parseAgentListCliOutput(""); - NodeAssert.equal(result.length, 0); - }); - - it("skips agents with unparseable permission JSON", () => { - const stdout = [ - "build (primary)", - " not valid json {", - "explore (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.name, "explore"); - }); - - it("handles real-world permission blocks with nested paths", () => { - const permissions = [ - { permission: "*", action: "allow", pattern: "*" }, - { - permission: "external_directory", - pattern: "C:\\Users\\test\\.local\\*", - action: "allow", - }, - { permission: "read", pattern: "*.env", action: "ask" }, - ]; - const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.permission.length, 3); - NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); - NodeAssert.equal(result[0]!.permission[2]!.action, "ask"); - }); - - it("handles agent names with spaces", () => { - const stdout = [ - "code reviewer (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - "my custom agent (primary)", - " " + JSON.stringify([{ permission: "edit", action: "ask", pattern: "*.ts" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 2); - NodeAssert.equal(result[0]!.name, "code reviewer"); - NodeAssert.equal(result[0]!.mode, "subagent"); - NodeAssert.equal(result[1]!.name, "my custom agent"); - NodeAssert.equal(result[1]!.mode, "primary"); - }); - - it("marks known hidden agents", () => { - const stdout = [ - "compaction (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - "build (primary)", - " " + JSON.stringify([{ permission: "*", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result[0]!.hidden, true); - NodeAssert.equal(result[1]!.hidden, false); - }); -}); - -describe("parseSkillsCliOutput", () => { - it("parses skill metadata from the CLI JSON output", () => { - const result = parseSkillsCliOutput( - JSON.stringify([ - { - name: "review-pr", - description: "Review a pull request.", - location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", - }, - ]), - ); - - NodeAssert.deepEqual(result, [ - { - name: "review-pr", - description: "Review a pull request.", - location: "/tmp/review-pr/SKILL.md", - content: "---\nname: review-pr\n---\n", - }, - ]); - }); - - it("degrades malformed output to an empty skill list", () => { - NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); - }); -}); diff --git a/apps/server/src/provider/opencodeRuntime.environment.test.ts b/apps/server/src/provider/opencodeRuntime.environment.test.ts deleted file mode 100644 index b56921a686fb..000000000000 --- a/apps/server/src/provider/opencodeRuntime.environment.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { resolveOpenCodeConfigContent } from "./opencodeRuntime.ts"; - -describe("resolveOpenCodeConfigContent", () => { - it("prefers the caller environment over the inherited environment", () => { - expect( - resolveOpenCodeConfigContent( - { OPENCODE_CONFIG_CONTENT: '{"source":"caller"}' }, - { OPENCODE_CONFIG_CONTENT: '{"source":"process"}' }, - ), - ).toBe('{"source":"caller"}'); - }); - - it("falls back to the inherited environment and then an empty config", () => { - expect( - resolveOpenCodeConfigContent(undefined, { - OPENCODE_CONFIG_CONTENT: '{"source":"process"}', - }), - ).toBe('{"source":"process"}'); - expect(resolveOpenCodeConfigContent(undefined, {})).toBe("{}"); - }); -}); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts deleted file mode 100644 index 8b22a52a2060..000000000000 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { OpencodeClient } from "@opencode-ai/sdk/v2"; -import { it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; - -import { OpenCodeRuntime, OpenCodeRuntimeLive } from "./opencodeRuntime.ts"; - -const testLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer)); - -it.layer(testLayer)("loadOpenCodeInventory", (it) => { - it.effect("keeps provider inventory when skill discovery fails", () => - Effect.gen(function* () { - const runtime = yield* OpenCodeRuntime; - const client = { - provider: { - list: () => - Promise.resolve({ - data: { - connected: ["openai"], - all: [], - default: {}, - }, - }), - }, - app: { - agents: () => Promise.resolve({ data: [] }), - skills: () => Promise.reject(new Error("skills endpoint unavailable")), - }, - } as unknown as OpencodeClient; - - const inventory = yield* runtime.loadOpenCodeInventory(client); - - NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - NodeAssert.deepEqual(inventory.agents, []); - NodeAssert.deepEqual(inventory.skills, []); - }), - ); -}); diff --git a/apps/server/src/provider/openCode2Runtime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts similarity index 84% rename from apps/server/src/provider/openCode2Runtime.test.ts rename to apps/server/src/provider/opencodeRuntime.test.ts index 3b9267bc8600..ed6c61185fff 100644 --- a/apps/server/src/provider/openCode2Runtime.test.ts +++ b/apps/server/src/provider/opencodeRuntime.test.ts @@ -10,7 +10,7 @@ import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import * as OpenCode2Runtime from "./openCode2Runtime.ts"; +import * as OpenCodeRuntime from "./opencodeRuntime.ts"; interface RecordedCommand { readonly binaryPath: string; @@ -18,7 +18,7 @@ interface RecordedCommand { readonly environment: NodeJS.ProcessEnv; } -function commandHandle(result: OpenCode2Runtime.OpenCode2CommandResult) { +function commandHandle(result: OpenCodeRuntime.OpenCodeCommandResult) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), @@ -66,6 +66,9 @@ function openApi( function fakeRuntime(input?: { readonly document?: unknown; + readonly onCommand?: ( + args: ReadonlyArray, + ) => OpenCodeRuntime.OpenCodeCommandResult | undefined; readonly onRequest?: (request: HttpClientRequest.HttpClientRequest) => Response; readonly onExecute?: (request: HttpClientRequest.HttpClientRequest) => Effect.Effect; }) { @@ -84,9 +87,10 @@ function fakeRuntime(input?: { environment: value.options.env, }); return commandHandle( - value.args.join(" ") === "service start" - ? { stdout: "http://127.0.0.1:49374\n", stderr: "", code: 0 } - : { stdout: "private-password\n", stderr: "", code: 0 }, + input?.onCommand?.(value.args) ?? + (value.args.join(" ") === "service start" + ? { stdout: "http://127.0.0.1:49374\n", stderr: "", code: 0 } + : { stdout: "private-password\n", stderr: "", code: 0 }), ); }), ); @@ -107,7 +111,7 @@ function fakeRuntime(input?: { return { commands, requests, - runtime: OpenCode2Runtime.make().pipe( + runtime: OpenCodeRuntime.make().pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), @@ -148,6 +152,28 @@ it.effect("memoizes one service attachment per binary and environment", () => { }); }); +it.effect("supports the current OpenCode 2 service password command", () => { + const fake = fakeRuntime({ + onCommand: (args) => + args.join(" ") === "service get password" + ? { stdout: "", stderr: "Unknown subcommand", code: 1 } + : undefined, + }); + return Effect.gen(function* () { + const runtime = yield* fake.runtime; + yield* runtime.attach({ binaryPath: "opencode2" }); + + NodeAssert.deepEqual( + fake.commands.map((command) => command.args), + [ + ["service", "start"], + ["service", "get", "password"], + ["service", "password"], + ], + ); + }); +}); + it.effect("refreshes a cached attachment when the shared service is no longer healthy", () => { let healthFails = false; const fake = fakeRuntime({ @@ -244,7 +270,7 @@ it.effect("fails explicitly when the preview OpenAPI shape is unknown", () => { const runtime = yield* fake.runtime; const error = yield* Effect.flip(runtime.attach({ binaryPath: "opencode2" })); - NodeAssert.equal(OpenCode2Runtime.isOpenCode2UnsupportedPreviewError(error), true); + NodeAssert.equal(OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(error), true); NodeAssert.equal(error.operation, "openapi.detect"); NodeAssert.match(error.message, /preview is not supported/i); }); @@ -270,7 +296,7 @@ it.effect("times out a wedged service command", () => { ), ); return Effect.gen(function* () { - const runtime = yield* OpenCode2Runtime.make().pipe( + const runtime = yield* OpenCodeRuntime.make().pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); @@ -279,8 +305,8 @@ it.effect("times out a wedged service command", () => { yield* TestClock.adjust("10 seconds"); const error = yield* Fiber.join(fiber).pipe(Effect.flip); - NodeAssert.equal(OpenCode2Runtime.isOpenCode2TimeoutError(error), true); - if (!OpenCode2Runtime.isOpenCode2TimeoutError(error)) return; + NodeAssert.equal(OpenCodeRuntime.isOpenCodeTimeoutError(error), true); + if (!OpenCodeRuntime.isOpenCodeTimeoutError(error)) return; NodeAssert.equal(error.operation, "service.start"); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())); }); @@ -305,8 +331,8 @@ it.effect("times out a wedged OpenAPI probe and releases the attachment lock", ( yield* TestClock.adjust("10 seconds"); const error = yield* Fiber.join(first).pipe(Effect.flip); - NodeAssert.equal(OpenCode2Runtime.isOpenCode2TimeoutError(error), true); - if (!OpenCode2Runtime.isOpenCode2TimeoutError(error)) return; + NodeAssert.equal(OpenCodeRuntime.isOpenCodeTimeoutError(error), true); + if (!OpenCodeRuntime.isOpenCodeTimeoutError(error)) return; NodeAssert.equal(error.operation, "openapi.get"); const connection = yield* runtime.attach({ @@ -319,19 +345,19 @@ it.effect("times out a wedged OpenAPI probe and releases the attachment lock", ( }); it("detects both adjacent preview prompt shapes", () => { - NodeAssert.deepEqual(OpenCode2Runtime.detectOpenCode2Protocol(openApi("text")), { + NodeAssert.deepEqual(OpenCodeRuntime.detectOpenCodeProtocol(openApi("text")), { promptShape: "flat", }); - NodeAssert.deepEqual(OpenCode2Runtime.detectOpenCode2Protocol(openApi("prompt")), { + NodeAssert.deepEqual(OpenCodeRuntime.detectOpenCodeProtocol(openApi("prompt")), { promptShape: "nested", }); NodeAssert.deepEqual( - OpenCode2Runtime.detectOpenCode2Protocol(openApi("prompt", "session.prompt")), + OpenCodeRuntime.detectOpenCodeProtocol(openApi("prompt", "session.prompt")), { promptShape: "nested", }, ); - NodeAssert.deepEqual(OpenCode2Runtime.detectOpenCode2Protocol(openApi("prompt", null)), { + NodeAssert.deepEqual(OpenCodeRuntime.detectOpenCodeProtocol(openApi("prompt", null)), { promptShape: "nested", }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..6a83acd952cb 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,107 +1,136 @@ -import * as NodeURL from "node:url"; - -import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; -import { - createOpencodeClient, - type Agent, - type FilePartInput, - type Model, - type OpencodeClient, - type PermissionRuleset, - type ProviderListResponse, - type QuestionAnswer, - type QuestionRequest, -} from "@opencode-ai/sdk/v2"; -import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as P from "effect/Predicate"; -import * as Ref from "effect/Ref"; -import * as Result from "effect/Result"; -import * as Scope from "effect/Scope"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as Sse from "effect/unstable/encoding/Sse"; -import { isWindowsCommandNotFound } from "../processRunner.ts"; -import { collectStreamAsString } from "./providerSnapshot.ts"; -import * as NetService from "@t3tools/shared/Net"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); -const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; +import { collectStreamAsString } from "./providerSnapshot.ts"; -export function resolveOpenCodeConfigContent( - inputEnvironment: Readonly> | undefined, - inheritedEnvironment: Readonly> = process.env, -): string { - return ( - inputEnvironment?.OPENCODE_CONFIG_CONTENT ?? - inheritedEnvironment.OPENCODE_CONFIG_CONTENT ?? - OPENCODE_EMPTY_CONFIG_CONTENT - ); +export type OpenCodeHttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +export interface OpenCodeProtocol { + readonly promptShape: "flat" | "nested"; } -const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; -const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000; -const DEFAULT_HOSTNAME = "127.0.0.1"; -export interface OpenCodeServerProcess { - readonly url: string; - readonly exitCode: Effect.Effect; +export interface OpenCodeEvent { + readonly id: string; + readonly type: string; + readonly data: unknown; + readonly durable?: { + readonly aggregateID: string; + readonly seq: number; + readonly version: number; + }; + readonly location?: { + readonly directory: string; + readonly workspaceID?: string; + }; + readonly metadata?: Readonly>; +} + +export interface OpenCodeRequestInput { + readonly operation: string; + readonly schema: S; + readonly body?: unknown; + readonly query?: Readonly>; } -export interface OpenCodeServerConnection { +export interface OpenCodeConnection { readonly url: string; - readonly exitCode: Effect.Effect | null; - readonly external: boolean; + readonly protocol: OpenCodeProtocol; + readonly request: ( + method: OpenCodeHttpMethod, + path: string, + input: OpenCodeRequestInput, + ) => Effect.Effect; + readonly globalEvents: Stream.Stream; } -const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; -export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ - readonly operation: string; - readonly cause?: unknown; - readonly detail: string; -}> { - static readonly is = (u: unknown): u is OpenCodeRuntimeError => - P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); +const OpenCodeRuntimeFailureReason = Schema.Literals([ + "command-exit", + "connection-ended", + "http-status", + "transport", +]); + +export class OpenCodeRuntimeError extends Schema.TaggedErrorClass()( + "OpenCodeRuntimeError", + { + operation: Schema.String, + reason: OpenCodeRuntimeFailureReason, + status: Schema.optionalKey(Schema.Number), + exitCode: Schema.optionalKey(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const status = this.status === undefined ? "" : `, HTTP ${this.status}`; + const exitCode = this.exitCode === undefined ? "" : `, exit ${this.exitCode}`; + return `OpenCode 2 operation '${this.operation}' failed (${this.reason}${status}${exitCode}).`; + } } -function encodeJsonStringForDiagnostics(input: unknown): string | undefined { - const result = encodeUnknownJsonStringExit(input); - return Exit.isSuccess(result) ? result.value : undefined; +export class OpenCodeUnsupportedPreviewError extends Schema.TaggedErrorClass()( + "OpenCodeUnsupportedPreviewError", + { + operation: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `This OpenCode 2 preview is not supported during '${this.operation}'.`; + } } -export function openCodeRuntimeErrorDetail(cause: unknown): string { - if (OpenCodeRuntimeError.is(cause)) return cause.detail; - if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); - if (cause && typeof cause === "object") { - // SDK v2 throws { response, request, error? } shapes — extract what's useful - const anyCause = cause as Record; - const status = (anyCause.response as { status?: number } | undefined)?.status; - const body = anyCause.error ?? anyCause.data ?? anyCause.body; - const encodedBody = encodeJsonStringForDiagnostics(body ?? cause); - if (encodedBody) { - return `status=${status ?? "?"} body=${encodedBody}`; - } +export class OpenCodeCommandNotFoundError extends Schema.TaggedErrorClass()( + "OpenCodeCommandNotFoundError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode 2 command was not found during '${this.operation}'.`; } - return String(cause); } -export const runOpenCodeSdk = ( - operation: string, - fn: () => Promise, -): Effect.Effect => - Effect.tryPromise({ - try: fn, - catch: (cause) => - new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), - }).pipe(Effect.withSpan(`opencode.${operation}`)); +export class OpenCodeTimeoutError extends Schema.TaggedErrorClass()( + "OpenCodeTimeoutError", + { operation: Schema.String }, +) { + override get message(): string { + return `OpenCode 2 operation '${this.operation}' timed out.`; + } +} + +export const isOpenCodeRuntimeError = Schema.is(OpenCodeRuntimeError); +export const isOpenCodeUnsupportedPreviewError = Schema.is(OpenCodeUnsupportedPreviewError); +export const isOpenCodeCommandNotFoundError = Schema.is(OpenCodeCommandNotFoundError); +export const isOpenCodeTimeoutError = Schema.is(OpenCodeTimeoutError); + +export type OpenCodeRuntimeFailure = + | OpenCodeRuntimeError + | OpenCodeUnsupportedPreviewError + | OpenCodeCommandNotFoundError + | OpenCodeTimeoutError; + +export class OpenCodeRuntime extends Context.Service< + OpenCodeRuntime, + { + readonly attach: (input: { + readonly binaryPath: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect; + } +>()("t3/provider/opencodeRuntime") {} export interface OpenCodeCommandResult { readonly stdout: string; @@ -109,720 +138,468 @@ export interface OpenCodeCommandResult { readonly code: number; } -export interface OpenCodeInventory { - readonly providerList: ProviderListResponse; - readonly agents: ReadonlyArray; - readonly skills: ReadonlyArray; +interface OpenCodeCommandInput { + readonly operation: string; + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly environment: NodeJS.ProcessEnv; } -export interface ParsedOpenCodeModelSlug { - readonly providerID: string; - readonly modelID: string; -} +const OpenCodeEventSchema = Schema.Struct({ + id: Schema.String, + type: Schema.String, + data: Schema.Unknown, + durable: Schema.optionalKey( + Schema.Struct({ + aggregateID: Schema.String, + seq: Schema.Number, + version: Schema.Number, + }), + ), + location: Schema.optionalKey( + Schema.Struct({ + directory: Schema.String, + workspaceID: Schema.optionalKey(Schema.String), + }), + ), + metadata: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}); + +const OPENCODE_ATTACH_REQUEST_TIMEOUT = "10 seconds"; -export interface OpenCodeSkill { - readonly name?: string | null; - readonly description?: string | null; - readonly location?: string | null; - readonly content?: string | null; +function environmentKey(environment: NodeJS.ProcessEnv): string { + return JSON.stringify( + Object.entries(environment) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .toSorted(([left], [right]) => left.localeCompare(right)), + ); } -const OpenCodeSkillSchema = Schema.Struct({ - name: Schema.optionalKey(Schema.NullOr(Schema.String)), - description: Schema.optionalKey(Schema.NullOr(Schema.String)), - location: Schema.optionalKey(Schema.NullOr(Schema.String)), - content: Schema.optionalKey(Schema.NullOr(Schema.String)), -}); -const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit( - Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)), -); - -export interface OpenCodeRuntimeShape { - /** - * Spawns a local OpenCode server process. Its lifetime is bound to the caller's - * `Scope.Scope` — the child is killed automatically when that scope closes. - * Consumers that want a long-lived server must create and hold a scope explicitly - * (see {@link Scope.make}) and close it when done. - */ - readonly startOpenCodeServerProcess: (input: { - readonly binaryPath: string; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - }) => Effect.Effect; - /** - * Returns a handle to either an externally-managed OpenCode server (when - * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a - * freshly spawned local server whose lifetime is bound to the caller's scope. - */ - readonly connectToOpenCodeServer: (input: { - readonly binaryPath: string; - readonly serverUrl?: string | null; - readonly environment?: NodeJS.ProcessEnv; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; - }) => Effect.Effect; - readonly runOpenCodeCommand: (input: { - readonly binaryPath: string; - readonly args: ReadonlyArray; - readonly environment?: NodeJS.ProcessEnv; - readonly cwd?: string; - }) => Effect.Effect; - readonly createOpenCodeSdkClient: (input: { - readonly baseUrl: string; - readonly directory: string; - readonly serverPassword?: string; - }) => OpencodeClient; - readonly loadOpenCodeInventory: ( - client: OpencodeClient, - ) => Effect.Effect; - readonly loadInventoryFromCli: (input: { - readonly binaryPath: string; - readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; - }) => Effect.Effect; +function attachKey(input: { + readonly binaryPath: string; + readonly environment: NodeJS.ProcessEnv; +}): string { + return `${input.binaryPath}\n${environmentKey(input.environment)}`; } -function parseServerUrlFromOutput(output: string): string | null { - for (const line of output.split("\n")) { - if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) { - continue; - } - const match = line.match(/on\s+(https?:\/\/[^\s]+)/); - return match?.[1] ?? null; +function parseServiceUrl(stdout: string): string | null { + for (const line of stdout.trim().split(/\r?\n/u).toReversed()) { + const candidate = line.trim(); + if (!URL.canParse(candidate)) continue; + const url = new URL(candidate); + if (url.protocol === "http:" || url.protocol === "https:") return url.toString(); } return null; } -const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/; -const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; - -// Agents that are always hidden in OpenCode but the CLI "agent list" command -// does not expose the hidden flag. Keep in sync with OpenCode agent -// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts). -const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); - -/** @internal */ -export function parseModelsCliOutput(stdout: string): { - readonly providers: ReadonlyMap< - string, - { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } - >; - readonly connected: ReadonlyArray; -} { - const providers = new Map< - string, - { id: string; name: string; models: { [key: string]: Model } } - >(); - const lines = stdout.split("\n"); - let currentSlug: string | null = null; - const jsonLines: Array = []; - - const flushModel = () => { - if (currentSlug !== null && jsonLines.length > 0) { - const jsonStr = jsonLines.join("\n").trim(); - if (jsonStr.length > 0) { - try { - const model = JSON.parse(jsonStr) as Model; - const separator = currentSlug.indexOf("/"); - if (separator > 0) { - const providerID = currentSlug.slice(0, separator); - const modelID = currentSlug.slice(separator + 1); - let provider = providers.get(providerID); - if (!provider) { - provider = { id: providerID, name: providerID, models: {} }; - providers.set(providerID, provider); - } - provider.models[modelID] = model; - } - } catch { - // Skip unparseable model JSON - } - } - } - currentSlug = null; - jsonLines.length = 0; - }; - - for (const line of lines) { - // A model's JSON body is a single `JSON.stringify` line starting with `{`, - // while a provider/model slug is a bare `provider/model` header. Only the - // latter can be a slug: without this guard a body line with no interior - // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose - // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against - // an empty body and the model is silently dropped. - const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); - if (slugMatch) { - flushModel(); - currentSlug = slugMatch[1]!; - } else if (currentSlug !== null) { - jsonLines.push(line); - } - } - flushModel(); - - return { providers, connected: [...providers.keys()] }; +function isRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === "object" && !Array.isArray(value); } -/** @internal */ -export function parseAgentListCliOutput(stdout: string): ReadonlyArray { - const agents: Array = []; - const lines = stdout.split("\n"); - let currentHeader: { name: string; mode: string } | null = null; - const blockLines: Array = []; - - const flushAgent = () => { - if (currentHeader !== null) { - const jsonStr = blockLines.join("\n").trim(); - if (jsonStr.length > 0) { - try { - const permission = JSON.parse(jsonStr); - agents.push({ - name: currentHeader.name, - mode: currentHeader.mode as Agent["mode"], - hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name), - permission, - options: {}, - }); - } catch { - // Skip unparseable agent - } - } - } - currentHeader = null; - blockLines.length = 0; - }; - - for (const line of lines) { - const match = AGENT_HEADER_RE.exec(line); - if (match) { - flushAgent(); - currentHeader = { name: match[1]!, mode: match[2]! }; - } else if (currentHeader !== null) { - blockLines.push(line); - } +function localRef(document: unknown, ref: string): unknown { + if (!ref.startsWith("#/")) return undefined; + let value = document; + for (const rawSegment of ref.slice(2).split("/")) { + if (!isRecord(value)) return undefined; + const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~"); + value = value[segment]; } - flushAgent(); - - return agents; + return value; } -/** @internal */ -export function parseSkillsCliOutput(stdout: string): ReadonlyArray { - const result = decodeOpenCodeSkillsCliOutputExit(stdout); - return Exit.isSuccess(result) ? result.value : []; +function schemaPropertyNames( + document: unknown, + schema: unknown, + visited = new Set(), +): ReadonlySet { + if (!isRecord(schema) || visited.has(schema)) return new Set(); + visited.add(schema); + + const properties = isRecord(schema.properties) ? Object.keys(schema.properties) : []; + const nestedSchemas: Array = [schema.allOf, schema.anyOf, schema.oneOf] + .filter(Array.isArray) + .flat(); + if (typeof schema.$ref === "string") nestedSchemas.push(localRef(document, schema.$ref)); + + const result = new Set(properties); + for (const nested of nestedSchemas) { + for (const name of schemaPropertyNames(document, nested, visited)) result.add(name); + } + return result; } -export function parseOpenCodeModelSlug( - slug: string | null | undefined, -): ParsedOpenCodeModelSlug | null { - if (typeof slug !== "string") { - return null; - } +function findOperation(value: unknown, operationId: string, visited = new Set()): unknown { + if (!isRecord(value) && !Array.isArray(value)) return undefined; + if (visited.has(value)) return undefined; + visited.add(value); - const trimmed = slug.trim(); - const separator = trimmed.indexOf("/"); - if (separator <= 0 || separator === trimmed.length - 1) { - return null; + if (isRecord(value) && value.operationId === operationId) return value; + for (const nested of Array.isArray(value) ? value : Object.values(value)) { + const found = findOperation(nested, operationId, visited); + if (found !== undefined) return found; } - - return { - providerID: trimmed.slice(0, separator), - modelID: trimmed.slice(separator + 1), - }; -} - -export function openCodeQuestionId( - index: number, - question: QuestionRequest["questions"][number], -): string { - const header = question.header - .trim() - .toLowerCase() - .replace(/[^a-z0-9_-]+/g, "-"); - return header.length > 0 ? `question-${index}-${header}` : `question-${index}`; + return undefined; } -export function toOpenCodeFileParts(input: { - readonly attachments: ReadonlyArray | undefined; - readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; -}): Array { - const parts: Array = []; - - for (const attachment of input.attachments ?? []) { - const attachmentPath = input.resolveAttachmentPath(attachment); - if (!attachmentPath) { - continue; - } - - parts.push({ - type: "file", - mime: attachment.mimeType, - filename: attachment.name, - url: NodeURL.pathToFileURL(attachmentPath).href, - }); +function findPromptOperation(document: unknown): unknown { + for (const operationId of ["v2.session.prompt", "session.prompt"]) { + const operation = findOperation(document, operationId); + if (operation !== undefined) return operation; } - return parts; -} - -export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset { - if (runtimeMode === "full-access") { - return [{ permission: "*", pattern: "*", action: "allow" }]; + if (!isRecord(document)) return undefined; + const paths = document.paths; + if (!isRecord(paths)) return undefined; + for (const [path, methods] of Object.entries(paths)) { + if (!/^\/api\/session\/(?:\{[^}]+\}|:[^/]+)\/prompt$/u.test(path)) continue; + if (isRecord(methods) && isRecord(methods.post)) return methods.post; } + return undefined; +} - return [ - { permission: "*", pattern: "*", action: "ask" }, - { permission: "bash", pattern: "*", action: "ask" }, - { permission: "edit", pattern: "*", action: "ask" }, - { permission: "webfetch", pattern: "*", action: "ask" }, - { permission: "websearch", pattern: "*", action: "ask" }, - { permission: "codesearch", pattern: "*", action: "ask" }, - { permission: "external_directory", pattern: "*", action: "ask" }, - { permission: "doom_loop", pattern: "*", action: "ask" }, - { permission: "question", pattern: "*", action: "allow" }, - ]; +export function detectOpenCodeProtocol(document: unknown): OpenCodeProtocol | null { + const operation = findPromptOperation(document); + if (!isRecord(operation)) return null; + const requestBody = operation.requestBody; + const content = isRecord(requestBody) ? requestBody.content : undefined; + const jsonContent = isRecord(content) ? content["application/json"] : undefined; + const schema = isRecord(jsonContent) ? jsonContent.schema : undefined; + const properties = schemaPropertyNames(document, schema); + const promptShape = properties.has("text") ? "flat" : properties.has("prompt") ? "nested" : null; + if (promptShape === null) return null; + return { promptShape }; } -export function toOpenCodePermissionReply( - decision: ProviderApprovalDecision, -): "once" | "always" | "reject" { - switch (decision) { - case "accept": - return "once"; - case "acceptForSession": - return "always"; - case "decline": - case "cancel": - default: - return "reject"; +function requestUrl( + baseUrl: string, + path: string, + query: OpenCodeRequestInput["query"], +): string { + const url = new URL(path, baseUrl); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); } + return url.toString(); } -export function toOpenCodeQuestionAnswers( - request: QuestionRequest, - answers: Record, -): Array { - return request.questions.map((question, index) => { - const raw = - answers[openCodeQuestionId(index, question)] ?? - answers[question.header] ?? - answers[question.question]; - if (Array.isArray(raw)) { - return raw.filter((value): value is string => typeof value === "string"); - } - if (typeof raw === "string") { - return raw.trim().length > 0 ? [raw] : []; - } - return []; - }); +function withAuthentication( + request: HttpClientRequest.HttpClientRequest, + password: string, +): HttpClientRequest.HttpClientRequest { + return request.pipe( + HttpClientRequest.basicAuth("opencode", password), + HttpClientRequest.acceptJson, + ); } -function ensureRuntimeError( - operation: OpenCodeRuntimeError["operation"], - detail: string, - cause: unknown, -): OpenCodeRuntimeError { - return OpenCodeRuntimeError.is(cause) - ? cause - : new OpenCodeRuntimeError({ operation, detail, cause }); +function makeRequest( + method: OpenCodeHttpMethod, + url: string, + body: unknown | undefined, +): HttpClientRequest.HttpClientRequest { + const request = HttpClientRequest.make(method)(url); + return body === undefined ? request : request.pipe(HttpClientRequest.bodyJsonUnsafe(body)); } -const makeOpenCodeRuntime = Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const netService = yield* NetService.NetService; - const hostPlatform = yield* HostProcessPlatform; - const resolveCommand = (command: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => - resolveSpawnCommand(command, args, env ? { env } : {}); - - const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => - Effect.gen(function* () { - const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - shell: spawnCommand.shell, - ...(input.cwd ? { cwd: input.cwd } : {}), - ...(input.environment ? { env: input.environment } : { extendEnv: true }), - }), - ); - const [stdout, stderr, code] = yield* Effect.all( - [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], - { concurrency: "unbounded" }, - ); - const exitCode = Number(code); - if (yield* isWindowsCommandNotFound(exitCode, stderr)) { - return yield* new OpenCodeRuntimeError({ - operation: "runOpenCodeCommand", - detail: `spawn ${input.binaryPath} ENOENT`, - }); - } - return { - stdout, - stderr, - code: exitCode, - } satisfies OpenCodeCommandResult; - }).pipe( - Effect.scoped, - Effect.mapError((cause) => - ensureRuntimeError( - "runOpenCodeCommand", - `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, - cause, - ), - ), - ); +function isCommandNotFoundCause(cause: unknown): boolean { + return ( + cause instanceof PlatformError.PlatformError && + cause.reason instanceof PlatformError.SystemError && + cause.reason._tag === "NotFound" + ); +} - const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => +function makeConnection(input: { + readonly baseUrl: string; + readonly password: string; + readonly httpClient: HttpClient.HttpClient; + readonly protocol: OpenCodeProtocol; +}): OpenCodeConnection { + const request: OpenCodeConnection["request"] = (method, path, requestInput) => Effect.gen(function* () { - // Bind this server's lifetime to the caller's scope. When the caller's - // scope closes, the spawned child is killed and all associated fibers - // are interrupted automatically — no `close()` method needed. - const runtimeScope = yield* Scope.Scope; - - const hostname = input.hostname ?? DEFAULT_HOSTNAME; - const port = - input.port ?? - (yield* netService.findAvailablePort(0).pipe( - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - }), + const response = yield* input.httpClient + .execute( + withAuthentication( + makeRequest( + method, + requestUrl(input.baseUrl, path, requestInput.query), + requestInput.body, + ), + input.password, ), - )); - const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; - const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment); - - const child = yield* spawner - .spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - detached: hostPlatform !== "win32", - shell: spawnCommand.shell, - env: { - ...input.environment, - // Respect an OPENCODE_CONFIG_CONTENT provided by the caller or - // the inherited process environment, only falling back to the - // empty config when neither is set. Setting it unconditionally - // previously clobbered the user's opencode config, hiding their - // providers/models. The value is set explicitly (rather than - // relying on inheritance) because `extendEnv` is false whenever - // `input.environment` is provided. - OPENCODE_CONFIG_CONTENT: resolveOpenCodeConfigContent(input.environment), - }, - extendEnv: input.environment === undefined, - }), ) .pipe( - Effect.provideService(Scope.Scope, runtimeScope), Effect.mapError( (cause) => new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, + operation: requestInput.operation, + reason: "transport", cause, }), ), ); - - const killOpenCodeProcessGroup = (signal: NodeJS.Signals) => - hostPlatform === "win32" - ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid) - : Effect.sync(() => { - try { - process.kill(-Number(child.pid), signal); - } catch { - // The direct child may already have exited after starting the - // server; the process group kill is best-effort cleanup for - // any serve process left in that group. - } - }); - const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe( - Effect.andThen(Effect.sleep("1 second")), - Effect.andThen(killOpenCodeProcessGroup("SIGKILL")), - Effect.ignore, - ); - yield* Scope.addFinalizer(runtimeScope, terminateChild); - - const stdoutRef = yield* Ref.make(""); - const stderrRef = yield* Ref.make(""); - const readyDeferred = yield* Deferred.make(); - - const setReadyFromStdoutChunk = (chunk: string) => - Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( - Effect.flatMap((nextStdout) => { - const parsed = parseServerUrlFromOutput(nextStdout); - return parsed - ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) - : Effect.void; - }), - ); - - const stdoutFiber = yield* child.stdout.pipe( - Stream.decodeText(), - Stream.runForEach(setReadyFromStdoutChunk), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - const stderrFiber = yield* child.stderr.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - - const exitFiber = yield* child.exitCode.pipe( - Effect.flatMap((code) => - Effect.gen(function* () { - const stdout = yield* Ref.get(stdoutRef); - const stderr = yield* Ref.get(stderrRef); - const exitCode = Number(code); - yield* Deferred.fail( - readyDeferred, - new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: [ - `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, - stdout.trim() ? `stdout:\n${stdout.trim()}` : null, - stderr.trim() ? `stderr:\n${stderr.trim()}` : null, - ] - .filter(Boolean) - .join("\n\n"), - cause: { exitCode, stdout, stderr }, - }), - ).pipe(Effect.ignore); - }), - ), - Effect.ignore, - Effect.forkIn(runtimeScope), - ); - - const readyExit = yield* Effect.exit( - Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), - ); - - // Startup-time fibers are no longer needed once ready has resolved (either - // way). The exit fiber is only interrupted on failure; on success it keeps - // the caller's `exitCode` effect observable until the scope closes. - yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); - yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); - - if (Exit.isFailure(readyExit)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); - const squashed = Cause.squash(readyExit.cause); - return yield* ensureRuntimeError( - "startOpenCodeServerProcess", - `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, - squashed, - ); - } - - const readyOption = readyExit.value; - if (Option.isNone(readyOption)) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + if (response.status < 200 || response.status >= 300) { return yield* new OpenCodeRuntimeError({ - operation: "startOpenCodeServerProcess", - detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + operation: requestInput.operation, + reason: "http-status", + status: response.status, }); } - - return { - url: readyOption.value, - exitCode: child.exitCode.pipe( - Effect.map(Number), - Effect.orElseSucceed(() => 0), + const decodeEmptyResponse = Schema.decodeUnknownEffect(requestInput.schema); + const decodeResponse = + response.status === 204 + ? decodeEmptyResponse(undefined) + : HttpClientResponse.schemaBodyJson(requestInput.schema)(response); + return yield* decodeResponse.pipe( + Effect.mapError( + (cause) => + new OpenCodeUnsupportedPreviewError({ + operation: requestInput.operation, + cause, + }), ), - } satisfies OpenCodeServerProcess; - }); - - const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { - const serverUrl = input.serverUrl?.trim(); - if (serverUrl) { - // We don't own externally-configured servers — no scope interaction. - return Effect.succeed({ - url: serverUrl, - exitCode: null, - external: true, - }); - } - - return startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - ...(input.environment !== undefined ? { environment: input.environment } : {}), - ...(input.port !== undefined ? { port: input.port } : {}), - ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), - ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), - }).pipe( - Effect.map((server) => ({ - url: server.url, - exitCode: server.exitCode, - external: false, - })), - ); - }; - - const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => - createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, - }, - } - : {}), - throwOnError: true, + ); }); - const loadProviders = (client: OpencodeClient) => - runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( - Effect.filterMapOrFail( - (list) => - list.data - ? Result.succeed(list.data) - : Result.fail( + const globalEvents = Stream.unwrap( + input.httpClient + .execute( + withAuthentication( + HttpClientRequest.get(requestUrl(input.baseUrl, "/api/event", undefined)).pipe( + HttpClientRequest.setHeader("accept", "text/event-stream"), + ), + input.password, + ), + ) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "event.subscribe", + reason: "transport", + cause, + }), + ), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? Effect.succeed(response.stream) + : Effect.fail( new OpenCodeRuntimeError({ - operation: "provider.list", - detail: "OpenCode provider list was empty.", + operation: "event.subscribe", + reason: "http-status", + status: response.status, }), ), - (result) => result, + ), ), - ); - - const loadAgents = (client: OpencodeClient) => - runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( - Effect.map((result) => result.data ?? []), - ); + ).pipe( + Stream.decodeText(), + Stream.pipeThroughChannel(Sse.decodeDataSchema(OpenCodeEventSchema)), + Stream.map((event) => event.data), + Stream.mapError((cause) => + isOpenCodeRuntimeError(cause) || + isOpenCodeUnsupportedPreviewError(cause) || + isOpenCodeCommandNotFoundError(cause) || + isOpenCodeTimeoutError(cause) + ? cause + : new OpenCodeUnsupportedPreviewError({ + operation: "event.subscribe", + cause, + }), + ), + ); - const loadSkills = (client: OpencodeClient) => - runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( - Effect.map((result) => (result.data ?? []) as ReadonlyArray), - Effect.orElseSucceed((): ReadonlyArray => []), - ); + return { + url: input.baseUrl, + protocol: input.protocol, + request, + globalEvents, + }; +} - const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => - Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { - concurrency: "unbounded", - }).pipe(Effect.map(([providerList, agents, skills]) => ({ providerList, agents, skills }))); +export const make = Effect.fn("OpenCodeRuntime.make")(function* () { + const httpClient = yield* HttpClient.HttpClient; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const connections = new Map(); + const attachLock = yield* Semaphore.make(1); - const loadInventoryFromCli: OpenCodeRuntimeShape["loadInventoryFromCli"] = (input) => + const runCommand = (command: OpenCodeCommandInput) => Effect.gen(function* () { - const env = input.environment !== undefined ? { environment: input.environment } : ({} as {}); - const commandContext = { cwd: input.cwd, ...env }; - - const runModelsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["models", "--verbose"], - ...commandContext, - }).pipe(Effect.exit); - const runAgentsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["agent", "list"], - ...commandContext, - }).pipe(Effect.exit); - const runSkillsCli = () => - runOpenCodeCommand({ - binaryPath: input.binaryPath, - args: ["debug", "skill"], - ...commandContext, - }).pipe(Effect.exit); - - // First attempt — run all inventory commands in parallel. - const [initialModelsResult, initialAgentsResult, initialSkillsResult] = yield* Effect.all( - [runModelsCli(), runAgentsCli(), runSkillsCli()], + const spawn = yield* resolveSpawnCommand(command.binaryPath, command.args, { + env: command.environment, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawn.command, spawn.args, { + env: command.environment, + shell: spawn.shell, + }), + ) + .pipe( + Effect.mapError((cause) => + isCommandNotFoundCause(cause) + ? new OpenCodeCommandNotFoundError({ + operation: command.operation, + cause, + }) + : new OpenCodeRuntimeError({ + operation: command.operation, + reason: "transport", + cause, + }), + ), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], { concurrency: "unbounded" }, + ).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: command.operation, + reason: "transport", + cause, + }), + ), ); - let modelsResult = initialModelsResult; - let agentsResult = initialAgentsResult; - let skillsResult = initialSkillsResult; - - // Retry once after 1s on transient failures (e.g. SQLite "database is locked") - const needsModelsRetry = modelsResult._tag === "Failure" || modelsResult.value.code !== 0; - const needsAgentsRetry = agentsResult._tag === "Failure" || agentsResult.value.code !== 0; - const needsSkillsRetry = skillsResult._tag === "Failure" || skillsResult.value.code !== 0; - if (needsModelsRetry || needsAgentsRetry || needsSkillsRetry) { - yield* Effect.sleep("1 second"); - const [m2, a2, s2] = yield* Effect.all( - [ - needsModelsRetry ? runModelsCli() : Effect.succeed(modelsResult), - needsAgentsRetry ? runAgentsCli() : Effect.succeed(agentsResult), - needsSkillsRetry ? runSkillsCli() : Effect.succeed(skillsResult), - ], - { concurrency: "unbounded" }, - ); - modelsResult = m2; - agentsResult = a2; - skillsResult = s2; - } + return { stdout, stderr, code }; + }).pipe( + Effect.scoped, + Effect.timeoutOption("10 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new OpenCodeTimeoutError({ + operation: command.operation, + }), + ), + onSome: Effect.succeed, + }), + ), + ); - if (modelsResult._tag === "Failure") { - const cause = Cause.squash(modelsResult.cause); - return yield* ensureRuntimeError( - "loadInventoryFromCli", - `Failed to load OpenCode models: ${openCodeRuntimeErrorDetail(cause)}`, - cause, - ); - } - if (modelsResult.value.code !== 0) { - return yield* new OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: `OpenCode models command exited with code ${modelsResult.value.code}.`, + const attach: OpenCodeRuntime["Service"]["attach"] = (attachInput) => { + const environment = attachInput.environment ?? process.env; + const key = attachKey({ binaryPath: attachInput.binaryPath, environment }); + return attachLock.withPermit( + Effect.gen(function* () { + const existing = connections.get(key); + if (existing) { + const health = yield* Effect.exit( + existing + .request("GET", "/api/health", { + operation: "health.get", + schema: Schema.Unknown, + }) + .pipe( + Effect.timeoutOption(OPENCODE_ATTACH_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new OpenCodeTimeoutError({ operation: "health.get" })), + onSome: Effect.succeed, + }), + ), + ), + ); + if (Exit.isSuccess(health)) return existing; + connections.delete(key); + } + + const start = yield* runCommand({ + operation: "service.start", + binaryPath: attachInput.binaryPath, + args: ["service", "start"], + environment, }); - } + if (start.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "service.start", + reason: "command-exit", + exitCode: start.code, + }); + } + const baseUrl = parseServiceUrl(start.stdout); + if (baseUrl === null) { + return yield* new OpenCodeUnsupportedPreviewError({ + operation: "service.start", + }); + } - const parsed = parseModelsCliOutput(modelsResult.value.stdout); - const connected = [...parsed.connected]; - const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( - (provider) => ({ - id: provider.id, - name: provider.name, - source: "config" as const, - env: [], - options: {}, - models: provider.models, - }), - ); + const legacyPasswordResult = yield* runCommand({ + operation: "service.get.password", + binaryPath: attachInput.binaryPath, + args: ["service", "get", "password"], + environment, + }); + const passwordResult = + legacyPasswordResult.code === 0 + ? legacyPasswordResult + : yield* runCommand({ + operation: "service.password", + binaryPath: attachInput.binaryPath, + args: ["service", "password"], + environment, + }); + if (passwordResult.code !== 0) { + return yield* new OpenCodeRuntimeError({ + operation: "service.password", + reason: "command-exit", + exitCode: passwordResult.code, + }); + } + const password = passwordResult.stdout.trim(); + if (password.length === 0) { + return yield* new OpenCodeUnsupportedPreviewError({ + operation: "service.password", + }); + } - // Agent and skill metadata enrich the provider snapshot but are not required - // for an authoritative model inventory, so either may degrade to an empty list. - let agents: ReadonlyArray = []; - if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { - agents = parseAgentListCliOutput(agentsResult.value.stdout); - } - let skills: ReadonlyArray = []; - if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { - skills = parseSkillsCliOutput(skillsResult.value.stdout); - } + const provisional = makeConnection({ + baseUrl, + password, + httpClient, + protocol: { promptShape: "flat" }, + }); + const document = yield* provisional + .request("GET", "/openapi.json", { + operation: "openapi.get", + schema: Schema.Unknown, + }) + .pipe( + Effect.timeoutOption(OPENCODE_ATTACH_REQUEST_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new OpenCodeTimeoutError({ operation: "openapi.get" })), + onSome: Effect.succeed, + }), + ), + ); + const protocol = detectOpenCodeProtocol(document); + if (protocol === null) { + return yield* new OpenCodeUnsupportedPreviewError({ + operation: "openapi.detect", + }); + } - return { - providerList: { all: allProviders, default: {}, connected }, - agents, - skills, - }; - }); + const connection = makeConnection({ + baseUrl, + password, + httpClient, + protocol, + }); + connections.set(key, connection); + return connection; + }), + ); + }; - return { - startOpenCodeServerProcess, - connectToOpenCodeServer, - runOpenCodeCommand, - createOpenCodeSdkClient, - loadOpenCodeInventory, - loadInventoryFromCli, - } satisfies OpenCodeRuntimeShape; + return OpenCodeRuntime.of({ attach }); }); -export class OpenCodeRuntime extends Context.Service()( - "t3/provider/opencodeRuntime", -) {} - -export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( - Layer.provide(NetService.layer), -); +export const layer = Layer.effect(OpenCodeRuntime, make()); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 06e5543fc0c4..6e5bbf1f5a22 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -35,7 +35,6 @@ import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; -import * as OpenCode2Runtime from "./provider/openCode2Runtime.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -393,10 +392,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Provided once at the runtime level so every consumer sees the same // logger instances. Layer.provideMerge(ProviderEventLoggers.layer), - // OpenCode drivers capture their runtime services while building instances. - // Providing both services here keeps one stable runtime per protocol for all - // consumers, including OpenCode 2's singleton background-service attachment. - Layer.provideMerge(Layer.merge(OpenCodeRuntime.OpenCodeRuntimeLive, OpenCode2Runtime.layer)), + // OpenCode captures one shared native runtime while building its instance. + Layer.provideMerge(OpenCodeRuntime.layer), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectFaviconResolverLayerLive), Layer.provideMerge(RepositoryIdentityResolver.layer), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 35ef5e976223..ad1c4a9529d5 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -560,9 +560,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: " /opt/homebrew/bin/claude ", }, opencode: { - binaryPath: " /opt/homebrew/bin/opencode ", - serverUrl: " http://127.0.0.1:4096 ", - serverPassword: " secret-password ", + binaryPath: " /opt/homebrew/bin/opencode2 ", }, }, }); @@ -583,11 +581,8 @@ it.layer(NodeServices.layer)("server settings", (it) => { launchArgs: "", }); assert.deepEqual(next.providers.opencode, { - // OpenCode is disabled by default; this update only touches paths. - enabled: false, - binaryPath: "/opt/homebrew/bin/opencode", - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", + enabled: true, + binaryPath: "/opt/homebrew/bin/opencode2", customModels: [], }); }).pipe(Effect.provide(makeServerSettingsLayer())), @@ -649,8 +644,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", }, opencode: { - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", + binaryPath: "/opt/homebrew/bin/opencode2-preview", }, }, automaticGitFetchInterval: Duration.seconds(10), @@ -671,8 +665,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", }, opencode: { - serverUrl: "http://127.0.0.1:4096", - serverPassword: "secret-password", + binaryPath: "/opt/homebrew/bin/opencode2-preview", }, }, backgroundActivity: { diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts deleted file mode 100644 index a91310755a01..000000000000 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as NodeAssert from "node:assert/strict"; - -import { it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; - -import { ProviderInstanceId } from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; - -import * as OpenCode2Runtime from "../provider/openCode2Runtime.ts"; -import { makeOpenCode2TextGeneration } from "./OpenCode2TextGeneration.ts"; - -interface CapturedRequest { - readonly method: string; - readonly path: string; - readonly query: unknown; - readonly body: unknown; -} - -function runtimeLayer(response: unknown, requests: Array) { - const connection: OpenCode2Runtime.OpenCode2Connection = { - url: "http://127.0.0.1:49374/", - protocol: { promptShape: "flat" }, - request: (( - method: string, - path: string, - input: { readonly query?: unknown; readonly body?: unknown }, - ) => { - requests.push({ method, path, query: input.query, body: input.body }); - return Effect.succeed(response); - }) as OpenCode2Runtime.OpenCode2Connection["request"], - globalEvents: Stream.empty, - }; - return Layer.succeed( - OpenCode2Runtime.OpenCode2Runtime, - OpenCode2Runtime.OpenCode2Runtime.of({ attach: () => Effect.succeed(connection) }), - ); -} - -it.effect("generates structured text through the attached OpenCode 2 service", () => { - const requests: Array = []; - return Effect.gen(function* () { - const textGeneration = yield* makeOpenCode2TextGeneration({ binaryPath: "opencode2" }); - const generated = yield* textGeneration.generateCommitMessage({ - cwd: "/work/project", - branch: "feat/opencode2", - stagedSummary: "M apps/server/src/provider/openCode2Runtime.ts", - stagedPatch: "diff --git a/runtime.ts b/runtime.ts", - modelSelection: createModelSelection(ProviderInstanceId.make("opencode2"), "openai/gpt-5.6", [ - { id: "variant", value: "high" }, - ]), - }); - - NodeAssert.deepEqual(generated, { - subject: "Add OpenCode 2 runtime", - body: "Attach to the shared service.", - }); - NodeAssert.equal(requests.length, 1); - const request = requests[0]; - NodeAssert.ok(request); - NodeAssert.equal(request.method, "POST"); - NodeAssert.equal(request.path, "/api/generate"); - NodeAssert.deepEqual(request.query, { - "location[directory]": "/work/project", - }); - NodeAssert.deepEqual(request.body, { - prompt: (request.body as { readonly prompt: string }).prompt, - model: { providerID: "openai", id: "gpt-5.6", variant: "high" }, - }); - NodeAssert.match((request.body as { readonly prompt: string }).prompt, /commit message/i); - }).pipe( - Effect.provide( - runtimeLayer( - { - text: JSON.stringify({ - subject: "Add OpenCode 2 runtime", - body: "Attach to the shared service.", - }), - }, - requests, - ), - ), - ); -}); - -it.effect("rejects model selections that do not identify an upstream provider", () => { - const requests: Array = []; - return Effect.gen(function* () { - const textGeneration = yield* makeOpenCode2TextGeneration({ binaryPath: "opencode2" }); - const error = yield* Effect.flip( - textGeneration.generateBranchName({ - cwd: "/work/project", - message: "add opencode 2", - modelSelection: createModelSelection(ProviderInstanceId.make("opencode2"), "gpt-5.6"), - }), - ); - - NodeAssert.equal(error._tag, "TextGenerationError"); - NodeAssert.match(error.detail, /provider\/model/); - NodeAssert.equal(requests.length, 0); - }).pipe(Effect.provide(runtimeLayer({ text: JSON.stringify({ branch: "unused" }) }, requests))); -}); diff --git a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts b/apps/server/src/textGeneration/OpenCode2TextGeneration.ts deleted file mode 100644 index 0fc3be9cb4f8..000000000000 --- a/apps/server/src/textGeneration/OpenCode2TextGeneration.ts +++ /dev/null @@ -1,229 +0,0 @@ -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import { TextGenerationError, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { extractJsonObject } from "@t3tools/shared/schemaJson"; - -import * as OpenCode2Runtime from "../provider/openCode2Runtime.ts"; -import type { OpenCode2ProviderSettings } from "../provider/Layers/OpenCode2Provider.ts"; -import * as TextGeneration from "./TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "./TextGenerationPrompts.ts"; -import { - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "./TextGenerationUtils.ts"; - -const OPENCODE2_GENERATE_TIMEOUT = "3 minutes"; -const isTextGenerationError = Schema.is(TextGenerationError); - -const OpenCode2GenerateResponseSchema = Schema.Union([ - Schema.Struct({ text: Schema.String }), - Schema.Struct({ data: Schema.Struct({ text: Schema.String }) }), -]); - -type OpenCode2TextGenerationOperation = - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - -function parseModelSelection(modelSelection: ModelSelection): { - readonly providerID: string; - readonly id: string; - readonly variant?: string; -} | null { - const separator = modelSelection.model.indexOf("/"); - if (separator <= 0 || separator === modelSelection.model.length - 1) return null; - const providerID = modelSelection.model.slice(0, separator).trim(); - const id = modelSelection.model.slice(separator + 1).trim(); - if (!providerID || !id) return null; - const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); - return { - providerID, - id, - ...(variant ? { variant } : {}), - }; -} - -function generatedText(response: typeof OpenCode2GenerateResponseSchema.Type): string { - return "data" in response ? response.data.text : response.text; -} - -export const makeOpenCode2TextGeneration = Effect.fn("makeOpenCode2TextGeneration")(function* ( - settings: Pick, - environment?: NodeJS.ProcessEnv, -) { - const runtime = yield* OpenCode2Runtime.OpenCode2Runtime; - - const runOpenCode2Json = Effect.fn("runOpenCode2Json")(function* (input: { - readonly operation: OpenCode2TextGenerationOperation; - readonly cwd: string; - readonly prompt: string; - readonly outputSchema: S; - readonly modelSelection: ModelSelection; - }): Effect.fn.Return { - const model = parseModelSelection(input.modelSelection); - if (model === null) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode 2 model selection must use the `provider/model` format.", - }); - } - - const response = yield* Effect.gen(function* () { - const connection = yield* runtime.attach({ - binaryPath: settings.binaryPath || "opencode2", - ...(environment ? { environment } : {}), - }); - return yield* connection.request("POST", "/api/generate", { - operation: "generate.text", - schema: OpenCode2GenerateResponseSchema, - query: { "location[directory]": input.cwd }, - body: { prompt: input.prompt, model }, - }); - }).pipe( - Effect.timeoutOption(OPENCODE2_GENERATE_TIMEOUT), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode 2 text generation timed out.", - }), - ), - onSome: Effect.succeed, - }), - ), - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation: input.operation, - detail: OpenCode2Runtime.isOpenCode2UnsupportedPreviewError(cause) - ? "This OpenCode 2 preview is not supported for text generation." - : "OpenCode 2 text generation request failed.", - cause, - }), - ), - ); - - const text = generatedText(response).trim(); - if (!text) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode 2 returned empty text generation output.", - }); - } - - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); - return yield* decodeOutput(extractJsonObject(text)).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode 2 returned invalid structured text generation output.", - cause, - }), - ), - ); - }); - - const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = - Effect.fn("OpenCode2TextGeneration.generateCommitMessage")(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - policy: input.policy, - }); - const generated = yield* runOpenCode2Json({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchema, - modelSelection: input.modelSelection, - }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = - Effect.fn("OpenCode2TextGeneration.generatePrContent")(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - policy: input.policy, - changeRequestTemplate: input.changeRequestTemplate, - }); - const generated = yield* runOpenCode2Json({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchema, - modelSelection: input.modelSelection, - }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = - Effect.fn("OpenCode2TextGeneration.generateBranchName")(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runOpenCode2Json({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchema, - modelSelection: input.modelSelection, - }); - return { branch: sanitizeBranchFragment(generated.branch) }; - }); - - const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = - Effect.fn("OpenCode2TextGeneration.generateThreadTitle")(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - previousTitle: input.previousTitle, - attachments: input.attachments, - }); - const generated = yield* runOpenCode2Json({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchema, - modelSelection: input.modelSelection, - }); - return { title: sanitizeThreadTitle(generated.title) }; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGeneration.TextGeneration["Service"]; -}); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c73a..c8b8cb054700 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,456 +1,103 @@ -import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeAssert from "node:assert/strict"; + import { it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import * as TestClock from "effect/testing/TestClock"; -import * as NetService from "@t3tools/shared/Net"; -import { beforeEach, expect } from "vite-plus/test"; - -import * as ServerConfig from "../config.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; -import * as TextGeneration from "./TextGeneration.ts"; +import * as Stream from "effect/Stream"; -const runtimeMock = { - state: { - startCalls: [] as string[], - promptUrls: [] as string[], - authHeaders: [] as Array, - closeCalls: [] as string[], - sessionCreateError: undefined as unknown, - sessionResult: undefined as { data?: { id: string } } | undefined, - promptRequestError: undefined as unknown, - promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array } } - | undefined, - }, - reset() { - this.state.startCalls.length = 0; - this.state.promptUrls.length = 0; - this.state.authHeaders.length = 0; - this.state.closeCalls.length = 0; - this.state.sessionCreateError = undefined; - this.state.sessionResult = undefined; - this.state.promptRequestError = undefined; - this.state.promptResult = undefined; - }, -}; - -const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => - Effect.gen(function* () { - const index = runtimeMock.state.startCalls.length + 1; - const url = `http://127.0.0.1:${4_300 + index}`; - runtimeMock.state.startCalls.push(binaryPath); - // The production runtime binds server lifetime to the caller's scope. - // Mirror that here so the closeCalls probe observes scope close. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - }), - ); - return { - url, - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }), - runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ - session: { - create: async () => { - if (runtimeMock.state.sessionCreateError !== undefined) { - throw runtimeMock.state.sessionCreateError; - } - return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; - }, - prompt: async () => { - runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - if (runtimeMock.state.promptRequestError !== undefined) { - throw runtimeMock.state.promptRequestError; - } - return ( - runtimeMock.state.promptResult ?? { - data: { - parts: [ - { - type: "text", - text: JSON.stringify({ - subject: "Improve OpenCode reuse", - body: "Reuse one server for the full action.", - }), - }, - ], - }, - } - ); - }, - }, - }) as unknown as ReturnType, - loadOpenCodeInventory: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", - cause: null, - }), - ), - loadInventoryFromCli: () => - Effect.fail( - new OpenCodeRuntime.OpenCodeRuntimeError({ - operation: "loadInventoryFromCli", - detail: "OpenCodeRuntimeTestDouble.loadInventoryFromCli not used in this test", - cause: null, - }), - ), -}; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; -const DEFAULT_TEST_MODEL_SELECTION = { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", -}; -const DEFAULT_COMMIT_MESSAGE_INPUT = { - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, -}; - -const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; - -const OpenCodeTextGenerationTestLayer = Layer.succeed( - OpenCodeRuntime.OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( - OpenCodeRuntime.OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-existing-server-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", -}); -const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", -}); +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; -function withOpenCodeTextGeneration( - settings: OpenCodeSettings, - effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, -) { - return Effect.gen(function* () { - const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); +interface CapturedRequest { + readonly method: string; + readonly path: string; + readonly query: unknown; + readonly body: unknown; } -beforeEach(() => { - runtimeMock.reset(); -}); - -const advanceIdleClock = Effect.gen(function* () { - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); - yield* Effect.yieldNow; -}); - -it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { - it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4301", - ]); - expect(runtimeMock.state.closeCalls).toEqual([]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("starts a new server after the warm server idles out", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - yield* advanceIdleClock; - - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4302", - ]); - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }), - ).pipe(Effect.provide(TestClock.layer())), +function runtimeLayer(response: unknown, requests: Array) { + const connection: OpenCodeRuntime.OpenCodeConnection = { + url: "http://127.0.0.1:49374/", + protocol: { promptShape: "flat" }, + request: (( + method: string, + path: string, + input: { readonly query?: unknown; readonly body?: unknown }, + ) => { + requests.push({ method, path, query: input.query, body: input.body }); + return Effect.succeed(response); + }) as OpenCodeRuntime.OpenCodeConnection["request"], + globalEvents: Stream.empty, + }; + return Layer.succeed( + OpenCodeRuntime.OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime.of({ attach: () => Effect.succeed(connection) }), ); +} - it.effect("preserves the SDK cause when session creation fails", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - const sdkCause = new Error("session endpoint unavailable"); - runtimeMock.state.sessionCreateError = sdkCause; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(TextGenerationError); - expect(error.message).toContain("OpenCode session.create request failed."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationSessionRequestError", - operation: "generateCommitMessage", - cwd: process.cwd(), - cause: sdkCause, - }); - expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); - }), - ), - ); - - it.effect("reports a missing session payload without manufacturing a cause", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.sessionResult = {}; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode session.create returned no session payload."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationSessionPayloadError", - operation: "generateCommitMessage", - cwd: process.cwd(), - }); - expect(error.cause).not.toHaveProperty("cause"); - }), - ), - ); - - it.effect("preserves the SDK cause and request context when prompting fails", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - const sdkCause = new Error("prompt endpoint unavailable"); - runtimeMock.state.promptRequestError = sdkCause; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode session.prompt request failed."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationPromptRequestError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - cause: sdkCause, - }); - expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); - }), - ), - ); - - it.effect("returns a typed empty-output error for malformed and blank response parts", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [null, { type: "tool" }, { type: "text", text: " " }], - }, - }; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode returned empty output."); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationEmptyOutputError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - responsePartCount: 3, - textPartCount: 1, - }); - expect(error.cause).not.toHaveProperty("cause"); - }), +it.effect("generates structured text through the attached OpenCode 2 service", () => { + const requests: Array = []; + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration({ binaryPath: "opencode2" }); + const generated = yield* textGeneration.generateCommitMessage({ + cwd: "/work/project", + branch: "feat/opencode2", + stagedSummary: "M apps/server/src/provider/opencodeRuntime.ts", + stagedPatch: "diff --git a/runtime.ts b/runtime.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5.6", [ + { id: "variant", value: "high" }, + ]), + }); + + NodeAssert.deepEqual(generated, { + subject: "Add OpenCode 2 runtime", + body: "Attach to the shared service.", + }); + NodeAssert.equal(requests.length, 1); + const request = requests[0]; + NodeAssert.ok(request); + NodeAssert.equal(request.method, "POST"); + NodeAssert.equal(request.path, "/api/generate"); + NodeAssert.deepEqual(request.query, { + "location[directory]": "/work/project", + }); + NodeAssert.deepEqual(request.body, { + prompt: (request.body as { readonly prompt: string }).prompt, + model: { providerID: "openai", id: "gpt-5.6", variant: "high" }, + }); + NodeAssert.match((request.body as { readonly prompt: string }).prompt, /commit message/i); + }).pipe( + Effect.provide( + runtimeLayer( + { + text: JSON.stringify({ + subject: "Add OpenCode 2 runtime", + body: "Attach to the shared service.", + }), + }, + requests, + ), ), ); +}); - it.effect("parses JSON returned as plain text output", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [ - { - type: "text", - text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', - }, - ], - }, - }; - - const result = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(result).toEqual({ - subject: "Tighten OpenCode parsing", - body: "Handle JSON text output locally.", - }); +it.effect("rejects model selections that do not identify an upstream provider", () => { + const requests: Array = []; + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration({ binaryPath: "opencode2" }); + const error = yield* Effect.flip( + textGeneration.generateBranchName({ + cwd: "/work/project", + message: "add opencode 2", + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "gpt-5.6"), }), - ), - ); - - it.effect("surfaces the upstream OpenCode structured-output error message", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - info: { - error: { - name: "StructuredOutputError", - data: { - message: "Model did not produce structured output", - retries: 2, - }, - }, - }, - }, - }; - - const error = yield* textGeneration - .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) - .pipe(Effect.flip); + ); - expect(error.message).toContain("Model did not produce structured output"); - expect(error.cause).toMatchObject({ - _tag: "OpenCodeTextGenerationPromptResponseError", - operation: "generateCommitMessage", - cwd: process.cwd(), - sessionId: "http://127.0.0.1:4301/session", - providerId: "openai", - modelId: "gpt-5", - providerErrorName: "StructuredOutputError", - providerMessage: "Model did not produce structured output", - }); - expect(error.cause).not.toHaveProperty("cause"); - }), - ), - ); + NodeAssert.equal(error._tag, "TextGenerationError"); + NodeAssert.match(error.detail, /provider\/model/); + NodeAssert.equal(requests.length, 0); + }).pipe(Effect.provide(runtimeLayer({ text: JSON.stringify({ branch: "unused" }) }, requests))); }); - -it.layer(OpenCodeTextGenerationExistingServerTestLayer)( - "OpenCodeTextGeneration with configured server URL", - (it) => { - it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => - withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual([]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:9999", - "http://127.0.0.1:9999", - ]); - expect(runtimeMock.state.authHeaders).toEqual([ - `Basic ${btoa("opencode:secret-password")}`, - `Basic ${btoa("opencode:secret-password")}`, - ]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual([]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - }, -); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index e09c3db2cffc..db98635af42a 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,525 +1,140 @@ import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import { - NonNegativeInt, - TextGenerationError, - type ChatAttachment, - type ModelSelection, - type OpenCodeSettings, -} from "@t3tools/contracts"; +import { TextGenerationError, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import * as ServerConfig from "../config.ts"; -import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import type { OpenCodeProviderSettings } from "../provider/Layers/OpenCodeProvider.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; -import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; -const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; +const OPENCODE_GENERATE_TIMEOUT = "3 minutes"; +const isTextGenerationError = Schema.is(TextGenerationError); -const OpenCodeTextGenerationOperation = Schema.Literals([ - "generateCommitMessage", - "generatePrContent", - "generateBranchName", - "generateThreadTitle", +const OpenCodeGenerateResponseSchema = Schema.Union([ + Schema.Struct({ text: Schema.String }), + Schema.Struct({ data: Schema.Struct({ text: Schema.String }) }), ]); -type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; - -const openCodeTextGenerationErrorContext = { - operation: OpenCodeTextGenerationOperation, - cwd: Schema.String, -}; - -export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationSessionRequestError", - { - ...openCodeTextGenerationErrorContext, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; - } -} - -export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationSessionPayloadError", - openCodeTextGenerationErrorContext, -) { - override get message(): string { - return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; - } -} - -const openCodePromptErrorContext = { - ...openCodeTextGenerationErrorContext, - sessionId: Schema.String, - providerId: Schema.String, - modelId: Schema.String, -}; - -export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationPromptRequestError", - { - ...openCodePromptErrorContext, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; - } -} - -export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationPromptResponseError", - { - ...openCodePromptErrorContext, - providerErrorName: Schema.optional(Schema.String), - providerMessage: Schema.String, - }, -) { - override get message(): string { - const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; - return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; - } -} - -export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedErrorClass()( - "OpenCodeTextGenerationEmptyOutputError", - { - ...openCodePromptErrorContext, - responsePartCount: NonNegativeInt, - textPartCount: NonNegativeInt, - }, -) { - override get message(): string { - return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; - } -} - -interface OpenCodePromptFailure { - readonly name?: string; - readonly message: string; -} - -interface OpenCodeTextPart { - readonly type: "text"; - readonly text: string; -} - -function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { - if (!error || typeof error !== "object") { - return null; - } - - const name = - "name" in error && typeof error.name === "string" && error.name.trim().length > 0 - ? error.name.trim() - : undefined; - const message = - "data" in error && - error.data && - typeof error.data === "object" && - "message" in error.data && - typeof error.data.message === "string" - ? error.data.message.trim() - : ""; - if (message.length > 0) { - return { - ...(name ? { name } : {}), - message, - }; - } - - if (name) { - return { name, message: name }; - } - - return null; -} - -function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { - return ( - part !== null && - typeof part === "object" && - "type" in part && - part.type === "text" && - "text" in part && - typeof part.text === "string" - ); -} - -function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { - return (parts ?? []) - .filter(isOpenCodeTextPart) - .map((part) => part.text) - .join("") - .trim(); +type OpenCodeTextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +function parseModelSelection(modelSelection: ModelSelection): { + readonly providerID: string; + readonly id: string; + readonly variant?: string; +} | null { + const separator = modelSelection.model.indexOf("/"); + if (separator <= 0 || separator === modelSelection.model.length - 1) return null; + const providerID = modelSelection.model.slice(0, separator).trim(); + const id = modelSelection.model.slice(separator + 1).trim(); + if (!providerID || !id) return null; + const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); + return { + providerID, + id, + ...(variant ? { variant } : {}), + }; } -interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeRuntime.OpenCodeServerProcess | null; - /** - * The scope that owns the shared server's lifetime. Closing this scope - * terminates the OpenCode child process and interrupts any fibers the - * runtime forked during startup. We don't hold a `close()` function on - * the server handle anymore — the scope is the only lifecycle handle. - */ - serverScope: Scope.Closeable | null; - binaryPath: string | null; - activeRequests: number; - idleCloseFiber: Fiber.Fiber | null; +function generatedText(response: typeof OpenCodeGenerateResponseSchema.Type): string { + return "data" in response ? response.data.text : response.text; } export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( - openCodeSettings: OpenCodeSettings, + settings: Pick, environment?: NodeJS.ProcessEnv, ) { - const serverConfig = yield* ServerConfig.ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; - const resolvedEnvironment = environment ?? process.env; - const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const sharedServerMutex = yield* Semaphore.make(1); - const sharedServerState: SharedOpenCodeTextGenerationServerState = { - server: null, - serverScope: null, - binaryPath: null, - activeRequests: 0, - idleCloseFiber: null, - }; - - const closeSharedServer = Effect.fn("closeSharedServer")(function* () { - const scope = sharedServerState.serverScope; - sharedServerState.server = null; - sharedServerState.serverScope = null; - sharedServerState.binaryPath = null; - if (scope !== null) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - }); - - const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { - const idleCloseFiber = sharedServerState.idleCloseFiber; - sharedServerState.idleCloseFiber = null; - if (idleCloseFiber !== null) { - yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); - } - }); - - const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeRuntime.OpenCodeServerProcess, - ) { - yield* cancelIdleCloseFiber(); - const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( - Effect.andThen( - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { - return; - } - sharedServerState.idleCloseFiber = null; - yield* closeSharedServer(); - }), - ), - ), - Effect.forkIn(idleFiberScope), - ); - sharedServerState.idleCloseFiber = fiber; - }); - - const acquireSharedServer = (input: { - readonly binaryPath: string; - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - }) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - - const existingServer = sharedServerState.server; - if (existingServer !== null) { - if ( - sharedServerState.binaryPath !== input.binaryPath && - sharedServerState.activeRequests === 0 - ) { - yield* closeSharedServer(); - } else { - if (sharedServerState.binaryPath !== input.binaryPath) { - yield* Effect.logWarning( - "OpenCode shared server binary path mismatch: requested " + - input.binaryPath + - " but active server uses " + - sharedServerState.binaryPath + - "; reusing existing server because there are active requests", - ); - } - sharedServerState.activeRequests += 1; - return existingServer; - } - } - - // Create a fresh scope that owns this shared server. The runtime - // will attach its child-process and fiber finalizers to this scope; - // closing it kills the server and interrupts those fibers. - // - // The `Scope.make` / spawn / record-or-close transitions run inside - // `uninterruptibleMask` so an interrupt arriving between any two - // steps can't orphan the scope (and the child process attached to - // it) before we either close it on failure or hand ownership to - // `sharedServerState`. `restore` keeps the actual spawn - // interruptible; an interrupt during the spawn is captured by - // `Effect.exit` and drives us through the failure branch that - // closes the fresh scope. - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const serverScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - restore( - openCodeRuntime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - environment: resolvedEnvironment, - }) - .pipe( - Effect.provideService(Scope.Scope, serverScope), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), - cause, - }), - ), - ), - ), - ); - if (startedExit._tag === "Failure") { - yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(startedExit.cause); - } - - const server = startedExit.value; - sharedServerState.server = server; - sharedServerState.serverScope = serverScope; - sharedServerState.binaryPath = input.binaryPath; - sharedServerState.activeRequests = 1; - return server; - }), - ); - }), - ); - - const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server) { - return; - } - sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); - if (sharedServerState.activeRequests === 0) { - yield* scheduleIdleClose(server); - } - }), - ); - - // Module-level finalizer: on layer shutdown, cancel the idle close fiber - // and close the shared server scope. Consumers therefore cannot leak - // the shared OpenCode server by forgetting to call anything. - yield* Effect.addFinalizer(() => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - sharedServerState.activeRequests = 0; - yield* closeSharedServer(); - }), - ), - ); + const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { readonly operation: OpenCodeTextGenerationOperation; readonly cwd: string; readonly prompt: string; - readonly outputSchemaJson: S; + readonly outputSchema: S; readonly modelSelection: ModelSelection; - readonly attachments?: ReadonlyArray | undefined; - }) { - const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); - if (!parsedModel) { + }): Effect.fn.Return { + const model = parseModelSelection(input.modelSelection); + if (model === null) { return yield* new TextGenerationError({ operation: input.operation, - detail: "OpenCode model selection must use the 'provider/model' format.", + detail: "OpenCode 2 model selection must use the `provider/model` format.", }); } - const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ - attachments: input.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - - const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( - function* (server: Pick) { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }); - const session = yield* Effect.tryPromise({ - try: () => - client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], - }), - catch: (cause) => - new OpenCodeTextGenerationSessionRequestError({ + const response = yield* Effect.gen(function* () { + const connection = yield* runtime.attach({ + binaryPath: settings.binaryPath || "opencode2", + ...(environment ? { environment } : {}), + }); + return yield* connection.request("POST", "/api/generate", { + operation: "generate.text", + schema: OpenCodeGenerateResponseSchema, + query: { "location[directory]": input.cwd }, + body: { prompt: input.prompt, model }, + }); + }).pipe( + Effect.timeoutOption(OPENCODE_GENERATE_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 text generation timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ operation: input.operation, - cwd: input.cwd, - cause, - }), - }); - if (!session.data) { - return yield* new OpenCodeTextGenerationSessionPayloadError({ - operation: input.operation, - cwd: input.cwd, - }); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); - const promptContext = { - operation: input.operation, - cwd: input.cwd, - sessionId: session.data.id, - providerId: parsedModel.providerID, - modelId: parsedModel.modelID, - }; - - const result = yield* Effect.tryPromise({ - try: () => - client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], - }), - catch: (cause) => - new OpenCodeTextGenerationPromptRequestError({ - ...promptContext, - cause, - }), - }); - const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); - if (promptFailure) { - return yield* new OpenCodeTextGenerationPromptResponseError({ - ...promptContext, - ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), - providerMessage: promptFailure.message, - }); - } - const responseParts = result.data?.parts ?? []; - const rawText = getOpenCodeTextResponse(responseParts); - if (rawText.length === 0) { - return yield* new OpenCodeTextGenerationEmptyOutputError({ - ...promptContext, - responsePartCount: responseParts.length, - textPartCount: responseParts.filter(isOpenCodeTextPart).length, - }); - } - return rawText; - }, - Effect.catchTags({ - OpenCodeTextGenerationSessionRequestError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.create request failed.", - cause, - }), - ), - OpenCodeTextGenerationSessionPayloadError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.create returned no session payload.", - cause, - }), - ), - OpenCodeTextGenerationPromptRequestError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode session.prompt request failed.", - cause, - }), - ), - OpenCodeTextGenerationPromptResponseError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: cause.providerMessage, + detail: OpenCodeRuntime.isOpenCodeUnsupportedPreviewError(cause) + ? "This OpenCode 2 preview is not supported for text generation." + : "OpenCode 2 text generation request failed.", cause, }), - ), - OpenCodeTextGenerationEmptyOutputError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: cause.operation, - detail: "OpenCode returned empty output.", - cause, - }), - ), - }), + ), ); - const rawOutput = - openCodeSettings.serverUrl.length > 0 - ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) - : yield* Effect.acquireUseRelease( - acquireSharedServer({ - binaryPath: openCodeSettings.binaryPath, - operation: input.operation, - }), - runAgainstServer, - releaseSharedServer, - ); + const text = generatedText(response).trim(); + if (!text) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned empty text generation output.", + }); + } - const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); - return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( - Effect.catchTags({ - SchemaError: (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - }), + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); + return yield* decodeOutput(extractJsonObject(text)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode 2 returned invalid structured text generation output.", + cause, + }), + ), ); }); @@ -536,10 +151,9 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, }); - return { subject: sanitizeCommitSubject(generated.subject), body: generated.body.trim(), @@ -564,10 +178,9 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generatePrContent", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, }); - return { title: sanitizePrTitle(generated.title), body: generated.body.trim(), @@ -584,14 +197,10 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generateBranchName", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, - attachments: input.attachments, }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; + return { branch: sanitizeBranchFragment(generated.branch) }; }); const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = @@ -605,14 +214,10 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" operation: "generateThreadTitle", cwd: input.cwd, prompt, - outputSchemaJson: outputSchema, + outputSchema, modelSelection: input.modelSelection, - attachments: input.attachments, }); - - return { - title: sanitizeThreadTitle(generated.title), - }; + return { title: sanitizeThreadTitle(generated.title) }; }); return { diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index ff58a1e8e95e..66b7ccd465f1 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,13 +8,7 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = - | "codex" - | "claudeAgent" - | "cursor" - | "grok" - | "opencode" - | "opencode2"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 597cb7fa1c10..842c616fe1fe 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -6,7 +6,6 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, - [ProviderDriverKind.make("opencode2")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, }; diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 5e211d9b8685..9a42961d13ee 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,7 +35,6 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); - const serverPassword = deriveProviderSettingsFields(opencode!).find( - (field) => field.key === "serverPassword", + const binaryPath = deriveProviderSettingsFields(opencode!).find( + (field) => field.key === "binaryPath", ); - expect(serverPassword).toMatchObject({ - label: "Server password", - description: "Stored in plain text on disk.", - control: "password", + expect(binaryPath).toMatchObject({ + label: "Binary path", + control: "text", }); }); - it("exposes the OpenCode 2 provider definition", () => { - const opencode2 = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode2")]; + it("exposes one OpenCode provider backed by OpenCode 2", () => { + const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; - expect(opencode2).toMatchObject({ label: "OpenCode 2", badgeLabel: "New" }); - expect(deriveProviderSettingsFields(opencode2!).map((field) => field.key)).toEqual([ + expect(opencode).toMatchObject({ label: "OpenCode", badgeLabel: "New" }); + expect(DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode2")]).toBeUndefined(); + expect(deriveProviderSettingsFields(opencode!).map((field) => field.key)).toEqual([ "binaryPath", ]); }); it("preserves unknown config keys while omitting empty configurable fields", () => { - const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; - expect(opencode).toBeDefined(); + const codex = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("codex")]; + expect(codex).toBeDefined(); - const serverUrl = deriveProviderSettingsFields(opencode!).find( - (field) => field.key === "serverUrl", - ); - expect(serverUrl).toBeDefined(); + const homePath = deriveProviderSettingsFields(codex!).find((field) => field.key === "homePath"); + expect(homePath).toBeDefined(); const next = nextProviderConfigWithFieldValue( - { forkOwned: 1, serverUrl: "http://127.0.0.1:4096" }, - serverUrl!, + { forkOwned: 1, homePath: "~/.codex" }, + homePath!, "", ); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 15e229323ddc..347ac16b8096 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,7 +3,6 @@ import { CodexSettings, CursorSettings, GrokSettings, - OpenCode2Settings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; @@ -66,14 +65,8 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = value: ProviderDriverKind.make("opencode"), label: "OpenCode", icon: OpenCodeIcon, - settingsSchema: OpenCodeSettings, - }, - { - value: ProviderDriverKind.make("opencode2"), - label: "OpenCode 2", - icon: OpenCodeIcon, badgeLabel: "New", - settingsSchema: OpenCode2Settings, + settingsSchema: OpenCodeSettings, }, ]; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ac4895985dac..3fe6681e09ed 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -812,7 +812,7 @@ function normalizeProviderModelOptions( ): ProviderOptionSelectionsByProvider | null { const candidate = value && typeof value === "object" ? (value as Record) : null; const result: ProviderOptionSelectionsByProvider = {}; - for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode", "opencode2"] as const) { + for (const providerKey of ["codex", "claudeAgent", "cursor", "opencode"] as const) { const selections = coerceProviderOptionSelections(candidate?.[providerKey]); if (selections) { result[providerKey] = selections; @@ -971,7 +971,7 @@ function legacyToModelSelectionByProvider( ): Partial> { const result: Partial> = {}; if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "opencode", "opencode2"] as const) { + for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { const options = modelOptions[provider]; if (options && options.length > 0) { const driverKind = ProviderDriverKind.make(provider); @@ -2773,13 +2773,7 @@ const composerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of [ - "codex", - "claudeAgent", - "cursor", - "opencode", - "opencode2", - ] as const) { + for (const provider of ["codex", "claudeAgent", "cursor", "opencode"] as const) { if (!modelOptions || !(provider in modelOptions)) continue; const opts = modelOptions[provider]; const driverKind = ProviderDriverKind.make(provider); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 9aeb9f1bb0a2..80f7d31cf2f9 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -38,8 +38,6 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Cursor"; case "opencode": return "OpenCode"; - case "opencode2": - return "OpenCode 2"; default: { // Title-case unknown driver kinds so they read reasonably. const trimmed = provider.replace(/Agent$/i, "").trim(); diff --git a/apps/web/src/openCode2AutoEnableNotification.test.ts b/apps/web/src/openCode2AutoEnableNotification.test.ts index e42bbb957e91..6a25de43aacf 100644 --- a/apps/web/src/openCode2AutoEnableNotification.test.ts +++ b/apps/web/src/openCode2AutoEnableNotification.test.ts @@ -25,8 +25,8 @@ function provider( input: Partial> = {}, ): ServerProvider { return { - instanceId: input.instanceId ?? ProviderInstanceId.make("opencode2"), - driver: input.driver ?? ProviderDriverKind.make("opencode2"), + instanceId: input.instanceId ?? ProviderInstanceId.make("opencode"), + driver: input.driver ?? ProviderDriverKind.make("opencode"), enabled: input.enabled ?? true, installed: input.installed ?? true, version: "2.0.0", @@ -55,12 +55,12 @@ describe("OpenCode 2 auto-enable notification", () => { ).toBeNull(); expect( resolveOpenCode2AutoEnableDetectionKey(environmentId, [ - provider({ driver: ProviderDriverKind.make("opencode") }), + provider({ driver: ProviderDriverKind.make("codex") }), ]), ).toBeNull(); expect( resolveOpenCode2AutoEnableDetectionKey(environmentId, [ - provider({ instanceId: ProviderInstanceId.make("opencode2-custom") }), + provider({ instanceId: ProviderInstanceId.make("opencode-custom") }), ]), ).toBeNull(); }); diff --git a/apps/web/src/openCode2AutoEnableNotification.ts b/apps/web/src/openCode2AutoEnableNotification.ts index eeef2f7cb54c..f73fc756e560 100644 --- a/apps/web/src/openCode2AutoEnableNotification.ts +++ b/apps/web/src/openCode2AutoEnableNotification.ts @@ -25,7 +25,7 @@ export const EMPTY_OPENCODE2_AUTO_ENABLE_NOTIFICATION_STATE: OpenCode2AutoEnable dismissedKeys: [], }; -const OPENCODE2_DRIVER = ProviderDriverKind.make("opencode2"); +const OPENCODE2_DRIVER = ProviderDriverKind.make("opencode"); const OPENCODE2_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCODE2_DRIVER); export function openCode2AutoEnableNotificationKey(environmentId: EnvironmentId): string { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d1fa645b0b2e..4824258422fb 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -40,12 +40,6 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, - { - value: ProviderDriverKind.make("opencode2"), - label: "OpenCode 2", - available: true, - pickerSidebarBadge: "new", - }, { value: ProviderDriverKind.make("cursor"), label: "Cursor", diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 45bda00218ce..da16f74d339f 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and OpenCode 2. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index f4fa8bf08b61..9b2989e38f70 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -107,7 +107,7 @@ build production behavior on receipts. ## Provider drivers Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, OpenCode, and OpenCode 2. A driver declares its kind and config schema and creates a +Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 8e90f40b4afc..b78a35bf8743 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,16 +7,15 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: -| Driver kind | Driver source | -| ------------- | ----------------------------------------- | -| `codex` | [`Drivers/CodexDriver.ts`][codex] | -| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | -| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | -| `grok` | [`Drivers/GrokDriver.ts`][grok] | -| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | -| `opencode2` | [`Drivers/OpenCode2Driver.ts`][opencode2] | +| Driver kind | Driver source | +| ------------- | --------------------------------------- | +| `codex` | [`Drivers/CodexDriver.ts`][codex] | +| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | +| `cursor` | [`Drivers/CursorDriver.ts`][cursor] | +| `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an adapter in a child scope. Adapter implementations live beside them in @@ -24,9 +23,9 @@ adapter in a child scope. Adapter implementations live beside them in [`ProviderAdapter.ts`][adapter]. Read the driver plus its adapter to see how a specific agent's transport, config, and event shapes are mapped. -OpenCode 2 follows the CLI's desktop lifecycle instead of owning a daemon. Its runtime runs the -idempotent `opencode2 service start`, reads the service credential with -`opencode2 service get password`, and attaches to that singleton over the native `/api/*` HTTP and +The OpenCode driver uses OpenCode 2 and follows the CLI's desktop lifecycle instead of owning a daemon. Its runtime runs the +idempotent `opencode2 service start`, reads the service credential with the CLI's service password +command, and attaches to that singleton over the native `/api/*` HTTP and SSE protocol. T3 Code never starts `opencode2 serve` and never stops or deletes the shared service. ## Registry and routing @@ -87,7 +86,6 @@ when a request opens (approval) or user input is requested, via [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts -[opencode2]: ../../apps/server/src/provider/Drivers/OpenCode2Driver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts [registry]: ../../apps/server/src/provider/Services/ProviderAdapterRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index 513c94c136dd..1fd0f8bd7705 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,19 +54,17 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ------------------ | ----------------------------------------------------- | -------------- | ---------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | -| OpenCode 2 Preview | [OpenCode 2](https://opencode.ai/v2) | `opencode2` | `opencode2 auth login` | - -Codex and Claude are on by default. OpenCode 2 is enabled automatically when T3 Code detects the -preview CLI, and a one-time notice links to its Settings card. Cursor, Grok Build, and stable -OpenCode are off by default; turn them on in **Settings** → the provider's card when you want to -use them. +| Provider | CLI | Default binary | Log in with | +| ---------- | ----------------------------------------------------- | -------------- | ---------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| OpenCode | [OpenCode 2](https://opencode.ai/v2) | `opencode2` | `opencode2 auth login` | + +Codex, Claude, and OpenCode are on by default. When T3 Code detects the OpenCode 2 CLI, a one-time +notice links to its Settings card. Cursor and Grok Build are off by default; turn them on in +**Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index cebd2e24b415..9fcd0d266dd6 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,7 +132,6 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); -const OPENCODE2_DRIVER_KIND = ProviderDriverKind.make("opencode2"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -154,7 +153,6 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", - [OPENCODE2_DRIVER_KIND]: "OpenCode 2", }; diff --git a/packages/contracts/src/providerRuntime.test.ts b/packages/contracts/src/providerRuntime.test.ts index 1b97dc78db06..8a8d8c075fe8 100644 --- a/packages/contracts/src/providerRuntime.test.ts +++ b/packages/contracts/src/providerRuntime.test.ts @@ -23,22 +23,22 @@ describe("ProviderRuntimeEvent", () => { expect(parsed.providerInstanceId).toBe("ollama_local"); }); - it("accepts raw OpenCode 2 SDK events", () => { + it("accepts raw native OpenCode events", () => { const parsed = decodeRuntimeEvent({ type: "session.started", - eventId: "event-opencode2-session", - provider: "opencode2", - providerInstanceId: "opencode2", + eventId: "event-opencode-session", + provider: "opencode", + providerInstanceId: "opencode", createdAt: "2026-08-21T00:00:00.000Z", threadId: "thread-1", payload: { message: "started" }, raw: { - source: "opencode2.api.event", + source: "opencode.api.event", payload: { type: "server.connected" }, }, }); - expect(parsed.raw?.source).toBe("opencode2.api.event"); + expect(parsed.raw?.source).toBe("opencode.api.event"); }); it("decodes turn.plan.updated for plan rendering", () => { diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 2424961c107f..4e927625fc9f 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -26,7 +26,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), - Schema.Literal("opencode2.api.event"), + Schema.Literal("opencode.api.event"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 66f014cb2170..cf318b4346cf 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -186,16 +186,15 @@ describe("provider enabled defaults", () => { expect(decoded.providers.claudeAgent.enabled).toBe(true); expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); - expect(decoded.providers.opencode.enabled).toBe(false); - expect(decoded.providers.opencode2.enabled).toBe(true); - expect(decoded.providers.opencode2.binaryPath).toBe("opencode2"); + expect(decoded.providers.opencode.enabled).toBe(true); + expect(decoded.providers.opencode.binaryPath).toBe("opencode2"); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); - expect(defaultEnabledForDriver(ProviderDriverKind.make("opencode2"))).toBe(true); + expect(defaultEnabledForDriver(ProviderDriverKind.make("opencode"))).toBe(true); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); @@ -306,7 +305,7 @@ describe("ServerSettingsPatch string normalization", () => { homePath: " ~/.codex ", launchArgs: " --strict-config --enable foo ", }, - opencode2: { + opencode: { binaryPath: " /opt/homebrew/bin/opencode2 ", }, }, @@ -325,7 +324,7 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); expect(patch.providers?.codex?.launchArgs).toBe("--strict-config --enable foo"); - expect(patch.providers?.opencode2?.binaryPath).toBe("/opt/homebrew/bin/opencode2"); + expect(patch.providers?.opencode?.binaryPath).toBe("/opt/homebrew/bin/opencode2"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 8492e72cf21a..94ece0c9b974 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -468,58 +468,6 @@ export const GrokSettings = makeProviderSettingsSchema( export type GrokSettings = typeof GrokSettings.Type; export const OpenCodeSettings = makeProviderSettingsSchema( - { - // Off by default (like Cursor and Grok): the binding is not yet stable - // enough to probe on every install. Users opt in from Settings. - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - binaryPath: makeBinaryPathSetting("opencode").pipe( - Schema.annotateKey({ - title: "Binary path", - description: "Path to the OpenCode binary.", - providerSettingsForm: { - placeholder: "opencode", - clearWhenEmpty: "omit", - }, - }), - ), - serverUrl: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server URL", - description: "Leave blank to let T3 Code spawn the server when needed.", - providerSettingsForm: { - placeholder: "http://127.0.0.1:4096", - clearWhenEmpty: "omit", - }, - }), - ), - serverPassword: TrimmedString.pipe( - Schema.withDecodingDefault(Effect.succeed("")), - Schema.annotateKey({ - title: "Server password", - description: "Stored in plain text on disk.", - providerSettingsForm: { - control: "password", - placeholder: "Optional", - clearWhenEmpty: "omit", - }, - }), - ), - customModels: Schema.Array(Schema.String).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: ["binaryPath", "serverUrl", "serverPassword"], - }, -); -export type OpenCodeSettings = typeof OpenCodeSettings.Type; - -export const OpenCode2Settings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), @@ -544,7 +492,7 @@ export const OpenCode2Settings = makeProviderSettingsSchema( order: ["binaryPath"], }, ); -export type OpenCode2Settings = typeof OpenCode2Settings.Type; +export type OpenCodeSettings = typeof OpenCodeSettings.Type; export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -688,7 +636,6 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - opencode2: OpenCode2Settings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -829,14 +776,6 @@ const GrokSettingsPatch = Schema.Struct({ }); const OpenCodeSettingsPatch = Schema.Struct({ - enabled: Schema.optionalKey(Schema.Boolean), - binaryPath: Schema.optionalKey(TrimmedString), - serverUrl: Schema.optionalKey(TrimmedString), - serverPassword: Schema.optionalKey(TrimmedString), - customModels: Schema.optionalKey(Schema.Array(Schema.String)), -}); - -const OpenCode2SettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), @@ -883,7 +822,6 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), - opencode2: Schema.optionalKey(OpenCode2SettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f66f69b87f0..601e464bc06d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -464,9 +464,6 @@ importers: '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) - '@opencode-ai/sdk': - specifier: ^1.3.15 - version: 1.15.13 '@pierre/diffs': specifier: 'catalog:' version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3285,9 +3282,6 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@opencode-ai/sdk@1.15.13': - resolution: {integrity: sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==} - '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -13435,10 +13429,6 @@ snapshots: '@open-draft/until@2.1.0': {} - '@opencode-ai/sdk@1.15.13': - dependencies: - cross-spawn: 7.0.6 - '@oslojs/encoding@1.1.0': {} '@oxc-project/runtime@0.138.0': {} From 07304c325b2cbb511be19c94f90aa1246e031aee Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:59:36 -0400 Subject: [PATCH 08/24] fix(opencode): preserve native provider behavior --- .../src/provider/Drivers/OpenCodeDriver.ts | 4 +- .../provider/Layers/OpenCodeAdapter.test.ts | 29 +++++ .../src/provider/Layers/OpenCodeAdapter.ts | 35 +++--- .../provider/Layers/OpenCodeProvider.test.ts | 46 ++++++++ .../src/provider/Layers/OpenCodeProvider.ts | 30 +++-- .../OpenCodeTextGeneration.test.ts | 83 ++++++++++++- .../textGeneration/OpenCodeTextGeneration.ts | 109 +++++++++++++++++- docs/internals/overview.md | 2 +- 8 files changed, 304 insertions(+), 34 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 3fd47babaa55..6bfc9fe7c37f 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -1,4 +1,5 @@ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -38,13 +39,14 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ export type OpenCodeDriverEnv = | BackgroundPolicy.BackgroundPolicy + | Crypto.Crypto | OpenCodeRuntime.OpenCodeRuntime | ProviderEventLoggers | ServerConfig | ServerSettingsService; function nativeBinaryPath(binaryPath: string): string { - return /(?:^|[/\\])opencode(?:\.exe)?$/iu.test(binaryPath) + return /^opencode(?:\.exe)?$/iu.test(binaryPath) ? binaryPath.replace(/opencode(?=(?:\.exe)?$)/iu, "opencode2") : binaryPath; } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 8e8f4798d00a..e0ea9bc6048e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -170,6 +170,35 @@ function collectEvents( ); } +it.effect("generates unique event ids when an adapter is recreated", () => + withHarness("flat", ({ adapter: first }) => + withHarness("flat", ({ adapter: second }) => + Effect.gen(function* () { + const firstEventId = yield* Deferred.make(); + const secondEventId = yield* Deferred.make(); + yield* collectEvents(first, [], (next) => + next.type === "session.started" + ? Deferred.succeed(firstEventId, next.eventId).pipe(Effect.ignore) + : Effect.void, + ); + yield* collectEvents(second, [], (next) => + next.type === "session.started" + ? Deferred.succeed(secondEventId, next.eventId).pipe(Effect.ignore) + : Effect.void, + ); + + yield* first.startSession({ threadId, runtimeMode: "full-access" }); + yield* second.startSession({ threadId, runtimeMode: "full-access" }); + + NodeAssert.notEqual( + yield* Deferred.await(firstEventId), + yield* Deferred.await(secondEventId), + ); + }), + ), + ), +); + it.effect("attaches once and maps native deltas, tools, and terminal events", () => withHarness("flat", ({ adapter, attachCount, calls, publish }) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 5cc9309c5901..3312e2f83aae 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -17,6 +17,7 @@ import { } from "@t3tools/contracts"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -35,10 +36,13 @@ import { ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, } from "../Errors.ts"; -import type { ProviderAdapterShape, ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; +export type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; + const PROVIDER = ProviderDriverKind.make("opencode"); const RESUME_VERSION = 1 as const; @@ -56,14 +60,6 @@ const MessagesResponseSchema = Schema.Struct({ const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecordSchema); const decodeUnknownRecordArray = Schema.decodeUnknownOption(UnknownRecordArraySchema); -type OpenCodeAdapterError = - | ProviderAdapterProcessError - | ProviderAdapterRequestError - | ProviderAdapterSessionNotFoundError - | ProviderAdapterValidationError; - -export type OpenCodeAdapterShape = ProviderAdapterShape; - interface OpenCodeTurnSnapshot { readonly id: TurnId; readonly items: Array; @@ -321,6 +317,7 @@ export function makeOpenCodeAdapter( return Effect.gen(function* () { const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const serverConfig = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; const scope = yield* Effect.scope; const instanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); const events = yield* Queue.unbounded(); @@ -328,14 +325,25 @@ export function makeOpenCodeAdapter( const sessionByProviderId = new Map(); const connectionLock = yield* Semaphore.make(1); let activeConnection: OpenCodeActiveConnection | undefined; - let eventCounter = 0; + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate an OpenCode runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, EventId.make); + const nextTurnId = Effect.map(randomUUIDv4, TurnId.make); const buildEventBase = Effect.fn("OpenCodeAdapter.buildEventBase")(function* ( input: EventBaseInput, ) { - eventCounter += 1; return { - eventId: EventId.make(`opencode-event-${eventCounter}`), + eventId: yield* nextEventId, provider: PROVIDER, providerInstanceId: instanceId, threadId: input.threadId, @@ -1154,8 +1162,7 @@ export function makeOpenCodeAdapter( } const steeringTurnId = context.activeTurnId; - eventCounter += 1; - const turnId = steeringTurnId ?? TurnId.make(`opencode-turn-${eventCounter}`); + const turnId = steeringTurnId ?? (yield* nextTurnId); context.activeTurnId = turnId; yield* updateSession( context, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index d2009b3060b4..b15575daae9b 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -19,6 +19,7 @@ const settings = { function runtimeLayer(input: { readonly responses?: Readonly>; readonly attachError?: OpenCodeRuntime.OpenCodeRuntimeFailure; + readonly failedPaths?: ReadonlySet; readonly paths?: Array; }) { const connection: OpenCodeRuntime.OpenCodeConnection = { @@ -26,6 +27,15 @@ function runtimeLayer(input: { protocol: { promptShape: "flat" }, request: ((method: string, path: string, _requestInput: { readonly schema: unknown }) => { input.paths?.push(`${method} ${path}`); + if (input.failedPaths?.has(path)) { + return Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: `probe${path}`, + reason: "http-status", + status: 404, + }), + ); + } return Effect.succeed(input.responses?.[path]); }) as OpenCodeRuntime.OpenCodeConnection["request"], globalEvents: Stream.empty, @@ -127,6 +137,42 @@ it.effect("builds model inventory from the attached OpenCode 2 service", () => { ); }); +it.effect("keeps core discovery ready when optional metadata endpoints fail", () => + Effect.gen(function* () { + const snapshot = yield* checkOpenCodeProviderStatus(settings); + + NodeAssert.equal(snapshot.status, "ready"); + NodeAssert.equal(snapshot.installed, true); + NodeAssert.deepEqual( + snapshot.models.map((model) => model.slug), + ["openai/gpt-5.6"], + ); + NodeAssert.deepEqual(snapshot.skills, []); + NodeAssert.deepEqual( + snapshot.models[0]?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id) ?? [], + [], + ); + }).pipe( + Effect.provide( + runtimeLayer({ + failedPaths: new Set(["/api/model/default", "/api/agent", "/api/skill"]), + responses: { + "/api/health": { healthy: true, version: "0.0.0-beta-17823" }, + "/api/model": { + data: [ + { + id: "gpt-5.6", + providerID: "openai", + name: "GPT-5.6", + }, + ], + }, + }, + }), + ), + ), +); + it.effect("reports unsupported adjacent preview protocols without hanging", () => Effect.gen(function* () { const snapshot = yield* checkOpenCodeProviderStatus(settings); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 9e12b0efde28..be3b5bb8bbe5 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -309,18 +309,24 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu operation: "model.list", schema: OpenCodeModelListSchema, }), - connection.request("GET", "/api/model/default", { - operation: "model.default", - schema: OpenCodeDefaultModelSchema, - }), - connection.request("GET", "/api/agent", { - operation: "agent.list", - schema: OpenCodeAgentListSchema, - }), - connection.request("GET", "/api/skill", { - operation: "skill.list", - schema: OpenCodeSkillListSchema, - }), + connection + .request("GET", "/api/model/default", { + operation: "model.default", + schema: OpenCodeDefaultModelSchema, + }) + .pipe(Effect.orElseSucceed(() => null)), + connection + .request("GET", "/api/agent", { + operation: "agent.list", + schema: OpenCodeAgentListSchema, + }) + .pipe(Effect.orElseSucceed(() => [])), + connection + .request("GET", "/api/skill", { + operation: "skill.list", + schema: OpenCodeSkillListSchema, + }) + .pipe(Effect.orElseSucceed(() => [])), ], { concurrency: "unbounded" }, ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index c8b8cb054700..3717cef5c2cb 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,5 +1,6 @@ import * as NodeAssert from "node:assert/strict"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -8,6 +9,7 @@ import * as Stream from "effect/Stream"; import { ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import { ServerConfig } from "../config.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; @@ -18,7 +20,13 @@ interface CapturedRequest { readonly body: unknown; } -function runtimeLayer(response: unknown, requests: Array) { +type RuntimeResponse = unknown | ((request: CapturedRequest) => unknown); + +const configLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-opencode-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function runtimeLayer(response: RuntimeResponse, requests: Array) { const connection: OpenCodeRuntime.OpenCodeConnection = { url: "http://127.0.0.1:49374/", protocol: { promptShape: "flat" }, @@ -27,8 +35,9 @@ function runtimeLayer(response: unknown, requests: Array) { path: string, input: { readonly query?: unknown; readonly body?: unknown }, ) => { - requests.push({ method, path, query: input.query, body: input.body }); - return Effect.succeed(response); + const request = { method, path, query: input.query, body: input.body }; + requests.push(request); + return Effect.succeed(typeof response === "function" ? response(request) : response); }) as OpenCodeRuntime.OpenCodeConnection["request"], globalEvents: Stream.empty, }; @@ -38,6 +47,10 @@ function runtimeLayer(response: unknown, requests: Array) { ); } +function testLayer(response: RuntimeResponse, requests: Array) { + return Layer.merge(runtimeLayer(response, requests), configLayer); +} + it.effect("generates structured text through the attached OpenCode 2 service", () => { const requests: Array = []; return Effect.gen(function* () { @@ -71,7 +84,7 @@ it.effect("generates structured text through the attached OpenCode 2 service", ( NodeAssert.match((request.body as { readonly prompt: string }).prompt, /commit message/i); }).pipe( Effect.provide( - runtimeLayer( + testLayer( { text: JSON.stringify({ subject: "Add OpenCode 2 runtime", @@ -99,5 +112,65 @@ it.effect("rejects model selections that do not identify an upstream provider", NodeAssert.equal(error._tag, "TextGenerationError"); NodeAssert.match(error.detail, /provider\/model/); NodeAssert.equal(requests.length, 0); - }).pipe(Effect.provide(runtimeLayer({ text: JSON.stringify({ branch: "unused" }) }, requests))); + }).pipe(Effect.provide(testLayer({ text: JSON.stringify({ branch: "unused" }) }, requests))); +}); + +it.effect("passes image attachments through a temporary native session", () => { + const requests: Array = []; + const response = (request: CapturedRequest) => { + if (request.method === "POST" && request.path === "/api/session") { + return { data: { id: "ses_generated" } }; + } + if (request.path.endsWith("/prompt")) return { data: { id: "msg_user" } }; + if (request.path.endsWith("/wait") || request.method === "DELETE") return undefined; + if (request.path.endsWith("/message")) { + return { + data: [ + { + type: "assistant", + content: [{ type: "text", text: JSON.stringify({ branch: "native-images" }) }], + }, + ], + }; + } + NodeAssert.fail(`Unexpected request: ${request.method} ${request.path}`); + }; + + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration({ binaryPath: "opencode2" }); + const generated = yield* textGeneration.generateBranchName({ + cwd: "/work/project", + message: "fix the layout shown here", + attachments: [ + { + type: "image", + id: "thread-native-images-9a30ddad-3b87-463b-a306-2aa270f089d2", + name: "layout.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ], + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5.6"), + }); + + NodeAssert.deepEqual(generated, { branch: "native-images" }); + NodeAssert.deepEqual( + requests.map((request) => `${request.method} ${request.path}`), + [ + "POST /api/session", + "POST /api/session/ses_generated/prompt", + "POST /api/session/ses_generated/wait", + "GET /api/session/ses_generated/message", + "DELETE /api/session/ses_generated", + ], + ); + const prompt = requests[1]?.body as { + readonly text: string; + readonly files: ReadonlyArray<{ readonly uri: string; readonly name: string }>; + readonly delivery: string; + }; + NodeAssert.equal(prompt.delivery, "steer"); + NodeAssert.equal(prompt.files[0]?.name, "layout.png"); + NodeAssert.match(prompt.files[0]?.uri ?? "", /thread-native-images-.*\.png$/u); + }).pipe(Effect.provide(testLayer(response, requests))); }); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index db98635af42a..95937fabec20 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -1,12 +1,17 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeURL from "node:url"; + import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { TextGenerationError, type ModelSelection } from "@t3tools/contracts"; +import { TextGenerationError, type ChatAttachment, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; import type { OpenCodeProviderSettings } from "../provider/Layers/OpenCodeProvider.ts"; import * as TextGeneration from "./TextGeneration.ts"; @@ -29,6 +34,23 @@ const OpenCodeGenerateResponseSchema = Schema.Union([ Schema.Struct({ text: Schema.String }), Schema.Struct({ data: Schema.Struct({ text: Schema.String }) }), ]); +const OpenCodeSessionResponseSchema = Schema.Struct({ + data: Schema.Struct({ id: Schema.String }), +}); +const OpenCodeMessageListResponseSchema = Schema.Struct({ + data: Schema.Array(Schema.Unknown), +}); +const OpenCodeAssistantMessageSchema = Schema.Struct({ + type: Schema.Literal("assistant"), + content: Schema.Array(Schema.Unknown), +}); +const OpenCodeTextPartSchema = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, +}); + +const decodeAssistantMessage = Schema.decodeUnknownOption(OpenCodeAssistantMessageSchema); +const decodeTextPart = Schema.decodeUnknownOption(OpenCodeTextPartSchema); type OpenCodeTextGenerationOperation = | "generateCommitMessage" @@ -58,11 +80,26 @@ function generatedText(response: typeof OpenCodeGenerateResponseSchema.Type): st return "data" in response ? response.data.text : response.text; } +function latestAssistantText(response: typeof OpenCodeMessageListResponseSchema.Type): string { + for (const value of response.data) { + const message = Option.getOrUndefined(decodeAssistantMessage(value)); + if (!message) continue; + const text = message.content + .flatMap((part) => Option.toArray(decodeTextPart(part))) + .map((part) => part.text) + .join("") + .trim(); + if (text.length > 0) return text; + } + return ""; +} + export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( settings: Pick, environment?: NodeJS.ProcessEnv, ) { const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; + const serverConfig = yield* ServerConfig; const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { readonly operation: OpenCodeTextGenerationOperation; @@ -70,6 +107,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" readonly prompt: string; readonly outputSchema: S; readonly modelSelection: ModelSelection; + readonly attachments?: ReadonlyArray | undefined; }): Effect.fn.Return { const model = parseModelSelection(input.modelSelection); if (model === null) { @@ -84,6 +122,73 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" binaryPath: settings.binaryPath || "opencode2", ...(environment ? { environment } : {}), }); + const attachments = input.attachments ?? []; + if (attachments.length > 0) { + const files: Array<{ readonly uri: string; readonly name: string }> = []; + for (const attachment of attachments) { + const path = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!path) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: `OpenCode 2 attachment '${attachment.name}' is unavailable.`, + }); + } + files.push({ + uri: NodeURL.pathToFileURL(path).href, + name: attachment.name, + }); + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + return yield* Effect.acquireUseRelease( + connection + .request("POST", "/api/session", { + operation: "session.create", + schema: OpenCodeSessionResponseSchema, + body: { + title: `T3 Code ${input.operation}`, + location: { directory: input.cwd }, + model, + ...(selectedAgent ? { agent: selectedAgent } : {}), + }, + }) + .pipe(Effect.map((response) => response.data.id)), + (sessionId) => { + const prompt = { text: input.prompt, files }; + const body = + connection.protocol.promptShape === "flat" + ? { ...prompt, delivery: "steer" as const } + : { prompt, delivery: "steer" as const }; + const sessionPath = `/api/session/${encodeURIComponent(sessionId)}`; + return Effect.gen(function* () { + yield* connection.request("POST", `${sessionPath}/prompt`, { + operation: "session.prompt", + schema: Schema.Unknown, + body, + }); + yield* connection.request("POST", `${sessionPath}/wait`, { + operation: "session.wait", + schema: Schema.Void, + }); + const messages = yield* connection.request("GET", `${sessionPath}/message`, { + operation: "message.list", + schema: OpenCodeMessageListResponseSchema, + query: { order: "desc", limit: 10 }, + }); + return { text: latestAssistantText(messages) }; + }); + }, + (sessionId) => + connection + .request("DELETE", `/api/session/${encodeURIComponent(sessionId)}`, { + operation: "session.remove", + schema: Schema.Void, + }) + .pipe(Effect.ignore), + ); + } return yield* connection.request("POST", "/api/generate", { operation: "generate.text", schema: OpenCodeGenerateResponseSchema, @@ -199,6 +304,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" prompt, outputSchema, modelSelection: input.modelSelection, + attachments: input.attachments, }); return { branch: sanitizeBranchFragment(generated.branch) }; }); @@ -216,6 +322,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" prompt, outputSchema, modelSelection: input.modelSelection, + attachments: input.attachments, }); return { title: sanitizeThreadTitle(generated.title) }; }); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 9b2989e38f70..b9454f7b58d0 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -106,7 +106,7 @@ build production behavior on receipts. ## Provider drivers -Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing From 913fb3686dc06034bc088099f8c2e1a7418296a8 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:04:39 -0400 Subject: [PATCH 09/24] fix(opencode): serialize native session setup --- .../provider/Layers/OpenCodeAdapter.test.ts | 60 ++++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 20 ++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index e0ea9bc6048e..0483dcaf3e41 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -13,6 +13,7 @@ import { createModelSelection } from "@t3tools/shared/model"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -35,6 +36,8 @@ interface Harness { readonly attachCount: () => number; readonly failNextRequest: (operation: string) => void; readonly missNextRequest: (operation: string) => void; + readonly blockNextRequest: (operation: string, blocker: Effect.Effect) => void; + readonly queueSessionIds: (...sessionIds: ReadonlyArray) => void; readonly publish: (event: OpenCodeRuntime.OpenCodeEvent) => Effect.Effect; readonly failEvents: (error: OpenCodeRuntime.OpenCodeRuntimeFailure) => Effect.Effect; } @@ -64,6 +67,8 @@ function withHarness( const calls: Array = []; const failedOperations = new Set(); const missingOperations = new Set(); + const blockedOperations = new Map>(); + const queuedSessionIds: Array = []; let attachCalls = 0; const request = (( method: OpenCodeRuntime.OpenCodeHttpMethod, @@ -77,6 +82,11 @@ function withHarness( operation: input.operation, ...(input.body !== undefined ? { body: input.body } : {}), }); + const blocker = blockedOperations.get(input.operation); + if (blocker) { + blockedOperations.delete(input.operation); + yield* blocker; + } if (failedOperations.delete(input.operation)) { return yield* new OpenCodeRuntime.OpenCodeRuntimeError({ operation: input.operation, @@ -91,7 +101,10 @@ function withHarness( }); } if (path === "/api/session" || (method === "GET" && path.startsWith("/api/session/"))) { - const id = path === "/api/session" ? "ses_test" : path.slice("/api/session/".length); + const id = + path === "/api/session" + ? (queuedSessionIds.shift() ?? "ses_test") + : path.slice("/api/session/".length); return { data: { id } }; } if (path.endsWith("/message")) return { data: [], cursor: {} }; @@ -149,6 +162,8 @@ function withHarness( attachCount: () => attachCalls, failNextRequest: (operation) => failedOperations.add(operation), missNextRequest: (operation) => missingOperations.add(operation), + blockNextRequest: (operation, blocker) => blockedOperations.set(operation, blocker), + queueSessionIds: (...sessionIds) => queuedSessionIds.push(...sessionIds), publish: (next) => Queue.offer(events, next).pipe(Effect.asVoid), failEvents: (error) => Queue.offer(events, error).pipe(Effect.asVoid), }); @@ -548,7 +563,7 @@ it.effect("resumes existing sessions and detaches without stop or delete request ); it.effect("starts a fresh native session when a stored resume cursor no longer exists", () => - withHarness("flat", ({ adapter, calls, missNextRequest }) => + withHarness("flat", ({ adapter, attachCount, calls, missNextRequest }) => Effect.gen(function* () { missNextRequest("session.get"); const session = yield* adapter.startSession({ @@ -567,6 +582,47 @@ it.effect("starts a fresh native session when a stored resume cursor no longer e ], ); NodeAssert.deepEqual(session.resumeCursor, { schemaVersion: 1, sessionId: "ses_test" }); + NodeAssert.equal(attachCount(), 1); + }), + ), +); + +it.effect("serializes concurrent starts for the same thread", () => + withHarness("flat", ({ adapter, blockNextRequest, calls, queueSessionIds }) => + Effect.gen(function* () { + const firstCreateStarted = yield* Deferred.make(); + const releaseFirstCreate = yield* Deferred.make(); + queueSessionIds("ses_first", "ses_second"); + blockNextRequest( + "session.create", + Deferred.succeed(firstCreateStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstCreate)), + ), + ); + + const first = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.forkScoped); + yield* Deferred.await(firstCreateStarted); + const second = yield* adapter + .startSession({ threadId, runtimeMode: "full-access" }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + NodeAssert.equal(calls.filter((call) => call.operation === "session.create").length, 1); + + yield* Deferred.succeed(releaseFirstCreate, undefined); + yield* Fiber.join(first); + yield* Fiber.join(second); + + const sessions = yield* adapter.listSessions(); + NodeAssert.equal(sessions.length, 1); + NodeAssert.deepEqual(sessions[0]?.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_second", + }); + yield* adapter.stopSession(threadId); + NodeAssert.deepEqual(yield* adapter.listSessions(), []); }), ), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 3312e2f83aae..953a842b386f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -310,6 +310,15 @@ function isMissingOpenCodeSession(cause: OpenCodeRuntime.OpenCodeRuntimeFailure) ); } +function shouldInvalidateConnection(cause: OpenCodeRuntime.OpenCodeRuntimeFailure): boolean { + return ( + OpenCodeRuntime.isOpenCodeRuntimeError(cause) && + (cause.reason === "transport" || + cause.reason === "connection-ended" || + (cause.reason === "http-status" && cause.status === 401)) + ); +} + export function makeOpenCodeAdapter( settings: OpenCodeSettings, options?: OpenCodeAdapterLiveOptions, @@ -324,6 +333,7 @@ export function makeOpenCodeAdapter( const sessions = new Map(); const sessionByProviderId = new Map(); const connectionLock = yield* Semaphore.make(1); + const sessionStartLock = yield* Semaphore.make(1); let activeConnection: OpenCodeActiveConnection | undefined; const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( @@ -899,7 +909,11 @@ export function makeOpenCodeAdapter( Effect.flatMap((entry) => entry.connection .request(method, path, input) - .pipe(Effect.tapError(() => invalidateConnection(entry))), + .pipe( + Effect.tapError((cause) => + shouldInvalidateConnection(cause) ? invalidateConnection(entry) : Effect.void, + ), + ), ), ); @@ -949,7 +963,7 @@ export function makeOpenCodeAdapter( }); }); - const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn( + const startSessionUnlocked: OpenCodeAdapterShape["startSession"] = Effect.fn( "OpenCodeAdapter.startSession", )(function* (input) { if (!settings.enabled) { @@ -1070,6 +1084,8 @@ export function makeOpenCodeAdapter( }); return session; }); + const startSession: OpenCodeAdapterShape["startSession"] = (input) => + sessionStartLock.withPermit(startSessionUnlocked(input)); const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("OpenCodeAdapter.sendTurn")( function* (input) { From 3135edc1e791ee8730c7ed75d88c934527f2fcd5 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:15:21 -0400 Subject: [PATCH 10/24] fix(web): dismiss OpenCode notice from settings action --- apps/web/src/components/OpenCode2AutoEnableNotification.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/OpenCode2AutoEnableNotification.tsx b/apps/web/src/components/OpenCode2AutoEnableNotification.tsx index ac00f6749f4c..4adc68219841 100644 --- a/apps/web/src/components/OpenCode2AutoEnableNotification.tsx +++ b/apps/web/src/components/OpenCode2AutoEnableNotification.tsx @@ -83,7 +83,11 @@ export function OpenCode2AutoEnableNotification() { timeout: 0, actionProps: { children: "Go to settings", - onClick: () => void navigate({ to: "/settings/providers" }), + onClick: () => { + dismiss(); + toastManager.close(toastId); + void navigate({ to: "/settings/providers" }); + }, }, actionVariant: "outline", data: { From 91eeb42ec23ab9c12b43fc387d4cf2f4e3799fbc Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:20:54 -0400 Subject: [PATCH 11/24] fix(opencode): preserve session setup error context --- .../src/provider/Layers/OpenCodeAdapter.test.ts | 13 ++++++++----- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 0483dcaf3e41..f457d4289899 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -757,8 +757,8 @@ it.effect("keeps the existing session when resumed setup fails", () => resumeCursor, }); failNextRequest("session.switchModel"); - const result = yield* adapter - .startSession({ + const error = yield* Effect.flip( + adapter.startSession({ threadId: secondThread, runtimeMode: "full-access", resumeCursor, @@ -766,10 +766,13 @@ it.effect("keeps the existing session when resumed setup fails", () => ProviderInstanceId.make("opencode"), "anthropic/claude-sonnet", ), - }) - .pipe(Effect.exit); + }), + ); - NodeAssert.equal(Exit.isFailure(result), true); + NodeAssert.equal(error._tag, "ProviderAdapterRequestError"); + if (error._tag === "ProviderAdapterRequestError") { + NodeAssert.equal(error.method, "session.switchModel"); + } NodeAssert.deepEqual( (yield* adapter.listSessions()).map((session) => session.threadId), [firstThread], diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 953a842b386f..234254f1ca18 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -293,7 +293,7 @@ function toProcessError(threadId: ThreadId, cause: OpenCodeRuntime.OpenCodeRunti return new ProviderAdapterProcessError({ provider: PROVIDER, threadId, - detail: "OpenCode 2 could not attach the requested session.", + detail: `OpenCode 2 could not attach the requested session during '${cause.operation}'.`, cause, }); } @@ -1025,14 +1025,14 @@ export function makeOpenCodeAdapter( operation: "session.switchModel", schema: Schema.Void, body: { model: modelRef }, - }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + }).pipe(Effect.mapError((cause) => toRequestError("session.switchModel", cause))); } if (resolution.resumedExistingSession && agent) { yield* request("POST", `/api/session/${encodedPathSegment(providerSessionId)}/agent`, { operation: "session.switchAgent", schema: Schema.Void, body: { agent }, - }).pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause))); + }).pipe(Effect.mapError((cause) => toRequestError("session.switchAgent", cause))); } for (const stale of staleContexts) { yield* detachContext(stale); From d66d5da296f415ab503cac04735315c5f9914f6d Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:26:33 -0400 Subject: [PATCH 12/24] fix(opencode): settle disconnected turns --- .../src/provider/Layers/OpenCodeAdapter.test.ts | 14 ++++++++++++-- .../src/provider/Layers/OpenCodeAdapter.ts | 17 +++++++---------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index f457d4289899..287730d8b490 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -21,7 +21,8 @@ import * as Stream from "effect/Stream"; import { ServerConfig } from "../../config.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; -import { makeOpenCodeAdapter, type OpenCodeAdapterShape } from "./OpenCodeAdapter.ts"; +import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { makeOpenCodeAdapter } from "./OpenCodeAdapter.ts"; interface RequestCall { readonly method: OpenCodeRuntime.OpenCodeHttpMethod; @@ -681,7 +682,7 @@ it.effect("reconnects after the native event stream fails", () => }), ); yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); - yield* adapter.sendTurn({ threadId, input: "before disconnect" }); + const firstTurn = yield* adapter.sendTurn({ threadId, input: "before disconnect" }); yield* failEvents( new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "event.subscribe", @@ -690,6 +691,15 @@ it.effect("reconnects after the native event stream fails", () => ); yield* Deferred.await(runtimeError); NodeAssert.equal((yield* adapter.listSessions())[0]?.activeTurnId, undefined); + NodeAssert.equal( + observed.some( + (event) => + event.type === "turn.completed" && + event.turnId === firstTurn.turnId && + event.payload.state === "failed", + ), + true, + ); yield* adapter.sendTurn({ threadId, input: "reconnect" }); yield* Deferred.await(secondTurnStarted); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 234254f1ca18..a2e2ab040fd3 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -41,8 +41,6 @@ import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; -export type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; - const PROVIDER = ProviderDriverKind.make("opencode"); const RESUME_VERSION = 1 as const; @@ -405,7 +403,7 @@ export function makeOpenCodeAdapter( const completeTurn = Effect.fn("OpenCodeAdapter.completeTurn")(function* ( context: OpenCodeSessionContext, state: "completed" | "failed" | "interrupted", - raw: OpenCodeRuntime.OpenCodeEvent, + raw: OpenCodeRuntime.OpenCodeEvent | undefined, errorMessage?: string, ) { const turnId = context.activeTurnId; @@ -805,15 +803,14 @@ export function makeOpenCodeAdapter( sessions.values(), (context) => Effect.gen(function* () { - context.activeTurnId = undefined; - yield* updateSession( - context, - { + if (context.activeTurnId) { + yield* completeTurn(context, "failed", undefined, cause.message); + } else { + yield* updateSession(context, { status: "error", lastError: cause.message, - }, - "activeTurnId", - ); + }); + } yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId })), type: "runtime.error", From cd9812ca0f4df5c28c353857962ef03456aa8785 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:28:48 -0400 Subject: [PATCH 13/24] fix(opencode): omit absent native event payload --- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index a2e2ab040fd3..482a94e53066 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -418,7 +418,11 @@ export function makeOpenCodeAdapter( "activeTurnId", ); yield* emit({ - ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, raw })), + ...(yield* buildEventBase({ + threadId: context.session.threadId, + turnId, + ...(raw ? { raw } : {}), + })), type: "turn.completed", payload: { state, From 54e7536fc5daa4978582605e811ab742db2bd27d Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:39:38 -0400 Subject: [PATCH 14/24] fix(opencode): honor native session safety --- .../provider/Layers/OpenCodeAdapter.test.ts | 146 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 45 +++++- 2 files changed, 187 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 287730d8b490..fecab4e9524e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -10,10 +10,12 @@ import { ThreadId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -36,9 +38,11 @@ interface Harness { readonly calls: Array; readonly attachCount: () => number; readonly failNextRequest: (operation: string) => void; + readonly failNextEventId: () => void; readonly missNextRequest: (operation: string) => void; readonly blockNextRequest: (operation: string, blocker: Effect.Effect) => void; readonly queueSessionIds: (...sessionIds: ReadonlyArray) => void; + readonly setSessionDirectory: (sessionId: string, directory: string) => void; readonly publish: (event: OpenCodeRuntime.OpenCodeEvent) => Effect.Effect; readonly failEvents: (error: OpenCodeRuntime.OpenCodeRuntimeFailure) => Effect.Effect; } @@ -62,6 +66,7 @@ function withHarness( failFirstHandshake = false, ) { return Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const events = yield* Queue.unbounded< OpenCodeRuntime.OpenCodeEvent | OpenCodeRuntime.OpenCodeRuntimeFailure >(); @@ -70,6 +75,8 @@ function withHarness( const missingOperations = new Set(); const blockedOperations = new Map>(); const queuedSessionIds: Array = []; + const sessionDirectories = new Map(); + let shouldFailNextEventId = false; let attachCalls = 0; const request = (( method: OpenCodeRuntime.OpenCodeHttpMethod, @@ -106,7 +113,14 @@ function withHarness( path === "/api/session" ? (queuedSessionIds.shift() ?? "ses_test") : path.slice("/api/session/".length); - return { data: { id } }; + return { + data: { + id, + ...(method === "GET" + ? { location: { directory: sessionDirectories.get(id) ?? process.cwd() } } + : {}), + }, + }; } if (path.endsWith("/message")) return { data: [], cursor: {} }; if (path.endsWith("/prompt")) return { data: { id: "msg_user" } }; @@ -152,8 +166,23 @@ function withHarness( ), ), }; + const uuidError = PlatformError.systemError({ + _tag: "Unknown", + module: "Crypto", + method: "randomUUIDv4", + description: "UUID generation unavailable", + }); + const testCrypto = { + ...crypto, + randomUUIDv4: Effect.suspend(() => { + if (!shouldFailNextEventId) return crypto.randomUUIDv4; + shouldFailNextEventId = false; + return Effect.fail(uuidError); + }), + }; const adapter = yield* makeOpenCodeAdapter({ ...settings, enabled }).pipe( Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + Effect.provideService(Crypto.Crypto, testCrypto), Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-opencode2-adapter-" })), ); yield* Effect.yieldNow; @@ -162,9 +191,13 @@ function withHarness( calls, attachCount: () => attachCalls, failNextRequest: (operation) => failedOperations.add(operation), + failNextEventId: () => { + shouldFailNextEventId = true; + }, missNextRequest: (operation) => missingOperations.add(operation), blockNextRequest: (operation, blocker) => blockedOperations.set(operation, blocker), queueSessionIds: (...sessionIds) => queuedSessionIds.push(...sessionIds), + setSessionDirectory: (sessionId, directory) => sessionDirectories.set(sessionId, directory), publish: (next) => Queue.offer(events, next).pipe(Effect.asVoid), failEvents: (error) => Queue.offer(events, error).pipe(Effect.asVoid), }); @@ -502,6 +535,46 @@ it.effect("replies to permissions and forms and interrupts through native routes ), ); +it.effect("auto-accepts native permissions in full-access mode", () => + withHarness("flat", ({ adapter, blockNextRequest, calls, publish }) => + Effect.gen(function* () { + const observed: Array = []; + const replyStarted = yield* Deferred.make(); + yield* collectEvents(adapter, observed); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "edit this" }); + blockNextRequest( + "permission.reply", + Deferred.succeed(replyStarted, undefined).pipe(Effect.ignore), + ); + + yield* publish( + event("permission", "permission.asked", { + id: "per_test", + sessionID: "ses_test", + action: "edit", + resources: ["src/index.ts"], + }), + ); + yield* Deferred.await(replyStarted); + + NodeAssert.deepEqual( + calls.find((call) => call.operation === "permission.reply"), + { + method: "POST", + path: "/api/session/ses_test/permission/per_test/reply", + operation: "permission.reply", + body: { reply: "once" }, + }, + ); + NodeAssert.equal( + observed.some((next) => next.type === "request.opened"), + false, + ); + }), + ), +); + it.effect("resumes existing sessions and detaches without stop or delete requests", () => withHarness("flat", ({ adapter, calls }) => Effect.gen(function* () { @@ -588,6 +661,38 @@ it.effect("starts a fresh native session when a stored resume cursor no longer e ), ); +it.effect("starts a fresh native session when the stored session uses another directory", () => + withHarness("flat", ({ adapter, calls, queueSessionIds, setSessionDirectory }) => + Effect.gen(function* () { + setSessionDirectory("ses_from_v1", "/tmp/opencode2-old"); + queueSessionIds("ses_fresh"); + const session = yield* adapter.startSession({ + threadId, + runtimeMode: "full-access", + cwd: "/tmp/opencode2-new", + resumeCursor: { schemaVersion: 1, sessionId: "ses_from_v1" }, + }); + + NodeAssert.deepEqual( + calls + .filter((call) => call.operation === "session.get" || call.operation === "session.create") + .map((call) => [call.method, call.path]), + [ + ["GET", "/api/session/ses_from_v1"], + ["POST", "/api/session"], + ], + ); + NodeAssert.deepEqual(calls.find((call) => call.operation === "session.create")?.body, { + location: { directory: "/tmp/opencode2-new" }, + }); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_fresh", + }); + }), + ), +); + it.effect("serializes concurrent starts for the same thread", () => withHarness("flat", ({ adapter, blockNextRequest, calls, queueSessionIds }) => Effect.gen(function* () { @@ -709,6 +814,45 @@ it.effect("reconnects after the native event stream fails", () => ), ); +it.effect("reconnects after native event processing fails", () => + withHarness("flat", ({ adapter, attachCount, failNextEventId, publish }) => + Effect.gen(function* () { + const runtimeError = yield* Deferred.make(); + const observed: Array = []; + yield* collectEvents(adapter, observed, (next) => + next.type === "runtime.error" + ? Deferred.succeed(runtimeError, undefined).pipe(Effect.ignore) + : Effect.void, + ); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + const firstTurn = yield* adapter.sendTurn({ threadId, input: "before event failure" }); + failNextEventId(); + yield* publish( + event("text", "session.text.delta", { + sessionID: "ses_test", + assistantMessageID: "msg_assistant", + ordinal: 0, + delta: "partial", + }), + ); + yield* Deferred.await(runtimeError); + + NodeAssert.equal((yield* adapter.listSessions())[0]?.activeTurnId, undefined); + NodeAssert.equal( + observed.some( + (next) => + next.type === "turn.completed" && + next.turnId === firstTurn.turnId && + next.payload.state === "failed", + ), + true, + ); + yield* adapter.sendTurn({ threadId, input: "after event failure" }); + NodeAssert.equal(attachCount(), 2); + }), + ), +); + it.effect("restores the regular agent after a plan turn", () => withHarness("flat", ({ adapter, calls }) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 482a94e53066..74ee833324cc 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import { @@ -49,6 +50,11 @@ const UnknownRecordArraySchema = Schema.Array(UnknownRecordSchema); const SessionResponseSchema = Schema.Struct({ data: Schema.Struct({ id: Schema.String, + location: Schema.optionalKey( + Schema.Struct({ + directory: Schema.String, + }), + ), }), }); const MessagesResponseSchema = Schema.Struct({ @@ -150,6 +156,10 @@ function parseResumeCursor(raw: unknown): string | undefined { return stringField(record, "sessionId"); } +function isSameDirectory(left: string, right: string): boolean { + return NodePath.resolve(left) === NodePath.resolve(right); +} + function parseModelRef(model: string, variant?: string): ModelRef | undefined { const separator = model.indexOf("/"); if (separator <= 0 || separator === model.length - 1) return undefined; @@ -456,6 +466,7 @@ export function makeOpenCodeAdapter( const handleEvent = Effect.fn("OpenCodeAdapter.handleEvent")(function* ( event: OpenCodeRuntime.OpenCodeEvent, + connection: OpenCodeRuntime.OpenCodeConnection, ) { if (normalizeEventType(event.type) === "server.connected") { return; @@ -673,6 +684,20 @@ export function makeOpenCodeAdapter( case "permission.asked": { const requestId = stringField(data, "id", "requestID"); if (!requestId) break; + if (context.session.runtimeMode === "full-access") { + yield* connection + .request( + "POST", + `/api/session/${encodedPathSegment(context.providerSessionId)}/permission/${encodedPathSegment(requestId)}/reply`, + { + operation: "permission.reply", + schema: Schema.Void, + body: { reply: "once" }, + }, + ) + .pipe(Effect.mapError((cause) => toRequestError("permission.reply", cause))); + break; + } const action = stringField(data, "action", "permission") ?? "unknown"; const resources = Array.isArray(data.resources) ? data.resources.filter((value): value is string => typeof value === "string") @@ -857,7 +882,16 @@ export function makeOpenCodeAdapter( Stream.runForEach((event) => normalizeEventType(event.type) === "server.connected" ? Deferred.succeed(connected, undefined).pipe(Effect.ignore) - : handleEvent(event), + : handleEvent(event, connection).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "event.process", + reason: "transport", + cause, + }), + ), + ), ), Effect.flatMap(() => Effect.fail( @@ -1003,14 +1037,19 @@ export function makeOpenCodeAdapter( ...(modelRef ? { model: modelRef } : {}), ...(agent ? { agent } : {}), }, - }).pipe(Effect.map((resolved) => ({ resolved, resumedExistingSession: false as const }))); + }).pipe(Effect.map((resolved) => ({ resolved, resumedExistingSession: false }))); const resolution = yield* ( resumeSessionId ? request("GET", `/api/session/${encodedPathSegment(resumeSessionId)}`, { operation: "session.get", schema: SessionResponseSchema, }).pipe( - Effect.map((resolved) => ({ resolved, resumedExistingSession: true as const })), + Effect.flatMap((resolved) => + resolved.data.location === undefined || + isSameDirectory(resolved.data.location.directory, directory) + ? Effect.succeed({ resolved, resumedExistingSession: true }) + : createSession, + ), Effect.catchIf(isMissingOpenCodeSession, () => createSession), ) : createSession From 2d48a87d6974de19db31acfcb9f56362f464b8c6 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:44:26 -0400 Subject: [PATCH 15/24] fix(opencode): preserve symlinked session context --- .../src/provider/Drivers/OpenCodeDriver.ts | 4 ++ .../provider/Layers/OpenCodeAdapter.test.ts | 18 ++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 39 ++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 6bfc9fe7c37f..f0bdbae9ef11 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -1,6 +1,8 @@ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -40,7 +42,9 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ export type OpenCodeDriverEnv = | BackgroundPolicy.BackgroundPolicy | Crypto.Crypto + | FileSystem.FileSystem | OpenCodeRuntime.OpenCodeRuntime + | Path.Path | ProviderEventLoggers | ServerConfig | ServerSettingsService; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index fecab4e9524e..82e20634ba78 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -14,7 +14,9 @@ import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; @@ -24,7 +26,7 @@ import * as Stream from "effect/Stream"; import { ServerConfig } from "../../config.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; -import { makeOpenCodeAdapter } from "./OpenCodeAdapter.ts"; +import { isSameOpenCodeDirectory, makeOpenCodeAdapter } from "./OpenCodeAdapter.ts"; interface RequestCall { readonly method: OpenCodeRuntime.OpenCodeHttpMethod; @@ -219,6 +221,20 @@ function collectEvents( ); } +it.effect("matches symlink-equivalent OpenCode session directories", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-opencode2-cwd-" }); + const directory = path.join(root, "directory"); + const alias = path.join(root, "alias"); + yield* fileSystem.makeDirectory(directory); + yield* fileSystem.symlink(directory, alias); + + NodeAssert.equal(yield* isSameOpenCodeDirectory(fileSystem, path, directory, alias), true); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +); + it.effect("generates unique event ids when an adapter is recreated", () => withHarness("flat", ({ adapter: first }) => withHarness("flat", ({ adapter: second }) => diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 74ee833324cc..a78d3e3d5327 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; import * as NodeURL from "node:url"; import { @@ -21,8 +20,10 @@ import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -156,8 +157,22 @@ function parseResumeCursor(raw: unknown): string | undefined { return stringField(record, "sessionId"); } -function isSameDirectory(left: string, right: string): boolean { - return NodePath.resolve(left) === NodePath.resolve(right); +export function isSameOpenCodeDirectory( + fileSystem: FileSystem.FileSystem, + path: Path.Path, + left: string, + right: string, +): Effect.Effect { + const lexicalLeft = path.resolve(left); + const lexicalRight = path.resolve(right); + if (lexicalLeft === lexicalRight) return Effect.succeed(true); + const canonicalize = (value: string) => + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); + return Effect.zipWith( + canonicalize(lexicalLeft), + canonicalize(lexicalRight), + (canonicalLeft, canonicalRight) => canonicalLeft === canonicalRight, + ); } function parseModelRef(model: string, variant?: string): ModelRef | undefined { @@ -335,6 +350,8 @@ export function makeOpenCodeAdapter( const runtime = yield* OpenCodeRuntime.OpenCodeRuntime; const serverConfig = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const scope = yield* Effect.scope; const instanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); const events = yield* Queue.unbounded(); @@ -1045,10 +1062,20 @@ export function makeOpenCodeAdapter( schema: SessionResponseSchema, }).pipe( Effect.flatMap((resolved) => - resolved.data.location === undefined || - isSameDirectory(resolved.data.location.directory, directory) + resolved.data.location === undefined ? Effect.succeed({ resolved, resumedExistingSession: true }) - : createSession, + : isSameOpenCodeDirectory( + fileSystem, + path, + resolved.data.location.directory, + directory, + ).pipe( + Effect.flatMap((sameDirectory) => + sameDirectory + ? Effect.succeed({ resolved, resumedExistingSession: true }) + : createSession, + ), + ), ), Effect.catchIf(isMissingOpenCodeSession, () => createSession), ) From 5b4823ff508a5fe189ac9ec3aabc8329aa1bc2e2 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:54:58 -0400 Subject: [PATCH 16/24] fix(opencode): isolate permission reply failures --- .../provider/Layers/OpenCodeAdapter.test.ts | 43 +++++++++++++++---- .../src/provider/Layers/OpenCodeAdapter.ts | 21 +++++---- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 82e20634ba78..00771e7a9276 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -551,18 +551,23 @@ it.effect("replies to permissions and forms and interrupts through native routes ), ); -it.effect("auto-accepts native permissions in full-access mode", () => - withHarness("flat", ({ adapter, blockNextRequest, calls, publish }) => +it.effect("auto-accepts native permissions and falls back to approval on failure", () => + withHarness("flat", ({ adapter, attachCount, calls, failNextRequest, publish }) => Effect.gen(function* () { const observed: Array = []; - const replyStarted = yield* Deferred.make(); - yield* collectEvents(adapter, observed); + const permissionResolved = yield* Deferred.make(); + const fallbackOpened = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => { + if (next.type === "request.resolved") { + return Deferred.succeed(permissionResolved, undefined).pipe(Effect.ignore); + } + if (next.type === "request.opened") { + return Deferred.succeed(fallbackOpened, undefined).pipe(Effect.ignore); + } + return Effect.void; + }); yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); yield* adapter.sendTurn({ threadId, input: "edit this" }); - blockNextRequest( - "permission.reply", - Deferred.succeed(replyStarted, undefined).pipe(Effect.ignore), - ); yield* publish( event("permission", "permission.asked", { @@ -572,7 +577,14 @@ it.effect("auto-accepts native permissions in full-access mode", () => resources: ["src/index.ts"], }), ); - yield* Deferred.await(replyStarted); + yield* publish( + event("permission-replied", "permission.replied", { + requestID: "per_test", + sessionID: "ses_test", + reply: "once", + }), + ); + yield* Deferred.await(permissionResolved); NodeAssert.deepEqual( calls.find((call) => call.operation === "permission.reply"), @@ -587,6 +599,19 @@ it.effect("auto-accepts native permissions in full-access mode", () => observed.some((next) => next.type === "request.opened"), false, ); + failNextRequest("permission.reply"); + yield* publish( + event("fallback-permission", "permission.asked", { + id: "per_fallback", + sessionID: "ses_test", + action: "edit", + resources: ["src/other.ts"], + }), + ); + yield* Deferred.await(fallbackOpened); + + NodeAssert.equal(attachCount(), 1); + NodeAssert.equal(calls.filter((call) => call.operation === "permission.reply").length, 2); }), ), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index a78d3e3d5327..b4dc1dc58734 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -701,8 +701,14 @@ export function makeOpenCodeAdapter( case "permission.asked": { const requestId = stringField(data, "id", "requestID"); if (!requestId) break; + const action = stringField(data, "action", "permission") ?? "unknown"; + const resources = Array.isArray(data.resources) + ? data.resources.filter((value): value is string => typeof value === "string") + : Array.isArray(data.patterns) + ? data.patterns.filter((value): value is string => typeof value === "string") + : []; if (context.session.runtimeMode === "full-access") { - yield* connection + const replied = yield* connection .request( "POST", `/api/session/${encodedPathSegment(context.providerSessionId)}/permission/${encodedPathSegment(requestId)}/reply`, @@ -712,15 +718,12 @@ export function makeOpenCodeAdapter( body: { reply: "once" }, }, ) - .pipe(Effect.mapError((cause) => toRequestError("permission.reply", cause))); - break; + .pipe( + Effect.as(true), + Effect.catchCause(() => Effect.succeed(false)), + ); + if (replied) break; } - const action = stringField(data, "action", "permission") ?? "unknown"; - const resources = Array.isArray(data.resources) - ? data.resources.filter((value): value is string => typeof value === "string") - : Array.isArray(data.patterns) - ? data.patterns.filter((value): value is string => typeof value === "string") - : []; context.pendingPermissions.add(requestId); yield* emit({ ...(yield* buildEventBase({ From d79aeaf15d40f75aa6e4a6a6a742fe3253d1d0ee Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:54:02 -0400 Subject: [PATCH 17/24] chore: remove PR upload instructions --- .agents/AGENTS.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .agents/AGENTS.md diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md deleted file mode 100644 index d4931d0bb680..000000000000 --- a/.agents/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -Install the `sudosubin/gh-attach` extension with `gh extension install sudosubin/gh-attach`, then use `gh attach` when adding files to GitHub. Never commit PR-only assets. From c03c50173bcaa4f7e0d108facb0a8c06c4470a96 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:26:56 -0400 Subject: [PATCH 18/24] fix(opencode): render native tool activity --- .../provider/Layers/OpenCodeAdapter.test.ts | 203 ++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 201 ++++++++++++++--- .../chat/MessagesTimeline.logic.test.ts | 16 ++ .../components/chat/MessagesTimeline.logic.ts | 3 +- .../src/components/chat/MessagesTimeline.tsx | 7 + 5 files changed, 399 insertions(+), 31 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 00771e7a9276..79d7103e079c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -410,6 +410,15 @@ it.effect("attaches once and maps native deltas, tools, and terminal events", () observed.some((next) => next.type === "item.completed"), true, ); + const toolUpdates = observed.filter( + (next) => next.itemId === "call-1" && next.type === "item.updated", + ); + const endedUpdate = toolUpdates[1]; + NodeAssert.ok(endedUpdate); + NodeAssert.equal( + (endedUpdate.payload as Record).detail, + "pwd", + ); NodeAssert.equal( observed.some((next) => next.type === "turn.completed" && next.turnId === turn.turnId), true, @@ -441,6 +450,200 @@ it.effect("attaches once and maps native deltas, tools, and terminal events", () ), ); +it.effect("maps OpenCode 2 tool calls to friendly titles and structured data", () => + withHarness("flat", ({ adapter, publish }) => + Effect.gen(function* () { + const observed: Array = []; + const completed = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => + next.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.ignore) + : Effect.void, + ); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "search" }); + + yield* publish( + event("grep-started", "session.next.tool.input.started", { + sessionID: "ses_test", + callID: "call-grep", + name: "grep", + }), + ); + yield* publish( + event("grep-called", "session.next.tool.called", { + sessionID: "ses_test", + callID: "call-grep", + input: { pattern: "toolCall" }, + }), + ); + yield* publish( + event("shell-input-started", "session.next.tool.input.started", { + sessionID: "ses_test", + callID: "call-shell", + name: "shell", + }), + ); + yield* publish( + event("shell-input-ended", "session.next.tool.input.ended", { + sessionID: "ses_test", + callID: "call-shell", + text: '{"command":"git status"}', + }), + ); + yield* publish( + event("shell-success-quiet", "session.next.tool.success", { + sessionID: "ses_test", + callID: "call-shell", + content: [{ type: "text", text: "(no output)\nCommand exited with code 0." }], + }), + ); + yield* publish( + event("settled", "session.next.execution.settled", { + sessionID: "ses_test", + outcome: "success", + }), + ); + yield* Deferred.await(completed); + + const itemEvents = observed.filter((next) => next.type.startsWith("item.")); + const payloadOf = (next: ProviderRuntimeEvent | undefined) => + next?.payload as Record; + const grepStarted = itemEvents.find( + (next) => next.itemId === "call-grep" && next.type === "item.started", + ); + NodeAssert.equal(payloadOf(grepStarted).title, "Searching files"); + const grepUpdated = itemEvents.find( + (next) => next.itemId === "call-grep" && next.type === "item.updated", + ); + NodeAssert.equal(payloadOf(grepUpdated).detail, "toolCall"); + NodeAssert.deepEqual(payloadOf(grepUpdated).data, { + toolCallId: "call-grep", + tool: "grep", + input: { pattern: "toolCall" }, + }); + const shellUpdated = itemEvents.find( + (next) => next.itemId === "call-shell" && next.type === "item.updated", + ); + NodeAssert.equal(payloadOf(shellUpdated).title, "Running command"); + NodeAssert.equal(payloadOf(shellUpdated).detail, "git status"); + NodeAssert.equal( + (payloadOf(shellUpdated).data as Record).command, + "git status", + ); + const shellCompleted = itemEvents.find( + (next) => next.itemId === "call-shell" && next.type === "item.completed", + ); + // "(no output)\nCommand exited with code 0." is shell padding; quiet + // commands complete without detail. + NodeAssert.equal(payloadOf(shellCompleted).detail, undefined); + NodeAssert.equal(payloadOf(shellCompleted).status, "completed"); + }), + ), +); + +it.effect("keeps non-zero exit codes in OpenCode 2 tool output detail", () => + withHarness("flat", ({ adapter, publish }) => + Effect.gen(function* () { + const observed: Array = []; + const completed = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => + next.type === "turn.completed" + ? Deferred.succeed(completed, undefined).pipe(Effect.ignore) + : Effect.void, + ); + yield* adapter.startSession({ threadId, runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "run" }); + yield* publish( + event("failed-shell", "session.next.tool.failed", { + sessionID: "ses_test", + callID: "call-fail", + name: "shell", + content: [{ type: "text", text: "(no output)\nCommand exited with code 1." }], + }), + ); + yield* publish( + event("settled", "session.next.execution.settled", { + sessionID: "ses_test", + outcome: "success", + }), + ); + yield* Deferred.await(completed); + + const failed = observed.find((next) => next.itemId === "call-fail"); + NodeAssert.ok(failed); + NodeAssert.equal( + (failed.payload as Record).detail, + "Command exited with code 1.", + ); + }), + ), +); + +it.effect("translates form answers into OpenCode 2 option values", () => + withHarness("nested", ({ adapter, calls, publish }) => + Effect.gen(function* () { + const observed: Array = []; + const formOpened = yield* Deferred.make(); + yield* collectEvents(adapter, observed, (next) => + next.type === "user-input.requested" + ? Deferred.succeed(formOpened, undefined).pipe(Effect.ignore) + : Effect.void, + ); + yield* adapter.startSession({ threadId, runtimeMode: "approval-required" }); + yield* publish( + event("form", "form.created", { + form: { + id: "frm_values", + sessionID: "ses_test", + title: "Choices", + fields: [ + { + key: "choice", + type: "string", + title: "Choice", + description: "Pick one", + options: [ + { value: "allow", label: "Allow search" }, + { value: "choose", label: "Choose another provider" }, + { value: "disable", label: "Disable web search" }, + ], + }, + { + key: "mode", + type: "string", + title: "Mode", + description: "Pick a mode", + options: [ + { value: "auto", label: "Auto" }, + { value: "manual", label: "Manual" }, + ], + }, + ], + }, + }), + ); + yield* Deferred.await(formOpened); + + // Labels map to values, display indices ("2") map to their option. + yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("frm_values"), { + choice: "Choose another provider", + mode: "2", + }); + + NodeAssert.deepEqual( + calls.find((call) => call.operation === "form.reply"), + { + method: "POST", + path: "/api/session/ses_test/form/frm_values/reply", + operation: "form.reply", + body: { answer: { choice: "choose", mode: "manual" } }, + }, + ); + }), + ), +); + it.effect("replies to permissions and forms and interrupts through native routes", () => withHarness("nested", ({ adapter, calls, publish }) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index b4dc1dc58734..715d07c450a4 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -64,18 +64,27 @@ const MessagesResponseSchema = Schema.Struct({ const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecordSchema); const decodeUnknownRecordArray = Schema.decodeUnknownOption(UnknownRecordArraySchema); +const decodeUnknownJsonString = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Unknown), +); interface OpenCodeTurnSnapshot { readonly id: TurnId; readonly items: Array; } +interface OpenCodeToolCallState { + readonly name: string; + input?: unknown; + inputText?: string; +} + interface OpenCodeSessionContext { session: ProviderSession; readonly providerSessionId: string; readonly pendingPermissions: Set; - readonly pendingForms: Set; - readonly toolNameByCallId: Map; + readonly pendingForms: Map>>; + readonly toolsByCallId: Map; activeTurnId: TurnId | undefined; currentModel: string | undefined; currentVariant: string | undefined; @@ -206,6 +215,75 @@ function toToolLifecycleItemType(toolName: string): ToolLifecycleItemType { return "dynamic_tool_call"; } +/** Human title for the common OpenCode 2 built-in tools; unknown tools keep their raw name. */ +function openCodeToolTitle(toolName: string): string { + const normalized = toolName.toLowerCase(); + if (normalized === "read") return "Reading file"; + if (normalized === "grep" || normalized.includes("search")) return "Searching files"; + if (normalized === "glob" || normalized.includes("find")) return "Finding files"; + if (normalized === "shell" || normalized.includes("bash")) return "Running command"; + if (normalized.includes("edit") || normalized.includes("patch")) return "Editing file"; + if (normalized.includes("write")) return "Writing file"; + if (normalized.includes("fetch")) return "Fetching page"; + if (normalized.includes("task") || normalized.includes("agent")) return "Running agent"; + return toolName; +} + +/** + * Short human summary of a tool call's input for the row preview. OpenCode 2 + * streams inputs as raw JSON text; parse out the field a human would scan. + */ +function openCodeToolInputDetail( + toolName: string, + input: unknown, +): string | undefined { + const record = recordFromUnknown(input); + if (!record) return undefined; + const firstString = (...keys: ReadonlyArray) => stringField(record, ...keys); + const normalized = toolName.toLowerCase(); + if (normalized === "grep" || normalized === "glob" || /search|find|list/u.test(normalized)) { + return firstString("pattern", "query", "include"); + } + if (/fetch|web/u.test(normalized)) return firstString("url"); + if (/read|edit|write|patch|apply/u.test(normalized)) { + return firstString("filePath", "path", "file"); + } + return firstString("command", "script", "url", "pattern", "filePath", "path", "file", "query"); +} + +/** + * Shape clients already understand: `toolCallId` keys started/updated/completed + * merges, and a flattened `command` lets them show live command previews. + */ +function toolItemData(callId: string, state: OpenCodeToolCallState): Record { + const inputRecord = recordFromUnknown(state.input); + const command = + typeof inputRecord?.command === "string" && inputRecord.command.trim().length > 0 + ? inputRecord.command + : undefined; + return { + toolCallId: callId, + tool: state.name, + ...(state.input !== undefined ? { input: state.input } : {}), + ...(command ? { command } : {}), + }; +} + +/** + * OpenCode 2's shell tool pads empty output with "(no output)" and always + * appends an exit-code line; drop the placeholder and the exit-0 line so quiet + * commands render as quietly succeeding. + */ +function cleanToolOutputText(text: string): string | undefined { + let cleaned = text.replace(/^\(no output\)\s*/u, ""); + const match = /(?:^|\n)\s*Command exited with code (\d+)\.\s*$/u.exec(cleaned); + if (match?.[1] === "0") { + cleaned = cleaned.slice(0, match.index).trimEnd(); + } + cleaned = cleaned.trim(); + return cleaned.length > 0 ? cleaned : undefined; +} + function permissionRequestType( action: string, ): "command_execution_approval" | "file_read_approval" | "file_change_approval" | "unknown" { @@ -279,6 +357,45 @@ function formQuestions(form: Readonly>): ReadonlyArray>, + answers: Readonly>, +): Record { + const fields = recordsFromUnknown(form.fields); + const toOptionValue = (options: ReadonlyArray>>) => + (candidate: unknown): unknown => { + if (typeof candidate !== "string") return candidate; + const text = candidate.trim(); + if (text.length === 0 || options.some((option) => stringField(option, "value") === text)) { + return text; + } + const byLabel = options.find( + (option) => stringField(option, "label", "value")?.toLowerCase() === text.toLowerCase(), + ); + if (byLabel) return stringField(byLabel, "value"); + if (/^\d+$/u.test(text)) { + const index = Number.parseInt(text, 10); + const option = index >= 1 && index <= options.length ? options[index - 1] : undefined; + if (option) return stringField(option, "value"); + } + return text; + }; + const normalized: Record = {}; + for (const [key, value] of Object.entries(answers)) { + const options = recordsFromUnknown( + fields.find((field) => stringField(field, "key", "id") === key)?.options, + ); + const translate = toOptionValue(options); + normalized[key] = Array.isArray(value) ? value.map(translate) : translate(value); + } + return normalized; +} + function contentItemId( data: Readonly>, kind: "text" | "reasoning", @@ -298,8 +415,10 @@ function toolDetail(data: Readonly>): string | undefined const texts = recordsFromUnknown(data.content) .map((item) => stringField(item, "text")) .filter((value): value is string => value !== undefined); - if (texts.length > 0) return texts.join("\n"); - if (typeof data.result === "string" && data.result.trim().length > 0) return data.result; + if (texts.length > 0) return cleanToolOutputText(texts.join("\n")); + if (typeof data.result === "string" && data.result.trim().length > 0) { + return cleanToolOutputText(data.result); + } return undefined; } @@ -600,7 +719,7 @@ export function makeOpenCodeAdapter( const callId = stringField(data, "callID", "id"); if (!callId) break; const tool = stringField(data, "tool", "name") ?? "tool"; - context.toolNameByCallId.set(callId, tool); + context.toolsByCallId.set(callId, { name: tool }); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -612,7 +731,7 @@ export function makeOpenCodeAdapter( payload: { itemType: toToolLifecycleItemType(tool), status: "inProgress", - title: tool, + title: openCodeToolTitle(tool), }, }); break; @@ -622,8 +741,20 @@ export function makeOpenCodeAdapter( case "session.tool.input.ended": { const callId = stringField(data, "callID", "id"); if (!callId) break; - const tool = context.toolNameByCallId.get(callId) ?? "tool"; - const detail = stringField(data, "delta", "text"); + const state = context.toolsByCallId.get(callId); + if (!state) break; + if (type === "session.tool.input.delta") { + const delta = stringField(data, "delta", "text"); + if (delta) state.inputText = `${state.inputText ?? ""}${delta}`; + } else if (state.input === undefined) { + // input.ended normally carries the full JSON text. Fall back to + // accumulated deltas for compatible servers that omit it. + const rawInput = stringField(data, "text") ?? state.inputText; + if (rawInput) { + state.input = Option.getOrElse(decodeUnknownJsonString(rawInput), () => rawInput); + } + } + const detail = openCodeToolInputDetail(state.name, state.input); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -633,11 +764,11 @@ export function makeOpenCodeAdapter( })), type: "item.updated", payload: { - itemType: toToolLifecycleItemType(tool), + itemType: toToolLifecycleItemType(state.name), status: "inProgress", - title: tool, + title: openCodeToolTitle(state.name), ...(detail ? { detail } : {}), - data, + data: toolItemData(callId, state), }, }); break; @@ -647,8 +778,14 @@ export function makeOpenCodeAdapter( const callId = stringField(data, "callID", "id"); if (!callId) break; const tool = stringField(data, "tool", "name") ?? "tool"; - const inputStarted = context.toolNameByCallId.has(callId); - context.toolNameByCallId.set(callId, tool); + const state = context.toolsByCallId.get(callId); + if (state && data.input !== undefined) { + state.input = data.input; + } else if (!state) { + context.toolsByCallId.set(callId, { name: tool, input: data.input }); + } + const resolved = context.toolsByCallId.get(callId)!; + const detail = openCodeToolInputDetail(resolved.name, resolved.input); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -656,15 +793,13 @@ export function makeOpenCodeAdapter( itemId: callId, raw: event, })), - type: inputStarted ? "item.updated" : "item.started", + type: state ? "item.updated" : "item.started", payload: { - itemType: toToolLifecycleItemType(tool), + itemType: toToolLifecycleItemType(resolved.name), status: "inProgress", - title: tool, - data: { - tool, - input: data.input, - }, + title: openCodeToolTitle(resolved.name), + ...(detail ? { detail } : {}), + data: toolItemData(callId, resolved), }, }); break; @@ -675,8 +810,10 @@ export function makeOpenCodeAdapter( case "session.tool.failed": { const callId = stringField(data, "callID", "id"); if (!callId) break; - const tool = context.toolNameByCallId.get(callId) ?? "tool"; + const state = context.toolsByCallId.get(callId); + const tool = state?.name ?? stringField(data, "tool", "name") ?? "tool"; const terminal = type !== "session.tool.progress"; + const output = terminal ? toolDetail(data) : undefined; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -689,12 +826,15 @@ export function makeOpenCodeAdapter( itemType: toToolLifecycleItemType(tool), status: type === "session.tool.failed" ? "failed" : terminal ? "completed" : "inProgress", - title: tool, - ...(toolDetail(data) ? { detail: toolDetail(data) } : {}), - data, + title: openCodeToolTitle(tool), + ...(output ? { detail: output } : {}), + data: { + ...toolItemData(callId, state ?? { name: tool }), + ...(output ? { output } : {}), + }, }, }); - if (terminal) context.toolNameByCallId.delete(callId); + if (terminal) context.toolsByCallId.delete(callId); break; } @@ -767,7 +907,7 @@ export function makeOpenCodeAdapter( if (!form) break; const formId = stringField(form, "id", "formID"); if (!formId) break; - context.pendingForms.add(formId); + context.pendingForms.set(formId, form); yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -1127,8 +1267,8 @@ export function makeOpenCodeAdapter( session, providerSessionId, pendingPermissions: new Set(), - pendingForms: new Set(), - toolNameByCallId: new Map(), + pendingForms: new Map(), + toolsByCallId: new Map(), activeTurnId: undefined, currentModel: selectedModel, currentVariant: variant, @@ -1347,7 +1487,8 @@ export function makeOpenCodeAdapter( "OpenCodeAdapter.respondToUserInput", )(function* (threadId, requestId, answers) { const context = yield* ensureSession(threadId); - if (!context.pendingForms.has(requestId)) { + const form = context.pendingForms.get(requestId); + if (!form) { return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "form.reply", @@ -1360,7 +1501,7 @@ export function makeOpenCodeAdapter( { operation: "form.reply", schema: Schema.Void, - body: { answer: answers }, + body: { answer: normalizeFormAnswers(form, answers) }, }, ).pipe(Effect.mapError((cause) => toRequestError("form.reply", cause))); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d9a9c37e023..b9971bb45e98 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -6,6 +6,7 @@ import { normalizeCompactToolLabel, resolveAssistantMessageCopyState, shouldPreserveAssistantLineBreaks, + toolGroupAction, } from "./MessagesTimeline.logic"; describe("shouldPreserveAssistantLineBreaks", () => { @@ -218,6 +219,21 @@ describe("normalizeCompactToolLabel", () => { }); }); +describe("toolGroupAction", () => { + it("groups the OpenCode 2 read title with file reads", () => { + expect( + toolGroupAction({ + id: "read-1", + createdAt: "2026-01-01T00:00:00Z", + label: "Reading file", + tone: "tool", + itemType: "dynamic_tool_call", + toolTitle: "Reading file", + }), + ).toBe("read"); + }); +}); + describe("resolveAssistantMessageCopyState", () => { it("returns enabled copy state for completed assistant messages", () => { expect( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 716a72ea8c14..af53deb4f009 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -288,7 +288,8 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || - (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File") + (entry.itemType === "dynamic_tool_call" && + (entry.toolTitle === "Read File" || entry.toolTitle === "Reading file")) ) { return "read"; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d89140d1105..8531b4499c1b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -59,6 +59,7 @@ import { SquarePenIcon, TerminalIcon, Undo2Icon, + WorkflowIcon, WrenchIcon, XIcon, ZapIcon, @@ -1523,6 +1524,9 @@ function toolGroupSummaryIconName( case "tone-tool": return "zap"; case "mixed": + // Several different tool kinds chained together (e.g. OpenCode 2 code + // mode fanning out shell + read + grep in one run). + return "workflow"; case null: return "hammer"; } @@ -2139,6 +2143,7 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "workflow" | "wrench" | "x" | "zap"; @@ -2167,6 +2172,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN return ; case "wrench": return ; + case "workflow": + return ; case "x": return ; case "zap": From 27109676be5167cabfe7b0a06ef7118cefd3666e Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:28:57 -0400 Subject: [PATCH 19/24] style(opencode): format native tool activity --- .../src/provider/Layers/OpenCodeAdapter.test.ts | 5 +---- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 12 ++++-------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 79d7103e079c..29dd7ccd95a5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -415,10 +415,7 @@ it.effect("attaches once and maps native deltas, tools, and terminal events", () ); const endedUpdate = toolUpdates[1]; NodeAssert.ok(endedUpdate); - NodeAssert.equal( - (endedUpdate.payload as Record).detail, - "pwd", - ); + NodeAssert.equal((endedUpdate.payload as Record).detail, "pwd"); NodeAssert.equal( observed.some((next) => next.type === "turn.completed" && next.turnId === turn.turnId), true, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 715d07c450a4..5120288677b9 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -64,9 +64,7 @@ const MessagesResponseSchema = Schema.Struct({ const decodeUnknownRecord = Schema.decodeUnknownOption(UnknownRecordSchema); const decodeUnknownRecordArray = Schema.decodeUnknownOption(UnknownRecordArraySchema); -const decodeUnknownJsonString = Schema.decodeUnknownOption( - Schema.fromJsonString(Schema.Unknown), -); +const decodeUnknownJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); interface OpenCodeTurnSnapshot { readonly id: TurnId; @@ -233,10 +231,7 @@ function openCodeToolTitle(toolName: string): string { * Short human summary of a tool call's input for the row preview. OpenCode 2 * streams inputs as raw JSON text; parse out the field a human would scan. */ -function openCodeToolInputDetail( - toolName: string, - input: unknown, -): string | undefined { +function openCodeToolInputDetail(toolName: string, input: unknown): string | undefined { const record = recordFromUnknown(input); if (!record) return undefined; const firstString = (...keys: ReadonlyArray) => stringField(record, ...keys); @@ -367,7 +362,8 @@ function normalizeFormAnswers( answers: Readonly>, ): Record { const fields = recordsFromUnknown(form.fields); - const toOptionValue = (options: ReadonlyArray>>) => + const toOptionValue = + (options: ReadonlyArray>>) => (candidate: unknown): unknown => { if (typeof candidate !== "string") return candidate; const text = candidate.trim(); From 5b40cadbdfaf63b224c6c68a63a59a71eb9b8096 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:29:35 -0400 Subject: [PATCH 20/24] fix(opencode): preserve free-text form answers --- .../src/provider/Layers/OpenCodeAdapter.test.ts | 15 ++++++++++++++- .../server/src/provider/Layers/OpenCodeAdapter.ts | 1 + 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 29dd7ccd95a5..1e6bd913ea20 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -616,6 +616,12 @@ it.effect("translates form answers into OpenCode 2 option values", () => { value: "manual", label: "Manual" }, ], }, + { + key: "notes", + type: "string", + title: "Notes", + description: "Add notes", + }, ], }, }), @@ -626,6 +632,7 @@ it.effect("translates form answers into OpenCode 2 option values", () => yield* adapter.respondToUserInput(threadId, ApprovalRequestId.make("frm_values"), { choice: "Choose another provider", mode: "2", + notes: " indented text ", }); NodeAssert.deepEqual( @@ -634,7 +641,13 @@ it.effect("translates form answers into OpenCode 2 option values", () => method: "POST", path: "/api/session/ses_test/form/frm_values/reply", operation: "form.reply", - body: { answer: { choice: "choose", mode: "manual" } }, + body: { + answer: { + choice: "choose", + mode: "manual", + notes: " indented text ", + }, + }, }, ); }), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 5120288677b9..017011699f83 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -365,6 +365,7 @@ function normalizeFormAnswers( const toOptionValue = (options: ReadonlyArray>>) => (candidate: unknown): unknown => { + if (options.length === 0) return candidate; if (typeof candidate !== "string") return candidate; const text = candidate.trim(); if (text.length === 0 || options.some((option) => stringField(option, "value") === text)) { From ad083a24cd79063fdb4682dd62d6cfa06cbf2f1e Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:35:54 -0400 Subject: [PATCH 21/24] docs(opencode): restore adapter invariants --- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 017011699f83..c6b4abd37d74 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -44,6 +44,12 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("opencode"); + +/** + * Version tag stamped into the OpenCode resume cursor. Bump if the cursor + * shape changes so stale-shaped cursors written by older builds are ignored + * rather than misread (mirrors GROK_RESUME_VERSION / CURSOR_RESUME_VERSION). + */ const RESUME_VERSION = 1 as const; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); @@ -164,6 +170,11 @@ function parseResumeCursor(raw: unknown): string | undefined { return stringField(record, "sessionId"); } +/** + * Whether two directory spellings name the same location. Lexically equal + * paths short-circuit; otherwise both sides use `realPath`, falling back to + * their lexical forms on failure, so the probe can only widen matches. + */ export function isSameOpenCodeDirectory( fileSystem: FileSystem.FileSystem, path: Path.Path, From 112a9e6e062ca4df4b5e39d10a80ac00bd085ee3 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:56:31 -0400 Subject: [PATCH 22/24] fix(web): remove OpenCode 2 notification --- .../OpenCode2AutoEnableNotification.tsx | 115 ------------------ .../openCode2AutoEnableNotification.test.ts | 100 --------------- .../src/openCode2AutoEnableNotification.ts | 101 --------------- apps/web/src/routes/__root.tsx | 2 - 4 files changed, 318 deletions(-) delete mode 100644 apps/web/src/components/OpenCode2AutoEnableNotification.tsx delete mode 100644 apps/web/src/openCode2AutoEnableNotification.test.ts delete mode 100644 apps/web/src/openCode2AutoEnableNotification.ts diff --git a/apps/web/src/components/OpenCode2AutoEnableNotification.tsx b/apps/web/src/components/OpenCode2AutoEnableNotification.tsx deleted file mode 100644 index 4adc68219841..000000000000 --- a/apps/web/src/components/OpenCode2AutoEnableNotification.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useNavigate } from "@tanstack/react-router"; -import { useEffect, useMemo, useRef, useState } from "react"; - -import { - dismissOpenCode2AutoEnableNotification, - isOpenCode2AutoEnableNotificationPending, - openCode2AutoEnableNotificationKey, - readOpenCode2AutoEnableNotificationState, - recordOpenCode2AutoEnableDetection, - resolveOpenCode2AutoEnableDetectionKey, - writeOpenCode2AutoEnableNotificationState, -} from "../openCode2AutoEnableNotification"; -import { primaryServerProvidersAtom } from "../state/server"; -import { usePrimaryEnvironment } from "../state/environments"; -import { OpenCodeIcon } from "./Icons"; -import { stackedThreadToast, toastManager } from "./ui/toast"; - -type OpenCode2ToastId = ReturnType; - -export function OpenCode2AutoEnableNotification() { - const navigate = useNavigate(); - const providers = useAtomValue(primaryServerProvidersAtom); - const primaryEnvironment = usePrimaryEnvironment(); - const [notificationState, setNotificationState] = useState( - readOpenCode2AutoEnableNotificationState, - ); - const activeToastRef = useRef<{ key: string; toastId: OpenCode2ToastId } | null>(null); - - const environmentKey = useMemo( - () => - primaryEnvironment === null - ? null - : openCode2AutoEnableNotificationKey(primaryEnvironment.environmentId), - [primaryEnvironment], - ); - const detectionKey = useMemo( - () => - resolveOpenCode2AutoEnableDetectionKey(primaryEnvironment?.environmentId ?? null, providers), - [primaryEnvironment, providers], - ); - - useEffect(() => { - if (detectionKey === null) return; - setNotificationState((current) => { - const next = recordOpenCode2AutoEnableDetection(current, detectionKey); - if (next !== current) writeOpenCode2AutoEnableNotificationState(next); - return next; - }); - }, [detectionKey]); - - const pendingKey = - environmentKey !== null && - isOpenCode2AutoEnableNotificationPending(notificationState, environmentKey) - ? environmentKey - : null; - - useEffect(() => { - const activeToast = activeToastRef.current; - if (activeToast !== null && activeToast.key !== pendingKey) { - toastManager.close(activeToast.toastId); - activeToastRef.current = null; - } - - if (pendingKey === null || activeToastRef.current !== null) return; - - let toastId: OpenCode2ToastId; - const dismiss = () => { - if (activeToastRef.current?.toastId === toastId) activeToastRef.current = null; - setNotificationState((current) => { - const next = dismissOpenCode2AutoEnableNotification(current, pendingKey); - if (next !== current) writeOpenCode2AutoEnableNotificationState(next); - return next; - }); - }; - - toastId = toastManager.add( - stackedThreadToast({ - type: "info", - title: "Hey! We support OpenCode 2", - description: - "It has been automatically enabled. If you want to disable it, go to Settings.", - timeout: 0, - actionProps: { - children: "Go to settings", - onClick: () => { - dismiss(); - toastManager.close(toastId); - void navigate({ to: "/settings/providers" }); - }, - }, - actionVariant: "outline", - data: { - leadingIcon: